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 435c4b25..307bf53c 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,6 +1,7 @@ package com.devkor.ifive.nadab.domain.stats.application; import com.devkor.ifive.nadab.domain.stats.application.helper.PeakStatsTracker; +import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DailyPeriodStatsViewModel; 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.dto.peak.PeakMetric; @@ -26,33 +27,34 @@ public class DailyStatsService { private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final int DAILY_CHART_SIZE = 7; - public DailyStatsViewModel getDailyStatsLast7Days() { + public DailyStatsViewModel getDailyStats(LocalDate selectedDate) { LocalDate today = TodayDateTimeProvider.getTodayDate(); - LocalDate startDate = today.minusDays(6); + LocalDate startDate = selectedDate.minusDays(DAILY_CHART_SIZE - 1L); // 라벨 7개 고정 생성 List days = new ArrayList<>(); - for (int i = 0; i < 7; i++) days.add(startDate.plusDays(i)); + for (int i = 0; i < DAILY_CHART_SIZE; 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)) { + for (Object[] row : repo.findSignupCountsLast7Days(startDate, selectedDate)) { DateCountDto dto = DailyStatsRepository.toDateCountDto(row); signupMap.put(dto.date(), dto.count()); } // 2) 할당 질문 수 Map assignedMap = new HashMap<>(); - for (DateCountDto dto : repo.findAssignedQuestionCountsLast7Days(startDate, today)) { + for (DateCountDto dto : repo.findAssignedQuestionCountsLast7Days(startDate, selectedDate)) { assignedMap.put(dto.date(), dto.count()); } // 3) COMPLETED 리포트 수 Map completedMap = new HashMap<>(); - for (DateCountDto dto : repo.findCompletedDailyReportCountsLast7Days(startDate, today)) { + for (DateCountDto dto : repo.findCompletedDailyReportCountsLast7Days(startDate, selectedDate)) { completedMap.put(dto.date(), dto.count()); } @@ -71,17 +73,26 @@ public DailyStatsViewModel getDailyStatsLast7Days() { PeakMetric.DAU, days, completedCounts, today ); - long sharedNow = repo.countSharedDailyReportsNow(); + int selectedIndex = days.size() - 1; + long sharedDailyReportCount = repo.countSharedDailyReports(selectedDate); + DailyPeriodStatsViewModel selectedPeriod = new DailyPeriodStatsViewModel( + selectedDate.toString(), + selectedDate.toString(), + signupCounts.get(selectedIndex), + assignedCounts.get(selectedIndex), + completedCounts.get(selectedIndex), + sharedDailyReportCount + ); return new DailyStatsViewModel( labels, signupCounts, assignedCounts, completedCounts, + selectedPeriod, signupPeak, assignedQuestionPeak, dauPeak, - sharedNow, OffsetDateTime.now(SEOUL).format(FMT) ); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/MonthlyStatsService.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/MonthlyStatsService.java index c65a392a..d83208b5 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/MonthlyStatsService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/MonthlyStatsService.java @@ -3,6 +3,7 @@ import com.devkor.ifive.nadab.domain.stats.application.helper.PeakStatsTracker; import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto; import com.devkor.ifive.nadab.domain.stats.core.dto.monthly.MonthlyStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.monthly.MonthlyPeriodStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakMetric; import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStatViewModel; import com.devkor.ifive.nadab.domain.stats.core.repository.MonthlyStatsRepository; @@ -34,13 +35,13 @@ public class MonthlyStatsService { DateTimeFormatter.ofPattern("yyyy-MM"); private static final int MONTHLY_CHART_SIZE = 5; - public MonthlyStatsViewModel getMonthlyStatsLast5Months() { + public MonthlyStatsViewModel getMonthlyStats(YearMonth selectedMonth) { LocalDate today = TodayDateTimeProvider.getTodayDate(); YearMonth currentMonth = YearMonth.from(today); - YearMonth startMonth = currentMonth.minusMonths(MONTHLY_CHART_SIZE - 1L); + YearMonth startMonth = selectedMonth.minusMonths(MONTHLY_CHART_SIZE - 1L); LocalDate startDate = startMonth.atDay(1); - LocalDate endDateInclusive = currentMonth.atEndOfMonth(); + LocalDate endDateInclusive = selectedMonth.atEndOfMonth(); List months = new ArrayList<>(); for (int i = 0; i < MONTHLY_CHART_SIZE; i++) { @@ -99,6 +100,18 @@ public MonthlyStatsViewModel getMonthlyStatsLast5Months() { long inProgressV1Now = repo.countInProgressMonthlyReportV1Now(); long inProgressV2Now = repo.countInProgressMonthlyReportV2Now(); long inProgressTotalNow = inProgressV1Now + inProgressV2Now; + int selectedIndex = months.size() - 1; + MonthlyPeriodStatsViewModel selectedPeriod = new MonthlyPeriodStatsViewModel( + selectedMonth.toString(), + selectedMonth.toString(), + signupCounts.get(selectedIndex), + assignedCounts.get(selectedIndex), + completedDailyCounts.get(selectedIndex), + completedV1Counts.get(selectedIndex), + completedV2Counts.get(selectedIndex), + completedTotalCounts.get(selectedIndex), + mauCounts.get(selectedIndex) + ); return new MonthlyStatsViewModel( labels, @@ -109,6 +122,7 @@ public MonthlyStatsViewModel getMonthlyStatsLast5Months() { completedV2Counts, completedTotalCounts, mauCounts, + selectedPeriod, signupPeak, assignedQuestionPeak, completedDailyReportPeak, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/TypeStatsService.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/TypeStatsService.java index f88ee9d7..b598b151 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/TypeStatsService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/TypeStatsService.java @@ -1,15 +1,24 @@ 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.type.TypeReportDateInterestCountDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeReportInterestCountDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeReportInterestSeriesViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.repository.TypeStatsRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; import java.util.List; +import java.util.Map; @Service @RequiredArgsConstructor @@ -20,22 +29,70 @@ public class TypeStatsService { private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final int RECENT_DAYS = 7; public TypeStatsViewModel getTypeStats() { + LocalDate today = TodayDateTimeProvider.getTodayDate(); + LocalDate startDate = today.minusDays(RECENT_DAYS - 1L); + long inProgressTypeReportCount = repo.countInProgressTypeReportsNow(); - List completedByInterest = repo.countCompletedTypeReportsByInterest(); + List activeCompletedByInterest = + repo.countActiveCompletedTypeReportsByInterest(); + List completedHistoryByInterest = + repo.countCompletedTypeReportHistoryByInterest(); + List completedByDateAndInterest = + repo.countCompletedTypeReportsByDateAndInterest(startDate, today); + + Map activeCompletedCountMap = new EnumMap<>(InterestCode.class); + for (TypeReportInterestCountDto dto : activeCompletedByInterest) { + activeCompletedCountMap.put(dto.interestCode(), dto.count()); + } - List interestLabels = completedByInterest.stream() - .map(LabelCountDto::label) + Map completedHistoryCountMap = new EnumMap<>(InterestCode.class); + for (TypeReportInterestCountDto dto : completedHistoryByInterest) { + completedHistoryCountMap.put(dto.interestCode(), dto.count()); + } + + List interests = List.of(InterestCode.values()); + List interestLabels = interests.stream() + .map(InterestCode::displayNameKo) + .toList(); + List activeCompletedTypeReportCounts = interests.stream() + .map(interest -> activeCompletedCountMap.getOrDefault(interest, 0L)) .toList(); - List completedTypeReportCounts = completedByInterest.stream() - .map(LabelCountDto::count) + List cumulativeCompletedTypeReportCounts = interests.stream() + .map(interest -> completedHistoryCountMap.getOrDefault(interest, 0L)) + .toList(); + + List recentDates = new ArrayList<>(RECENT_DAYS); + for (int i = 0; i < RECENT_DAYS; i++) { + recentDates.add(startDate.plusDays(i)); + } + + Map> dailyCountMap = new HashMap<>(); + for (TypeReportDateInterestCountDto dto : completedByDateAndInterest) { + dailyCountMap.computeIfAbsent(dto.date(), ignored -> new EnumMap<>(InterestCode.class)) + .put(dto.interestCode(), dto.count()); + } + + List completedTypeReportSeries = interests.stream() + .map(interest -> new TypeReportInterestSeriesViewModel( + interest.name(), + interest.displayNameKo(), + recentDates.stream() + .map(date -> dailyCountMap.getOrDefault(date, Map.of()) + .getOrDefault(interest, 0L)) + .toList() + )) .toList(); return new TypeStatsViewModel( inProgressTypeReportCount, interestLabels, - completedTypeReportCounts, + activeCompletedTypeReportCounts, + cumulativeCompletedTypeReportCounts, + recentDates.stream().map(LocalDate::toString).toList(), + completedTypeReportSeries, OffsetDateTime.now(SEOUL).format(FMT) ); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WeeklyStatsService.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WeeklyStatsService.java index 5b67a51a..c0d77222 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WeeklyStatsService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/WeeklyStatsService.java @@ -1,10 +1,12 @@ package com.devkor.ifive.nadab.domain.stats.application; import com.devkor.ifive.nadab.domain.stats.application.helper.PeakStatsTracker; +import com.devkor.ifive.nadab.domain.stats.application.helper.StatsPeriodResolver; import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto; import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakMetric; import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStatViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.weekly.WeeklyStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.weekly.WeeklyPeriodStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.repository.WeeklyStatsRepository; import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; import lombok.RequiredArgsConstructor; @@ -33,15 +35,17 @@ public class WeeklyStatsService { DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private static final DateTimeFormatter WEEK_LABEL_FMT = DateTimeFormatter.ofPattern("MM-dd"); + private static final int WEEKLY_CHART_SIZE = 5; - public WeeklyStatsViewModel getWeeklyStatsLast7Weeks() { + public WeeklyStatsViewModel getWeeklyStats(LocalDate selectedWeek) { LocalDate today = TodayDateTimeProvider.getTodayDate(); LocalDate currentWeekStart = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); - LocalDate startWeekStart = currentWeekStart.minusWeeks(6); - LocalDate endDateInclusive = currentWeekStart.plusDays(6); + LocalDate selectedWeekStart = selectedWeek.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + LocalDate startWeekStart = selectedWeekStart.minusWeeks(WEEKLY_CHART_SIZE - 1L); + LocalDate endDateInclusive = selectedWeekStart.plusDays(6); List weekStarts = new ArrayList<>(); - for (int i = 0; i < 7; i++) { + for (int i = 0; i < WEEKLY_CHART_SIZE; i++) { weekStarts.add(startWeekStart.plusWeeks(i)); } @@ -89,6 +93,16 @@ public WeeklyStatsViewModel getWeeklyStatsLast7Weeks() { ); long inProgressNow = repo.countInProgressWeeklyReportsNow(); + int selectedIndex = weekStarts.size() - 1; + WeeklyPeriodStatsViewModel selectedPeriod = new WeeklyPeriodStatsViewModel( + StatsPeriodResolver.formatIsoWeek(selectedWeekStart), + selectedWeekStart + " ~ " + selectedWeekStart.plusDays(6), + signupCounts.get(selectedIndex), + assignedCounts.get(selectedIndex), + completedDailyCounts.get(selectedIndex), + completedCounts.get(selectedIndex), + wauCounts.get(selectedIndex) + ); return new WeeklyStatsViewModel( labels, @@ -97,6 +111,7 @@ public WeeklyStatsViewModel getWeeklyStatsLast7Weeks() { completedDailyCounts, completedCounts, wauCounts, + selectedPeriod, signupPeak, assignedQuestionPeak, completedDailyReportPeak, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/helper/StatsPeriodResolver.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/helper/StatsPeriodResolver.java new file mode 100644 index 00000000..1aa33def --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/helper/StatsPeriodResolver.java @@ -0,0 +1,102 @@ +package com.devkor.ifive.nadab.domain.stats.application.helper; + +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.BadRequestException; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.format.DateTimeParseException; +import java.time.temporal.IsoFields; +import java.time.temporal.TemporalAdjusters; +import java.util.Locale; + +public final class StatsPeriodResolver { + + private static final DateTimeFormatter ISO_WEEK_FORMATTER = new DateTimeFormatterBuilder() + .appendValue(IsoFields.WEEK_BASED_YEAR, 4) + .appendLiteral("-W") + .appendValue(IsoFields.WEEK_OF_WEEK_BASED_YEAR, 2) + .toFormatter(Locale.ROOT); + + private StatsPeriodResolver() {} + + public static LocalDate resolveDaily(String value) { + return resolveDaily(value, TodayDateTimeProvider.getTodayDate()); + } + + public static LocalDate resolveWeekly(String value) { + return resolveWeekly(value, TodayDateTimeProvider.getTodayDate()); + } + + public static YearMonth resolveMonthly(String value) { + return resolveMonthly(value, TodayDateTimeProvider.getTodayDate()); + } + + public static String formatIsoWeek(LocalDate weekStart) { + return ISO_WEEK_FORMATTER.format( + weekStart.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + ); + } + + static LocalDate resolveDaily(String value, LocalDate today) { + LocalDate selectedDate = isBlank(value) ? today : parseDate(value); + if (selectedDate.isAfter(today)) { + throw invalidPeriod(); + } + return selectedDate; + } + + static LocalDate resolveWeekly(String value, LocalDate today) { + LocalDate currentWeekStart = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + LocalDate selectedWeekStart = isBlank(value) ? currentWeekStart : parseWeekStart(value); + if (selectedWeekStart.isAfter(currentWeekStart)) { + throw invalidPeriod(); + } + return selectedWeekStart; + } + + static YearMonth resolveMonthly(String value, LocalDate today) { + YearMonth currentMonth = YearMonth.from(today); + YearMonth selectedMonth = isBlank(value) ? currentMonth : parseMonth(value); + if (selectedMonth.isAfter(currentMonth)) { + throw invalidPeriod(); + } + return selectedMonth; + } + + private static LocalDate parseDate(String value) { + try { + return LocalDate.parse(value); + } catch (DateTimeParseException e) { + throw invalidPeriod(); + } + } + + private static LocalDate parseWeekStart(String value) { + try { + return LocalDate.parse(value + "-1", DateTimeFormatter.ISO_WEEK_DATE); + } catch (DateTimeParseException e) { + throw invalidPeriod(); + } + } + + private static YearMonth parseMonth(String value) { + try { + return YearMonth.parse(value); + } catch (DateTimeParseException e) { + throw invalidPeriod(); + } + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private static BadRequestException invalidPeriod() { + return new BadRequestException(ErrorCode.VALIDATION_FAILED); + } +} 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 2e5a18ed..2a76748f 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 @@ -6,6 +6,7 @@ import com.devkor.ifive.nadab.domain.stats.application.TypeStatsService; import com.devkor.ifive.nadab.domain.stats.application.WithdrawalStatsService; import com.devkor.ifive.nadab.domain.stats.application.WeeklyStatsService; +import com.devkor.ifive.nadab.domain.stats.application.helper.StatsPeriodResolver; import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DailyStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.monthly.MonthlyStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.total.TotalStatsViewModel; @@ -16,6 +17,10 @@ import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.time.LocalDate; +import java.time.YearMonth; @Controller @RequiredArgsConstructor @@ -29,25 +34,37 @@ public class StatsController { private final WithdrawalStatsService withdrawalStatsService; - @GetMapping("stats/daily") - public String dailyStats(Model model) { - DailyStatsViewModel vm = dailyStatsService.getDailyStatsLast7Days(); + @GetMapping("/stats/daily") + public String dailyStats( + @RequestParam(required = false) String date, + Model model + ) { + LocalDate selectedDate = StatsPeriodResolver.resolveDaily(date); + DailyStatsViewModel vm = dailyStatsService.getDailyStats(selectedDate); model.addAttribute("vm", vm); model.addAttribute("activeTab", "daily"); return "stats/daily"; } @GetMapping("/stats/weekly") - public String weeklyStats(Model model) { - WeeklyStatsViewModel vm = weeklyStatsService.getWeeklyStatsLast7Weeks(); + public String weeklyStats( + @RequestParam(required = false) String week, + Model model + ) { + LocalDate selectedWeekStart = StatsPeriodResolver.resolveWeekly(week); + WeeklyStatsViewModel vm = weeklyStatsService.getWeeklyStats(selectedWeekStart); model.addAttribute("vm", vm); model.addAttribute("activeTab", "weekly"); return "stats/weekly"; } @GetMapping("/stats/monthly") - public String monthlyStats(Model model) { - MonthlyStatsViewModel vm = monthlyStatsService.getMonthlyStatsLast5Months(); + public String monthlyStats( + @RequestParam(required = false) String month, + Model model + ) { + YearMonth selectedMonth = StatsPeriodResolver.resolveMonthly(month); + MonthlyStatsViewModel vm = monthlyStatsService.getMonthlyStats(selectedMonth); model.addAttribute("vm", vm); model.addAttribute("activeTab", "monthly"); return "stats/monthly"; diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyPeriodStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyPeriodStatsViewModel.java new file mode 100644 index 00000000..269f29bf --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyPeriodStatsViewModel.java @@ -0,0 +1,10 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.daily; + +public record DailyPeriodStatsViewModel( + String periodValue, + String periodLabel, + long signupCount, + long assignedQuestionCount, + long dauCount, + long sharedDailyReportCount +) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyStatsViewModel.java index 00776dfc..07f801f2 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyStatsViewModel.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/daily/DailyStatsViewModel.java @@ -9,9 +9,9 @@ public record DailyStatsViewModel( List signupCounts, List assignedQuestionCounts, List completedDailyReportCounts, + DailyPeriodStatsViewModel selectedPeriod, PeakStatViewModel signupPeak, PeakStatViewModel assignedQuestionPeak, PeakStatViewModel dauPeak, - long sharedDailyReportCount, String refreshedAt // "2026-02-27 21:34:12" ) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/monthly/MonthlyPeriodStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/monthly/MonthlyPeriodStatsViewModel.java new file mode 100644 index 00000000..9091d68d --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/monthly/MonthlyPeriodStatsViewModel.java @@ -0,0 +1,13 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.monthly; + +public record MonthlyPeriodStatsViewModel( + String periodValue, + String periodLabel, + long signupCount, + long assignedQuestionCount, + long completedDailyReportCount, + long completedMonthlyReportV1Count, + long completedMonthlyReportV2Count, + long completedMonthlyReportTotalCount, + long mauCount +) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/monthly/MonthlyStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/monthly/MonthlyStatsViewModel.java index 0abca917..fe3e9390 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/monthly/MonthlyStatsViewModel.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/monthly/MonthlyStatsViewModel.java @@ -13,6 +13,7 @@ public record MonthlyStatsViewModel( List completedMonthlyReportV2Counts, List completedMonthlyReportTotalCounts, List mauCounts, + MonthlyPeriodStatsViewModel selectedPeriod, PeakStatViewModel signupPeak, PeakStatViewModel assignedQuestionPeak, PeakStatViewModel completedDailyReportPeak, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportDateInterestCountDto.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportDateInterestCountDto.java new file mode 100644 index 00000000..5d6ee77f --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportDateInterestCountDto.java @@ -0,0 +1,11 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.type; + +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; + +import java.time.LocalDate; + +public record TypeReportDateInterestCountDto( + LocalDate date, + InterestCode interestCode, + long count +) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportInterestCountDto.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportInterestCountDto.java new file mode 100644 index 00000000..81f5eccd --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportInterestCountDto.java @@ -0,0 +1,8 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.type; + +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; + +public record TypeReportInterestCountDto( + InterestCode interestCode, + long count +) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportInterestSeriesViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportInterestSeriesViewModel.java new file mode 100644 index 00000000..3fcf1171 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeReportInterestSeriesViewModel.java @@ -0,0 +1,9 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.type; + +import java.util.List; + +public record TypeReportInterestSeriesViewModel( + String interestCode, + String interestLabel, + List counts +) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeStatsViewModel.java index c2e6042f..89ab86e6 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeStatsViewModel.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/type/TypeStatsViewModel.java @@ -5,6 +5,9 @@ public record TypeStatsViewModel( long inProgressTypeReportCount, List interestLabels, - List completedTypeReportCounts, + List activeCompletedTypeReportCounts, + List cumulativeCompletedTypeReportCounts, + List recentDateLabels, + List completedTypeReportSeries, String refreshedAt ) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/weekly/WeeklyPeriodStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/weekly/WeeklyPeriodStatsViewModel.java new file mode 100644 index 00000000..ecc9951b --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/weekly/WeeklyPeriodStatsViewModel.java @@ -0,0 +1,11 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.weekly; + +public record WeeklyPeriodStatsViewModel( + String periodValue, + String periodLabel, + long signupCount, + long assignedQuestionCount, + long completedDailyReportCount, + long completedWeeklyReportCount, + long wauCount +) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/weekly/WeeklyStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/weekly/WeeklyStatsViewModel.java index 5e79d50f..41a30b52 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/weekly/WeeklyStatsViewModel.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/weekly/WeeklyStatsViewModel.java @@ -11,6 +11,7 @@ public record WeeklyStatsViewModel( List completedDailyReportCounts, List completedWeeklyReportCounts, List wauCounts, + WeeklyPeriodStatsViewModel selectedPeriod, PeakStatViewModel signupPeak, PeakStatViewModel assignedQuestionPeak, PeakStatViewModel completedDailyReportPeak, 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 756a5f35..419011eb 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,7 +1,6 @@ package com.devkor.ifive.nadab.domain.stats.core.repository; 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; import org.springframework.stereotype.Repository; @@ -76,21 +75,15 @@ public List findCompletedDailyReportCountsLast7Days(LocalDate star .getResultList(); } - /** - * 현재 공유 중(isShared=true) daily_reports 개수 - * - 보통 COMPLETED만 공유 의미가 있으니 status도 거는 걸 추천 - */ - public long countSharedDailyReportsNow() { - LocalDate today = TodayDateTimeProvider.getTodayDate(); - + public long countSharedDailyReports(LocalDate date) { return em.createQuery(""" select count(dr.id) from DailyReport dr - where dr.date = :today + where dr.date = :date and dr.isShared = true and dr.status = 'COMPLETED' """, Long.class) - .setParameter("today", today) + .setParameter("date", date) .getSingleResult(); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/TypeStatsRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/TypeStatsRepository.java index 22895953..dc96a2fc 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/TypeStatsRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/TypeStatsRepository.java @@ -1,10 +1,12 @@ package com.devkor.ifive.nadab.domain.stats.core.repository; -import com.devkor.ifive.nadab.domain.stats.core.dto.total.LabelCountDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeReportDateInterestCountDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeReportInterestCountDto; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.time.LocalDate; import java.util.List; @Repository @@ -23,19 +25,53 @@ select count(tr.id) .getSingleResult(); } - public List countCompletedTypeReportsByInterest() { + public List countActiveCompletedTypeReportsByInterest() { return em.createQuery(""" - select new com.devkor.ifive.nadab.domain.stats.core.dto.total.LabelCountDto( - cast(i.code as string), + select new com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeReportInterestCountDto( + tr.interestCode, count(tr.id) ) - from Interest i - left join TypeReport tr on tr.interestCode = i.code - and tr.status = com.devkor.ifive.nadab.domain.typereport.core.entity.TypeReportStatus.COMPLETED - and tr.deletedAt is null - group by i.code - order by i.code - """, LabelCountDto.class) + from TypeReport tr + where tr.status = com.devkor.ifive.nadab.domain.typereport.core.entity.TypeReportStatus.COMPLETED + and tr.deletedAt is null + group by tr.interestCode + order by tr.interestCode + """, TypeReportInterestCountDto.class) + .getResultList(); + } + + public List countCompletedTypeReportHistoryByInterest() { + return em.createQuery(""" + select new com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeReportInterestCountDto( + tr.interestCode, + count(tr.id) + ) + from TypeReport tr + where tr.status = com.devkor.ifive.nadab.domain.typereport.core.entity.TypeReportStatus.COMPLETED + group by tr.interestCode + order by tr.interestCode + """, TypeReportInterestCountDto.class) + .getResultList(); + } + + public List countCompletedTypeReportsByDateAndInterest( + LocalDate startDate, + LocalDate endDateInclusive + ) { + return em.createQuery(""" + select new com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeReportDateInterestCountDto( + tr.date, + tr.interestCode, + count(tr.id) + ) + from TypeReport tr + where tr.status = com.devkor.ifive.nadab.domain.typereport.core.entity.TypeReportStatus.COMPLETED + and tr.date between :startDate and :endDate + group by tr.date, tr.interestCode + order by tr.date, tr.interestCode + """, TypeReportDateInterestCountDto.class) + .setParameter("startDate", startDate) + .setParameter("endDate", endDateInclusive) .getResultList(); } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/typereport/api/TypeReportController.java b/src/main/java/com/devkor/ifive/nadab/domain/typereport/api/TypeReportController.java index b8e226d6..4f93ef11 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/typereport/api/TypeReportController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/typereport/api/TypeReportController.java @@ -43,7 +43,7 @@ public class TypeReportController { - **PREFERENCE** : 취향 - **EMOTION** : 감정 - **ROUTINE** : 루틴 - - **RELATIONSHIP** : 인간관계 + - **RELATIONSHIP** : 관계 - **LOVE** : 사랑 - **VALUES** : 가치관 """, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/user/api/UserController.java b/src/main/java/com/devkor/ifive/nadab/domain/user/api/UserController.java index e80c022d..e2ae9eb2 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/user/api/UserController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/user/api/UserController.java @@ -241,7 +241,7 @@ public ResponseEntity> checkNickname( - **PREFERENCE** : 취향 - **EMOTION** : 감정 - **ROUTINE** : 루틴 - - **RELATIONSHIP** : 인간관계 + - **RELATIONSHIP** : 관계 - **LOVE** : 사랑 - **VALUES** : 가치관 """, 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 f9566376..81d566ed 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 @@ -28,7 +28,7 @@ public String displayNameKo() { case PREFERENCE -> "취향"; case EMOTION -> "감정"; case ROUTINE -> "루틴"; - case RELATIONSHIP -> "인간관계"; + case RELATIONSHIP -> "관계"; case LOVE -> "사랑"; case VALUES -> "가치관"; }; diff --git a/src/main/resources/db/migration/V20260820_1200__IS_add_type_report_stats_index.sql b/src/main/resources/db/migration/V20260820_1200__IS_add_type_report_stats_index.sql new file mode 100644 index 00000000..dcd74e86 --- /dev/null +++ b/src/main/resources/db/migration/V20260820_1200__IS_add_type_report_stats_index.sql @@ -0,0 +1,3 @@ +CREATE INDEX idx_type_reports_completed_date_interest + ON type_reports (date, interest_code) + WHERE status = 'COMPLETED'; diff --git a/src/main/resources/static/css/stats-period.css b/src/main/resources/static/css/stats-period.css new file mode 100644 index 00000000..6c1a2715 --- /dev/null +++ b/src/main/resources/static/css/stats-period.css @@ -0,0 +1,208 @@ +.period-panel { + margin-bottom: 20px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + animation: fadeUp .35s ease both; +} + +.period-panel-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 18px 24px; + padding: 20px 22px; + border-bottom: 1px solid var(--border); +} + +.period-panel-eyebrow { + margin-bottom: 5px; + color: var(--accent); + font-size: .68rem; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; +} + +.period-panel-title { + color: var(--text-1); + font-family: 'DM Mono', monospace; + font-size: 1.25rem; + font-weight: 500; +} + +.period-picker { + display: flex; + align-items: flex-end; + gap: 8px; +} + +.period-picker-field { + display: grid; + gap: 5px; +} + +.period-picker-label { + color: var(--text-2); + font-size: .68rem; + font-weight: 600; +} + +.period-picker-input { + min-height: 36px; + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + outline: none; + background: var(--surface-2); + color: var(--text-1); + color-scheme: dark; + font-family: 'DM Mono', monospace; + font-size: .76rem; + transition: border-color .2s, box-shadow .2s; +} + +.period-picker-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(108, 143, 255, .12); +} + +.period-picker-submit, +.period-picker-current { + display: inline-flex; + min-height: 36px; + align-items: center; + justify-content: center; + padding: 7px 13px; + border-radius: var(--radius-sm); + font-family: 'Syne', sans-serif; + font-size: .74rem; + font-weight: 700; + text-decoration: none; + white-space: nowrap; + cursor: pointer; +} + +.period-picker-submit { + border: 1px solid var(--accent); + background: var(--accent); + color: #fff; +} + +.period-picker-current { + border: 1px solid var(--border); + background: transparent; + color: var(--text-2); + transition: border-color .2s, color .2s, background .2s; +} + +.period-picker-current:hover { + border-color: var(--accent); + background: rgba(108, 143, 255, .08); + color: var(--accent); +} + +.period-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1px; + background: var(--border); +} + +.period-summary-item { + min-width: 0; + padding: 17px 20px 18px; + background: var(--surface); +} + +.period-summary-label { + margin-bottom: 7px; + color: var(--text-2); + font-size: .7rem; + font-weight: 600; + line-height: 1.35; +} + +.period-summary-value { + color: var(--text-1); + font-family: 'DM Mono', monospace; + font-size: 1.55rem; + font-weight: 500; + line-height: 1; +} + +.period-summary-breakdown { + display: flex; + flex-wrap: wrap; + gap: 5px 12px; + margin-top: 8px; + color: var(--text-2); + font-family: 'DM Mono', monospace; + font-size: .66rem; +} + +.period-summary-breakdown strong { + margin-left: 4px; + color: var(--text-1); + font-weight: 500; +} + +@media (max-width: 680px) { + .page-header { + align-items: stretch; + flex-direction: column; + gap: 12px; + } + + .header-right { + justify-content: flex-start; + flex-wrap: wrap; + gap: 8px 12px; + } + + .tabs { + width: 100%; + max-width: 100%; + overflow-x: auto; + scrollbar-width: none; + } + + .tabs::-webkit-scrollbar { + display: none; + } + + .tab-link { + flex: 0 0 auto; + padding: 7px 12px; + font-size: .76rem; + } + + .period-panel-header { + align-items: stretch; + flex-direction: column; + } + + .period-picker { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + } + + .period-picker-input { + width: 100%; + } + + .period-summary-item:last-child:nth-child(odd) { + grid-column: 1 / -1; + } +} + +@media (max-width: 440px) { + .period-picker { + grid-template-columns: 1fr 1fr; + } + + .period-picker-field { + grid-column: 1 / -1; + } +} diff --git a/src/main/resources/templates/stats/daily.html b/src/main/resources/templates/stats/daily.html index db8502b5..e34109c6 100644 --- a/src/main/resources/templates/stats/daily.html +++ b/src/main/resources/templates/stats/daily.html @@ -244,6 +244,7 @@ .chart-card:nth-child(3) { animation-delay: .15s; } + @@ -289,14 +290,6 @@ th:href="@{/stats/total}">전체 - -
-
-
현재 공유 중인 일간 리포트
-
0
-
-
-
@@ -348,6 +341,8 @@
+
+