From fdb323b7377d6e216c14ce8a3e4a11a6a3e34805 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sun, 27 Sep 2026 17:51:10 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(stats):=20=EB=AC=BC=EC=96=B4=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=ED=86=B5=EA=B3=84=20=ED=83=AD=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=EA=B3=BC=20=EC=9C=84=EC=B9=98=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/templates/stats/fragments/tabs.html | 6 +++--- .../stats/controller/StatsControllerTemplateTest.java | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/main/resources/templates/stats/fragments/tabs.html b/src/main/resources/templates/stats/fragments/tabs.html index 31412b1e..42be6d6b 100644 --- a/src/main/resources/templates/stats/fragments/tabs.html +++ b/src/main/resources/templates/stats/fragments/tabs.html @@ -14,6 +14,9 @@ 유형 + 물어보기 질문 @@ -23,9 +26,6 @@ 전체 - Ask Chat 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 index c2a4e619..ea7a435b 100644 --- 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 @@ -626,8 +626,12 @@ void all_stats_templates_use_common_tab_fragment() throws Exception { "th:href=\"@{/stats/question}\"", ">질문", "th:href=\"@{/stats/ask-chat}\"", - ">Ask Chat" + ">물어보기" ); + assertThat(fragment.indexOf(">유형")) + .isLessThan(fragment.indexOf(">물어보기")); + assertThat(fragment.indexOf(">물어보기")) + .isLessThan(fragment.indexOf(">질문")); for (String template : List.of("daily", "weekly", "monthly", "type", "question", "question-overview", "withdrawal", "total", "ask-chat")) { String source = new ClassPathResource("templates/stats/" + template + ".html") From 5edd515327d66834f2120fcd1c281a418f26d2fa Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sun, 27 Sep 2026 18:24:59 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(stats):=20=EB=AC=BC=EC=96=B4=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=ED=86=B5=EA=B3=84=20=EC=9D=BC=EA=B0=84=C2=B7?= =?UTF-8?q?=EC=A3=BC=EA=B0=84=C2=B7=EC=9B=94=EA=B0=84=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/AskChatStatsService.java | 8 -- .../stats/controller/StatsController.java | 40 ++++++++- .../resources/templates/stats/ask-chat.html | 84 ++++++++++++++++++- .../StatsControllerTemplateTest.java | 69 ++++++++++++++- 4 files changed, 184 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/AskChatStatsService.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/AskChatStatsService.java index d77f22a4..82e41316 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/AskChatStatsService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/AskChatStatsService.java @@ -19,7 +19,6 @@ import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatWalletStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatWalletSummaryDto; import com.devkor.ifive.nadab.domain.stats.core.repository.AskChatStatsRepository; -import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -38,19 +37,12 @@ @Transactional(readOnly = true) public class AskChatStatsService { - public static final int DEFAULT_CHART_DAYS = 7; - private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); private static final DateTimeFormatter REFRESHED_AT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private final AskChatStatsRepository repository; - public AskChatStatsViewModel getAskChatStats() { - LocalDate endDate = TodayDateTimeProvider.getTodayDate(); - return getAskChatStats(endDate.minusDays(DEFAULT_CHART_DAYS - 1L), endDate); - } - public AskChatStatsViewModel getAskChatStats(LocalDate startDate, LocalDate endDate) { Objects.requireNonNull(startDate, "startDate must not be null"); Objects.requireNonNull(endDate, "endDate must not be null"); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java index 50f4323d..da1831b6 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java @@ -25,6 +25,7 @@ import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; import com.devkor.ifive.nadab.global.core.response.ErrorCode; import com.devkor.ifive.nadab.global.exception.BadRequestException; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -57,10 +58,45 @@ public class StatsController { private final DailyQuestionOverviewCsvExporter dailyQuestionOverviewCsvExporter; @GetMapping("/stats/ask-chat") - public String askChatStats(Model model) { - AskChatStatsViewModel vm = askChatStatsService.getAskChatStats(); + public String askChatStats( + @RequestParam(defaultValue = "daily") String period, + @RequestParam(required = false) String date, + @RequestParam(required = false) String week, + @RequestParam(required = false) String month, + Model model + ) { + LocalDate startDate; + LocalDate endDate; + String periodValue; + switch (period) { + case "daily" -> { + startDate = StatsPeriodResolver.resolveDaily(date); + endDate = startDate; + periodValue = startDate.toString(); + } + case "weekly" -> { + startDate = StatsPeriodResolver.resolveWeekly(week); + endDate = startDate.plusDays(6); + periodValue = StatsPeriodResolver.formatIsoWeek(startDate); + } + case "monthly" -> { + YearMonth selectedMonth = StatsPeriodResolver.resolveMonthly(month); + startDate = selectedMonth.atDay(1); + endDate = selectedMonth.atEndOfMonth(); + periodValue = selectedMonth.toString(); + } + default -> throw new BadRequestException(ErrorCode.VALIDATION_FAILED); + } + LocalDate today = TodayDateTimeProvider.getTodayDate(); + if (endDate.isAfter(today)) { + endDate = today; + } + + AskChatStatsViewModel vm = askChatStatsService.getAskChatStats(startDate, endDate); model.addAttribute("vm", vm); model.addAttribute("activeTab", "ask-chat"); + model.addAttribute("period", period); + model.addAttribute("periodValue", periodValue); return "stats/ask-chat"; } diff --git a/src/main/resources/templates/stats/ask-chat.html b/src/main/resources/templates/stats/ask-chat.html index e80d7dae..5b4d74a4 100644 --- a/src/main/resources/templates/stats/ask-chat.html +++ b/src/main/resources/templates/stats/ask-chat.html @@ -3,7 +3,7 @@ - NADAB — Ask Chat Stats + NADAB — Stats @@ -121,6 +121,20 @@ .tab-link:hover { color: var(--text-1); } .tab-link.active { color: #fff; background: var(--accent); } + .period-tabs { display: flex; gap: 8px; margin-bottom: 12px; } + .period-link { + padding: 8px 18px; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--text-2); + background: var(--surface); + font-size: .78rem; + font-weight: 600; + text-decoration: none; + } + .period-link:hover { color: var(--text-1); border-color: var(--accent); } + .period-link.active { color: #fff; border-color: var(--accent); background: var(--accent); } + .period-panel, .panel, .summary-card { @@ -129,6 +143,7 @@ background: var(--surface); } .period-panel { margin-bottom: 20px; padding: 20px 22px; } + .period-panel-header { display: flex; align-items: center; justify-content: space-between; gap: 20px; } .period-eyebrow { margin-bottom: 7px; color: var(--accent); @@ -139,6 +154,33 @@ } .period-title { color: var(--text-1); font-family: 'DM Mono', monospace; font-size: 1.28rem; font-weight: 500; } .period-note { margin-top: 8px; color: var(--text-2); font-size: .78rem; line-height: 1.5; } + .period-picker { display: flex; align-items: end; flex-wrap: wrap; gap: 8px; } + .period-picker-field { display: grid; gap: 6px; } + .period-picker-label { color: var(--text-2); font-size: .68rem; } + .period-picker-input { + min-height: 36px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-1); + background: var(--surface-2); + color-scheme: dark; + font: 500 .78rem 'DM Mono', monospace; + } + .period-picker-submit, .period-picker-current { + display: inline-flex; + align-items: center; + min-height: 36px; + padding: 7px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-1); + background: var(--surface-2); + font: 600 .78rem 'Syne', sans-serif; + text-decoration: none; + cursor: pointer; + } + .period-picker-submit:hover, .period-picker-current:hover { border-color: var(--accent); } .summary-grid { display: grid; @@ -214,6 +256,7 @@ body { padding: 20px 14px 40px; } .page-header { align-items: stretch; flex-direction: column; } .header-right { flex-wrap: wrap; gap: 8px 12px; } + .period-panel-header { align-items: stretch; flex-direction: column; } .chart-grid, .detail-grid { grid-template-columns: 1fr; } } @media (max-width: 520px) { @@ -248,10 +291,43 @@
+ +
-
Ask Chat 집계 기간
-
2026-08-25 ~ 2026-08-31
-
최근 7일 · Asia/Seoul 기준 · 세션, 메시지, 대화권, RAG read model을 한 화면에서 확인합니다.
+
+
+
Ask Chat 집계 기간
+
2026-08-25 ~ 2026-08-31
+
Asia/Seoul 기준 · 진행 중인 주·월은 오늘까지 집계합니다.
+
+
+ +
+ + + + +
+ + 오늘 + 이번 주 + 이번 달 +
+
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 index ea7a435b..36fc033a 100644 --- 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 @@ -10,6 +10,7 @@ import com.devkor.ifive.nadab.domain.stats.application.WithdrawalStatsService; import com.devkor.ifive.nadab.domain.stats.application.WeeklyStatsService; import com.devkor.ifive.nadab.domain.stats.application.helper.DailyQuestionOverviewCsvExporter; +import com.devkor.ifive.nadab.domain.stats.application.helper.StatsPeriodResolver; import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatDailyRagStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatDailyStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.askchat.AskChatDailyWalletStatsViewModel; @@ -41,6 +42,7 @@ import com.devkor.ifive.nadab.domain.stats.core.dto.withdrawal.WithdrawalStatsViewModel; import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; import com.devkor.ifive.nadab.global.security.filter.JwtAuthenticationFilter; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -49,6 +51,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.context.annotation.Import; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.ui.ExtendedModelMap; import org.springframework.test.web.servlet.MockMvc; import java.nio.charset.StandardCharsets; @@ -59,6 +62,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -76,6 +80,8 @@ class StatsControllerTemplateTest { @Autowired private MockMvc mockMvc; + @Autowired + private StatsController statsController; @MockitoBean private AskChatStatsService askChatStatsService; @@ -227,11 +233,14 @@ void askChatStats_renders_usage_quality_wallet_and_rag_dashboard() throws Except ), "2026-08-31 12:00:00" ); - when(askChatStatsService.getAskChatStats()).thenReturn(stats); + when(askChatStatsService.getAskChatStats(any(LocalDate.class), any(LocalDate.class))).thenReturn(stats); String html = mockMvc.perform(get("/stats/ask-chat")) .andExpect(status().isOk()) .andExpect(content().string(containsString("Ask Chat 집계 기간"))) + .andExpect(content().string(containsString("aria-label=\"물어보기 통계 기간\""))) + .andExpect(content().string(containsString("name=\"date\" type=\"date\""))) + .andExpect(content().string(containsString("name=\"period\" value=\"daily\""))) .andExpect(content().string(containsString("2026-08-25 ~ 2026-08-31"))) .andExpect(content().string(containsString("usageChart"))) .andExpect(content().string(containsString("답변 성공률"))) @@ -245,8 +254,54 @@ void askChatStats_renders_usage_quality_wallet_and_rag_dashboard() throws Except assertThat(html) .contains("일별 Ask Chat 상세", "TIMEOUT", "90.0%", "320.5 ms", "780 ms") - .contains("href=\"/stats/ask-chat\""); - verify(askChatStatsService).getAskChatStats(); + .contains("href=\"/stats/ask-chat\"", "href=\"/stats/ask-chat?period=weekly\""); + LocalDate today = TodayDateTimeProvider.getTodayDate(); + verify(askChatStatsService).getAskChatStats(today, today); + + mockMvc.perform(get("/stats/ask-chat").param("period", "weekly").param("week", "2025-W01")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("name=\"week\" type=\"week\""))) + .andExpect(content().string(containsString("value=\"2025-W01\""))) + .andExpect(content().string(containsString("name=\"period\" value=\"weekly\""))) + .andExpect(content().string(containsString("이번 주"))); + + mockMvc.perform(get("/stats/ask-chat").param("period", "monthly").param("month", "2026-08")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("name=\"month\" type=\"month\""))) + .andExpect(content().string(containsString("value=\"2026-08\""))) + .andExpect(content().string(containsString("name=\"period\" value=\"monthly\""))) + .andExpect(content().string(containsString("이번 달"))); + } + + @Test + void askChatStats_selects_daily_weekly_and_monthly_ranges() { + ExtendedModelMap model = new ExtendedModelMap(); + + statsController.askChatStats("daily", "2026-08-13", null, null, model); + assertThat(model.get("periodValue")).isEqualTo("2026-08-13"); + verify(askChatStatsService).getAskChatStats( + LocalDate.of(2026, 8, 13), LocalDate.of(2026, 8, 13)); + + statsController.askChatStats("weekly", null, "2025-W01", null, model); + assertThat(model.get("periodValue")).isEqualTo("2025-W01"); + verify(askChatStatsService).getAskChatStats( + LocalDate.of(2024, 12, 30), LocalDate.of(2025, 1, 5)); + + statsController.askChatStats("monthly", null, null, "2026-08", model); + assertThat(model.get("periodValue")).isEqualTo("2026-08"); + verify(askChatStatsService).getAskChatStats( + LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31)); + } + + @Test + void askChatStats_caps_current_week_and_month_at_today() { + LocalDate today = TodayDateTimeProvider.getTodayDate(); + + statsController.askChatStats("weekly", null, null, null, new ExtendedModelMap()); + verify(askChatStatsService).getAskChatStats(StatsPeriodResolver.resolveWeekly(null), today); + + statsController.askChatStats("monthly", null, null, null, new ExtendedModelMap()); + verify(askChatStatsService).getAskChatStats(today.withDayOfMonth(1), today); } @Test @@ -329,6 +384,14 @@ void statsPages_reject_invalid_or_future_period_parameters() throws Exception { .andExpect(status().isBadRequest()); mockMvc.perform(get("/stats/monthly").param("month", "2026-13")) .andExpect(status().isBadRequest()); + mockMvc.perform(get("/stats/ask-chat").param("period", "yearly")) + .andExpect(status().isBadRequest()); + mockMvc.perform(get("/stats/ask-chat").param("date", "2999-01-01")) + .andExpect(status().isBadRequest()); + mockMvc.perform(get("/stats/ask-chat").param("period", "weekly").param("week", "invalid")) + .andExpect(status().isBadRequest()); + mockMvc.perform(get("/stats/ask-chat").param("period", "monthly").param("month", "2026-13")) + .andExpect(status().isBadRequest()); mockMvc.perform(get("/stats/question").param("questionId", "not-a-number")) .andExpect(status().isBadRequest()); mockMvc.perform(get("/stats/question/overview").param("questionLevel", "0"))