Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,11 @@ public MonthlyStatsViewModel getMonthlyStatsLast5Months() {
Map<YearMonth, Long> assignedMap = aggregateByMonth(
repo.findAssignedQuestionCountsByDateBetween(startDate, endDateInclusive)
);
Map<YearMonth, Long> completedMap = aggregateByMonth(
repo.findCompletedMonthlyReportCountsByDateBetween(startDate, endDateInclusive)
Map<YearMonth, Long> completedV1Map = aggregateByMonth(
repo.findCompletedMonthlyReportV1CountsByDateBetween(startDate, endDateInclusive)
);
Map<YearMonth, Long> completedV2Map = aggregateByMonth(
repo.findCompletedMonthlyReportV2CountsByDateBetween(startDate, endDateInclusive)
);
Map<YearMonth, Long> completedDailyMap = aggregateByMonth(
repo.findCompletedDailyReportCountsByDateBetween(startDate, endDateInclusive)
Expand All @@ -66,19 +69,27 @@ public MonthlyStatsViewModel getMonthlyStatsLast5Months() {
List<Long> signupCounts = months.stream().map(m -> signupMap.getOrDefault(m, 0L)).toList();
List<Long> assignedCounts = months.stream().map(m -> assignedMap.getOrDefault(m, 0L)).toList();
List<Long> completedDailyCounts = months.stream().map(m -> completedDailyMap.getOrDefault(m, 0L)).toList();
List<Long> completedCounts = months.stream().map(m -> completedMap.getOrDefault(m, 0L)).toList();
List<Long> completedV1Counts = months.stream().map(m -> completedV1Map.getOrDefault(m, 0L)).toList();
List<Long> completedV2Counts = months.stream().map(m -> completedV2Map.getOrDefault(m, 0L)).toList();
List<Long> completedTotalCounts = sumCounts(completedV1Counts, completedV2Counts);
List<Long> 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)
);
}
Expand All @@ -91,4 +102,12 @@ private Map<YearMonth, Long> aggregateByMonth(List<DateCountDto> dailyCounts) {
}
return map;
}

private List<Long> sumCounts(List<Long> first, List<Long> second) {
List<Long> totals = new ArrayList<>(first.size());
for (int i = 0; i < first.size(); i++) {
totals.add(first.get(i) + second.get(i));
}
return totals;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ public record MonthlyStatsViewModel(
List<Long> signupCounts,
List<Long> assignedQuestionCounts,
List<Long> completedDailyReportCounts,
List<Long> completedMonthlyReportCounts,
List<Long> completedMonthlyReportV1Counts,
List<Long> completedMonthlyReportV2Counts,
List<Long> completedMonthlyReportTotalCounts,
List<Long> mauCounts,
long inProgressMonthlyReportCount,
long inProgressMonthlyReportV1Count,
long inProgressMonthlyReportV2Count,
long inProgressMonthlyReportTotalCount,
String refreshedAt
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public List<DateCountDto> findAssignedQuestionCountsByDateBetween(LocalDate star
.getResultList();
}

public List<DateCountDto> findCompletedMonthlyReportCountsByDateBetween(LocalDate startDate, LocalDate endDateInclusive) {
public List<DateCountDto> 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
Expand All @@ -69,6 +69,20 @@ public List<DateCountDto> findCompletedMonthlyReportCountsByDateBetween(LocalDat
.getResultList();
}

public List<DateCountDto> 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<DateCountDto> 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))
Expand All @@ -83,7 +97,7 @@ public List<DateCountDto> findCompletedDailyReportCountsByDateBetween(LocalDate
.getResultList();
}

public long countInProgressMonthlyReportsNow() {
public long countInProgressMonthlyReportV1Now() {
return em.createQuery("""
select count(mr.id)
from MonthlyReport mr
Expand All @@ -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<DateCountDto> findMonthlyActiveUserCountsByDateBetween(LocalDate startDate, LocalDate endDateInclusive) {
List<Object[]> rows = em.createQuery("""
select function('date_trunc', 'month', dr.date), count(distinct dr.answerEntry.user.id)
Expand Down
79 changes: 56 additions & 23 deletions src/main/resources/templates/stats/monthly.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -267,8 +280,12 @@

<div class="kpi-card">
<div>
<div class="kpi-label">현재 생성 중 월간 리포트</div>
<div class="kpi-value" th:text="${vm.inProgressMonthlyReportCount}">0</div>
<div class="kpi-label">현재 생성 중 월간 리포트 · Total</div>
<div class="kpi-value" th:text="${vm.inProgressMonthlyReportTotalCount}">0</div>
<div class="kpi-breakdown">
<span>monthly_reports<strong th:text="${vm.inProgressMonthlyReportV1Count}">0</strong></span>
<span>monthly_reports_v2<strong th:text="${vm.inProgressMonthlyReportV2Count}">0</strong></span>
</div>
</div>
</div>

Expand Down Expand Up @@ -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';
Expand All @@ -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)',
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<DateCountDto> completedV1 = monthlyStatsRepository
.findCompletedMonthlyReportV1CountsByDateBetween(reportDate, reportDate);
List<DateCountDto> 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
);
}
}
Loading