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 a08cb4ed..435c4b25 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,10 @@ 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.DailyStatsViewModel; 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.repository.DailyStatsRepository; import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; import lombok.RequiredArgsConstructor; @@ -18,6 +21,7 @@ public class DailyStatsService { private final DailyStatsRepository repo; + private final PeakStatsTracker peakStatsTracker; private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); private static final DateTimeFormatter FMT = @@ -57,6 +61,16 @@ public DailyStatsViewModel getDailyStatsLast7Days() { List assignedCounts = days.stream().map(d -> assignedMap.getOrDefault(d, 0L)).toList(); List completedCounts = days.stream().map(d -> completedMap.getOrDefault(d, 0L)).toList(); + PeakStatViewModel signupPeak = peakStatsTracker.updateAndGet( + PeakMetric.DAILY_SIGNUP, days, signupCounts, today + ); + PeakStatViewModel assignedQuestionPeak = peakStatsTracker.updateAndGet( + PeakMetric.DAILY_ASSIGNED_QUESTION, days, assignedCounts, today + ); + PeakStatViewModel dauPeak = peakStatsTracker.updateAndGet( + PeakMetric.DAU, days, completedCounts, today + ); + long sharedNow = repo.countSharedDailyReportsNow(); return new DailyStatsViewModel( @@ -64,8 +78,11 @@ public DailyStatsViewModel getDailyStatsLast7Days() { signupCounts, assignedCounts, completedCounts, + signupPeak, + assignedQuestionPeak, + dauPeak, sharedNow, OffsetDateTime.now(SEOUL).format(FMT) ); } -} \ No newline at end of file +} 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 d8bf1442..c65a392a 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 @@ -1,7 +1,10 @@ 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.DateCountDto; import com.devkor.ifive.nadab.domain.stats.core.dto.monthly.MonthlyStatsViewModel; +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; import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; import lombok.RequiredArgsConstructor; @@ -22,6 +25,7 @@ public class MonthlyStatsService { private final MonthlyStatsRepository repo; + private final PeakStatsTracker peakStatsTracker; private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); private static final DateTimeFormatter FMT = @@ -74,6 +78,24 @@ public MonthlyStatsViewModel getMonthlyStatsLast5Months() { List completedTotalCounts = sumCounts(completedV1Counts, completedV2Counts); List mauCounts = months.stream().map(m -> mauMap.getOrDefault(m, 0L)).toList(); + List monthStarts = months.stream().map(month -> month.atDay(1)).toList(); + LocalDate currentMonthStart = currentMonth.atDay(1); + PeakStatViewModel signupPeak = peakStatsTracker.updateAndGet( + PeakMetric.MONTHLY_SIGNUP, monthStarts, signupCounts, currentMonthStart + ); + PeakStatViewModel assignedQuestionPeak = peakStatsTracker.updateAndGet( + PeakMetric.MONTHLY_ASSIGNED_QUESTION, monthStarts, assignedCounts, currentMonthStart + ); + PeakStatViewModel completedDailyReportPeak = peakStatsTracker.updateAndGet( + PeakMetric.MONTHLY_DAILY_REPORT, monthStarts, completedDailyCounts, currentMonthStart + ); + PeakStatViewModel completedMonthlyReportPeak = peakStatsTracker.updateAndGet( + PeakMetric.MONTHLY_REPORT, monthStarts, completedTotalCounts, currentMonthStart + ); + PeakStatViewModel mauPeak = peakStatsTracker.updateAndGet( + PeakMetric.MAU, monthStarts, mauCounts, currentMonthStart + ); + long inProgressV1Now = repo.countInProgressMonthlyReportV1Now(); long inProgressV2Now = repo.countInProgressMonthlyReportV2Now(); long inProgressTotalNow = inProgressV1Now + inProgressV2Now; @@ -87,6 +109,11 @@ public MonthlyStatsViewModel getMonthlyStatsLast5Months() { completedV2Counts, completedTotalCounts, mauCounts, + signupPeak, + assignedQuestionPeak, + completedDailyReportPeak, + completedMonthlyReportPeak, + mauPeak, inProgressV1Now, inProgressV2Now, inProgressTotalNow, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/PeakStatsInitializer.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/PeakStatsInitializer.java new file mode 100644 index 00000000..855f6811 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/PeakStatsInitializer.java @@ -0,0 +1,29 @@ +package com.devkor.ifive.nadab.domain.stats.application; + +import com.devkor.ifive.nadab.domain.stats.core.repository.PeakStatsRepository; +import com.devkor.ifive.nadab.domain.stats.core.repository.PeakStatsStore; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PeakStatsInitializer implements ApplicationRunner { + + private final PeakStatsRepository peakStatsRepository; + private final PeakStatsStore peakStatsStore; + + @Override + public void run(ApplicationArguments args) { + try { + var peaks = peakStatsRepository.findAllPeakStats(); + peaks.forEach(peakStatsStore::updateIfGreater); + log.info("Peak statistics initialized: {} metrics", peaks.size()); + } catch (RuntimeException e) { + log.error("Failed to initialize peak statistics", e); + } + } +} 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 f1594379..5b67a51a 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,6 +1,9 @@ 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.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.repository.WeeklyStatsRepository; import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; @@ -23,6 +26,7 @@ public class WeeklyStatsService { private final WeeklyStatsRepository repo; + private final PeakStatsTracker peakStatsTracker; private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); private static final DateTimeFormatter FMT = @@ -68,6 +72,22 @@ public WeeklyStatsViewModel getWeeklyStatsLast7Weeks() { List completedCounts = weekStarts.stream().map(d -> completedMap.getOrDefault(d, 0L)).toList(); List wauCounts = weekStarts.stream().map(d -> wauMap.getOrDefault(d, 0L)).toList(); + PeakStatViewModel signupPeak = peakStatsTracker.updateAndGet( + PeakMetric.WEEKLY_SIGNUP, weekStarts, signupCounts, currentWeekStart + ); + PeakStatViewModel assignedQuestionPeak = peakStatsTracker.updateAndGet( + PeakMetric.WEEKLY_ASSIGNED_QUESTION, weekStarts, assignedCounts, currentWeekStart + ); + PeakStatViewModel completedDailyReportPeak = peakStatsTracker.updateAndGet( + PeakMetric.WEEKLY_DAILY_REPORT, weekStarts, completedDailyCounts, currentWeekStart + ); + PeakStatViewModel completedWeeklyReportPeak = peakStatsTracker.updateAndGet( + PeakMetric.WEEKLY_REPORT, weekStarts, completedCounts, currentWeekStart + ); + PeakStatViewModel wauPeak = peakStatsTracker.updateAndGet( + PeakMetric.WAU, weekStarts, wauCounts, currentWeekStart + ); + long inProgressNow = repo.countInProgressWeeklyReportsNow(); return new WeeklyStatsViewModel( @@ -77,6 +97,11 @@ public WeeklyStatsViewModel getWeeklyStatsLast7Weeks() { completedDailyCounts, completedCounts, wauCounts, + signupPeak, + assignedQuestionPeak, + completedDailyReportPeak, + completedWeeklyReportPeak, + wauPeak, inProgressNow, OffsetDateTime.now(SEOUL).format(FMT) ); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/helper/PeakStatsTracker.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/helper/PeakStatsTracker.java new file mode 100644 index 00000000..6433c713 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/helper/PeakStatsTracker.java @@ -0,0 +1,58 @@ +package com.devkor.ifive.nadab.domain.stats.application.helper; + +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakMetric; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStat; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStatViewModel; +import com.devkor.ifive.nadab.domain.stats.core.repository.PeakStatsStore; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class PeakStatsTracker { + + private final PeakStatsStore peakStatsStore; + + public PeakStatViewModel updateAndGet( + PeakMetric metric, + List periodStarts, + List values, + LocalDate currentPeriodStart + ) { + if (periodStarts.size() != values.size()) { + throw new IllegalArgumentException("periodStarts and values must have the same size"); + } + + PeakStat candidate = findPeakCandidate(periodStarts, values); + if (candidate != null) { + peakStatsStore.updateIfGreater(metric, candidate); + } + + return peakStatsStore.find(metric) + .map(peak -> PeakStatViewModel.from(peak, metric.periodUnit(), currentPeriodStart)) + .orElseGet(PeakStatViewModel::empty); + } + + private PeakStat findPeakCandidate(List periodStarts, List values) { + PeakStat candidate = null; + + for (int i = 0; i < periodStarts.size(); i++) { + long value = values.get(i); + LocalDate periodStart = periodStarts.get(i); + + if (value <= 0) { + continue; + } + if (candidate == null + || value > candidate.value() + || value == candidate.value() && periodStart.isAfter(candidate.periodStart())) { + candidate = new PeakStat(value, periodStart); + } + } + + return candidate; + } +} 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 80c6f032..00776dfc 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 @@ -1,5 +1,7 @@ package com.devkor.ifive.nadab.domain.stats.core.dto.daily; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStatViewModel; + import java.util.List; public record DailyStatsViewModel( @@ -7,6 +9,9 @@ public record DailyStatsViewModel( List signupCounts, List assignedQuestionCounts, List completedDailyReportCounts, + 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/MonthlyStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/monthly/MonthlyStatsViewModel.java index 9b4b3b20..0abca917 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 @@ -1,5 +1,7 @@ package com.devkor.ifive.nadab.domain.stats.core.dto.monthly; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStatViewModel; + import java.util.List; public record MonthlyStatsViewModel( @@ -11,6 +13,11 @@ public record MonthlyStatsViewModel( List completedMonthlyReportV2Counts, List completedMonthlyReportTotalCounts, List mauCounts, + PeakStatViewModel signupPeak, + PeakStatViewModel assignedQuestionPeak, + PeakStatViewModel completedDailyReportPeak, + PeakStatViewModel completedMonthlyReportPeak, + PeakStatViewModel mauPeak, long inProgressMonthlyReportV1Count, long inProgressMonthlyReportV2Count, long inProgressMonthlyReportTotalCount, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/peak/PeakMetric.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/peak/PeakMetric.java new file mode 100644 index 00000000..706491f6 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/peak/PeakMetric.java @@ -0,0 +1,27 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.peak; + +public enum PeakMetric { + DAILY_SIGNUP(PeakPeriodUnit.DAY), + DAILY_ASSIGNED_QUESTION(PeakPeriodUnit.DAY), + DAU(PeakPeriodUnit.DAY), + WEEKLY_SIGNUP(PeakPeriodUnit.WEEK), + WEEKLY_ASSIGNED_QUESTION(PeakPeriodUnit.WEEK), + WEEKLY_DAILY_REPORT(PeakPeriodUnit.WEEK), + WEEKLY_REPORT(PeakPeriodUnit.WEEK), + WAU(PeakPeriodUnit.WEEK), + MONTHLY_SIGNUP(PeakPeriodUnit.MONTH), + MONTHLY_ASSIGNED_QUESTION(PeakPeriodUnit.MONTH), + MONTHLY_DAILY_REPORT(PeakPeriodUnit.MONTH), + MONTHLY_REPORT(PeakPeriodUnit.MONTH), + MAU(PeakPeriodUnit.MONTH); + + private final PeakPeriodUnit periodUnit; + + PeakMetric(PeakPeriodUnit periodUnit) { + this.periodUnit = periodUnit; + } + + public PeakPeriodUnit periodUnit() { + return periodUnit; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/peak/PeakPeriodUnit.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/peak/PeakPeriodUnit.java new file mode 100644 index 00000000..8fc997b0 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/peak/PeakPeriodUnit.java @@ -0,0 +1,7 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.peak; + +public enum PeakPeriodUnit { + DAY, + WEEK, + MONTH +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/peak/PeakStat.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/peak/PeakStat.java new file mode 100644 index 00000000..7cd4b428 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/peak/PeakStat.java @@ -0,0 +1,17 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.peak; + +import java.time.LocalDate; +import java.util.Objects; + +public record PeakStat( + long value, + LocalDate periodStart +) { + + public PeakStat { + if (value < 0) { + throw new IllegalArgumentException("Peak value must not be negative"); + } + Objects.requireNonNull(periodStart, "periodStart must not be null"); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/peak/PeakStatViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/peak/PeakStatViewModel.java new file mode 100644 index 00000000..006b8e64 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/peak/PeakStatViewModel.java @@ -0,0 +1,41 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.peak; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +public record PeakStatViewModel( + boolean available, + long value, + String periodStart, + String periodLabel, + boolean currentPeriod +) { + + private static final DateTimeFormatter MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM"); + + public static PeakStatViewModel from( + PeakStat peak, + PeakPeriodUnit periodUnit, + LocalDate currentPeriodStart + ) { + return new PeakStatViewModel( + true, + peak.value(), + peak.periodStart().toString(), + formatPeriodLabel(peak.periodStart(), periodUnit), + peak.periodStart().equals(currentPeriodStart) + ); + } + + public static PeakStatViewModel empty() { + return new PeakStatViewModel(false, 0L, "", "", false); + } + + private static String formatPeriodLabel(LocalDate periodStart, PeakPeriodUnit periodUnit) { + return switch (periodUnit) { + case DAY -> periodStart.toString(); + case WEEK -> periodStart + " ~ " + periodStart.plusDays(6); + case MONTH -> MONTH_FORMATTER.format(periodStart); + }; + } +} 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 d58a6851..5e79d50f 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 @@ -1,5 +1,7 @@ package com.devkor.ifive.nadab.domain.stats.core.dto.weekly; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStatViewModel; + import java.util.List; public record WeeklyStatsViewModel( @@ -9,6 +11,11 @@ public record WeeklyStatsViewModel( List completedDailyReportCounts, List completedWeeklyReportCounts, List wauCounts, + PeakStatViewModel signupPeak, + PeakStatViewModel assignedQuestionPeak, + PeakStatViewModel completedDailyReportPeak, + PeakStatViewModel completedWeeklyReportPeak, + PeakStatViewModel wauPeak, long inProgressWeeklyReportCount, String refreshedAt ) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/PeakStatsRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/PeakStatsRepository.java new file mode 100644 index 00000000..aef9e4b9 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/PeakStatsRepository.java @@ -0,0 +1,154 @@ +package com.devkor.ifive.nadab.domain.stats.core.repository; + +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakMetric; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStat; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.sql.Date; +import java.time.LocalDate; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +@Repository +@RequiredArgsConstructor +public class PeakStatsRepository { + + private static final String FIND_ALL_PEAKS_QUERY = """ + with signup_daily as ( + select cast(u.registered_at at time zone 'Asia/Seoul' as date) as period_start, + count(*) as value + from users u + where u.signup_status = 'COMPLETED' + and u.registered_at is not null + and cast(u.registered_at at time zone 'Asia/Seoul' as date) + <= cast(current_timestamp at time zone 'Asia/Seoul' as date) + group by cast(u.registered_at at time zone 'Asia/Seoul' as date) + ), + assigned_daily as ( + select udq.date as period_start, + count(*) as value + from user_daily_questions udq + where udq.date <= cast(current_timestamp at time zone 'Asia/Seoul' as date) + group by udq.date + ), + completed_daily_reports as ( + select dr.date as report_date, + ae.user_id + from daily_reports dr + join answer_entries ae on ae.id = dr.answer_entry_id + where dr.status = 'COMPLETED' + and dr.date <= cast(current_timestamp at time zone 'Asia/Seoul' as date) + ), + completed_monthly_reports as ( + select cast(date_trunc('month', mr.date) as date) as period_start, + count(*) as value + from monthly_reports mr + where mr.status = 'COMPLETED' + and mr.date <= cast(current_timestamp at time zone 'Asia/Seoul' as date) + group by cast(date_trunc('month', mr.date) as date) + union all + select cast(date_trunc('month', mr.date) as date) as period_start, + count(*) as value + from monthly_reports_v2 mr + where mr.status = 'COMPLETED' + and mr.date <= cast(current_timestamp at time zone 'Asia/Seoul' as date) + group by cast(date_trunc('month', mr.date) as date) + ), + metric_counts as ( + select 'DAILY_SIGNUP' as metric, period_start, value + from signup_daily + union all + select 'DAILY_ASSIGNED_QUESTION', period_start, value + from assigned_daily + union all + select 'DAU', report_date, count(distinct user_id) + from completed_daily_reports + group by report_date + union all + select 'WEEKLY_SIGNUP', cast(date_trunc('week', period_start) as date), sum(value) + from signup_daily + group by cast(date_trunc('week', period_start) as date) + union all + select 'WEEKLY_ASSIGNED_QUESTION', cast(date_trunc('week', period_start) as date), sum(value) + from assigned_daily + group by cast(date_trunc('week', period_start) as date) + union all + select 'WEEKLY_DAILY_REPORT', cast(date_trunc('week', report_date) as date), count(*) + from completed_daily_reports + group by cast(date_trunc('week', report_date) as date) + union all + select 'WEEKLY_REPORT', cast(date_trunc('week', wr.date) as date), count(*) + from weekly_reports wr + where wr.status = 'COMPLETED' + and wr.date <= cast(current_timestamp at time zone 'Asia/Seoul' as date) + group by cast(date_trunc('week', wr.date) as date) + union all + select 'WAU', cast(date_trunc('week', report_date) as date), count(distinct user_id) + from completed_daily_reports + group by cast(date_trunc('week', report_date) as date) + union all + select 'MONTHLY_SIGNUP', cast(date_trunc('month', period_start) as date), sum(value) + from signup_daily + group by cast(date_trunc('month', period_start) as date) + union all + select 'MONTHLY_ASSIGNED_QUESTION', cast(date_trunc('month', period_start) as date), sum(value) + from assigned_daily + group by cast(date_trunc('month', period_start) as date) + union all + select 'MONTHLY_DAILY_REPORT', cast(date_trunc('month', report_date) as date), count(*) + from completed_daily_reports + group by cast(date_trunc('month', report_date) as date) + union all + select 'MONTHLY_REPORT', period_start, sum(value) + from completed_monthly_reports + group by period_start + union all + select 'MAU', cast(date_trunc('month', report_date) as date), count(distinct user_id) + from completed_daily_reports + group by cast(date_trunc('month', report_date) as date) + ), + ranked as ( + select metric, + period_start, + value, + row_number() over ( + partition by metric + order by value desc, period_start desc + ) as ranking + from metric_counts + where value > 0 + ) + select metric, period_start, value + from ranked + where ranking = 1 + order by metric + """; + + private final EntityManager em; + + public Map findAllPeakStats() { + List rows = em.createNativeQuery(FIND_ALL_PEAKS_QUERY).getResultList(); + EnumMap peaks = new EnumMap<>(PeakMetric.class); + + for (Object rawRow : rows) { + Object[] row = (Object[]) rawRow; + PeakMetric metric = PeakMetric.valueOf(row[0].toString()); + peaks.put(metric, new PeakStat(((Number) row[2]).longValue(), toLocalDate(row[1]))); + } + + return Map.copyOf(peaks); + } + + private LocalDate toLocalDate(Object value) { + if (value instanceof LocalDate localDate) { + return localDate; + } + if (value instanceof Date date) { + return date.toLocalDate(); + } + return LocalDate.parse(value.toString()); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/PeakStatsStore.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/PeakStatsStore.java new file mode 100644 index 00000000..25cec17c --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/PeakStatsStore.java @@ -0,0 +1,17 @@ +package com.devkor.ifive.nadab.domain.stats.core.repository; + +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakMetric; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStat; + +import java.util.Optional; + +public interface PeakStatsStore { + + Optional find(PeakMetric metric); + + /** + * Stores a positive candidate when it is greater than the current record. + * When values are equal, the more recent period is retained. + */ + void updateIfGreater(PeakMetric metric, PeakStat candidate); +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/infra/InMemoryPeakStatsStore.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/infra/InMemoryPeakStatsStore.java new file mode 100644 index 00000000..ba784e83 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/infra/InMemoryPeakStatsStore.java @@ -0,0 +1,43 @@ +package com.devkor.ifive.nadab.domain.stats.infra; + +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakMetric; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStat; +import com.devkor.ifive.nadab.domain.stats.core.repository.PeakStatsStore; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import static java.util.Objects.requireNonNull; + +@Component +public class InMemoryPeakStatsStore implements PeakStatsStore { + + private final ConcurrentMap peaks = new ConcurrentHashMap<>(); + + @Override + public Optional find(PeakMetric metric) { + return Optional.ofNullable(peaks.get(requireNonNull(metric, "metric must not be null"))); + } + + @Override + public void updateIfGreater(PeakMetric metric, PeakStat candidate) { + requireNonNull(metric, "metric must not be null"); + requireNonNull(candidate, "candidate must not be null"); + + if (candidate.value() == 0) { + return; + } + + peaks.compute(metric, (key, current) -> shouldReplace(current, candidate) ? candidate : current); + } + + private boolean shouldReplace(PeakStat current, PeakStat candidate) { + if (current == null || candidate.value() > current.value()) { + return true; + } + return candidate.value() == current.value() + && candidate.periodStart().isAfter(current.periodStart()); + } +} diff --git a/src/main/resources/static/css/stats-peak.css b/src/main/resources/static/css/stats-peak.css new file mode 100644 index 00000000..2bd5caf1 --- /dev/null +++ b/src/main/resources/static/css/stats-peak.css @@ -0,0 +1,110 @@ +.chart-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + flex-wrap: wrap; + gap: 14px 20px; +} + +.chart-card-heading { + flex: 1 1 220px; + min-width: 0; +} + +.peak-record { + flex: 0 0 auto; + min-width: 220px; + min-height: 69px; + padding: 9px 11px 10px; + border: 1px solid rgba(245, 165, 36, .32); + border-radius: 10px; + background: linear-gradient(135deg, rgba(245, 165, 36, .11), rgba(245, 165, 36, .035)); + font-family: 'DM Mono', monospace; +} + +.peak-record-label-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 5px; +} + +.peak-record-label { + display: inline-flex; + align-items: center; + gap: 6px; + color: #f5a524; + font-family: 'Syne', sans-serif; + font-size: .66rem; + font-weight: 700; + letter-spacing: .06em; +} + +.peak-record-label::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 2px; + background: #f5a524; + transform: rotate(45deg); + box-shadow: 0 0 7px rgba(245, 165, 36, .45); +} + +.peak-record-current { + padding: 2px 6px; + border: 1px solid rgba(77, 232, 194, .28); + border-radius: 999px; + background: rgba(77, 232, 194, .08); + color: var(--accent-3); + font-family: 'Syne', sans-serif; + font-size: .58rem; + font-weight: 700; + white-space: nowrap; +} + +.peak-record-detail { + display: block; +} + +.peak-record-value { + color: var(--text-1); + font-size: 1.16rem; + font-weight: 500; + line-height: 1.1; +} + +.peak-record-period { + display: block; + margin-top: 3px; + color: var(--text-2); + font-size: .66rem; + line-height: 1.35; + white-space: nowrap; +} + +.peak-record-no-data { + color: var(--text-3); + font-size: .72rem; +} + +.peak-record-empty { + border-color: var(--border); + background: rgba(255, 255, 255, .015); +} + +.peak-record-empty .peak-record-label { + color: var(--text-3); +} + +.peak-record-empty .peak-record-label::before { + background: var(--text-3); + box-shadow: none; +} + +@media (max-width: 560px) { + .peak-record { + width: 100%; + min-width: 0; + } +} diff --git a/src/main/resources/templates/stats/daily.html b/src/main/resources/templates/stats/daily.html index 391d3dd8..db8502b5 100644 --- a/src/main/resources/templates/stats/daily.html +++ b/src/main/resources/templates/stats/daily.html @@ -243,6 +243,7 @@ .chart-card:nth-child(2) { animation-delay: .1s; } .chart-card:nth-child(3) { animation-delay: .15s; } + @@ -301,10 +302,13 @@
-
- 일간 가입자 수 +
+
+ 일간 가입자 수 +
+
최근 7일
-
최근 7일
+
@@ -314,10 +318,13 @@
-
- 할당된 질문 개수 +
+
+ 할당된 질문 개수 +
+
최근 7일 · 홈 화면 진입 시 할당
-
최근 7일 · 홈 화면 진입 시 할당
+
@@ -327,10 +334,13 @@
-
- 생성된 일간 리포트 개수 (DAU) +
+
+ 생성된 일간 리포트 개수 (DAU) +
+
최근 7일
-
최근 7일
+
diff --git a/src/main/resources/templates/stats/fragments/peak-record.html b/src/main/resources/templates/stats/fragments/peak-record.html new file mode 100644 index 00000000..6f59eb16 --- /dev/null +++ b/src/main/resources/templates/stats/fragments/peak-record.html @@ -0,0 +1,22 @@ + + + +
+
+ 역대 최고 + 현재 기간 +
+
+ 0 + 2026-08-13 +
+
기록 없음
+
+ + diff --git a/src/main/resources/templates/stats/monthly.html b/src/main/resources/templates/stats/monthly.html index a9b9a05a..c7b473ea 100644 --- a/src/main/resources/templates/stats/monthly.html +++ b/src/main/resources/templates/stats/monthly.html @@ -234,6 +234,7 @@ .dot-teal { background: var(--accent-3); } .dot-orange { background: #f5a524; } + @@ -292,8 +293,11 @@
-
월간 가입자 수
-
최근 5개월
+
+
월간 가입자 수
+
최근 5개월
+
+
@@ -302,8 +306,11 @@
-
월간 할당 질문 수
-
최근 5개월
+
+
월간 할당 질문 수
+
최근 5개월
+
+
@@ -312,8 +319,11 @@
-
생성된 일간 리포트 수
-
최근 5개월
+
+
생성된 일간 리포트 수
+
최근 5개월
+
+
@@ -322,8 +332,11 @@
-
생성된 월간 리포트 수
-
최근 5개월
+
+
생성된 월간 리포트 수
+
최근 5개월
+
+
@@ -331,8 +344,11 @@
-
MAU
-
활성 사용자 기준 : 일간 리포트를 작성한 고유 사용자 수
+
+
MAU
+
활성 사용자 기준 : 일간 리포트를 작성한 고유 사용자 수
+
+
diff --git a/src/main/resources/templates/stats/weekly.html b/src/main/resources/templates/stats/weekly.html index 7ebc1192..8554e08e 100644 --- a/src/main/resources/templates/stats/weekly.html +++ b/src/main/resources/templates/stats/weekly.html @@ -221,6 +221,7 @@ .dot-teal { background: var(--accent-3); } .dot-orange { background: #f5a524; } + @@ -275,8 +276,11 @@
-
주간 가입자 수
-
최근 5주
+
+
주간 가입자 수
+
최근 5주
+
+
@@ -285,8 +289,11 @@
-
주간 할당 질문 수
-
최근 5주
+
+
주간 할당 질문 수
+
최근 5주
+
+
@@ -295,8 +302,11 @@
-
생성된 일간 리포트 수
-
최근 5주
+
+
생성된 일간 리포트 수
+
최근 5주
+
+
@@ -305,8 +315,11 @@
-
생성된 주간 리포트 수
-
최근 5주
+
+
생성된 주간 리포트 수
+
최근 5주
+
+
@@ -314,8 +327,11 @@
-
WAU
-
활성 사용자 기준 : 일간 리포트를 작성한 고유 사용자 수
+
+
WAU
+
활성 사용자 기준 : 일간 리포트를 작성한 고유 사용자 수
+
+
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/stats/application/DailyStatsServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/DailyStatsServiceTest.java new file mode 100644 index 00000000..09f7b077 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/DailyStatsServiceTest.java @@ -0,0 +1,47 @@ +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.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.domain.stats.infra.InMemoryPeakStatsStore; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; +import org.junit.jupiter.api.Test; + +import java.sql.Date; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DailyStatsServiceTest { + + @Test + void getDailyStats_updates_daily_metric_peaks_from_chart_data() { + DailyStatsRepository repo = mock(DailyStatsRepository.class); + DailyStatsService service = new DailyStatsService( + repo, + new PeakStatsTracker(new InMemoryPeakStatsStore()) + ); + LocalDate today = TodayDateTimeProvider.getTodayDate(); + LocalDate twoDaysAgo = today.minusDays(2); + + when(repo.findSignupCountsLast7Days(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of(new Object[]{Date.valueOf(twoDaysAgo), 5L})); + when(repo.findAssignedQuestionCountsLast7Days(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of(new DateCountDto(today.minusDays(1), 7L))); + when(repo.findCompletedDailyReportCountsLast7Days(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of(new DateCountDto(today, 9L))); + + DailyStatsViewModel vm = service.getDailyStatsLast7Days(); + + assertThat(vm.signupPeak().value()).isEqualTo(5L); + assertThat(vm.signupPeak().periodStart()).isEqualTo(twoDaysAgo.toString()); + assertThat(vm.assignedQuestionPeak().value()).isEqualTo(7L); + assertThat(vm.dauPeak().value()).isEqualTo(9L); + assertThat(vm.dauPeak().currentPeriod()).isTrue(); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/stats/application/MonthlyStatsServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/MonthlyStatsServiceTest.java index 5436d33f..98a67b52 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/stats/application/MonthlyStatsServiceTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/MonthlyStatsServiceTest.java @@ -1,8 +1,10 @@ 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.DateCountDto; import com.devkor.ifive.nadab.domain.stats.core.dto.monthly.MonthlyStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.repository.MonthlyStatsRepository; +import com.devkor.ifive.nadab.domain.stats.infra.InMemoryPeakStatsStore; import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; import org.junit.jupiter.api.Test; @@ -21,7 +23,10 @@ class MonthlyStatsServiceTest { void getMonthlyStats_combines_v1_and_v2_monthly_report_counts() { // given MonthlyStatsRepository repo = mock(MonthlyStatsRepository.class); - MonthlyStatsService service = new MonthlyStatsService(repo); + MonthlyStatsService service = new MonthlyStatsService( + repo, + new PeakStatsTracker(new InMemoryPeakStatsStore()) + ); YearMonth currentMonth = YearMonth.from(TodayDateTimeProvider.getTodayDate()); YearMonth previousMonth = currentMonth.minusMonths(1); @@ -54,6 +59,10 @@ void getMonthlyStats_combines_v1_and_v2_monthly_report_counts() { assertThat(vm.completedMonthlyReportV1Counts()).containsExactly(0L, 0L, 0L, 2L, 3L); assertThat(vm.completedMonthlyReportV2Counts()).containsExactly(0L, 0L, 0L, 5L, 7L); assertThat(vm.completedMonthlyReportTotalCounts()).containsExactly(0L, 0L, 0L, 7L, 10L); + assertThat(vm.completedMonthlyReportPeak().value()).isEqualTo(10L); + assertThat(vm.completedMonthlyReportPeak().periodLabel()).isEqualTo(currentMonth.toString()); + assertThat(vm.completedMonthlyReportPeak().currentPeriod()).isTrue(); + assertThat(vm.signupPeak().available()).isFalse(); assertThat(vm.inProgressMonthlyReportV1Count()).isEqualTo(1L); assertThat(vm.inProgressMonthlyReportV2Count()).isEqualTo(4L); assertThat(vm.inProgressMonthlyReportTotalCount()).isEqualTo(5L); diff --git a/src/test/java/com/devkor/ifive/nadab/domain/stats/application/PeakStatsInitializerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/PeakStatsInitializerTest.java new file mode 100644 index 00000000..962a72fb --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/PeakStatsInitializerTest.java @@ -0,0 +1,47 @@ +package com.devkor.ifive.nadab.domain.stats.application; + +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakMetric; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStat; +import com.devkor.ifive.nadab.domain.stats.core.repository.PeakStatsRepository; +import com.devkor.ifive.nadab.domain.stats.core.repository.PeakStatsStore; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class PeakStatsInitializerTest { + + private final PeakStatsRepository repository = mock(PeakStatsRepository.class); + private final PeakStatsStore store = mock(PeakStatsStore.class); + private final PeakStatsInitializer initializer = new PeakStatsInitializer(repository, store); + + @Test + void run_initializes_all_aggregated_peak_statistics() { + PeakStat dau = new PeakStat(10L, LocalDate.of(2026, 8, 1)); + PeakStat wau = new PeakStat(20L, LocalDate.of(2026, 7, 27)); + when(repository.findAllPeakStats()).thenReturn(Map.of( + PeakMetric.DAU, dau, + PeakMetric.WAU, wau + )); + + initializer.run(null); + + verify(store).updateIfGreater(PeakMetric.DAU, dau); + verify(store).updateIfGreater(PeakMetric.WAU, wau); + } + + @Test + void run_does_not_fail_application_when_initial_aggregation_fails() { + when(repository.findAllPeakStats()).thenThrow(new IllegalStateException("database unavailable")); + + assertThatCode(() -> initializer.run(null)).doesNotThrowAnyException(); + + verifyNoInteractions(store); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/stats/application/WeeklyStatsServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/WeeklyStatsServiceTest.java new file mode 100644 index 00000000..a441f3b8 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/WeeklyStatsServiceTest.java @@ -0,0 +1,58 @@ +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.DateCountDto; +import com.devkor.ifive.nadab.domain.stats.core.dto.weekly.WeeklyStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.repository.WeeklyStatsRepository; +import com.devkor.ifive.nadab.domain.stats.infra.InMemoryPeakStatsStore; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; +import org.junit.jupiter.api.Test; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.temporal.TemporalAdjusters; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class WeeklyStatsServiceTest { + + @Test + void getWeeklyStats_updates_weekly_metric_peaks_from_aggregated_chart_data() { + WeeklyStatsRepository repo = mock(WeeklyStatsRepository.class); + WeeklyStatsService service = new WeeklyStatsService( + repo, + new PeakStatsTracker(new InMemoryPeakStatsStore()) + ); + LocalDate currentWeekStart = TodayDateTimeProvider.getTodayDate() + .with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + LocalDate previousWeekStart = currentWeekStart.minusWeeks(1); + + when(repo.findSignupCountsByDateBetween(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of(new DateCountDto(previousWeekStart.plusDays(1), 4L))); + when(repo.findAssignedQuestionCountsByDateBetween(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of(new DateCountDto(currentWeekStart.plusDays(1), 6L))); + when(repo.findCompletedDailyReportCountsByDateBetween(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of( + new DateCountDto(previousWeekStart, 5L), + new DateCountDto(previousWeekStart.plusDays(1), 7L) + )); + when(repo.findCompletedWeeklyReportCountsByDateBetween(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of(new DateCountDto(currentWeekStart, 3L))); + when(repo.findWeeklyActiveUserCountsByDateBetween(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of(new DateCountDto(currentWeekStart, 8L))); + + WeeklyStatsViewModel vm = service.getWeeklyStatsLast7Weeks(); + + assertThat(vm.signupPeak().value()).isEqualTo(4L); + assertThat(vm.assignedQuestionPeak().value()).isEqualTo(6L); + assertThat(vm.completedDailyReportPeak().value()).isEqualTo(12L); + assertThat(vm.completedDailyReportPeak().periodStart()).isEqualTo(previousWeekStart.toString()); + assertThat(vm.completedWeeklyReportPeak().value()).isEqualTo(3L); + assertThat(vm.wauPeak().value()).isEqualTo(8L); + assertThat(vm.wauPeak().currentPeriod()).isTrue(); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/stats/application/helper/PeakStatsTrackerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/helper/PeakStatsTrackerTest.java new file mode 100644 index 00000000..28416e96 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/helper/PeakStatsTrackerTest.java @@ -0,0 +1,96 @@ +package com.devkor.ifive.nadab.domain.stats.application.helper; + +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakMetric; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStat; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStatViewModel; +import com.devkor.ifive.nadab.domain.stats.infra.InMemoryPeakStatsStore; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PeakStatsTrackerTest { + + private final InMemoryPeakStatsStore store = new InMemoryPeakStatsStore(); + private final PeakStatsTracker tracker = new PeakStatsTracker(store); + + @Test + void updateAndGet_updates_peak_with_latest_period_when_values_are_equal() { + LocalDate currentPeriodStart = LocalDate.of(2026, 8, 3); + + PeakStatViewModel peak = tracker.updateAndGet( + PeakMetric.DAU, + List.of(currentPeriodStart.minusDays(2), currentPeriodStart.minusDays(1), currentPeriodStart), + List.of(4L, 9L, 9L), + currentPeriodStart + ); + + assertThat(store.find(PeakMetric.DAU)) + .contains(new PeakStat(9L, currentPeriodStart)); + assertThat(peak).isEqualTo(new PeakStatViewModel( + true, 9L, "2026-08-03", "2026-08-03", true + )); + } + + @Test + void updateAndGet_keeps_greater_historical_peak() { + LocalDate historicalPeriod = LocalDate.of(2026, 7, 1); + store.updateIfGreater(PeakMetric.DAU, new PeakStat(20L, historicalPeriod)); + + PeakStatViewModel peak = tracker.updateAndGet( + PeakMetric.DAU, + List.of(LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 2)), + List.of(8L, 10L), + LocalDate.of(2026, 8, 2) + ); + + assertThat(peak.value()).isEqualTo(20L); + assertThat(peak.periodStart()).isEqualTo("2026-07-01"); + assertThat(peak.currentPeriod()).isFalse(); + } + + @Test + void updateAndGet_formats_week_and_month_periods() { + PeakStatViewModel weeklyPeak = tracker.updateAndGet( + PeakMetric.WAU, + List.of(LocalDate.of(2026, 8, 10)), + List.of(10L), + LocalDate.of(2026, 8, 10) + ); + PeakStatViewModel monthlyPeak = tracker.updateAndGet( + PeakMetric.MAU, + List.of(LocalDate.of(2026, 8, 1)), + List.of(20L), + LocalDate.of(2026, 8, 1) + ); + + assertThat(weeklyPeak.periodLabel()).isEqualTo("2026-08-10 ~ 2026-08-16"); + assertThat(monthlyPeak.periodLabel()).isEqualTo("2026-08"); + } + + @Test + void updateAndGet_returns_empty_view_model_when_all_values_are_zero() { + PeakStatViewModel peak = tracker.updateAndGet( + PeakMetric.DAILY_SIGNUP, + List.of(LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 2)), + List.of(0L, 0L), + LocalDate.of(2026, 8, 2) + ); + + assertThat(peak).isEqualTo(PeakStatViewModel.empty()); + assertThat(store.find(PeakMetric.DAILY_SIGNUP)).isEmpty(); + } + + @Test + void updateAndGet_rejects_different_period_and_value_sizes() { + assertThatThrownBy(() -> tracker.updateAndGet( + PeakMetric.DAU, + List.of(LocalDate.of(2026, 8, 1)), + List.of(1L, 2L), + LocalDate.of(2026, 8, 1) + )).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/stats/controller/StatsControllerTemplateTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/controller/StatsControllerTemplateTest.java new file mode 100644 index 00000000..a6af4f84 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/controller/StatsControllerTemplateTest.java @@ -0,0 +1,144 @@ +package com.devkor.ifive.nadab.domain.stats.controller; + +import com.devkor.ifive.nadab.domain.admin.infra.security.AdminPageAuthInterceptor; +import com.devkor.ifive.nadab.domain.stats.application.DailyStatsService; +import com.devkor.ifive.nadab.domain.stats.application.MonthlyStatsService; +import com.devkor.ifive.nadab.domain.stats.application.TotalStatsService; +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.core.dto.daily.DailyStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.monthly.MonthlyStatsViewModel; +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.global.security.filter.JwtAuthenticationFilter; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.hamcrest.Matchers.containsString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(StatsController.class) +@AutoConfigureMockMvc(addFilters = false) +class StatsControllerTemplateTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private DailyStatsService dailyStatsService; + @MockitoBean + private WeeklyStatsService weeklyStatsService; + @MockitoBean + private MonthlyStatsService monthlyStatsService; + @MockitoBean + private TotalStatsService totalStatsService; + @MockitoBean + private TypeStatsService typeStatsService; + @MockitoBean + private WithdrawalStatsService withdrawalStatsService; + @MockitoBean + private AdminPageAuthInterceptor adminPageAuthInterceptor; + @MockitoBean + private JwtAuthenticationFilter jwtAuthenticationFilter; + + @Test + void dailyStats_renders_peak_value_period_current_badge_and_empty_state() throws Exception { + PeakStatViewModel currentPeak = peak(1_234L, "2026-08-13", true); + PeakStatViewModel pastPeak = peak(900L, "2026-08-12", false); + when(dailyStatsService.getDailyStatsLast7Days()).thenReturn(new DailyStatsViewModel( + List.of("2026-08-13"), + List.of(1L), + List.of(2L), + List.of(3L), + currentPeak, + pastPeak, + PeakStatViewModel.empty(), + 0L, + "2026-08-13 12:00:00" + )); + + mockMvc.perform(get("/stats/daily")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("/css/stats-peak.css"))) + .andExpect(content().string(containsString("역대 최고"))) + .andExpect(content().string(containsString("1,234"))) + .andExpect(content().string(containsString("2026-08-12"))) + .andExpect(content().string(containsString("현재 기간"))) + .andExpect(content().string(containsString("기록 없음"))); + } + + @Test + void weeklyStats_renders_weekly_peak_period() throws Exception { + PeakStatViewModel weeklyPeak = peak(80L, "2026-08-10 ~ 2026-08-16", false); + when(weeklyStatsService.getWeeklyStatsLast7Weeks()).thenReturn(new WeeklyStatsViewModel( + List.of("08-10 ~ 08-16"), + List.of(1L), + List.of(2L), + List.of(3L), + List.of(4L), + List.of(5L), + weeklyPeak, + weeklyPeak, + weeklyPeak, + weeklyPeak, + weeklyPeak, + 0L, + "2026-08-13 12:00:00" + )); + + mockMvc.perform(get("/stats/weekly")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("2026-08-10 ~ 2026-08-16"))) + .andExpect(content().string(containsString("WAU"))); + } + + @Test + void monthlyStats_renders_monthly_peak_period() throws Exception { + PeakStatViewModel monthlyPeak = peak(120L, "2026-08", false); + when(monthlyStatsService.getMonthlyStatsLast5Months()).thenReturn(new MonthlyStatsViewModel( + List.of("2026-08"), + List.of(1L), + List.of(2L), + List.of(3L), + List.of(4L), + List.of(5L), + List.of(9L), + List.of(6L), + monthlyPeak, + monthlyPeak, + monthlyPeak, + monthlyPeak, + monthlyPeak, + 0L, + 0L, + 0L, + "2026-08-13 12:00:00" + )); + + mockMvc.perform(get("/stats/monthly")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("2026-08"))) + .andExpect(content().string(containsString("MAU"))); + } + + @Test + void peakStylesheet_is_served_as_static_resource() throws Exception { + mockMvc.perform(get("/css/stats-peak.css")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString(".peak-record-current"))); + } + + private PeakStatViewModel peak(long value, String periodLabel, boolean currentPeriod) { + return new PeakStatViewModel(true, value, "2026-08-01", periodLabel, currentPeriod); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/stats/core/repository/PeakStatsRepositoryTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/core/repository/PeakStatsRepositoryTest.java new file mode 100644 index 00000000..67c5c6fb --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/core/repository/PeakStatsRepositoryTest.java @@ -0,0 +1,192 @@ +package com.devkor.ifive.nadab.domain.stats.core.repository; + +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.AnswerEntry; +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReport; +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReportStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlyReportV2ContentFactory; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReport; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportComparisonType; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportImageStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestion; +import com.devkor.ifive.nadab.domain.question.core.entity.UserDailyQuestion; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakMetric; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStat; +import com.devkor.ifive.nadab.domain.user.core.entity.SignupStatusType; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.weeklyreport.core.entity.WeeklyReport; +import com.devkor.ifive.nadab.domain.weeklyreport.core.entity.WeeklyReportStatus; +import com.devkor.ifive.nadab.global.shared.reportcontent.ReportContentFactory; +import com.devkor.ifive.nadab.infra.builder.UserBuilder; +import com.devkor.ifive.nadab.infra.db.PostgresIntegrationTestSupport; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +@Import(PeakStatsRepository.class) +class PeakStatsRepositoryTest extends PostgresIntegrationTestSupport { + + private static final LocalDate JANUARY_4 = LocalDate.of(2026, 1, 4); + private static final LocalDate JANUARY_5 = LocalDate.of(2026, 1, 5); + private static final LocalDate FEBRUARY_1 = LocalDate.of(2026, 2, 1); + private static final LocalDate FUTURE_DATE = LocalDate.of(2100, 1, 4); + + @Autowired + PeakStatsRepository repository; + + @Autowired + TestEntityManager em; + + @Test + void findAllPeakStats_returns_empty_map_when_no_statistics_exist() { + assertThat(repository.findAllPeakStats()).isEmpty(); + } + + @Test + void findAllPeakStats_aggregates_all_metrics_and_uses_latest_period_for_ties() { + User first = completedUser(OffsetDateTime.parse("2026-01-03T15:30:00Z")); + User second = completedUser(OffsetDateTime.parse("2026-01-04T10:00:00+09:00")); + User third = completedUser(OffsetDateTime.parse("2026-01-04T15:30:00Z")); + User fourth = completedUser(OffsetDateTime.parse("2026-01-05T10:00:00+09:00")); + incompleteUser(OffsetDateTime.parse("2026-01-05T11:00:00+09:00")); + DailyQuestion question = em.getEntityManager().getReference(DailyQuestion.class, 1L); + + assignedQuestion(first, question, JANUARY_4); + assignedQuestion(second, question, JANUARY_4); + assignedQuestion(third, question, JANUARY_5); + assignedQuestion(fourth, question, JANUARY_5); + + dailyReport(first, question, JANUARY_4, DailyReportStatus.COMPLETED); + dailyReport(second, question, JANUARY_4, DailyReportStatus.COMPLETED); + dailyReport(first, question, JANUARY_5, DailyReportStatus.COMPLETED); + dailyReport(second, question, JANUARY_5, DailyReportStatus.COMPLETED); + dailyReport(first, question, LocalDate.of(2026, 1, 6), DailyReportStatus.COMPLETED); + dailyReport(first, question, FEBRUARY_1, DailyReportStatus.COMPLETED); + dailyReport(second, question, FEBRUARY_1, DailyReportStatus.COMPLETED); + dailyReport(third, question, LocalDate.of(2026, 2, 2), DailyReportStatus.FAILED); + + weeklyReport(first, JANUARY_4, WeeklyReportStatus.COMPLETED); + weeklyReport(first, JANUARY_5, WeeklyReportStatus.COMPLETED); + weeklyReport(second, JANUARY_5, WeeklyReportStatus.COMPLETED); + weeklyReport(third, LocalDate.of(2026, 1, 6), WeeklyReportStatus.FAILED); + + monthlyReportV1(first, LocalDate.of(2026, 1, 15), MonthlyReportStatus.COMPLETED); + monthlyReportV2(second, LocalDate.of(2026, 1, 20), MonthlyReportStatus.COMPLETED); + monthlyReportV1(first, LocalDate.of(2026, 2, 5), MonthlyReportStatus.COMPLETED); + monthlyReportV2(second, LocalDate.of(2026, 2, 6), MonthlyReportStatus.COMPLETED); + monthlyReportV1(third, LocalDate.of(2026, 3, 5), MonthlyReportStatus.FAILED); + createFutureStatistics(question); + em.flush(); + em.clear(); + + Map peaks = repository.findAllPeakStats(); + + assertThat(peaks).hasSize(PeakMetric.values().length); + assertThat(peaks) + .containsEntry(PeakMetric.DAILY_SIGNUP, peak(2L, JANUARY_5)) + .containsEntry(PeakMetric.DAILY_ASSIGNED_QUESTION, peak(2L, JANUARY_5)) + .containsEntry(PeakMetric.DAU, peak(2L, FEBRUARY_1)) + .containsEntry(PeakMetric.WEEKLY_SIGNUP, peak(2L, JANUARY_5)) + .containsEntry(PeakMetric.WEEKLY_ASSIGNED_QUESTION, peak(2L, JANUARY_5)) + .containsEntry(PeakMetric.WEEKLY_DAILY_REPORT, peak(3L, JANUARY_5)) + .containsEntry(PeakMetric.WEEKLY_REPORT, peak(2L, JANUARY_5)) + .containsEntry(PeakMetric.WAU, peak(2L, LocalDate.of(2026, 1, 26))) + .containsEntry(PeakMetric.MONTHLY_SIGNUP, peak(4L, LocalDate.of(2026, 1, 1))) + .containsEntry(PeakMetric.MONTHLY_ASSIGNED_QUESTION, peak(4L, LocalDate.of(2026, 1, 1))) + .containsEntry(PeakMetric.MONTHLY_DAILY_REPORT, peak(5L, LocalDate.of(2026, 1, 1))) + .containsEntry(PeakMetric.MONTHLY_REPORT, peak(2L, LocalDate.of(2026, 2, 1))) + .containsEntry(PeakMetric.MAU, peak(2L, LocalDate.of(2026, 2, 1))); + } + + private void createFutureStatistics(DailyQuestion question) { + User first = completedUser(OffsetDateTime.parse("2100-01-04T10:00:00+09:00")); + User second = completedUser(OffsetDateTime.parse("2100-01-04T11:00:00+09:00")); + User third = completedUser(OffsetDateTime.parse("2100-01-04T12:00:00+09:00")); + + for (User user : new User[]{first, second, third}) { + assignedQuestion(user, question, FUTURE_DATE); + dailyReport(user, question, FUTURE_DATE, DailyReportStatus.COMPLETED); + weeklyReport(user, FUTURE_DATE, WeeklyReportStatus.COMPLETED); + } + monthlyReportV1(first, FUTURE_DATE, MonthlyReportStatus.COMPLETED); + monthlyReportV1(second, FUTURE_DATE, MonthlyReportStatus.COMPLETED); + monthlyReportV2(third, FUTURE_DATE, MonthlyReportStatus.COMPLETED); + } + + private User completedUser(OffsetDateTime registeredAt) { + User user = new UserBuilder(em).build(); + user.updateSignupStatus(SignupStatusType.COMPLETED); + ReflectionTestUtils.setField(user, "registeredAt", registeredAt); + return user; + } + + private void incompleteUser(OffsetDateTime registeredAt) { + User user = new UserBuilder(em).build(); + ReflectionTestUtils.setField(user, "registeredAt", registeredAt); + } + + private void assignedQuestion(User user, DailyQuestion question, LocalDate date) { + em.persist(UserDailyQuestion.create(user, date, question)); + } + + private void dailyReport(User user, DailyQuestion question, LocalDate date, DailyReportStatus status) { + AnswerEntry answer = AnswerEntry.create(user, question, "answer", date, null); + em.persist(answer); + em.persist(DailyReport.create(answer, null, "report", date, status)); + } + + private void weeklyReport(User user, LocalDate date, WeeklyReportStatus status) { + LocalDate weekStart = date.minusDays(date.getDayOfWeek().getValue() - 1L); + em.persist(WeeklyReport.create( + user, + weekStart, + weekStart.plusDays(6), + ReportContentFactory.empty(), + date, + status + )); + } + + private void monthlyReportV1(User user, LocalDate date, MonthlyReportStatus status) { + LocalDate targetMonth = date.minusMonths(1).withDayOfMonth(1); + em.persist(MonthlyReport.create( + user, + targetMonth, + targetMonth.withDayOfMonth(targetMonth.lengthOfMonth()), + ReportContentFactory.empty(), + date, + status + )); + } + + private void monthlyReportV2(User user, LocalDate date, MonthlyReportStatus status) { + LocalDate targetMonth = date.minusMonths(1).withDayOfMonth(1); + em.persist(MonthlyReportV2.create( + user, + targetMonth, + targetMonth.withDayOfMonth(targetMonth.lengthOfMonth()), + MonthlyReportV2ContentFactory.empty(), + date, + status, + MonthlyReportImageStatus.PENDING, + MonthlyReportComparisonType.BASELINE + )); + } + + private PeakStat peak(long value, LocalDate periodStart) { + return new PeakStat(value, periodStart); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/stats/infra/InMemoryPeakStatsStoreTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/infra/InMemoryPeakStatsStoreTest.java new file mode 100644 index 00000000..102a7c7b --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/infra/InMemoryPeakStatsStoreTest.java @@ -0,0 +1,114 @@ +package com.devkor.ifive.nadab.domain.stats.infra; + +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakMetric; +import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStat; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class InMemoryPeakStatsStoreTest { + + private final InMemoryPeakStatsStore store = new InMemoryPeakStatsStore(); + + @Test + void updateIfGreater_stores_first_positive_record() { + PeakStat peak = new PeakStat(10L, LocalDate.of(2026, 8, 1)); + + store.updateIfGreater(PeakMetric.DAU, peak); + + assertThat(store.find(PeakMetric.DAU)).contains(peak); + } + + @Test + void updateIfGreater_replaces_record_with_greater_value() { + store.updateIfGreater(PeakMetric.DAU, new PeakStat(10L, LocalDate.of(2026, 8, 1))); + PeakStat greater = new PeakStat(11L, LocalDate.of(2026, 8, 2)); + + store.updateIfGreater(PeakMetric.DAU, greater); + + assertThat(store.find(PeakMetric.DAU)).contains(greater); + } + + @Test + void updateIfGreater_keeps_record_when_candidate_is_smaller() { + PeakStat current = new PeakStat(10L, LocalDate.of(2026, 8, 1)); + store.updateIfGreater(PeakMetric.DAU, current); + + store.updateIfGreater(PeakMetric.DAU, new PeakStat(9L, LocalDate.of(2026, 8, 2))); + + assertThat(store.find(PeakMetric.DAU)).contains(current); + } + + @Test + void updateIfGreater_uses_latest_period_when_values_are_equal() { + store.updateIfGreater(PeakMetric.DAU, new PeakStat(10L, LocalDate.of(2026, 8, 1))); + PeakStat latest = new PeakStat(10L, LocalDate.of(2026, 8, 2)); + + store.updateIfGreater(PeakMetric.DAU, latest); + + assertThat(store.find(PeakMetric.DAU)).contains(latest); + } + + @Test + void updateIfGreater_keeps_latest_period_when_equal_candidate_is_older() { + PeakStat latest = new PeakStat(10L, LocalDate.of(2026, 8, 2)); + store.updateIfGreater(PeakMetric.DAU, latest); + + store.updateIfGreater(PeakMetric.DAU, new PeakStat(10L, LocalDate.of(2026, 8, 1))); + + assertThat(store.find(PeakMetric.DAU)).contains(latest); + } + + @Test + void updateIfGreater_ignores_zero_value() { + store.updateIfGreater(PeakMetric.DAU, new PeakStat(0L, LocalDate.of(2026, 8, 1))); + + assertThat(store.find(PeakMetric.DAU)).isEmpty(); + } + + @Test + void updateIfGreater_keeps_greatest_value_during_concurrent_updates() + throws InterruptedException, ExecutionException { + int candidateCount = 100; + ExecutorService executor = Executors.newFixedThreadPool(8); + List> updates = new ArrayList<>(); + for (int value = 1; value <= candidateCount; value++) { + int candidateValue = value; + updates.add(() -> { + store.updateIfGreater( + PeakMetric.DAU, + new PeakStat(candidateValue, LocalDate.of(2026, 8, 1).plusDays(candidateValue)) + ); + return null; + }); + } + + try { + List> futures = executor.invokeAll(updates); + for (Future future : futures) { + future.get(); + } + } finally { + executor.shutdownNow(); + } + + assertThat(store.find(PeakMetric.DAU)) + .contains(new PeakStat(candidateCount, LocalDate.of(2026, 8, 1).plusDays(candidateCount))); + } + + @Test + void peakStat_rejects_negative_value() { + assertThatThrownBy(() -> new PeakStat(-1L, LocalDate.of(2026, 8, 1))) + .isInstanceOf(IllegalArgumentException.class); + } +}