From 0e1526ca20ee3b77a9c0d9987a527c70003b9b09 Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Tue, 26 May 2026 17:38:51 +0900 Subject: [PATCH 01/10] =?UTF-8?q?today-stats=20=EC=9D=91=EB=8B=B5=EC=97=90?= =?UTF-8?q?=20=EB=88=84=EC=A0=81=20=ED=86=B5=EA=B3=84=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=203=EA=B0=9C=20=EC=B6=94=EA=B0=80=20:=20docs=20:=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=20spec=20=EC=9E=91=EC=84=B1=20#40?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...26-today-stats-cumulative-fields-design.md | 250 ++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-26-today-stats-cumulative-fields-design.md diff --git a/docs/superpowers/specs/2026-05-26-today-stats-cumulative-fields-design.md b/docs/superpowers/specs/2026-05-26-today-stats-cumulative-fields-design.md new file mode 100644 index 0000000..d1404cc --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-today-stats-cumulative-fields-design.md @@ -0,0 +1,250 @@ +# today-stats 응답에 누적 통계 필드 3개 추가 (Design Spec) + +- **이슈**: #40 +- **브랜치**: `20260526_#40_today_stats_응답에_누적_통계_필드_3개_추가` +- **작성일**: 2026-05-26 +- **종류**: API 확장 (호환성 유지) + +--- + +## 1. 배경 / 문제 정의 + +### 1.1 현재 동작 +클라이언트의 누적 통계 Provider 3종이 `GET /api/timer-sessions`의 **첫 페이지(20개)만** 합산해 통계를 계산하고 있다. + +- `totalStudyMinutesProvider` +- `totalSessionCountProvider` +- `monthlyStudyMinutesProvider` + +### 1.2 영향 +| 영역 | 증상 | +|------|------| +| 프로필 화면 | "공부 시간" 통계 카드가 최근 20세션 기준으로만 표시 — 실제 누적과 다름 | +| 뱃지 시스템 | "총 100시간 공부", "총 50회 세션 완료" 류 조건 평가가 부정확 → 영원히 해금되지 않을 가능성 | +| API 트래픽 | 클라가 모든 페이지 순회 시 N번 API 호출 — 세션이 많아질수록 비효율 | + +### 1.3 해결 방향 +새 엔드포인트를 만드는 대신 **기존 `GET /api/timer-sessions/today-stats` 응답을 확장**한다. + +선택 사유: +- 클라의 호출 시점·캐시 정책이 today-stats와 동일 (홈/프로필 진입 시). +- API 표면적 최소화. +- 기존 `TodayStatsResponse` 스키마와 자연스러운 확장 관계. + +--- + +## 2. 변경 범위 + +### 2.1 API 계약 (불변/추가) +- 엔드포인트, 메서드, 인증, query/header 모두 **불변**. +- 응답에 필드 3개 **추가** (기존 필드 순서·이름 불변). + +### 2.2 응답 스키마 — `TodayStatsResponse` + +| 필드 | 타입 | 기존/신규 | 의미 | +|------|------|-----------|------| +| `totalMinutes` | Integer | 기존 | 오늘 총 공부 시간 (분, KST) | +| `sessionCount` | Integer | 기존 | 오늘 완료한 세션 수 (KST) | +| `streak` | Integer | 기존 | 연속 공부 일수 (오늘 포함, KST) | +| `lifetimeMinutes` | Integer | **신규** | 회원의 전체 누적 공부 시간 (분) | +| `lifetimeSessionCount` | Integer | **신규** | 회원의 전체 세션 수 | +| `monthlyMinutes` | Integer | **신규** | 이번 달 누적 공부 시간 (분, KST 기준) | + +#### 응답 예시 +```json +{ + "totalMinutes": 180, + "sessionCount": 3, + "streak": 7, + "lifetimeMinutes": 12450, + "lifetimeSessionCount": 287, + "monthlyMinutes": 1820 +} +``` + +### 2.3 0건 케이스 +- 세션이 0건인 회원은 신규 3필드 모두 `0` 반환. +- **`null` 금지** (DTO/스키마 모두 비-null 정수). +- DB 단에서는 `COALESCE(SUM(...), 0L)`로 NULL 방지. + +--- + +## 3. 시간 경계 정의 + +### 3.1 "이번 달" 경계 (KST) +- streak 계산과 **동일한 타임존(Asia/Seoul) 기준**. +- 시작: 이번 달 1일 00:00 KST +- 종료(exclusive): 다음 달 1일 00:00 KST + +### 3.2 산정식 (의사코드) +``` +todayKst = LocalDate.now(clock, Asia/Seoul) +monthStartKst = todayKst.withDayOfMonth(1) +monthEndKst = monthStartKst.plusMonths(1) +monthStartUtc = monthStartKst.atStartOfDay(Asia/Seoul) → UTC LocalDateTime +monthEndUtc = monthEndKst.atStartOfDay(Asia/Seoul) → UTC LocalDateTime + +monthlyMinutes = SUM(duration_minutes) + WHERE user_id = ? + AND started_at >= monthStartUtc + AND started_at < monthEndUtc +``` + +### 3.3 KST 월 경계 예시 +- UTC `2026-04-30 16:00:00` = KST `2026-05-01 01:00:00` → **5월**에 집계. +- UTC `2026-04-30 14:59:00` = KST `2026-04-30 23:59:00` → **4월**에 집계. + +--- + +## 4. 구현 설계 + +### 4.1 영향 모듈 +| 모듈 | 파일 | +|------|------| +| SS-Study | `dto/TodayStatsResponse.java` (필드 추가) | +| SS-Study | `repository/TimerSessionRepository.java` (메서드 2개 추가) | +| SS-Study | `service/TimerSessionService.java` (`getTodayStats` 합산 로직 확장) | +| SS-Web | `controller/timer/TimerSessionController.java` (Swagger `examples` 갱신) | +| docs | `docs/api-specs/03_timer.md` (today-stats 응답 섹션 갱신) | +| docs | `docs/api-docs.json` (수동 관리 시 동기화 — 빌드 자동 생성이면 생략) | + +### 4.2 Repository 변경 +```java +// 추가: 전체 누적 분 (COALESCE로 NULL 방지) +@Query("SELECT COALESCE(SUM(s.durationMinutes), 0L) FROM TimerSession s " + + "WHERE s.userId = :userId") +Long sumDurationByUserId(@Param("userId") Long userId); + +// 추가: 전체 세션 수 (Spring Data 메서드 명명 규칙) +long countByUserId(Long userId); +``` +- `monthlyMinutes`는 **기존 `sumDurationBetween(userId, start, end)` 재사용** — 신규 메서드 X. +- 인덱스 `idx_timer_sessions_user_started (user_id, started_at DESC)` 가 기존에 존재 → 신규 두 쿼리도 동일 인덱스로 커버. + +### 4.3 Service 변경 — `getTodayStats` + +```java +public TodayStatsResponse getTodayStats(Long userId) { + // ── 기존 로직 (today + streak) ───────────────────────── + LocalDate todayKst = LocalDate.now(clock.withZone(ZONE_KST)); + LocalDateTime todayStartUtc = toUtcLdt(todayKst.atStartOfDay(ZONE_KST)); + LocalDateTime tomorrowStartUtc = toUtcLdt(todayKst.plusDays(1).atStartOfDay(ZONE_KST)); + + long totalMinutes = Optional.ofNullable( + sessionRepository.sumDurationBetween(userId, todayStartUtc, tomorrowStartUtc)) + .orElse(0L); + long sessionCount = sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan( + userId, todayStartUtc, tomorrowStartUtc); + + LocalDateTime lookbackStart = toUtcLdt( + todayKst.minusDays(STREAK_LOOKBACK_DAYS).atStartOfDay(ZONE_KST)); + List startedAts = sessionRepository + .findStartedAtsAfter(userId, lookbackStart); + int streak = computeStreak(startedAts, todayKst); + + // ── 신규: 이번 달 (KST) ──────────────────────────────── + LocalDate monthStartKst = todayKst.withDayOfMonth(1); + LocalDateTime monthStartUtc = toUtcLdt(monthStartKst.atStartOfDay(ZONE_KST)); + LocalDateTime monthEndUtc = toUtcLdt(monthStartKst.plusMonths(1).atStartOfDay(ZONE_KST)); + long monthlyMinutes = Optional.ofNullable( + sessionRepository.sumDurationBetween(userId, monthStartUtc, monthEndUtc)) + .orElse(0L); + + // ── 신규: 전체 누적 ──────────────────────────────────── + long lifetimeMinutes = Optional.ofNullable( + sessionRepository.sumDurationByUserId(userId)).orElse(0L); + long lifetimeSessionCount = sessionRepository.countByUserId(userId); + + return new TodayStatsResponse( + Math.toIntExact(totalMinutes), + (int) sessionCount, + streak, + Math.toIntExact(lifetimeMinutes), + Math.toIntExact(lifetimeSessionCount), + Math.toIntExact(monthlyMinutes) + ); +} +``` + +### 4.4 쿼리 개수 +| 단계 | 쿼리 수 | +|------|---------| +| 기존 (today + streak) | 3 | +| 신규 (lifetime SUM + lifetime COUNT + monthly SUM) | +3 | +| **합계** | **6** | + +모두 동일 인덱스 + 단일 사용자 한정 범위 스캔이라 ms 단위 영향. 캐싱/비정규화는 도입하지 않음 (YAGNI). + +### 4.5 오버플로우 안전 +- `Math.toIntExact(Long → int)` 적용 — overflow 발생 시 즉시 `ArithmeticException`. +- 분 단위 누적은 `1440 × 365 × 10년 ≈ 5.25M`로 int 안전 범위 내. + +--- + +## 5. 호환성 / 마이그레이션 + +| 항목 | 영향 | +|------|------| +| DB 스키마 | 변경 없음 (마이그레이션 파일 추가 X) | +| 인덱스 | 변경 없음 | +| 기존 클라 | 신규 필드는 무시 → **무중단** | +| 신규 클라 | 누적값 즉시 사용 가능 | +| 롤백 | DTO/Service만 되돌리면 됨 (DB 영향 0) | + +--- + +## 6. 테스트 계획 + +### 6.1 Service 단위 테스트 (`TimerSessionServiceTest`) +| 케이스 | 기대값 | +|--------|--------| +| 세션 0건 | `lifetimeMinutes=0`, `lifetimeSessionCount=0`, `monthlyMinutes=0` | +| 오늘만 1건 (90분) | `total=90`, `lifetime=90`, `monthly=90`, `lifetimeCount=1` | +| 지난달 + 이번 달 혼합 | `monthly < lifetime`, `lifetime = SUM(전체)` | +| KST 월 경계: UTC 4/30 16:00 세션 | KST 5/1 01:00 → 5월 `monthlyMinutes`에 포함 | +| 큰 누적 (수십 시간) | 정수 반환, 음수/NULL 없음 | + +### 6.2 Controller 통합 테스트 (`TimerSessionControllerTest` MockMvc) +- `GET /api/timer-sessions/today-stats` 응답 JSON에 **신규 필드 3개 존재** 검증. +- 응답 200 + 필드 타입 정수 검증. +- 인증 누락 시 401 (기존 동작 회귀 없음). + +### 6.3 회귀 테스트 +- 기존 today/streak 테스트가 모두 통과해야 함 (수정 없이). + +--- + +## 7. 문서 갱신 + +### 7.1 `docs/api-specs/03_timer.md` +- `today-stats` 섹션의 응답 예시·필드 표에 신규 3필드 추가. +- 시간 경계(KST) 명시. + +### 7.2 `docs/api-docs.json` +- springdoc이 빌드 시 자동 생성하는 산출물이면 별도 수정 불필요. +- 수동으로 PR에 포함시키는 정책이면 빌드 후 산출물 동기화. +- (확인 후 plan 단계에서 결정) + +### 7.3 Swagger 어노테이션 +- `TodayStatsResponse` 각 필드에 `@Schema(description=...)` 추가. +- Controller `@ApiResponse` `examples`를 신규 필드 포함 형태로 교체. + +--- + +## 8. 비결정/추후 검토 (Out of Scope) +- members 테이블에 누적 통계 비정규화 (현재 규모에서 불필요). +- Redis TTL 캐싱. +- 주간/연간 통계 (요청 범위 외). +- 클라 측 Provider 로직 수정 (백엔드 PR 외 작업). + +--- + +## 9. 완료 조건 (Definition of Done) +- [ ] `TodayStatsResponse` 6필드 record로 확장 + `@Schema` 적용. +- [ ] Repository 메서드 2개 추가 (`sumDurationByUserId`, `countByUserId`). +- [ ] Service `getTodayStats` 합산 로직 확장. +- [ ] Controller Swagger `examples` 갱신. +- [ ] Service/Controller 테스트 6.1·6.2 케이스 추가, 전 테스트 그린. +- [ ] `docs/api-specs/03_timer.md` 갱신. +- [ ] 빌드 그린 (`./gradlew build`). From 2f7de4c6594f3e2588447ed394a19290c4125065 Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Tue, 26 May 2026 17:56:50 +0900 Subject: [PATCH 02/10] =?UTF-8?q?today-stats=20=EC=9D=91=EB=8B=B5=EC=97=90?= =?UTF-8?q?=20=EB=88=84=EC=A0=81=20=ED=86=B5=EA=B3=84=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=203=EA=B0=9C=20=EC=B6=94=EA=B0=80=20:=20docs=20:=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20plan=20=EC=9E=91=EC=84=B1=20#40?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...026-05-26-today-stats-cumulative-fields.md | 701 ++++++++++++++++++ 1 file changed, 701 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-26-today-stats-cumulative-fields.md diff --git a/docs/superpowers/plans/2026-05-26-today-stats-cumulative-fields.md b/docs/superpowers/plans/2026-05-26-today-stats-cumulative-fields.md new file mode 100644 index 0000000..316e621 --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-today-stats-cumulative-fields.md @@ -0,0 +1,701 @@ +# today-stats 누적 통계 필드 3개 추가 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `GET /api/timer-sessions/today-stats` 응답에 `lifetimeMinutes`, `lifetimeSessionCount`, `monthlyMinutes` 3개 필드를 추가해 클라이언트의 누적 통계(프로필 카드·뱃지 해금)가 정확히 동작하도록 한다. + +**Architecture:** 기존 record `TodayStatsResponse`에 비-null Integer 3개를 append-only로 추가한다. Service는 KST 기준 "이번 달" 경계를 계산해 기존 `sumDurationBetween`을 재사용하고, lifetime용 SUM/COUNT는 신규 Repository 메서드 2개로 처리한다. 인덱스(`user_id, started_at DESC`)는 기존 것을 그대로 사용, 마이그레이션·캐싱은 도입하지 않는다. + +**Tech Stack:** Spring Boot 4.0.x · Spring Data JPA · JUnit 5 · Mockito · AssertJ · Springdoc(Swagger) · PostgreSQL + +**참조 문서:** +- 설계 spec: `docs/superpowers/specs/2026-05-26-today-stats-cumulative-fields-design.md` +- API spec: `docs/api-specs/03_timer.md` +- 코드 컨벤션: 루트 `CLAUDE.md` + +**커밋 메시지 컨벤션 (프로젝트 규칙):** +``` +today-stats 응답에 누적 통계 필드 3개 추가 : {type} : {설명} #40 +``` +- `type`: `feat` / `fix` / `refactor` / `test` / `docs` / `chore` +- 이모지·Co-Authored-By 금지 + +--- + +## File Structure + +| 모듈 | 경로 | 변경 종류 | 역할 | +|------|------|----------|------| +| SS-Study | `study/timer/dto/TodayStatsResponse.java` | Modify | record에 필드 3개 append + `@Schema` 추가 | +| SS-Study | `study/timer/repository/TimerSessionRepository.java` | Modify | `sumDurationByUserId`, `countByUserId` 추가 | +| SS-Study | `study/timer/service/TimerSessionService.java` | Modify | `getTodayStats` 합산 로직 확장 | +| SS-Study | `study/timer/repository/TimerSessionRepositoryTest.java` | Modify | 신규 메서드 통합 테스트 2건 추가 | +| SS-Study | `study/timer/service/TimerSessionServiceTest.java` | Modify | 기존 today-stats 테스트 stub/expected 갱신 + 신규 케이스 3건 추가 | +| SS-Web | `controller/timer/TimerSessionController.java` | Modify | `@ApiResponse examples` JSON 갱신 | +| SS-Web | `controller/timer/TimerSessionControllerTest.java` | Modify | `todayStats_200` 6필드 검증으로 갱신 | +| docs | `docs/api-specs/03_timer.md` | Modify | today-stats 응답 섹션 + 필드 표 갱신 | + +DB 마이그레이션 / Entity / 인덱스 변경 **없음**. + +--- + +## Task 1: TodayStatsResponse DTO 확장 (필드 3개 추가) + +**목표:** record에 필드를 추가해 컴파일 베이스라인을 확장한다. 이 단계에서는 신규 필드 모두 `0`을 임시로 주입해 빌드를 그린 상태로 유지한다. Repository/Service 로직은 후속 task에서 채운다. + +**Files:** +- Modify: `SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TodayStatsResponse.java` +- Modify: `SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java:183` +- Modify: `SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java` (기존 5개 테스트 컴파일 fix) +- Modify: `SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java:238` (생성자 인자 수 fix) + +- [ ] **Step 1: `TodayStatsResponse` 필드 3개 추가** + +`SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TodayStatsResponse.java` 전체를 아래로 교체: + +```java +package com.elipair.spacestudyship.study.timer.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "오늘 공부 통계 + 누적 통계 (KST 기준)") +public record TodayStatsResponse( + @Schema(description = "오늘 총 공부 시간 (분)") Integer totalMinutes, + @Schema(description = "오늘 완료한 세션 수") Integer sessionCount, + @Schema(description = "연속 공부 일수 (오늘 포함, KST 기준)") Integer streak, + @Schema(description = "회원의 전체 누적 공부 시간 (분)") Integer lifetimeMinutes, + @Schema(description = "회원의 전체 세션 수") Integer lifetimeSessionCount, + @Schema(description = "이번 달 누적 공부 시간 (분, KST 기준)") Integer monthlyMinutes +) {} +``` + +- [ ] **Step 2: `TimerSessionService.getTodayStats` 반환문 임시 fix** + +`SS-Study/.../service/TimerSessionService.java` 의 `getTodayStats` 마지막 return 문: + +기존: +```java +return new TodayStatsResponse(Math.toIntExact(totalMinutes), (int) sessionCount, streak); +``` + +변경: +```java +// Task 3에서 lifetime/monthly 합산 로직으로 교체 — 현재는 컴파일 유지용 0 주입 +return new TodayStatsResponse(Math.toIntExact(totalMinutes), (int) sessionCount, streak, 0, 0, 0); +``` + +- [ ] **Step 3: 기존 Service 테스트 5개의 생성자 호출 fix** + +`SS-Study/src/test/java/.../service/TimerSessionServiceTest.java` 에서 `new TodayStatsResponse(...)`로 비교하는 단 1건 (line 327)만 6필드로 변경: + +기존: +```java +assertThat(res).isEqualTo(new TodayStatsResponse(0, 0, 0)); +``` + +변경: +```java +assertThat(res).isEqualTo(new TodayStatsResponse(0, 0, 0, 0, 0, 0)); +``` + +나머지 4건(`todayStats_withData`, `streak_yesterdayLatest`, `streak_brokenChain`, `streak_futureLatest_clampedToToday`)은 개별 필드 `.totalMinutes()/.sessionCount()/.streak()`만 검증하므로 시그니처 변경 영향 없음 — 수정하지 않는다. + +- [ ] **Step 4: 기존 Controller 테스트 생성자 호출 fix** + +`SS-Web/src/test/java/.../controller/timer/TimerSessionControllerTest.java:238` 의 `todayStats_200`: + +기존: +```java +given(service.getTodayStats(1L)) + .willReturn(new TodayStatsResponse(180, 3, 7)); +``` + +변경: +```java +given(service.getTodayStats(1L)) + .willReturn(new TodayStatsResponse(180, 3, 7, 12450, 287, 1820)); +``` + +(jsonPath 검증은 Task 4에서 확장) + +- [ ] **Step 5: 빌드/테스트 그린 확인** + +Run: +```bash +./gradlew :SS-Study:test :SS-Web:test +``` +Expected: PASS (모든 기존 테스트 통과, 컴파일 에러 0) + +- [ ] **Step 6: 커밋** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TodayStatsResponse.java \ + SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java \ + SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java \ + SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java +git commit -m "today-stats 응답에 누적 통계 필드 3개 추가 : feat : TodayStatsResponse에 lifetime/monthly 필드 추가 (값은 임시 0) #40" +``` + +--- + +## Task 2: Repository 메서드 2개 추가 (TDD) + +**목표:** lifetime SUM/COUNT용 메서드 2개를 추가하고 통합 테스트로 검증한다. `monthlyMinutes`는 기존 `sumDurationBetween`을 그대로 재사용하므로 추가하지 않는다. + +**Files:** +- Modify: `SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepository.java` +- Modify: `SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepositoryTest.java` + +- [ ] **Step 1: 통합 테스트 2건 작성 (실패 예상)** + +`TimerSessionRepositoryTest.java` 의 클래스 끝부분(마지막 `}` 직전)에 아래 두 테스트를 추가: + +```java + @Test + @DisplayName("sumDurationByUserId: 본인 전체 합산, 다른 user 제외, 빈 결과는 0") + void sumDurationByUserId() { + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-04-10T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-01T01:00:00"), 60, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-25T01:00:00"), 90, null, null)); + repository.saveAndFlush(session(2L, LocalDateTime.parse("2026-05-25T01:00:00"), 120, null, null)); + + assertThat(repository.sumDurationByUserId(1L)).isEqualTo(180L); + assertThat(repository.sumDurationByUserId(2L)).isEqualTo(120L); + assertThat(repository.sumDurationByUserId(999L)).isZero(); + } + + @Test + @DisplayName("countByUserId: 본인 세션 수, 다른 user 제외, 빈 결과는 0") + void countByUserId() { + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-23T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-24T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-25T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(2L, LocalDateTime.parse("2026-05-25T01:00:00"), 30, null, null)); + + assertThat(repository.countByUserId(1L)).isEqualTo(3); + assertThat(repository.countByUserId(2L)).isEqualTo(1); + assertThat(repository.countByUserId(999L)).isZero(); + } +``` + +- [ ] **Step 2: 테스트 실행 → 실패 확인** + +Run: +```bash +./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.timer.repository.TimerSessionRepositoryTest" +``` +Expected: 컴파일 실패 — `sumDurationByUserId(Long)`, `countByUserId(Long)` 미정의 + +- [ ] **Step 3: Repository에 메서드 2개 추가** + +`SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepository.java` 의 `findStartedAtsAfter` 메서드 아래(클래스 닫는 `}` 직전)에 추가: + +```java + // 전체 누적 분: SUM(Integer) → Long, COALESCE로 NULL 방지 + @Query("SELECT COALESCE(SUM(s.durationMinutes), 0L) FROM TimerSession s " + + "WHERE s.userId = :userId") + Long sumDurationByUserId(@Param("userId") Long userId); + + // 전체 세션 수: Spring Data 명명 규칙 + long countByUserId(Long userId); +``` + +- [ ] **Step 4: 테스트 실행 → 통과 확인** + +Run: +```bash +./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.timer.repository.TimerSessionRepositoryTest" +``` +Expected: PASS (신규 2개 + 기존 8개 모두 통과) + +- [ ] **Step 5: 커밋** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepository.java \ + SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepositoryTest.java +git commit -m "today-stats 응답에 누적 통계 필드 3개 추가 : feat : Repository에 sumDurationByUserId/countByUserId 추가 #40" +``` + +--- + +## Task 3: Service 합산 로직 확장 (TDD) + +**목표:** `getTodayStats`에 lifetime SUM/COUNT 호출과 KST 기준 이번 달 SUM 호출을 추가해 응답 6필드를 채운다. + +**Files:** +- Modify: `SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java` +- Modify: `SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java` + +- [ ] **Step 1: 기존 today-stats 테스트 5건에 신규 stub 추가** + +신규 Repository 메서드(`sumDurationByUserId`, `countByUserId`) 호출을 추가하면 기존 5개 테스트(`todayStats_empty`, `todayStats_withData`, `streak_yesterdayLatest`, `streak_brokenChain`, `streak_futureLatest_clampedToToday`)에서 lenient mode가 아니라면 stub 누락으로 NullPointerException 발생 가능. **방어적으로 모든 today-stats 관련 테스트에 stub 추가.** + +또한 `sumDurationBetween`는 기존에 `any(), any()` 두 호출(오늘 + 이번 달)을 모두 매칭하므로 `0L` 반환 stub만 있으면 충분하지만, `todayStats_withData`처럼 특정 값을 기대하는 경우 `any(), any()`가 두 호출 모두 같은 값을 반환하면 today=monthly가 됨 → 그게 의도된 동작이므로 OK. + +각 테스트의 `@BeforeEach` 이후 `given(...)` 블록 직후에 아래 두 줄 추가: + +```java +given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(0L); +given(sessionRepository.countByUserId(eq(1L))).willReturn(0L); +``` + +`todayStats_empty` (line 317~) 전체를 아래로 교체: + +```java + @Test + @DisplayName("today-stats: 빈 데이터 → 모두 0") + void todayStats_empty() { + given(sessionRepository.sumDurationBetween(eq(1L), any(), any())).willReturn(0L); + given(sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) + .willReturn(0L); + given(sessionRepository.findStartedAtsAfter(eq(1L), any())).willReturn(List.of()); + given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(0L); + given(sessionRepository.countByUserId(eq(1L))).willReturn(0L); + + TodayStatsResponse res = service.getTodayStats(1L); + + assertThat(res).isEqualTo(new TodayStatsResponse(0, 0, 0, 0, 0, 0)); + } +``` + +`todayStats_withData` (line 331~) 전체를 아래로 교체 (lifetime/monthly stub 분리 — `eq` Matcher로 호출 구분): + +```java + @Test + @DisplayName("today-stats: 정상 데이터 + streak + lifetime/monthly 계산") + void todayStats_withData() { + // sumDurationBetween는 오늘 + 이번 달 두 번 호출됨. + // fixedClock=2026-05-25T12:00:00Z → KST 2026-05-25 21:00 → 오늘=2026-05-25 KST, 월 시작=2026-05-01 KST. + // 둘 다 any() 매칭 시 마지막 stub이 우선이므로, 명시적으로 호출별 stub을 분리한다. + given(sessionRepository.sumDurationBetween(eq(1L), + eq(LocalDateTime.parse("2026-05-24T15:00:00")), // 오늘 시작 (KST 5/25 00:00 = UTC 5/24 15:00) + eq(LocalDateTime.parse("2026-05-25T15:00:00")))) // 내일 시작 (KST 5/26 00:00 = UTC 5/25 15:00) + .willReturn(180L); + given(sessionRepository.sumDurationBetween(eq(1L), + eq(LocalDateTime.parse("2026-04-30T15:00:00")), // 5월 시작 KST 5/1 00:00 = UTC 4/30 15:00 + eq(LocalDateTime.parse("2026-05-31T15:00:00")))) // 6월 시작 KST 6/1 00:00 = UTC 5/31 15:00 + .willReturn(1820L); + given(sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) + .willReturn(3L); + given(sessionRepository.findStartedAtsAfter(eq(1L), any())) + .willReturn(List.of( + LocalDateTime.parse("2026-05-25T02:00:00"), + LocalDateTime.parse("2026-05-23T16:00:00"), + LocalDateTime.parse("2026-05-22T16:00:00") + )); + given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(12450L); + given(sessionRepository.countByUserId(eq(1L))).willReturn(287L); + + TodayStatsResponse res = service.getTodayStats(1L); + + assertThat(res.totalMinutes()).isEqualTo(180); + assertThat(res.sessionCount()).isEqualTo(3); + assertThat(res.streak()).isEqualTo(3); + assertThat(res.lifetimeMinutes()).isEqualTo(12450); + assertThat(res.lifetimeSessionCount()).isEqualTo(287); + assertThat(res.monthlyMinutes()).isEqualTo(1820); + } +``` + +`streak_yesterdayLatest`, `streak_brokenChain`, `streak_futureLatest_clampedToToday` 3건은 streak만 검증하므로, 각 테스트의 `given(sessionRepository.findStartedAtsAfter...)` 줄 **다음**에 아래 stub 두 줄을 추가: + +```java +given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(0L); +given(sessionRepository.countByUserId(eq(1L))).willReturn(0L); +``` + +- [ ] **Step 2: 신규 테스트 3건 추가** + +`TimerSessionServiceTest.java` 의 `streak_futureLatest_clampedToToday` 메서드 **다음**(클래스 닫는 `}` 직전)에 추가: + +```java + @Test + @DisplayName("today-stats: lifetime/monthly — KST 월 경계가 sumDurationBetween 인자에 정확히 매핑") + void todayStats_monthlyBoundary_kst() { + // fixedClock=2026-05-25T12:00:00Z → KST 5/25. + // 이번 달 시작 KST 2026-05-01 00:00 = UTC 2026-04-30 15:00 + // 다음 달 시작 KST 2026-06-01 00:00 = UTC 2026-05-31 15:00 + LocalDateTime expectedMonthStartUtc = LocalDateTime.parse("2026-04-30T15:00:00"); + LocalDateTime expectedMonthEndUtc = LocalDateTime.parse("2026-05-31T15:00:00"); + + given(sessionRepository.sumDurationBetween(eq(1L), any(), any())).willReturn(0L); + given(sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) + .willReturn(0L); + given(sessionRepository.findStartedAtsAfter(eq(1L), any())).willReturn(List.of()); + given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(0L); + given(sessionRepository.countByUserId(eq(1L))).willReturn(0L); + + service.getTodayStats(1L); + + // sumDurationBetween가 정확히 KST 월 경계(UTC 변환된 값)로 호출됐는지 검증 + verify(sessionRepository).sumDurationBetween(eq(1L), + eq(expectedMonthStartUtc), eq(expectedMonthEndUtc)); + } + + @Test + @DisplayName("today-stats: lifetime 0건 → 3 필드 모두 0 (null 금지)") + void todayStats_lifetimeZero_returnsZeroNotNull() { + given(sessionRepository.sumDurationBetween(eq(1L), any(), any())).willReturn(0L); + given(sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) + .willReturn(0L); + given(sessionRepository.findStartedAtsAfter(eq(1L), any())).willReturn(List.of()); + given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(0L); + given(sessionRepository.countByUserId(eq(1L))).willReturn(0L); + + TodayStatsResponse res = service.getTodayStats(1L); + + assertThat(res.lifetimeMinutes()).isNotNull().isZero(); + assertThat(res.lifetimeSessionCount()).isNotNull().isZero(); + assertThat(res.monthlyMinutes()).isNotNull().isZero(); + } + + @Test + @DisplayName("today-stats: 지난달 + 이번 달 혼합 → monthly < lifetime, lifetime = 전체 합") + void todayStats_mixedMonths_lifetimeGreaterThanMonthly() { + // sumDurationBetween는 (오늘, 이번 달) 두 번 호출 — 호출 인자로 구분 + given(sessionRepository.sumDurationBetween(eq(1L), + eq(LocalDateTime.parse("2026-05-24T15:00:00")), + eq(LocalDateTime.parse("2026-05-25T15:00:00")))) + .willReturn(0L); + given(sessionRepository.sumDurationBetween(eq(1L), + eq(LocalDateTime.parse("2026-04-30T15:00:00")), + eq(LocalDateTime.parse("2026-05-31T15:00:00")))) + .willReturn(1820L); + given(sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) + .willReturn(0L); + given(sessionRepository.findStartedAtsAfter(eq(1L), any())).willReturn(List.of()); + given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(12450L); + given(sessionRepository.countByUserId(eq(1L))).willReturn(287L); + + TodayStatsResponse res = service.getTodayStats(1L); + + assertThat(res.lifetimeMinutes()).isEqualTo(12450); + assertThat(res.monthlyMinutes()).isEqualTo(1820); + assertThat(res.monthlyMinutes()).isLessThan(res.lifetimeMinutes()); + assertThat(res.lifetimeSessionCount()).isEqualTo(287); + } +``` + +- [ ] **Step 3: 테스트 실행 → 실패 확인** + +Run: +```bash +./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.timer.service.TimerSessionServiceTest" +``` +Expected: FAIL — Service가 아직 lifetime/monthly를 계산하지 않으므로 `lifetimeMinutes/lifetimeSessionCount/monthlyMinutes`가 모두 0으로 반환. `todayStats_withData`, `todayStats_monthlyBoundary_kst`, `todayStats_mixedMonths_lifetimeGreaterThanMonthly` 실패. + +- [ ] **Step 4: Service `getTodayStats` 본문 교체** + +`SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java` 의 `getTodayStats` 메서드(line 165~184) 전체를 아래로 교체: + +```java + public TodayStatsResponse getTodayStats(Long userId) { + LocalDate todayKst = LocalDate.now(clock.withZone(ZONE_KST)); + LocalDateTime todayStartUtc = toUtcLdt(todayKst.atStartOfDay(ZONE_KST)); + LocalDateTime tomorrowStartUtc = toUtcLdt(todayKst.plusDays(1).atStartOfDay(ZONE_KST)); + + long totalMinutes = Optional.ofNullable( + sessionRepository.sumDurationBetween(userId, todayStartUtc, tomorrowStartUtc)) + .orElse(0L); + long sessionCount = sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan( + userId, todayStartUtc, tomorrowStartUtc); + + LocalDateTime lookbackStart = toUtcLdt( + todayKst.minusDays(STREAK_LOOKBACK_DAYS).atStartOfDay(ZONE_KST)); + List startedAts = sessionRepository + .findStartedAtsAfter(userId, lookbackStart); + int streak = computeStreak(startedAts, todayKst); + + // 이번 달 KST 경계 → UTC 변환 후 기존 sumDurationBetween 재사용 + LocalDate monthStartKst = todayKst.withDayOfMonth(1); + LocalDateTime monthStartUtc = toUtcLdt(monthStartKst.atStartOfDay(ZONE_KST)); + LocalDateTime monthEndUtc = toUtcLdt(monthStartKst.plusMonths(1).atStartOfDay(ZONE_KST)); + long monthlyMinutes = Optional.ofNullable( + sessionRepository.sumDurationBetween(userId, monthStartUtc, monthEndUtc)) + .orElse(0L); + + // 전체 누적 (lifetime) — Repository에서 COALESCE로 NULL-safe + long lifetimeMinutes = Optional.ofNullable( + sessionRepository.sumDurationByUserId(userId)).orElse(0L); + long lifetimeSessionCount = sessionRepository.countByUserId(userId); + + return new TodayStatsResponse( + Math.toIntExact(totalMinutes), + (int) sessionCount, + streak, + Math.toIntExact(lifetimeMinutes), + Math.toIntExact(lifetimeSessionCount), + Math.toIntExact(monthlyMinutes) + ); + } +``` + +- [ ] **Step 5: 테스트 실행 → 통과 확인** + +Run: +```bash +./gradlew :SS-Study:test --tests "com.elipair.spacestudyship.study.timer.service.TimerSessionServiceTest" +``` +Expected: PASS (기존 + 신규 모두 통과) + +- [ ] **Step 6: 커밋** + +```bash +git add SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java \ + SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java +git commit -m "today-stats 응답에 누적 통계 필드 3개 추가 : feat : Service에 lifetime/monthly 합산 로직 추가 (KST 월 경계) #40" +``` + +--- + +## Task 4: Controller MockMvc 테스트 확장 + Swagger 갱신 + +**목표:** Controller 레벨에서 신규 3필드가 JSON 응답에 직렬화되는지 검증하고, Swagger `examples`를 갱신해 문서가 최신 응답 형태를 반영하게 한다. + +**Files:** +- Modify: `SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java:236-245` +- Modify: `SS-Web/src/main/java/com/elipair/spacestudyship/controller/timer/TimerSessionController.java:138` + +- [ ] **Step 1: MockMvc 테스트에 신규 필드 jsonPath 검증 추가** + +`TimerSessionControllerTest.java` 의 `todayStats_200` (line 234~245) 전체를 아래로 교체: + +```java + @Test + @DisplayName("GET /api/timer-sessions/today-stats — 200, 6필드 (today + lifetime + monthly)") + void todayStats_200() throws Exception { + given(service.getTodayStats(1L)) + .willReturn(new TodayStatsResponse(180, 3, 7, 12450, 287, 1820)); + + mockMvc.perform(get("/api/timer-sessions/today-stats")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalMinutes").value(180)) + .andExpect(jsonPath("$.sessionCount").value(3)) + .andExpect(jsonPath("$.streak").value(7)) + .andExpect(jsonPath("$.lifetimeMinutes").value(12450)) + .andExpect(jsonPath("$.lifetimeSessionCount").value(287)) + .andExpect(jsonPath("$.monthlyMinutes").value(1820)); + } +``` + +- [ ] **Step 2: 0건 케이스 — null 금지 검증 테스트 추가** + +`todayStats_200` 메서드 **다음**에 추가: + +```java + @Test + @DisplayName("GET /api/timer-sessions/today-stats — 0건 회원: 신규 3필드도 0 (null 아님)") + void todayStats_zero_neverNull() throws Exception { + given(service.getTodayStats(1L)) + .willReturn(new TodayStatsResponse(0, 0, 0, 0, 0, 0)); + + mockMvc.perform(get("/api/timer-sessions/today-stats")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lifetimeMinutes").value(0)) + .andExpect(jsonPath("$.lifetimeSessionCount").value(0)) + .andExpect(jsonPath("$.monthlyMinutes").value(0)); + } +``` + +- [ ] **Step 3: 테스트 실행 → 통과 확인** + +Run: +```bash +./gradlew :SS-Web:test --tests "com.elipair.spacestudyship.controller.timer.TimerSessionControllerTest" +``` +Expected: PASS (모든 테스트 그린) + +- [ ] **Step 4: Controller Swagger `examples`·`description` 갱신** + +`SS-Web/.../controller/timer/TimerSessionController.java` 의 `getTodayStats` 메서드 어노테이션 블록(line 132~140) 전체를 아래로 교체: + +기존: +```java + @Operation(summary = "오늘 공부 통계", + description = "KST(Asia/Seoul) 기준 오늘의 총 분 / 세션 수 / 연속 일수(streak)") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = TodayStatsResponse.class), + examples = @ExampleObject(value = "{\"totalMinutes\":180,\"sessionCount\":3,\"streak\":7}"))), + @ApiResponse(responseCode = "401", description = "인증 필요") + }) +``` + +변경: +```java + @Operation(summary = "오늘 공부 통계 + 누적 통계", + description = """ + KST(Asia/Seoul) 기준 통계. + + ### 응답 필드 + - `totalMinutes`, `sessionCount`: 오늘 (KST) + - `streak`: 연속 공부 일수 (오늘 포함, KST) + - `lifetimeMinutes`, `lifetimeSessionCount`: 회원의 전체 누적 + - `monthlyMinutes`: 이번 달 누적 (KST 1일 00:00 ~ 다음 달 1일 00:00) + + 세션 0건 회원도 6개 필드 모두 `0`을 반환합니다 (null 금지). + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = TodayStatsResponse.class), + examples = @ExampleObject(value = """ + { + "totalMinutes": 180, + "sessionCount": 3, + "streak": 7, + "lifetimeMinutes": 12450, + "lifetimeSessionCount": 287, + "monthlyMinutes": 1820 + } + """))), + @ApiResponse(responseCode = "401", description = "인증 필요") + }) +``` + +- [ ] **Step 5: 컨트롤러 테스트 재실행 (회귀 확인)** + +Run: +```bash +./gradlew :SS-Web:test --tests "com.elipair.spacestudyship.controller.timer.TimerSessionControllerTest" +``` +Expected: PASS + +- [ ] **Step 6: 커밋** + +```bash +git add SS-Web/src/main/java/com/elipair/spacestudyship/controller/timer/TimerSessionController.java \ + SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java +git commit -m "today-stats 응답에 누적 통계 필드 3개 추가 : feat : Controller MockMvc 테스트 확장 + Swagger examples 갱신 #40" +``` + +--- + +## Task 5: API spec 문서 갱신 + +**목표:** `docs/api-specs/03_timer.md` 의 today-stats 섹션을 6필드 응답으로 갱신. + +**Files:** +- Modify: `docs/api-specs/03_timer.md` + +- [ ] **Step 1: today-stats 섹션 위치 확인** + +Run: +```bash +grep -n "today-stats" docs/api-specs/03_timer.md +``` +Expected: today-stats 헤더가 있는 라인 번호 출력. + +- [ ] **Step 2: today-stats 응답 섹션 갱신** + +`docs/api-specs/03_timer.md` 에서 today-stats 섹션의 `### Response` 블록(필드 표 + JSON 예시 포함)을 아래로 교체. 정확한 위치는 Step 1에서 확인한 라인 번호 기준. + +교체 내용 (응답 본문 블록 전체): + +````markdown +### Response + +`200 OK` + +| 필드 | 타입 | Nullable | 설명 | +|------|------|----------|------| +| `totalMinutes` | Integer | X | 오늘 총 공부 시간 (분, KST) | +| `sessionCount` | Integer | X | 오늘 완료한 세션 수 | +| `streak` | Integer | X | 연속 공부 일수 (오늘 포함, KST 기준) | +| `lifetimeMinutes` | Integer | X | 회원의 전체 누적 공부 시간 (분) | +| `lifetimeSessionCount` | Integer | X | 회원의 전체 세션 수 | +| `monthlyMinutes` | Integer | X | 이번 달 누적 공부 시간 (분, KST 기준) | + +> 세션 0건 회원도 6필드 모두 `0`을 반환합니다. `null` 절대 반환하지 않습니다. + +```json +{ + "totalMinutes": 180, + "sessionCount": 3, + "streak": 7, + "lifetimeMinutes": 12450, + "lifetimeSessionCount": 287, + "monthlyMinutes": 1820 +} +``` + +#### 시간 경계 정의 +- **오늘**: `KST 00:00:00` ~ `KST 23:59:59` +- **이번 달**: 이번 달 1일 `KST 00:00:00` ~ 다음 달 1일 `KST 00:00:00` (반열림 `[start, end)`) +- **streak**: 마지막 공부일이 오늘이면 오늘 포함, 어제까지만 했으면 어제 기준. KST 기준 일자 단위 연속. +```` + +(기존 텍스트와 정확히 매칭되는 큰 블록을 한 번에 교체. 다른 섹션 — 엔드포인트 요약 표 등 — 은 건드리지 않는다.) + +- [ ] **Step 3: 마크다운 렌더 확인 (선택)** + +문서 형식만 확인. 빌드/테스트 영향 없음. + +- [ ] **Step 4: 커밋** + +```bash +git add docs/api-specs/03_timer.md +git commit -m "today-stats 응답에 누적 통계 필드 3개 추가 : docs : API spec 03_timer.md 응답 6필드로 갱신 #40" +``` + +--- + +## Task 6: 전체 빌드 + 회귀 검증 + +**목표:** 멀티 모듈 전체 빌드와 테스트가 그린인지 최종 확인. + +- [ ] **Step 1: 전체 빌드** + +Run: +```bash +./gradlew clean build +``` +Expected: `BUILD SUCCESSFUL` — 모든 모듈 컴파일·테스트 그린. + +- [ ] **Step 2: 실패 시 회귀 분석** + +만약 실패하면 stdout/stderr에서: +- 컴파일 에러: `TodayStatsResponse` 시그니처를 사용하는 추가 위치 누락 가능 → grep으로 점검 + ```bash + grep -rn "new TodayStatsResponse(" --include="*.java" . + ``` + 발견된 모든 호출이 6-인자 형태인지 확인. +- 테스트 실패: Mockito stub 누락 → Task 3 Step 1 가이드 다시 검토. + +Step 1·2를 반복해서 그린 상태로 만든다. + +- [ ] **Step 3: 최종 git status 확인 (작업 트리 클린)** + +Run: +```bash +git status +``` +Expected: `nothing to commit, working tree clean`. 미커밋 변경 없음. + +- [ ] **Step 4: 변경 요약 확인** + +Run: +```bash +git log --oneline origin/main..HEAD +``` +Expected: 5개 커밋 (Task 1~5)이 표시됨. 메시지는 모두 `today-stats 응답에 누적 통계 필드 3개 추가 : {type} : ... #40` 형태. + +--- + +## 완료 조건 (Definition of Done) + +- [ ] `TodayStatsResponse` record가 6필드(Integer, 모두 비-null)로 확장됨. +- [ ] Repository에 `sumDurationByUserId`, `countByUserId` 메서드 2개 추가. +- [ ] Service `getTodayStats`가 lifetime/monthly 합산 호출을 포함하고, KST 월 경계가 정확히 UTC로 변환됨. +- [ ] Controller Swagger `examples`·`description`이 신규 필드 포함 형태로 갱신. +- [ ] `docs/api-specs/03_timer.md` 갱신. +- [ ] 신규/기존 테스트 모두 통과: Repository(10건), Service(20+건), Controller(12+건). +- [ ] `./gradlew clean build` 그린. +- [ ] DB 마이그레이션·인덱스·Entity 변경 0건 (호환성 보장). From 95c4a0ab570e01719c47f3b3e6b4024f710d7d34 Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Tue, 26 May 2026 18:05:51 +0900 Subject: [PATCH 03/10] =?UTF-8?q?today-stats=20=EC=9D=91=EB=8B=B5=EC=97=90?= =?UTF-8?q?=20=EB=88=84=EC=A0=81=20=ED=86=B5=EA=B3=84=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=203=EA=B0=9C=20=EC=B6=94=EA=B0=80=20:=20feat=20:=20TodayStatsR?= =?UTF-8?q?esponse=EC=97=90=20lifetime/monthly=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(=EA=B0=92=EC=9D=80=20=EC=9E=84=EC=8B=9C?= =?UTF-8?q?=200)=20#40?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spacestudyship/study/timer/dto/TodayStatsResponse.java | 7 +++++-- .../study/timer/service/TimerSessionService.java | 3 ++- .../study/timer/service/TimerSessionServiceTest.java | 2 +- .../controller/timer/TimerSessionControllerTest.java | 2 +- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TodayStatsResponse.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TodayStatsResponse.java index b5e1285..2b2e7b8 100644 --- a/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TodayStatsResponse.java +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/dto/TodayStatsResponse.java @@ -2,9 +2,12 @@ import io.swagger.v3.oas.annotations.media.Schema; -@Schema(description = "오늘 공부 통계 (KST 기준)") +@Schema(description = "오늘 공부 통계 + 누적 통계 (KST 기준)") public record TodayStatsResponse( @Schema(description = "오늘 총 공부 시간 (분)") Integer totalMinutes, @Schema(description = "오늘 완료한 세션 수") Integer sessionCount, - @Schema(description = "연속 공부 일수 (오늘 포함, KST 기준)") Integer streak + @Schema(description = "연속 공부 일수 (오늘 포함, KST 기준)") Integer streak, + @Schema(description = "회원의 전체 누적 공부 시간 (분)") Integer lifetimeMinutes, + @Schema(description = "회원의 전체 세션 수") Integer lifetimeSessionCount, + @Schema(description = "이번 달 누적 공부 시간 (분, KST 기준)") Integer monthlyMinutes ) {} diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java index 08bf107..1929641 100644 --- a/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java @@ -180,7 +180,8 @@ public TodayStatsResponse getTodayStats(Long userId) { .findStartedAtsAfter(userId, lookbackStart); int streak = computeStreak(startedAts, todayKst); - return new TodayStatsResponse(Math.toIntExact(totalMinutes), (int) sessionCount, streak); + // Task 3에서 lifetime/monthly 합산 로직으로 교체 — 현재는 컴파일 유지용 0 주입 + return new TodayStatsResponse(Math.toIntExact(totalMinutes), (int) sessionCount, streak, 0, 0, 0); } private LocalDateTime toUtcLdt(ZonedDateTime kst) { diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java index 8c2ebff..506e307 100644 --- a/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java @@ -324,7 +324,7 @@ void todayStats_empty() { TodayStatsResponse res = service.getTodayStats(1L); - assertThat(res).isEqualTo(new TodayStatsResponse(0, 0, 0)); + assertThat(res).isEqualTo(new TodayStatsResponse(0, 0, 0, 0, 0, 0)); } @Test diff --git a/SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java b/SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java index 23cb504..28e6e7f 100644 --- a/SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java +++ b/SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java @@ -235,7 +235,7 @@ void getList_negativePage_400() throws Exception { @DisplayName("GET /api/timer-sessions/today-stats — 200") void todayStats_200() throws Exception { given(service.getTodayStats(1L)) - .willReturn(new TodayStatsResponse(180, 3, 7)); + .willReturn(new TodayStatsResponse(180, 3, 7, 12450, 287, 1820)); mockMvc.perform(get("/api/timer-sessions/today-stats")) .andExpect(status().isOk()) From a6553fb4af32d3306fa1f5731802558e315a7f8c Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Tue, 26 May 2026 18:10:27 +0900 Subject: [PATCH 04/10] =?UTF-8?q?today-stats=20=EC=9D=91=EB=8B=B5=EC=97=90?= =?UTF-8?q?=20=EB=88=84=EC=A0=81=20=ED=86=B5=EA=B3=84=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=203=EA=B0=9C=20=EC=B6=94=EA=B0=80=20:=20feat=20:=20Repository?= =?UTF-8?q?=EC=97=90=20sumDurationByUserId/countByUserId=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#40?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/TimerSessionRepository.java | 8 ++++++ .../TimerSessionRepositoryTest.java | 26 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepository.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepository.java index 7c23cb9..94747eb 100644 --- a/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepository.java +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepository.java @@ -48,4 +48,12 @@ long countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan( "WHERE s.userId = :userId AND s.startedAt >= :start") List findStartedAtsAfter(@Param("userId") Long userId, @Param("start") LocalDateTime start); + + // 전체 누적 분: SUM(Integer) → Long, COALESCE로 NULL 방지 + @Query("SELECT COALESCE(SUM(s.durationMinutes), 0L) FROM TimerSession s " + + "WHERE s.userId = :userId") + Long sumDurationByUserId(@Param("userId") Long userId); + + // 전체 세션 수: Spring Data 명명 규칙 + long countByUserId(Long userId); } diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepositoryTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepositoryTest.java index 7a4d4e1..2e81774 100644 --- a/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepositoryTest.java +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/repository/TimerSessionRepositoryTest.java @@ -189,4 +189,30 @@ void findStartedAtsAfter() { assertThat(dates).hasSize(2); } + + @Test + @DisplayName("sumDurationByUserId: 본인 전체 합산, 다른 user 제외, 빈 결과는 0") + void sumDurationByUserId() { + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-04-10T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-01T01:00:00"), 60, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-25T01:00:00"), 90, null, null)); + repository.saveAndFlush(session(2L, LocalDateTime.parse("2026-05-25T01:00:00"), 120, null, null)); + + assertThat(repository.sumDurationByUserId(1L)).isEqualTo(180L); + assertThat(repository.sumDurationByUserId(2L)).isEqualTo(120L); + assertThat(repository.sumDurationByUserId(999L)).isZero(); + } + + @Test + @DisplayName("countByUserId: 본인 세션 수, 다른 user 제외, 빈 결과는 0") + void countByUserId() { + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-23T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-24T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(1L, LocalDateTime.parse("2026-05-25T01:00:00"), 30, null, null)); + repository.saveAndFlush(session(2L, LocalDateTime.parse("2026-05-25T01:00:00"), 30, null, null)); + + assertThat(repository.countByUserId(1L)).isEqualTo(3); + assertThat(repository.countByUserId(2L)).isEqualTo(1); + assertThat(repository.countByUserId(999L)).isZero(); + } } From 45e978caa12288683ea48f74b5d627ab8db1ba60 Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Tue, 26 May 2026 18:16:14 +0900 Subject: [PATCH 05/10] =?UTF-8?q?today-stats=20=EC=9D=91=EB=8B=B5=EC=97=90?= =?UTF-8?q?=20=EB=88=84=EC=A0=81=20=ED=86=B5=EA=B3=84=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=203=EA=B0=9C=20=EC=B6=94=EA=B0=80=20:=20feat=20:=20Service?= =?UTF-8?q?=EC=97=90=20lifetime/monthly=20=ED=95=A9=EC=82=B0=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80=20(KST=20=EC=9B=94=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84)=20#40?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/service/TimerSessionService.java | 25 ++++- .../service/TimerSessionServiceTest.java | 96 ++++++++++++++++++- 2 files changed, 116 insertions(+), 5 deletions(-) diff --git a/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java b/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java index 1929641..d8e9cd4 100644 --- a/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java +++ b/SS-Study/src/main/java/com/elipair/spacestudyship/study/timer/service/TimerSessionService.java @@ -178,10 +178,29 @@ public TodayStatsResponse getTodayStats(Long userId) { todayKst.minusDays(STREAK_LOOKBACK_DAYS).atStartOfDay(ZONE_KST)); List startedAts = sessionRepository .findStartedAtsAfter(userId, lookbackStart); - int streak = computeStreak(startedAts, todayKst); - // Task 3에서 lifetime/monthly 합산 로직으로 교체 — 현재는 컴파일 유지용 0 주입 - return new TodayStatsResponse(Math.toIntExact(totalMinutes), (int) sessionCount, streak, 0, 0, 0); + + // 이번 달 KST 경계 → UTC 변환 후 기존 sumDurationBetween 재사용 + LocalDate monthStartKst = todayKst.withDayOfMonth(1); + LocalDateTime monthStartUtc = toUtcLdt(monthStartKst.atStartOfDay(ZONE_KST)); + LocalDateTime monthEndUtc = toUtcLdt(monthStartKst.plusMonths(1).atStartOfDay(ZONE_KST)); + long monthlyMinutes = Optional.ofNullable( + sessionRepository.sumDurationBetween(userId, monthStartUtc, monthEndUtc)) + .orElse(0L); + + // 전체 누적 (lifetime) — Repository에서 COALESCE로 NULL-safe + long lifetimeMinutes = Optional.ofNullable( + sessionRepository.sumDurationByUserId(userId)).orElse(0L); + long lifetimeSessionCount = sessionRepository.countByUserId(userId); + + return new TodayStatsResponse( + Math.toIntExact(totalMinutes), + (int) sessionCount, + streak, + Math.toIntExact(lifetimeMinutes), + Math.toIntExact(lifetimeSessionCount), + Math.toIntExact(monthlyMinutes) + ); } private LocalDateTime toUtcLdt(ZonedDateTime kst) { diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java index 506e307..f85d694 100644 --- a/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java @@ -321,6 +321,8 @@ void todayStats_empty() { .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) .willReturn(0L); given(sessionRepository.findStartedAtsAfter(eq(1L), any())).willReturn(List.of()); + given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(0L); + given(sessionRepository.countByUserId(eq(1L))).willReturn(0L); TodayStatsResponse res = service.getTodayStats(1L); @@ -328,9 +330,19 @@ void todayStats_empty() { } @Test - @DisplayName("today-stats: 정상 데이터 + streak 계산") + @DisplayName("today-stats: 정상 데이터 + streak + lifetime/monthly 계산") void todayStats_withData() { - given(sessionRepository.sumDurationBetween(eq(1L), any(), any())).willReturn(180L); + // sumDurationBetween는 오늘 + 이번 달 두 번 호출됨. + // fixedClock=2026-05-25T12:00:00Z → KST 2026-05-25 21:00 → 오늘=2026-05-25 KST, 월 시작=2026-05-01 KST. + // 둘 다 any() 매칭 시 마지막 stub이 우선이므로, 명시적으로 호출별 stub을 분리한다. + given(sessionRepository.sumDurationBetween(eq(1L), + eq(LocalDateTime.parse("2026-05-24T15:00:00")), // 오늘 시작 (KST 5/25 00:00 = UTC 5/24 15:00) + eq(LocalDateTime.parse("2026-05-25T15:00:00")))) // 내일 시작 (KST 5/26 00:00 = UTC 5/25 15:00) + .willReturn(180L); + given(sessionRepository.sumDurationBetween(eq(1L), + eq(LocalDateTime.parse("2026-04-30T15:00:00")), // 5월 시작 KST 5/1 00:00 = UTC 4/30 15:00 + eq(LocalDateTime.parse("2026-05-31T15:00:00")))) // 6월 시작 KST 6/1 00:00 = UTC 5/31 15:00 + .willReturn(1820L); given(sessionRepository .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) .willReturn(3L); @@ -340,12 +352,17 @@ void todayStats_withData() { LocalDateTime.parse("2026-05-23T16:00:00"), LocalDateTime.parse("2026-05-22T16:00:00") )); + given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(12450L); + given(sessionRepository.countByUserId(eq(1L))).willReturn(287L); TodayStatsResponse res = service.getTodayStats(1L); assertThat(res.totalMinutes()).isEqualTo(180); assertThat(res.sessionCount()).isEqualTo(3); assertThat(res.streak()).isEqualTo(3); + assertThat(res.lifetimeMinutes()).isEqualTo(12450); + assertThat(res.lifetimeSessionCount()).isEqualTo(287); + assertThat(res.monthlyMinutes()).isEqualTo(1820); } @Test @@ -360,6 +377,8 @@ void streak_yesterdayLatest() { LocalDateTime.parse("2026-05-23T16:00:00"), LocalDateTime.parse("2026-05-22T16:00:00") )); + given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(0L); + given(sessionRepository.countByUserId(eq(1L))).willReturn(0L); TodayStatsResponse res = service.getTodayStats(1L); @@ -375,6 +394,8 @@ void streak_brokenChain() { .willReturn(0L); given(sessionRepository.findStartedAtsAfter(eq(1L), any())) .willReturn(List.of(LocalDateTime.parse("2026-05-22T16:00:00"))); + given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(0L); + given(sessionRepository.countByUserId(eq(1L))).willReturn(0L); TodayStatsResponse res = service.getTodayStats(1L); @@ -394,9 +415,80 @@ void streak_futureLatest_clampedToToday() { LocalDateTime.parse("2026-05-25T01:00:00"), LocalDateTime.parse("2026-05-23T16:00:00") )); + given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(0L); + given(sessionRepository.countByUserId(eq(1L))).willReturn(0L); TodayStatsResponse res = service.getTodayStats(1L); assertThat(res.streak()).isEqualTo(2); } + + @Test + @DisplayName("today-stats: lifetime/monthly — KST 월 경계가 sumDurationBetween 인자에 정확히 매핑") + void todayStats_monthlyBoundary_kst() { + // fixedClock=2026-05-25T12:00:00Z → KST 5/25. + // 이번 달 시작 KST 2026-05-01 00:00 = UTC 2026-04-30 15:00 + // 다음 달 시작 KST 2026-06-01 00:00 = UTC 2026-05-31 15:00 + LocalDateTime expectedMonthStartUtc = LocalDateTime.parse("2026-04-30T15:00:00"); + LocalDateTime expectedMonthEndUtc = LocalDateTime.parse("2026-05-31T15:00:00"); + + given(sessionRepository.sumDurationBetween(eq(1L), any(), any())).willReturn(0L); + given(sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) + .willReturn(0L); + given(sessionRepository.findStartedAtsAfter(eq(1L), any())).willReturn(List.of()); + given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(0L); + given(sessionRepository.countByUserId(eq(1L))).willReturn(0L); + + service.getTodayStats(1L); + + // sumDurationBetween가 정확히 KST 월 경계(UTC 변환된 값)로 호출됐는지 검증 + verify(sessionRepository).sumDurationBetween(eq(1L), + eq(expectedMonthStartUtc), eq(expectedMonthEndUtc)); + } + + @Test + @DisplayName("today-stats: lifetime 0건 → 3 필드 모두 0 (null 금지)") + void todayStats_lifetimeZero_returnsZeroNotNull() { + given(sessionRepository.sumDurationBetween(eq(1L), any(), any())).willReturn(0L); + given(sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) + .willReturn(0L); + given(sessionRepository.findStartedAtsAfter(eq(1L), any())).willReturn(List.of()); + given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(0L); + given(sessionRepository.countByUserId(eq(1L))).willReturn(0L); + + TodayStatsResponse res = service.getTodayStats(1L); + + assertThat(res.lifetimeMinutes()).isNotNull().isZero(); + assertThat(res.lifetimeSessionCount()).isNotNull().isZero(); + assertThat(res.monthlyMinutes()).isNotNull().isZero(); + } + + @Test + @DisplayName("today-stats: 지난달 + 이번 달 혼합 → monthly < lifetime, lifetime = 전체 합") + void todayStats_mixedMonths_lifetimeGreaterThanMonthly() { + // sumDurationBetween는 (오늘, 이번 달) 두 번 호출 — 호출 인자로 구분 + given(sessionRepository.sumDurationBetween(eq(1L), + eq(LocalDateTime.parse("2026-05-24T15:00:00")), + eq(LocalDateTime.parse("2026-05-25T15:00:00")))) + .willReturn(0L); + given(sessionRepository.sumDurationBetween(eq(1L), + eq(LocalDateTime.parse("2026-04-30T15:00:00")), + eq(LocalDateTime.parse("2026-05-31T15:00:00")))) + .willReturn(1820L); + given(sessionRepository + .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) + .willReturn(0L); + given(sessionRepository.findStartedAtsAfter(eq(1L), any())).willReturn(List.of()); + given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(12450L); + given(sessionRepository.countByUserId(eq(1L))).willReturn(287L); + + TodayStatsResponse res = service.getTodayStats(1L); + + assertThat(res.lifetimeMinutes()).isEqualTo(12450); + assertThat(res.monthlyMinutes()).isEqualTo(1820); + assertThat(res.monthlyMinutes()).isLessThan(res.lifetimeMinutes()); + assertThat(res.lifetimeSessionCount()).isEqualTo(287); + } } From 45dda74346009e7ebcd92a6bf6883fe22743149b Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Tue, 26 May 2026 18:21:34 +0900 Subject: [PATCH 06/10] =?UTF-8?q?today-stats=20=EC=9D=91=EB=8B=B5=EC=97=90?= =?UTF-8?q?=20=EB=88=84=EC=A0=81=20=ED=86=B5=EA=B3=84=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=203=EA=B0=9C=20=EC=B6=94=EA=B0=80=20:=20test=20:=20null-handli?= =?UTF-8?q?ng=20=EB=B0=A9=EC=96=B4=20=EA=B3=84=EC=B8=B5=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A1=9C=20refocus=20(?= =?UTF-8?q?=EC=9D=B4=EC=A0=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=8A=94=20tod?= =?UTF-8?q?ayStats=5Fempty=EC=99=80=20=EC=A4=91=EB=B3=B5)=20#40?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/service/TimerSessionServiceTest.java | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java b/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java index f85d694..df7e430 100644 --- a/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java +++ b/SS-Study/src/test/java/com/elipair/spacestudyship/study/timer/service/TimerSessionServiceTest.java @@ -448,21 +448,25 @@ void todayStats_monthlyBoundary_kst() { } @Test - @DisplayName("today-stats: lifetime 0건 → 3 필드 모두 0 (null 금지)") - void todayStats_lifetimeZero_returnsZeroNotNull() { - given(sessionRepository.sumDurationBetween(eq(1L), any(), any())).willReturn(0L); + @DisplayName("today-stats: 레포가 null 반환해도 (방어적) → lifetimeMinutes/monthlyMinutes 모두 0 (null 금지)") + void todayStats_repoReturnsNull_serviceWrapsToZero() { + // 실제 레포는 COALESCE(SUM, 0L)로 NULL을 막지만, Service 측 Optional.ofNullable.orElse 가드가 + // 실제로 동작하는지 검증한다 (방어 계층 회귀 방지). sumDurationBetween/sumDurationByUserId가 + // null을 반환한 경우에도 응답 필드는 0이어야 한다. + given(sessionRepository.sumDurationBetween(eq(1L), any(), any())).willReturn(null); given(sessionRepository .countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(eq(1L), any(), any())) .willReturn(0L); given(sessionRepository.findStartedAtsAfter(eq(1L), any())).willReturn(List.of()); - given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(0L); + given(sessionRepository.sumDurationByUserId(eq(1L))).willReturn(null); given(sessionRepository.countByUserId(eq(1L))).willReturn(0L); TodayStatsResponse res = service.getTodayStats(1L); + assertThat(res.totalMinutes()).isNotNull().isZero(); + assertThat(res.monthlyMinutes()).isNotNull().isZero(); assertThat(res.lifetimeMinutes()).isNotNull().isZero(); assertThat(res.lifetimeSessionCount()).isNotNull().isZero(); - assertThat(res.monthlyMinutes()).isNotNull().isZero(); } @Test From 04d23fb146329188885599abc1a3a58cbce267f9 Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Tue, 26 May 2026 19:08:42 +0900 Subject: [PATCH 07/10] =?UTF-8?q?today-stats=20=EC=9D=91=EB=8B=B5=EC=97=90?= =?UTF-8?q?=20=EB=88=84=EC=A0=81=20=ED=86=B5=EA=B3=84=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=203=EA=B0=9C=20=EC=B6=94=EA=B0=80=20:=20feat=20:=20Controller?= =?UTF-8?q?=20MockMvc=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=99=95=EC=9E=A5?= =?UTF-8?q?=20+=20Swagger=20examples=20=EA=B0=B1=EC=8B=A0=20#40?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timer/TimerSessionController.java | 25 ++++++++++++++++--- .../timer/TimerSessionControllerTest.java | 20 +++++++++++++-- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/SS-Web/src/main/java/com/elipair/spacestudyship/controller/timer/TimerSessionController.java b/SS-Web/src/main/java/com/elipair/spacestudyship/controller/timer/TimerSessionController.java index 14a3f62..c79d653 100644 --- a/SS-Web/src/main/java/com/elipair/spacestudyship/controller/timer/TimerSessionController.java +++ b/SS-Web/src/main/java/com/elipair/spacestudyship/controller/timer/TimerSessionController.java @@ -129,13 +129,32 @@ public ResponseEntity getList( loginMember.memberId(), startDate, endDate, todoId, page, size)); } - @Operation(summary = "오늘 공부 통계", - description = "KST(Asia/Seoul) 기준 오늘의 총 분 / 세션 수 / 연속 일수(streak)") + @Operation(summary = "오늘 공부 통계 + 누적 통계", + description = """ + KST(Asia/Seoul) 기준 통계. + + ### 응답 필드 + - `totalMinutes`, `sessionCount`: 오늘 (KST) + - `streak`: 연속 공부 일수 (오늘 포함, KST) + - `lifetimeMinutes`, `lifetimeSessionCount`: 회원의 전체 누적 + - `monthlyMinutes`: 이번 달 누적 (KST 1일 00:00 ~ 다음 달 1일 00:00) + + 세션 0건 회원도 6개 필드 모두 `0`을 반환합니다 (null 금지). + """) @ApiResponses({ @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(mediaType = "application/json", schema = @Schema(implementation = TodayStatsResponse.class), - examples = @ExampleObject(value = "{\"totalMinutes\":180,\"sessionCount\":3,\"streak\":7}"))), + examples = @ExampleObject(value = """ + { + "totalMinutes": 180, + "sessionCount": 3, + "streak": 7, + "lifetimeMinutes": 12450, + "lifetimeSessionCount": 287, + "monthlyMinutes": 1820 + } + """))), @ApiResponse(responseCode = "401", description = "인증 필요") }) @GetMapping("/today-stats") diff --git a/SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java b/SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java index 28e6e7f..4f9c5bc 100644 --- a/SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java +++ b/SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java @@ -232,7 +232,7 @@ void getList_negativePage_400() throws Exception { } @Test - @DisplayName("GET /api/timer-sessions/today-stats — 200") + @DisplayName("GET /api/timer-sessions/today-stats — 200, 6필드 (today + lifetime + monthly)") void todayStats_200() throws Exception { given(service.getTodayStats(1L)) .willReturn(new TodayStatsResponse(180, 3, 7, 12450, 287, 1820)); @@ -241,6 +241,22 @@ void todayStats_200() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.totalMinutes").value(180)) .andExpect(jsonPath("$.sessionCount").value(3)) - .andExpect(jsonPath("$.streak").value(7)); + .andExpect(jsonPath("$.streak").value(7)) + .andExpect(jsonPath("$.lifetimeMinutes").value(12450)) + .andExpect(jsonPath("$.lifetimeSessionCount").value(287)) + .andExpect(jsonPath("$.monthlyMinutes").value(1820)); + } + + @Test + @DisplayName("GET /api/timer-sessions/today-stats — 0건 회원: 신규 3필드도 0 (null 아님)") + void todayStats_zero_neverNull() throws Exception { + given(service.getTodayStats(1L)) + .willReturn(new TodayStatsResponse(0, 0, 0, 0, 0, 0)); + + mockMvc.perform(get("/api/timer-sessions/today-stats")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lifetimeMinutes").value(0)) + .andExpect(jsonPath("$.lifetimeSessionCount").value(0)) + .andExpect(jsonPath("$.monthlyMinutes").value(0)); } } From 7227b90148c2ee3e53484c01c0ce6a56e918a343 Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Tue, 26 May 2026 19:12:55 +0900 Subject: [PATCH 08/10] =?UTF-8?q?today-stats=20=EC=9D=91=EB=8B=B5=EC=97=90?= =?UTF-8?q?=20=EB=88=84=EC=A0=81=20=ED=86=B5=EA=B3=84=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=203=EA=B0=9C=20=EC=B6=94=EA=B0=80=20:=20test=20:=20todayStats?= =?UTF-8?q?=5Fzero=5FneverNull=EC=97=90=20.isNumber()=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80=20(null=20=EB=B0=A9?= =?UTF-8?q?=EC=96=B4=20=EA=B0=95=ED=99=94)=20#40?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/timer/TimerSessionControllerTest.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java b/SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java index 4f9c5bc..aadb72d 100644 --- a/SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java +++ b/SS-Web/src/test/java/com/elipair/spacestudyship/controller/timer/TimerSessionControllerTest.java @@ -255,8 +255,11 @@ void todayStats_zero_neverNull() throws Exception { mockMvc.perform(get("/api/timer-sessions/today-stats")) .andExpect(status().isOk()) + .andExpect(jsonPath("$.lifetimeMinutes").isNumber()) .andExpect(jsonPath("$.lifetimeMinutes").value(0)) + .andExpect(jsonPath("$.lifetimeSessionCount").isNumber()) .andExpect(jsonPath("$.lifetimeSessionCount").value(0)) + .andExpect(jsonPath("$.monthlyMinutes").isNumber()) .andExpect(jsonPath("$.monthlyMinutes").value(0)); } } From 66ea227f869a6ccd6996853f16916a245d6822bd Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Tue, 26 May 2026 19:14:11 +0900 Subject: [PATCH 09/10] =?UTF-8?q?today-stats=20=EC=9D=91=EB=8B=B5=EC=97=90?= =?UTF-8?q?=20=EB=88=84=EC=A0=81=20=ED=86=B5=EA=B3=84=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=203=EA=B0=9C=20=EC=B6=94=EA=B0=80=20:=20docs=20:=20API=20spec?= =?UTF-8?q?=2003=5Ftimer.md=20=EC=9D=91=EB=8B=B5=206=ED=95=84=EB=93=9C?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=B1=EC=8B=A0=20#40?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-specs/03_timer.md | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/docs/api-specs/03_timer.md b/docs/api-specs/03_timer.md index d8e933e..bd5079b 100644 --- a/docs/api-specs/03_timer.md +++ b/docs/api-specs/03_timer.md @@ -223,21 +223,34 @@ GET /api/timer-sessions?todoId=todo-uuid-5678 ### Response -**200 OK** +`200 OK` + +| 필드 | 타입 | Nullable | 설명 | +|------|------|----------|------| +| `totalMinutes` | Integer | X | 오늘 총 공부 시간 (분, KST) | +| `sessionCount` | Integer | X | 오늘 완료한 세션 수 | +| `streak` | Integer | X | 연속 공부 일수 (오늘 포함, KST 기준) | +| `lifetimeMinutes` | Integer | X | 회원의 전체 누적 공부 시간 (분) | +| `lifetimeSessionCount` | Integer | X | 회원의 전체 세션 수 | +| `monthlyMinutes` | Integer | X | 이번 달 누적 공부 시간 (분, KST 기준) | + +> 세션 0건 회원도 6필드 모두 `0`을 반환합니다. `null` 절대 반환하지 않습니다. ```json { "totalMinutes": 180, "sessionCount": 3, - "streak": 7 + "streak": 7, + "lifetimeMinutes": 12450, + "lifetimeSessionCount": 287, + "monthlyMinutes": 1820 } ``` -| 필드 | 타입 | 설명 | -|------|------|------| -| `totalMinutes` | Integer | 오늘 총 공부 시간 (분) | -| `sessionCount` | Integer | 오늘 완료한 세션 수 | -| `streak` | Integer | 연속 공부 일수 (오늘 포함) | +#### 시간 경계 정의 +- **오늘**: `KST 00:00:00` ~ `KST 23:59:59` +- **이번 달**: 이번 달 1일 `KST 00:00:00` ~ 다음 달 1일 `KST 00:00:00` (반열림 `[start, end)`) +- **streak**: 마지막 공부일이 오늘이면 오늘 포함, 어제까지만 했으면 어제 기준. KST 기준 일자 단위 연속. ### 연속 일수 (Streak) 계산 로직 From c7d309812268f83afcb7affa28dac89ae2812877 Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Tue, 26 May 2026 19:15:55 +0900 Subject: [PATCH 10/10] =?UTF-8?q?today-stats=20=EC=9D=91=EB=8B=B5=EC=97=90?= =?UTF-8?q?=20=EB=88=84=EC=A0=81=20=ED=86=B5=EA=B3=84=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=203=EA=B0=9C=20=EC=B6=94=EA=B0=80=20:=20docs=20:=20200=20OK=20?= =?UTF-8?q?=ED=91=9C=EA=B8=B0=EB=A5=BC=20sections=201&2=EC=99=80=20?= =?UTF-8?q?=EB=8F=99=EC=9D=BC=ED=95=98=EA=B2=8C=20bold=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20#40?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-specs/03_timer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-specs/03_timer.md b/docs/api-specs/03_timer.md index bd5079b..e538884 100644 --- a/docs/api-specs/03_timer.md +++ b/docs/api-specs/03_timer.md @@ -223,7 +223,7 @@ GET /api/timer-sessions?todoId=todo-uuid-5678 ### Response -`200 OK` +**200 OK** | 필드 | 타입 | Nullable | 설명 | |------|------|----------|------|