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 46e93e30..d8bf1442 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 @@ -53,8 +53,11 @@ public MonthlyStatsViewModel getMonthlyStatsLast5Months() { Map assignedMap = aggregateByMonth( repo.findAssignedQuestionCountsByDateBetween(startDate, endDateInclusive) ); - Map completedMap = aggregateByMonth( - repo.findCompletedMonthlyReportCountsByDateBetween(startDate, endDateInclusive) + Map completedV1Map = aggregateByMonth( + repo.findCompletedMonthlyReportV1CountsByDateBetween(startDate, endDateInclusive) + ); + Map completedV2Map = aggregateByMonth( + repo.findCompletedMonthlyReportV2CountsByDateBetween(startDate, endDateInclusive) ); Map completedDailyMap = aggregateByMonth( repo.findCompletedDailyReportCountsByDateBetween(startDate, endDateInclusive) @@ -66,19 +69,27 @@ public MonthlyStatsViewModel getMonthlyStatsLast5Months() { List signupCounts = months.stream().map(m -> signupMap.getOrDefault(m, 0L)).toList(); List assignedCounts = months.stream().map(m -> assignedMap.getOrDefault(m, 0L)).toList(); List completedDailyCounts = months.stream().map(m -> completedDailyMap.getOrDefault(m, 0L)).toList(); - List completedCounts = months.stream().map(m -> completedMap.getOrDefault(m, 0L)).toList(); + List completedV1Counts = months.stream().map(m -> completedV1Map.getOrDefault(m, 0L)).toList(); + List completedV2Counts = months.stream().map(m -> completedV2Map.getOrDefault(m, 0L)).toList(); + List completedTotalCounts = sumCounts(completedV1Counts, completedV2Counts); List mauCounts = months.stream().map(m -> mauMap.getOrDefault(m, 0L)).toList(); - long inProgressNow = repo.countInProgressMonthlyReportsNow(); + long inProgressV1Now = repo.countInProgressMonthlyReportV1Now(); + long inProgressV2Now = repo.countInProgressMonthlyReportV2Now(); + long inProgressTotalNow = inProgressV1Now + inProgressV2Now; return new MonthlyStatsViewModel( labels, signupCounts, assignedCounts, completedDailyCounts, - completedCounts, + completedV1Counts, + completedV2Counts, + completedTotalCounts, mauCounts, - inProgressNow, + inProgressV1Now, + inProgressV2Now, + inProgressTotalNow, OffsetDateTime.now(SEOUL).format(FMT) ); } @@ -91,4 +102,12 @@ private Map aggregateByMonth(List dailyCounts) { } return map; } + + private List sumCounts(List first, List second) { + List totals = new ArrayList<>(first.size()); + for (int i = 0; i < first.size(); i++) { + totals.add(first.get(i) + second.get(i)); + } + return totals; + } } 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 47ecf510..9b4b3b20 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 @@ -7,8 +7,12 @@ public record MonthlyStatsViewModel( List signupCounts, List assignedQuestionCounts, List completedDailyReportCounts, - List completedMonthlyReportCounts, + List completedMonthlyReportV1Counts, + List completedMonthlyReportV2Counts, + List completedMonthlyReportTotalCounts, List mauCounts, - long inProgressMonthlyReportCount, + long inProgressMonthlyReportV1Count, + long inProgressMonthlyReportV2Count, + long inProgressMonthlyReportTotalCount, String refreshedAt ) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/MonthlyStatsRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/MonthlyStatsRepository.java index 1863a8ac..3d76aebf 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/MonthlyStatsRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/MonthlyStatsRepository.java @@ -55,7 +55,7 @@ public List findAssignedQuestionCountsByDateBetween(LocalDate star .getResultList(); } - public List findCompletedMonthlyReportCountsByDateBetween(LocalDate startDate, LocalDate endDateInclusive) { + public List findCompletedMonthlyReportV1CountsByDateBetween(LocalDate startDate, LocalDate endDateInclusive) { return em.createQuery(""" select new com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto(mr.date, count(mr.id)) from MonthlyReport mr @@ -69,6 +69,20 @@ public List findCompletedMonthlyReportCountsByDateBetween(LocalDat .getResultList(); } + public List findCompletedMonthlyReportV2CountsByDateBetween(LocalDate startDate, LocalDate endDateInclusive) { + return em.createQuery(""" + select new com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto(mr.date, count(mr.id)) + from MonthlyReportV2 mr + where mr.date between :startDate and :endDate + and mr.status = com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus.COMPLETED + group by mr.date + order by mr.date + """, DateCountDto.class) + .setParameter("startDate", startDate) + .setParameter("endDate", endDateInclusive) + .getResultList(); + } + public List findCompletedDailyReportCountsByDateBetween(LocalDate startDate, LocalDate endDateInclusive) { return em.createQuery(""" select new com.devkor.ifive.nadab.domain.stats.core.dto.daily.DateCountDto(dr.date, count(dr.id)) @@ -83,7 +97,7 @@ public List findCompletedDailyReportCountsByDateBetween(LocalDate .getResultList(); } - public long countInProgressMonthlyReportsNow() { + public long countInProgressMonthlyReportV1Now() { return em.createQuery(""" select count(mr.id) from MonthlyReport mr @@ -92,6 +106,15 @@ select count(mr.id) .getSingleResult(); } + public long countInProgressMonthlyReportV2Now() { + return em.createQuery(""" + select count(mr.id) + from MonthlyReportV2 mr + where mr.status = com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus.IN_PROGRESS + """, Long.class) + .getSingleResult(); + } + public List findMonthlyActiveUserCountsByDateBetween(LocalDate startDate, LocalDate endDateInclusive) { List rows = em.createQuery(""" select function('date_trunc', 'month', dr.date), count(distinct dr.answerEntry.user.id) diff --git a/src/main/resources/templates/stats/monthly.html b/src/main/resources/templates/stats/monthly.html index 4a2aa44e..a9b9a05a 100644 --- a/src/main/resources/templates/stats/monthly.html +++ b/src/main/resources/templates/stats/monthly.html @@ -173,6 +173,19 @@ line-height: 1; color: var(--text-1); } + .kpi-breakdown { + display: flex; + gap: 20px; + margin-top: 12px; + font-family: 'DM Mono', monospace; + font-size: .78rem; + color: var(--text-2); + } + .kpi-breakdown strong { + margin-left: 6px; + color: var(--text-1); + font-weight: 500; + } .charts-grid { display: grid; @@ -267,8 +280,12 @@
-
현재 생성 중 월간 리포트
-
0
+
현재 생성 중 월간 리포트 · Total
+
0
+
+ monthly_reports0 + monthly_reports_v20 +
@@ -328,7 +345,9 @@ const signupCounts = [[${vm.signupCounts}]].map(v => parseInt(v, 10)); const assignedCounts = [[${vm.assignedQuestionCounts}]].map(v => parseInt(v, 10)); const completedDailyCounts = [[${vm.completedDailyReportCounts}]].map(v => parseInt(v, 10)); - const completedMonthlyCounts = [[${vm.completedMonthlyReportCounts}]].map(v => parseInt(v, 10)); + const completedMonthlyV1Counts = [[${vm.completedMonthlyReportV1Counts}]].map(v => parseInt(v, 10)); + const completedMonthlyV2Counts = [[${vm.completedMonthlyReportV2Counts}]].map(v => parseInt(v, 10)); + const completedMonthlyTotalCounts = [[${vm.completedMonthlyReportTotalCounts}]].map(v => parseInt(v, 10)); const mauCounts = [[${vm.mauCounts}]].map(v => parseInt(v, 10)); Chart.defaults.color = '#8b91a8'; @@ -346,40 +365,54 @@ } const configs = [ - { id: 'mauChart', label: 'MAU', data: mauCounts, color: 'rgba(108,143,255,1)' }, - { id: 'signupChart', label: '가입자 수', data: signupCounts, color: 'rgba(108,143,255,1)' }, - { id: 'assignedChart', label: '할당 질문 수', data: assignedCounts, color: 'rgba(255,126,179,1)' }, - { id: 'completedDailyChart', label: '일간 리포트 생성 수', data: completedDailyCounts, color: 'rgba(77,232,194,1)' }, - { id: 'completedMonthlyChart', label: '월간 리포트 생성 수', data: completedMonthlyCounts, color: 'rgba(245,165,36,1)' }, + { id: 'mauChart', datasets: [{ label: 'MAU', data: mauCounts, color: 'rgba(108,143,255,1)' }] }, + { id: 'signupChart', datasets: [{ label: '가입자 수', data: signupCounts, color: 'rgba(108,143,255,1)' }] }, + { id: 'assignedChart', datasets: [{ label: '할당 질문 수', data: assignedCounts, color: 'rgba(255,126,179,1)' }] }, + { id: 'completedDailyChart', datasets: [{ label: '일간 리포트 생성 수', data: completedDailyCounts, color: 'rgba(77,232,194,1)' }] }, + { + id: 'completedMonthlyChart', + showLegend: true, + datasets: [ + { label: 'monthly_reports', data: completedMonthlyV1Counts, color: 'rgba(139,145,168,1)', fill: false, borderDash: [6, 4] }, + { label: 'monthly_reports_v2', data: completedMonthlyV2Counts, color: 'rgba(168,135,255,1)', fill: false }, + { label: 'Total', data: completedMonthlyTotalCounts, color: 'rgba(245,165,36,1)', borderWidth: 3 }, + ] + }, ]; - configs.forEach(({ id, label, data, color }) => { + configs.forEach(({ id, datasets, showLegend = false }) => { const canvasEl = document.getElementById(id); const ctx2d = canvasEl.getContext('2d'); - const gradient = makeGradient(ctx2d, color); + const chartDatasets = datasets.map(({ + label, data, color, fill = true, borderWidth = 2, borderDash = [] + }) => ({ + label, + data, + tension: 0.35, + fill, + backgroundColor: fill ? makeGradient(ctx2d, color) : 'transparent', + borderColor: color, + borderWidth, + borderDash, + pointBackgroundColor: color, + pointRadius: 4, + pointHoverRadius: 6, + })); new Chart(canvasEl, { type: 'line', data: { labels, - datasets: [{ - label, - data, - tension: 0.35, - fill: true, - backgroundColor: gradient, - borderColor: color, - borderWidth: 2, - pointBackgroundColor: color, - pointRadius: 4, - pointHoverRadius: 6, - }] + datasets: chartDatasets }, options: { responsive: true, interaction: { mode: 'index', intersect: false }, plugins: { - legend: { display: false }, + legend: { + display: showLegend, + labels: { usePointStyle: true, pointStyle: 'line' }, + }, tooltip: { backgroundColor: '#1e2230', borderColor: 'rgba(255,255,255,0.1)', 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 new file mode 100644 index 00000000..5436d33f --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/MonthlyStatsServiceTest.java @@ -0,0 +1,61 @@ +package com.devkor.ifive.nadab.domain.stats.application; + +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.global.shared.util.TodayDateTimeProvider; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.YearMonth; +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 MonthlyStatsServiceTest { + + @Test + void getMonthlyStats_combines_v1_and_v2_monthly_report_counts() { + // given + MonthlyStatsRepository repo = mock(MonthlyStatsRepository.class); + MonthlyStatsService service = new MonthlyStatsService(repo); + + YearMonth currentMonth = YearMonth.from(TodayDateTimeProvider.getTodayDate()); + YearMonth previousMonth = currentMonth.minusMonths(1); + + when(repo.findSignupCountsByDateBetween(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of()); + when(repo.findAssignedQuestionCountsByDateBetween(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of()); + when(repo.findCompletedDailyReportCountsByDateBetween(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of()); + when(repo.findMonthlyActiveUserCountsByDateBetween(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of()); + when(repo.findCompletedMonthlyReportV1CountsByDateBetween(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of( + new DateCountDto(previousMonth.atDay(15), 2L), + new DateCountDto(currentMonth.atDay(1), 3L) + )); + when(repo.findCompletedMonthlyReportV2CountsByDateBetween(any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of( + new DateCountDto(previousMonth.atDay(20), 5L), + new DateCountDto(currentMonth.atDay(2), 7L) + )); + when(repo.countInProgressMonthlyReportV1Now()).thenReturn(1L); + when(repo.countInProgressMonthlyReportV2Now()).thenReturn(4L); + + // when + MonthlyStatsViewModel vm = service.getMonthlyStatsLast5Months(); + + // then + 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.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/core/repository/MonthlyStatsRepositoryTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/core/repository/MonthlyStatsRepositoryTest.java new file mode 100644 index 00000000..7334512d --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/core/repository/MonthlyStatsRepositoryTest.java @@ -0,0 +1,84 @@ +package com.devkor.ifive.nadab.domain.stats.core.repository; + +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.stats.core.dto.daily.DateCountDto; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +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 java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +@Import(MonthlyStatsRepository.class) +class MonthlyStatsRepositoryTest extends PostgresIntegrationTestSupport { + + @Autowired + MonthlyStatsRepository monthlyStatsRepository; + + @Autowired + TestEntityManager em; + + @Test + void counts_completed_and_in_progress_monthly_reports_by_version() { + LocalDate reportDate = LocalDate.of(2026, 8, 1); + + em.persist(v1Report(new UserBuilder(em).build(), reportDate, MonthlyReportStatus.COMPLETED)); + em.persist(v1Report(new UserBuilder(em).build(), reportDate, MonthlyReportStatus.IN_PROGRESS)); + em.persist(v2Report(new UserBuilder(em).build(), reportDate, MonthlyReportStatus.COMPLETED)); + em.persist(v2Report(new UserBuilder(em).build(), reportDate, MonthlyReportStatus.IN_PROGRESS)); + em.flush(); + em.clear(); + + List completedV1 = monthlyStatsRepository + .findCompletedMonthlyReportV1CountsByDateBetween(reportDate, reportDate); + List completedV2 = monthlyStatsRepository + .findCompletedMonthlyReportV2CountsByDateBetween(reportDate, reportDate); + + assertThat(completedV1).containsExactly(new DateCountDto(reportDate, 1L)); + assertThat(completedV2).containsExactly(new DateCountDto(reportDate, 1L)); + assertThat(monthlyStatsRepository.countInProgressMonthlyReportV1Now()).isEqualTo(1L); + assertThat(monthlyStatsRepository.countInProgressMonthlyReportV2Now()).isEqualTo(1L); + } + + private MonthlyReport v1Report(User user, LocalDate reportDate, MonthlyReportStatus status) { + LocalDate monthStartDate = reportDate.minusMonths(1).withDayOfMonth(1); + return MonthlyReport.create( + user, + monthStartDate, + monthStartDate.withDayOfMonth(monthStartDate.lengthOfMonth()), + ReportContentFactory.empty(), + reportDate, + status + ); + } + + private MonthlyReportV2 v2Report(User user, LocalDate reportDate, MonthlyReportStatus status) { + LocalDate monthStartDate = reportDate.minusMonths(1).withDayOfMonth(1); + return MonthlyReportV2.create( + user, + monthStartDate, + monthStartDate.withDayOfMonth(monthStartDate.lengthOfMonth()), + MonthlyReportV2ContentFactory.empty(), + reportDate, + status, + MonthlyReportImageStatus.PENDING, + MonthlyReportComparisonType.BASELINE + ); + } +}