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 @@ -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
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,12 @@ long countByUserIdAndStartedAtGreaterThanEqualAndStartedAtLessThan(
"WHERE s.userId = :userId AND s.startedAt >= :start")
List<LocalDateTime> 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,29 @@ public TodayStatsResponse getTodayStats(Long userId) {
todayKst.minusDays(STREAK_LOOKBACK_DAYS).atStartOfDay(ZONE_KST));
List<LocalDateTime> startedAts = sessionRepository
.findStartedAtsAfter(userId, lookbackStart);

int streak = computeStreak(startedAts, todayKst);
return new TodayStatsResponse(Math.toIntExact(totalMinutes), (int) sessionCount, streak);

// 이번 달 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -321,16 +321,28 @@ 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);

assertThat(res).isEqualTo(new TodayStatsResponse(0, 0, 0));
assertThat(res).isEqualTo(new TodayStatsResponse(0, 0, 0, 0, 0, 0));
}

@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);
Expand All @@ -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
Expand All @@ -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);

Expand All @@ -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);

Expand All @@ -394,9 +415,84 @@ 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: 레포가 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(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();
}

@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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,32 @@ public ResponseEntity<TimerSessionListResponse> 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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,15 +232,34 @@ 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));
.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("$.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").isNumber())
.andExpect(jsonPath("$.lifetimeMinutes").value(0))
.andExpect(jsonPath("$.lifetimeSessionCount").isNumber())
.andExpect(jsonPath("$.lifetimeSessionCount").value(0))
.andExpect(jsonPath("$.monthlyMinutes").isNumber())
.andExpect(jsonPath("$.monthlyMinutes").value(0));
}
}
25 changes: 19 additions & 6 deletions docs/api-specs/03_timer.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,19 +225,32 @@ GET /api/timer-sessions?todoId=todo-uuid-5678

**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)`)
Comment on lines +250 to +252

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

오늘 경계 표기를 반열림으로 통일해 주세요.

Line 251의 KST 00:00:00 ~ KST 23:59:59 표기는 Line 252의 반열림 표기와 기준이 달라 경계 해석이 흔들릴 수 있습니다. 오늘[KST 00:00:00, 다음날 KST 00:00:00)로 맞추는 게 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/api-specs/03_timer.md` around lines 250 - 252, Update the "오늘"
time-bound description to use a half-open interval consistent with "이번 달":
replace "KST 00:00:00 ~ KST 23:59:59" with "[KST 00:00:00, 다음날 KST 00:00:00)" so
both "오늘" and "이번 달" use the same [start, end) convention; ensure the heading
"오늘" text is the only change and keep the timezone labels (KST) intact.

- **streak**: 마지막 공부일이 오늘이면 오늘 포함, 어제까지만 했으면 어제 기준. KST 기준 일자 단위 연속.

### 연속 일수 (Streak) 계산 로직

Expand Down
Loading
Loading