From 76a9a63fb44eefa3b6a09467048f867fa13ba1de Mon Sep 17 00:00:00 2001 From: 1Seob Date: Fri, 27 Feb 2026 15:41:01 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20DailyStatsRepository=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/stats/core/dto/DateCountDto.java | 8 ++ .../core/repository/DailyStatsRepository.java | 100 ++++++++++++++++++ .../shared/util/TodayDateTimeProvider.java | 19 +++- .../util/dto/TodayDateTimeRangeDto.java | 9 ++ 4 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/DateCountDto.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/DailyStatsRepository.java create mode 100644 src/main/java/com/devkor/ifive/nadab/global/shared/util/dto/TodayDateTimeRangeDto.java diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/DateCountDto.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/DateCountDto.java new file mode 100644 index 00000000..a7a39a14 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/DateCountDto.java @@ -0,0 +1,8 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto; + +import java.time.LocalDate; + +public record DateCountDto( + LocalDate date, + long count +) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/DailyStatsRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/DailyStatsRepository.java new file mode 100644 index 00000000..3121a4c4 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/DailyStatsRepository.java @@ -0,0 +1,100 @@ +package com.devkor.ifive.nadab.domain.stats.core.repository; + +import com.devkor.ifive.nadab.domain.stats.core.dto.DateCountDto; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.sql.Date; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class DailyStatsRepository { + + private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); + private final EntityManager em; + + /** + * 최근 7일 가입자 수 (registeredAt 기준) + */ + public List findSignupCountsLast7Days(LocalDate startDate, LocalDate endDateInclusive) { + OffsetDateTime start = startDate.atStartOfDay(SEOUL).toOffsetDateTime(); + OffsetDateTime endExclusive = endDateInclusive.plusDays(1).atStartOfDay(SEOUL).toOffsetDateTime(); + + // JPQL에서 "날짜 단위"로 묶기 위해 Postgres date() 함수 사용 + // function('date', u.registeredAt) -> java.sql.Date 로 내려오는 경우가 많아서 Object[]로 받는게 안전 + return em.createQuery(""" + select function('date', u.registeredAt), count(u.id) + from User u + where u.deletedAt is null + and u.signupStatus = com.devkor.ifive.nadab.domain.user.core.entity.SignupStatusType.COMPLETED + and u.registeredAt is not null + and u.registeredAt >= :start + and u.registeredAt < :endExclusive + group by function('date', u.registeredAt) + order by function('date', u.registeredAt) + """, Object[].class) + .setParameter("start", start) + .setParameter("endExclusive", endExclusive) + .getResultList(); + } + + /** + * 최근 7일 할당된 질문 수 (user_daily_questions) + */ + public List findAssignedQuestionCountsLast7Days(LocalDate startDate, LocalDate endDateInclusive) { + return em.createQuery(""" + select new com.devkor.ifive.nadab.domain.stats.core.dto.DateCountDto(udq.date, count(udq.id)) + from UserDailyQuestion udq + where udq.date between :startDate and :endDate + group by udq.date + order by udq.date + """, DateCountDto.class) + .setParameter("startDate", startDate) + .setParameter("endDate", endDateInclusive) + .getResultList(); + } + + /** + * 최근 7일 COMPLETED daily_reports 수 + */ + public List findCompletedDailyReportCountsLast7Days(LocalDate startDate, LocalDate endDateInclusive) { + return em.createQuery(""" + select new com.devkor.ifive.nadab.domain.stats.core.dto.DateCountDto(dr.date, count(dr.id)) + from DailyReport dr + where dr.date between :startDate and :endDate + and dr.status = 'COMPLETED' + group by dr.date + order by dr.date + """, DateCountDto.class) + .setParameter("startDate", startDate) + .setParameter("endDate", endDateInclusive) + .getResultList(); + } + + /** + * 현재 공유 중(isShared=true) daily_reports 개수 + * - 보통 COMPLETED만 공유 의미가 있으니 status도 거는 걸 추천 + */ + public long countSharedDailyReportsNow() { + return em.createQuery(""" + select count(dr.id) + from DailyReport dr + where dr.isShared = true + and dr.status = 'COMPLETED' + """, Long.class) + .getSingleResult(); + } + + // ---------- 유틸: Object[] -> DateCountDto 변환에 사용할 때 ---------- + public static DateCountDto toDateCountDto(Object[] row) { + // row[0] = java.sql.Date (대개), row[1] = Long + LocalDate date = ((Date) row[0]).toLocalDate(); + long count = (Long) row[1]; + return new DateCountDto(date, count); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/global/shared/util/TodayDateTimeProvider.java b/src/main/java/com/devkor/ifive/nadab/global/shared/util/TodayDateTimeProvider.java index 44235f6d..ad8ac7dd 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/shared/util/TodayDateTimeProvider.java +++ b/src/main/java/com/devkor/ifive/nadab/global/shared/util/TodayDateTimeProvider.java @@ -1,10 +1,13 @@ package com.devkor.ifive.nadab.global.shared.util; +import com.devkor.ifive.nadab.global.shared.util.dto.TodayDateTimeRangeDto; + import java.time.LocalDate; +import java.time.OffsetDateTime; import java.time.ZoneId; /** - * 오늘 날짜를 제공하는 유틸리티 클래스 + * 오늘 날짜, 오늘 날짜의 시작과 내일 날짜의 시작 등을 제공하는 유틸리티 클래스 * repository 등에서 오늘 날짜 범위 조회 시 사용 */ public class TodayDateTimeProvider { @@ -14,6 +17,20 @@ public class TodayDateTimeProvider { private TodayDateTimeProvider() { } + public static TodayDateTimeRangeDto getRange() { + LocalDate today = LocalDate.now(SEOUL); + + OffsetDateTime startOfToday = + today.atStartOfDay(SEOUL).toOffsetDateTime(); + + OffsetDateTime startOfTomorrow = + today.plusDays(1) + .atStartOfDay(SEOUL) + .toOffsetDateTime(); + + return new TodayDateTimeRangeDto(startOfToday, startOfTomorrow); + } + public static LocalDate getTodayDate() { return LocalDate.now(SEOUL); } diff --git a/src/main/java/com/devkor/ifive/nadab/global/shared/util/dto/TodayDateTimeRangeDto.java b/src/main/java/com/devkor/ifive/nadab/global/shared/util/dto/TodayDateTimeRangeDto.java new file mode 100644 index 00000000..42767c02 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/global/shared/util/dto/TodayDateTimeRangeDto.java @@ -0,0 +1,9 @@ +package com.devkor.ifive.nadab.global.shared.util.dto; + +import java.time.OffsetDateTime; + +public record TodayDateTimeRangeDto( + OffsetDateTime startOfToday, + OffsetDateTime startOfTomorrow +) { +} \ No newline at end of file From 02cf27eb6a7c7b4c7d430cd1657779307895830e Mon Sep 17 00:00:00 2001 From: 1Seob Date: Fri, 27 Feb 2026 16:25:53 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=EC=9D=BC=EA=B0=84=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 + .../stats/application/DailyStatsService.java | 71 ++++ .../stats/controller/StatsController.java | 29 ++ .../stats/core/dto/DailyStatsViewModel.java | 12 + .../core/repository/DailyStatsRepository.java | 15 +- src/main/resources/templates/stats/daily.html | 372 ++++++++++++++++++ src/main/resources/templates/stats/total.html | 27 ++ 7 files changed, 524 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/application/DailyStatsService.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/DailyStatsViewModel.java create mode 100644 src/main/resources/templates/stats/daily.html create mode 100644 src/main/resources/templates/stats/total.html diff --git a/build.gradle b/build.gradle index ba264d9e..8bd3c7be 100644 --- a/build.gradle +++ b/build.gradle @@ -80,6 +80,9 @@ dependencies { // Hypersistence Utils implementation "io.hypersistence:hypersistence-utils-hibernate-63:3.7.6" + + // Thymeleaf (통계 페이지용) + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' } dependencyManagement { diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/DailyStatsService.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/DailyStatsService.java new file mode 100644 index 00000000..25fa7039 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/DailyStatsService.java @@ -0,0 +1,71 @@ +package com.devkor.ifive.nadab.domain.stats.application; + +import com.devkor.ifive.nadab.domain.stats.core.dto.DailyStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.DateCountDto; +import com.devkor.ifive.nadab.domain.stats.core.repository.DailyStatsRepository; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.time.LocalDate; + +@Service +@RequiredArgsConstructor +public class DailyStatsService { + + private final DailyStatsRepository repo; + + private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); + private static final DateTimeFormatter FMT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + public DailyStatsViewModel getDailyStatsLast7Days() { + LocalDate today = TodayDateTimeProvider.getTodayDate(); + LocalDate startDate = today.minusDays(6); + + // 라벨 7개 고정 생성 + List days = new ArrayList<>(); + for (int i = 0; i < 7; i++) days.add(startDate.plusDays(i)); + + List labels = days.stream().map(LocalDate::toString).toList(); + + // 1) 가입자 수 + Map signupMap = new HashMap<>(); + for (Object[] row : repo.findSignupCountsLast7Days(startDate, today)) { + DateCountDto dto = DailyStatsRepository.toDateCountDto(row); + signupMap.put(dto.date(), dto.count()); + } + + // 2) 할당 질문 수 + Map assignedMap = new HashMap<>(); + for (DateCountDto dto : repo.findAssignedQuestionCountsLast7Days(startDate, today)) { + assignedMap.put(dto.date(), dto.count()); + } + + // 3) COMPLETED 리포트 수 + Map completedMap = new HashMap<>(); + for (DateCountDto dto : repo.findCompletedDailyReportCountsLast7Days(startDate, today)) { + completedMap.put(dto.date(), dto.count()); + } + + // 빈 날짜는 0 채우기 + List signupCounts = days.stream().map(d -> signupMap.getOrDefault(d, 0L)).toList(); + List assignedCounts = days.stream().map(d -> assignedMap.getOrDefault(d, 0L)).toList(); + List completedCounts = days.stream().map(d -> completedMap.getOrDefault(d, 0L)).toList(); + + long sharedNow = repo.countSharedDailyReportsNow(); + + return new DailyStatsViewModel( + labels, + signupCounts, + assignedCounts, + completedCounts, + sharedNow, + OffsetDateTime.now(SEOUL).format(FMT) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java new file mode 100644 index 00000000..de8dcc7d --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java @@ -0,0 +1,29 @@ +package com.devkor.ifive.nadab.domain.stats.controller; + +import com.devkor.ifive.nadab.domain.stats.application.DailyStatsService; +import com.devkor.ifive.nadab.domain.stats.core.dto.DailyStatsViewModel; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +@RequiredArgsConstructor +public class StatsController { + + private final DailyStatsService dailyStatsService; + + @GetMapping("stats/daily") + public String dailyStats(Model model) { + DailyStatsViewModel vm = dailyStatsService.getDailyStatsLast7Days(); + model.addAttribute("vm", vm); + model.addAttribute("activeTab", "daily"); + return "stats/daily"; + } + + @GetMapping("/stats/total") + public String totalStats(Model model) { + model.addAttribute("activeTab", "total"); + return "stats/total"; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/DailyStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/DailyStatsViewModel.java new file mode 100644 index 00000000..04150b6d --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/DailyStatsViewModel.java @@ -0,0 +1,12 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto; + +import java.util.List; + +public record DailyStatsViewModel( + List labels, // yyyy-MM-dd + List signupCounts, + List assignedQuestionCounts, + List completedDailyReportCounts, + long sharedDailyReportCount, + String refreshedAt // "2026-02-27 21:34:12" +) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/DailyStatsRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/DailyStatsRepository.java index 3121a4c4..e0a5e27e 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/DailyStatsRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/DailyStatsRepository.java @@ -1,6 +1,7 @@ package com.devkor.ifive.nadab.domain.stats.core.repository; import com.devkor.ifive.nadab.domain.stats.core.dto.DateCountDto; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -81,12 +82,16 @@ public List findCompletedDailyReportCountsLast7Days(LocalDate star * - 보통 COMPLETED만 공유 의미가 있으니 status도 거는 걸 추천 */ public long countSharedDailyReportsNow() { + LocalDate today = TodayDateTimeProvider.getTodayDate(); + return em.createQuery(""" - select count(dr.id) - from DailyReport dr - where dr.isShared = true - and dr.status = 'COMPLETED' - """, Long.class) + select count(dr.id) + from DailyReport dr + where dr.date = :today + and dr.isShared = true + and dr.status = 'COMPLETED' + """, Long.class) + .setParameter("today", today) .getSingleResult(); } diff --git a/src/main/resources/templates/stats/daily.html b/src/main/resources/templates/stats/daily.html new file mode 100644 index 00000000..dbb5bb59 --- /dev/null +++ b/src/main/resources/templates/stats/daily.html @@ -0,0 +1,372 @@ + + + + + + Admin — Daily Stats + + + + + + + + + + +
+ 일간 + 전체 +
+ + +
+
+
현재 공유 중인 일간 리포트
+
0
+
+
+ + +
+ +
+
+
+ 일간 가입자 수 +
+
최근 7일
+
+
+ +
+
+ + +
+
+
+ 할당된 질문 개수 +
+
최근 7일 · 홈 화면 진입 시 할당
+
+
+ +
+
+ + +
+
+
+ 생성된 일간 리포트 개수 +
+
최근 7일
+
+
+ +
+
+
+ + + + + \ No newline at end of file diff --git a/src/main/resources/templates/stats/total.html b/src/main/resources/templates/stats/total.html new file mode 100644 index 00000000..32235cdb --- /dev/null +++ b/src/main/resources/templates/stats/total.html @@ -0,0 +1,27 @@ + + + + + + Admin - Total Stats + + + +
+ + +
전체 통계는 다음 단계에서 추가
+
+ + \ No newline at end of file From 3a37777a6eb39445291b0b3b27459126d97caae5 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Fri, 27 Feb 2026 16:46:13 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20TotalStatsRepository=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stats/application/DailyStatsService.java | 4 +- .../stats/controller/StatsController.java | 2 +- .../dto/{ => daily}/DailyStatsViewModel.java | 2 +- .../core/dto/{ => daily}/DateCountDto.java | 2 +- .../stats/core/dto/total/LabelCountDto.java | 6 ++ .../core/dto/total/TotalStatsViewModel.java | 19 ++++ .../core/repository/DailyStatsRepository.java | 2 +- .../core/repository/TotalStatsRepository.java | 90 +++++++++++++++++++ 8 files changed, 121 insertions(+), 6 deletions(-) rename src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/{ => daily}/DailyStatsViewModel.java (84%) rename src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/{ => daily}/DateCountDto.java (63%) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/total/LabelCountDto.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/total/TotalStatsViewModel.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/TotalStatsRepository.java diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/DailyStatsService.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/DailyStatsService.java index 25fa7039..a08cb4ed 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/DailyStatsService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/DailyStatsService.java @@ -1,7 +1,7 @@ package com.devkor.ifive.nadab.domain.stats.application; -import com.devkor.ifive.nadab.domain.stats.core.dto.DailyStatsViewModel; -import com.devkor.ifive.nadab.domain.stats.core.dto.DateCountDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DailyStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto; import com.devkor.ifive.nadab.domain.stats.core.repository.DailyStatsRepository; import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java index de8dcc7d..ebbb7509 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java @@ -1,7 +1,7 @@ package com.devkor.ifive.nadab.domain.stats.controller; import com.devkor.ifive.nadab.domain.stats.application.DailyStatsService; -import com.devkor.ifive.nadab.domain.stats.core.dto.DailyStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DailyStatsViewModel; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/DailyStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyStatsViewModel.java similarity index 84% rename from src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/DailyStatsViewModel.java rename to src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyStatsViewModel.java index 04150b6d..80c6f032 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/DailyStatsViewModel.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyStatsViewModel.java @@ -1,4 +1,4 @@ -package com.devkor.ifive.nadab.domain.stats.core.dto; +package com.devkor.ifive.nadab.domain.stats.core.dto.daily; import java.util.List; diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/DateCountDto.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DateCountDto.java similarity index 63% rename from src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/DateCountDto.java rename to src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DateCountDto.java index a7a39a14..ff2c238d 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/DateCountDto.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DateCountDto.java @@ -1,4 +1,4 @@ -package com.devkor.ifive.nadab.domain.stats.core.dto; +package com.devkor.ifive.nadab.domain.stats.core.dto.daily; import java.time.LocalDate; diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/total/LabelCountDto.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/total/LabelCountDto.java new file mode 100644 index 00000000..1727305b --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/total/LabelCountDto.java @@ -0,0 +1,6 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.total; + +public record LabelCountDto( + String label, + long count +) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/total/TotalStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/total/TotalStatsViewModel.java new file mode 100644 index 00000000..e3cc362e --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/total/TotalStatsViewModel.java @@ -0,0 +1,19 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.total; + +import java.util.List; + +public record TotalStatsViewModel( + + long totalUserCount, + + // provider labels: ["GOOGLE","KAKAO","NAVER","NORMAL"] + List providerLabels, + List providerCounts, + + // interest labels: ["PREFERENCE", ...] + List interestLabels, + List interestSelectedCounts, // pie + List interestDailyReportCounts, // bar (COMPLETED) + + String refreshedAt +) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/DailyStatsRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/DailyStatsRepository.java index e0a5e27e..ad091ae5 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/DailyStatsRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/DailyStatsRepository.java @@ -1,6 +1,6 @@ package com.devkor.ifive.nadab.domain.stats.core.repository; -import com.devkor.ifive.nadab.domain.stats.core.dto.DateCountDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto; import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/TotalStatsRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/TotalStatsRepository.java new file mode 100644 index 00000000..ae493c95 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/TotalStatsRepository.java @@ -0,0 +1,90 @@ +package com.devkor.ifive.nadab.domain.stats.core.repository; + +import com.devkor.ifive.nadab.domain.stats.core.dto.total.LabelCountDto; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class TotalStatsRepository { + + private final EntityManager em; + + /** 총 유저 수 (가입 완료 + 미삭제) */ + public long countTotalUsers() { + return em.createQuery(""" + select count(u.id) + from User u + where u.deletedAt is null + and u.signupStatus = com.devkor.ifive.nadab.domain.user.core.entity.SignupStatusType.COMPLETED + """, Long.class) + .getSingleResult(); + } + + /** 소셜별 유저 수 (가입 완료 + 미삭제, providerType 기준) */ + public List countUsersByProvider() { + // row[0] = ProviderType, row[1] = Long + return em.createQuery(""" + select sa.providerType, count(distinct u.id) + from SocialAccount sa + join sa.user u + where u.deletedAt is null + and u.signupStatus = com.devkor.ifive.nadab.domain.user.core.entity.SignupStatusType.COMPLETED + group by sa.providerType + order by sa.providerType + """, Object[].class) + .getResultList(); + } + + /** 일반 유저 수 = 가입 완료 + 미삭제 & social_account 없는 유저 */ + public long countNormalUsers() { + return em.createQuery(""" + select count(u.id) + from User u + where u.deletedAt is null + and u.signupStatus = com.devkor.ifive.nadab.domain.user.core.entity.SignupStatusType.COMPLETED + and not exists ( + select 1 + from SocialAccount sa + where sa.user = u + ) + """, Long.class) + .getSingleResult(); + } + + /** 주제별 선택 수 (user_interests) - 0도 포함되게 Interest 기준 LEFT JOIN */ + public List countInterestSelections() { + return em.createQuery(""" + select new com.devkor.ifive.nadab.domain.stats.core.dto.total.LabelCountDto( + cast(i.code as string), + count(ui.id) + ) + from Interest i + left join UserInterest ui on ui.interest = i + group by i.code + order by i.code + """, LabelCountDto.class) + .getResultList(); + } + + /** 주제별 COMPLETED 일간 리포트 생성 수 - 0도 포함되게 Interest 기준 LEFT JOIN */ + public List countCompletedDailyReportsByInterest() { + return em.createQuery(""" + select new com.devkor.ifive.nadab.domain.stats.core.dto.total.LabelCountDto( + cast(i.code as string), + count(dr.id) + ) + from Interest i + left join DailyQuestion dq on dq.interest = i + left join AnswerEntry ae on ae.question = dq + left join DailyReport dr on dr.answerEntry = ae + and dr.status = 'COMPLETED' + group by i.code + order by i.code + """, LabelCountDto.class) + .getResultList(); + } +} From 09dfa3d60abd5e25665414d79da3f116bcf931e1 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sat, 28 Feb 2026 01:18:06 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=EC=A0=84=EC=B2=B4=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stats/application/TotalStatsService.java | 69 +++ .../stats/controller/StatsController.java | 6 + .../core/repository/DailyStatsRepository.java | 4 +- .../domain/user/core/entity/InterestCode.java | 11 + src/main/resources/templates/stats/daily.html | 6 +- src/main/resources/templates/stats/total.html | 427 +++++++++++++++++- 6 files changed, 500 insertions(+), 23 deletions(-) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/application/TotalStatsService.java diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/TotalStatsService.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/TotalStatsService.java new file mode 100644 index 00000000..4f52c7a1 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/TotalStatsService.java @@ -0,0 +1,69 @@ +package com.devkor.ifive.nadab.domain.stats.application; + +import com.devkor.ifive.nadab.domain.stats.core.dto.total.LabelCountDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.total.TotalStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.repository.TotalStatsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.*; + +@Service +@RequiredArgsConstructor +public class TotalStatsService { + + private final TotalStatsRepository repo; + + private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); + private static final DateTimeFormatter FMT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + public TotalStatsViewModel getTotalStats() { + long totalUserCount = repo.countTotalUsers(); + + // provider + Map providerMap = new HashMap<>(); + for (Object[] row : repo.countUsersByProvider()) { + String provider = String.valueOf(row[0]); // GOOGLE/KAKAO/NAVER + long cnt = (Long) row[1]; + providerMap.put(provider, cnt); + } + long normal = repo.countNormalUsers(); + providerMap.put("NORMAL", normal); + + // 표시 순서 고정 + List providerLabels = List.of("GOOGLE", "KAKAO", "NAVER", "NORMAL"); + List providerCounts = providerLabels.stream() + .map(l -> providerMap.getOrDefault(l, 0L)) + .toList(); + + // interest pie + List selected = repo.countInterestSelections(); + List interestLabels = selected.stream().map(LabelCountDto::label).toList(); + List interestSelectedCounts = selected.stream().map(LabelCountDto::count).toList(); + + // interest bar (completed daily reports) + Map reportMap = new HashMap<>(); + for (LabelCountDto dto : repo.countCompletedDailyReportsByInterest()) { + reportMap.put(dto.label(), dto.count()); + } + List interestDailyReportCounts = interestLabels.stream() + .map(l -> reportMap.getOrDefault(l, 0L)) + .toList(); + + String refreshedAt = OffsetDateTime.now(SEOUL).format(FMT); + + return new TotalStatsViewModel( + totalUserCount, + providerLabels, + providerCounts, + interestLabels, + interestSelectedCounts, + interestDailyReportCounts, + refreshedAt + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java index ebbb7509..2e725948 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java @@ -1,7 +1,9 @@ package com.devkor.ifive.nadab.domain.stats.controller; import com.devkor.ifive.nadab.domain.stats.application.DailyStatsService; +import com.devkor.ifive.nadab.domain.stats.application.TotalStatsService; import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DailyStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.total.TotalStatsViewModel; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; @@ -12,6 +14,8 @@ public class StatsController { private final DailyStatsService dailyStatsService; + private final TotalStatsService totalStatsService; + @GetMapping("stats/daily") public String dailyStats(Model model) { @@ -23,6 +27,8 @@ public String dailyStats(Model model) { @GetMapping("/stats/total") public String totalStats(Model model) { + TotalStatsViewModel vm = totalStatsService.getTotalStats(); + model.addAttribute("vm", vm); model.addAttribute("activeTab", "total"); return "stats/total"; } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/DailyStatsRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/DailyStatsRepository.java index ad091ae5..a2c45c81 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/DailyStatsRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/DailyStatsRepository.java @@ -49,7 +49,7 @@ order by function('date', u.registeredAt) */ public List findAssignedQuestionCountsLast7Days(LocalDate startDate, LocalDate endDateInclusive) { return em.createQuery(""" - select new com.devkor.ifive.nadab.domain.stats.core.dto.DateCountDto(udq.date, count(udq.id)) + select new com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto(udq.date, count(udq.id)) from UserDailyQuestion udq where udq.date between :startDate and :endDate group by udq.date @@ -65,7 +65,7 @@ public List findAssignedQuestionCountsLast7Days(LocalDate startDat */ public List findCompletedDailyReportCountsLast7Days(LocalDate startDate, LocalDate endDateInclusive) { return em.createQuery(""" - select new com.devkor.ifive.nadab.domain.stats.core.dto.DateCountDto(dr.date, count(dr.id)) + select new com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto(dr.date, count(dr.id)) from DailyReport dr where dr.date between :startDate and :endDate and dr.status = 'COMPLETED' diff --git a/src/main/java/com/devkor/ifive/nadab/domain/user/core/entity/InterestCode.java b/src/main/java/com/devkor/ifive/nadab/domain/user/core/entity/InterestCode.java index 0de0f9bb..f9566376 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/user/core/entity/InterestCode.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/user/core/entity/InterestCode.java @@ -22,4 +22,15 @@ public static InterestCode fromString(String code) { } throw new BadRequestException(ErrorCode.USER_INTEREST_CODE_INVALID); } + + public String displayNameKo() { + return switch (this) { + case PREFERENCE -> "취향"; + case EMOTION -> "감정"; + case ROUTINE -> "루틴"; + case RELATIONSHIP -> "인간관계"; + case LOVE -> "사랑"; + case VALUES -> "가치관"; + }; + } } diff --git a/src/main/resources/templates/stats/daily.html b/src/main/resources/templates/stats/daily.html index dbb5bb59..c4625f7c 100644 --- a/src/main/resources/templates/stats/daily.html +++ b/src/main/resources/templates/stats/daily.html @@ -3,7 +3,7 @@ - Admin — Daily Stats + NADAB — Daily Stats @@ -232,10 +232,10 @@
일간 + th:href="@{/stats/daily}">일간 전체 + th:href="@{/stats/total}">전체
diff --git a/src/main/resources/templates/stats/total.html b/src/main/resources/templates/stats/total.html index 32235cdb..8ab20762 100644 --- a/src/main/resources/templates/stats/total.html +++ b/src/main/resources/templates/stats/total.html @@ -3,25 +3,416 @@ - Admin - Total Stats - + NADAB — Total Stats + + + + - -
- - -
전체 통계는 다음 단계에서 추가
+ + + + + +
+ 일간 + 전체 +
+ +
+ + +
+
+
유저 통계
+
+
+
총 유저 수
+
0
+ +
+ + + +
+
+ + +
+
+
관심 주제
+
+
+
+
+
주제별 선택 수
+ +
+
+
주제별 일간 리포트 총 생성 수
+ +
+
+
+
+
+ + + \ No newline at end of file From 19fbb301ff47b2596fc70bec2e65a6195a944543 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sat, 28 Feb 2026 17:40:14 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=EC=84=9C=EB=B2=84=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=B1=83=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/templates/stats/daily.html | 60 +++++++++++++++++++ src/main/resources/templates/stats/total.html | 60 +++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/src/main/resources/templates/stats/daily.html b/src/main/resources/templates/stats/daily.html index c4625f7c..1f6e4826 100644 --- a/src/main/resources/templates/stats/daily.html +++ b/src/main/resources/templates/stats/daily.html @@ -84,6 +84,40 @@ } .btn-refresh svg { width:14px; height:14px; } + /* ─── Health status ─── */ + .health-bar { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 18px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface); + font-family: 'DM Mono', monospace; + font-size: .75rem; + color: var(--text-2); + transition: border-color .3s; + } + .health-bar.ok { border-color: rgba(77,232,194,.45); } + .health-bar.error { border-color: rgba(255,126,179,.45); } + + .health-dot { + width: 8px; height: 8px; + border-radius: 50%; + flex-shrink: 0; + background: var(--text-3); + transition: background .3s, box-shadow .3s; + } + .health-bar.ok .health-dot { background: var(--accent-3); box-shadow: 0 0 6px rgba(77,232,194,.7); } + .health-bar.error .health-dot { background: var(--accent-2); box-shadow: 0 0 6px rgba(255,126,179,.7); } + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: .25; } + } + .health-bar.loading .health-dot { animation: pulse 1.1s ease infinite; } + .health-text { color: var(--text-1); font-weight: 500; } + /* ─── Tabs ─── */ .tabs { display: flex; @@ -215,6 +249,10 @@