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 @@ -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;
Expand All @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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";
}

Expand Down
84 changes: 80 additions & 4 deletions src/main/resources/templates/stats/ask-chat.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>NADAB — Ask Chat Stats</title>
<title>NADAB — Stats</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet"/>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -248,10 +291,43 @@

<div th:replace="~{stats/fragments/tabs :: tabs(${activeTab})}"></div>

<nav class="period-tabs" aria-label="물어보기 통계 기간">
<a class="period-link" th:classappend="${period == 'daily'} ? ' active' : ''"
th:attr="aria-current=${period == 'daily' ? 'page' : null}"
th:href="@{/stats/ask-chat(period='daily')}">일간</a>
<a class="period-link" th:classappend="${period == 'weekly'} ? ' active' : ''"
th:attr="aria-current=${period == 'weekly' ? 'page' : null}"
th:href="@{/stats/ask-chat(period='weekly')}">주간</a>
<a class="period-link" th:classappend="${period == 'monthly'} ? ' active' : ''"
th:attr="aria-current=${period == 'monthly' ? 'page' : null}"
th:href="@{/stats/ask-chat(period='monthly')}">월간</a>
</nav>

<section class="period-panel">
<div class="period-eyebrow">Ask Chat 집계 기간</div>
<div class="period-title" th:text="|${vm.startDate} ~ ${vm.endDate}|">2026-08-25 ~ 2026-08-31</div>
<div class="period-note">최근 7일 · Asia/Seoul 기준 · 세션, 메시지, 대화권, RAG read model을 한 화면에서 확인합니다.</div>
<div class="period-panel-header">
<div>
<div class="period-eyebrow">Ask Chat 집계 기간</div>
<div class="period-title" th:text="|${vm.startDate} ~ ${vm.endDate}|">2026-08-25 ~ 2026-08-31</div>
<div class="period-note">Asia/Seoul 기준 · 진행 중인 주·월은 오늘까지 집계합니다.</div>
</div>
<form class="period-picker" method="get" th:action="@{/stats/ask-chat}">
<input type="hidden" name="period" th:value="${period}"/>
<div class="period-picker-field">
<label class="period-picker-label" for="periodValue"
th:text="${period == 'daily' ? '기준일' : period == 'weekly' ? '기준 주' : '기준 월'}">기준일</label>
<input class="period-picker-input" id="periodValue" name="date" type="date"
th:if="${period == 'daily'}" th:value="${periodValue}" required/>
<input class="period-picker-input" id="periodValue" name="week" type="week"
th:if="${period == 'weekly'}" th:value="${periodValue}" required/>
<input class="period-picker-input" id="periodValue" name="month" type="month"
th:if="${period == 'monthly'}" th:value="${periodValue}" required/>
</div>
<button class="period-picker-submit" type="submit">조회</button>
<a class="period-picker-current" th:if="${period == 'daily'}" th:href="@{/stats/ask-chat(period='daily')}">오늘</a>
<a class="period-picker-current" th:if="${period == 'weekly'}" th:href="@{/stats/ask-chat(period='weekly')}">이번 주</a>
<a class="period-picker-current" th:if="${period == 'monthly'}" th:href="@{/stats/ask-chat(period='monthly')}">이번 달</a>
</form>
</div>
</section>

<section class="summary-grid">
Expand Down
6 changes: 3 additions & 3 deletions src/main/resources/templates/stats/fragments/tabs.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
<a class="tab-link"
th:classappend="${activeTab} == 'type' ? ' active' : ''"
th:href="@{/stats/type}">유형</a>
<a class="tab-link"
th:classappend="${activeTab} == 'ask-chat' ? ' active' : ''"
th:href="@{/stats/ask-chat}">물어보기</a>
<a class="tab-link"
th:classappend="${activeTab} == 'question' ? ' active' : ''"
th:href="@{/stats/question}">질문</a>
Expand All @@ -23,9 +26,6 @@
<a class="tab-link"
th:classappend="${activeTab} == 'total' ? ' active' : ''"
th:href="@{/stats/total}">전체</a>
<a class="tab-link"
th:classappend="${activeTab} == 'ask-chat' ? ' active' : ''"
th:href="@{/stats/ask-chat}">Ask Chat</a>
</div>
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -76,6 +80,8 @@ class StatsControllerTemplateTest {

@Autowired
private MockMvc mockMvc;
@Autowired
private StatsController statsController;

@MockitoBean
private AskChatStatsService askChatStatsService;
Expand Down Expand Up @@ -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("답변 성공률")))
Expand All @@ -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
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -626,8 +689,12 @@ void all_stats_templates_use_common_tab_fragment() throws Exception {
"th:href=\"@{/stats/question}\"",
">질문</a>",
"th:href=\"@{/stats/ask-chat}\"",
">Ask Chat</a>"
">물어보기</a>"
);
assertThat(fragment.indexOf(">유형</a>"))
.isLessThan(fragment.indexOf(">물어보기</a>"));
assertThat(fragment.indexOf(">물어보기</a>"))
.isLessThan(fragment.indexOf(">질문</a>"));

for (String template : List.of("daily", "weekly", "monthly", "type", "question", "question-overview", "withdrawal", "total", "ask-chat")) {
String source = new ClassPathResource("templates/stats/" + template + ".html")
Expand Down
Loading