From 559bd6863df5a65b16929c2af10581c0a314e9f0 Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Wed, 27 May 2026 00:16:52 +0900 Subject: [PATCH 01/10] =?UTF-8?q?docs=20:=20today-stats=20=EB=88=84?= =?UTF-8?q?=EC=A0=81=20=ED=86=B5=EA=B3=84=20=ED=95=84=EB=93=9C=203?= =?UTF-8?q?=EA=B0=9C=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20design=20spec=20#83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...stats-cumulative-fields-frontend-design.md | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-27-today-stats-cumulative-fields-frontend-design.md diff --git a/docs/superpowers/specs/2026-05-27-today-stats-cumulative-fields-frontend-design.md b/docs/superpowers/specs/2026-05-27-today-stats-cumulative-fields-frontend-design.md new file mode 100644 index 0000000..1756f86 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-today-stats-cumulative-fields-frontend-design.md @@ -0,0 +1,269 @@ +# today-stats 누적 통계 필드 3개 클라이언트 연동 — Design Spec + +- **Issue**: `issue/today-stats-cumulative-fields-frontend.md` (Frontend #83) +- **Backend pair**: 이미 머지됨. `GET /api/timer-sessions/today-stats` 응답에 누적 필드 3개 추가 완료. `docs/api-docs.json` 의 `TodayStatsResponse` 가 6필드(`totalMinutes`, `sessionCount`, `streak`, `lifetimeMinutes`, `lifetimeSessionCount`, `monthlyMinutes`)로 갱신됨. +- **Branch**: `20260526_#83_today_stats_누적_통계_필드_3개_클라이언트_연동` + +--- + +## 1. 문제 정의 + +인증 사용자의 누적 통계(전체 공부 시간 / 전체 세션 수 / 이번 달 공부 시간) 가 클라이언트에서 잘못 계산되고 있다. + +**현재 동작** + +`lib/features/timer/presentation/providers/study_stats_provider.dart` 의 3개 provider — +`totalStudyMinutes`, `totalSessionCount`, `monthlyStudyMinutes` — 가 전부 로컬 `timerSessionListNotifierProvider` 의 결과를 fold 해서 계산한다. + +인증 모드에서 `timerSessionListNotifierProvider` 는 서버 페이지네이션(`size=20`) 의 **첫 페이지만** 보유한다. 결과적으로: + +- 프로필 화면 (`profile_screen.dart:83`) 의 "공부 시간" 통계 카드가 최근 20세션만 합산. +- 뱃지 해금 (`badge_provider.dart:53,55`) 의 "총 100시간 공부" / "총 50회 세션 완료" 류 조건이 영구 미해금. + +게스트 모드는 모든 세션이 인메모리(로컬 저장) 에 있어 fold 합산이 정확하다 — 분기 처리 필요. + +--- + +## 2. 솔루션 개요 + +서버가 반환하기 시작한 누적 필드 3개를 클라이언트가 수신·노출한다. 인증 모드는 서버 값을 신뢰하고, 게스트 모드는 현재 로컬 합산 로직을 그대로 유지한다. + +`today_stats_provider.dart` 에 이미 존재하는 인증/게스트 분기 패턴을 그대로 활용한다 — 새 provider 를 만들지 않고 기존 `todayStatsNotifierProvider` 결과를 `study_stats_provider` 에서 끌어 쓴다. + +--- + +## 3. 변경 범위 — 파일 목록 + +| 파일 | 변경 내용 | +|---|---| +| `lib/features/timer/data/models/today_stats_response_model.dart` | 필드 3개 (`lifetimeMinutes`, `lifetimeSessionCount`, `monthlyMinutes`) 추가 + `toEntity()` 매핑 갱신 | +| `lib/features/timer/domain/entities/today_stats.dart` | 동일 3필드 추가 + `TodayStats.empty()` 의 0 초기값 6필드로 확장 | +| `lib/features/timer/presentation/providers/study_stats_provider.dart` | `totalStudyMinutes`, `totalSessionCount`, `monthlyStudyMinutes` 에 인증/게스트 분기 추가 | +| `*.freezed.dart`, `*.g.dart` | `flutter pub run build_runner build --delete-conflicting-outputs` 로 재생성 | +| `test/features/timer/data/models/today_stats_response_model_test.dart` | 새 필드 직렬화 + `toEntity()` 매핑 검증 추가 | +| `test/features/timer/domain/entities/today_stats_test.dart` | 새 필드 + `empty()` 검증 추가 | +| `test/features/timer/presentation/providers/study_stats_provider_test.dart` | **신규 파일**. 3개 provider 의 인증/게스트 분기 검증 | +| `test/features/timer/presentation/providers/today_stats_provider_test.dart` | 회귀 가드 — 기존 케이스 통과 유지 (변경 없음) | + +--- + +## 4. 상세 설계 + +### 4.1 DTO — `today_stats_response_model.dart` + +```dart +@freezed +class TodayStatsResponseModel with _$TodayStatsResponseModel { + const TodayStatsResponseModel._(); + + const factory TodayStatsResponseModel({ + required int totalMinutes, + required int sessionCount, + required int streak, + required int lifetimeMinutes, + required int lifetimeSessionCount, + required int monthlyMinutes, + }) = _TodayStatsResponseModel; + + factory TodayStatsResponseModel.fromJson(Map json) => + _$TodayStatsResponseModelFromJson(json); + + TodayStats toEntity() => TodayStats( + totalMinutes: totalMinutes, + sessionCount: sessionCount, + streak: streak, + lifetimeMinutes: lifetimeMinutes, + lifetimeSessionCount: lifetimeSessionCount, + monthlyMinutes: monthlyMinutes, + ); +} +``` + +서버 스펙(api-docs.json:1277) 에 따라 6필드 모두 non-null `int` (0 보장). `required` 그대로 사용. + +### 4.2 Entity — `today_stats.dart` + +```dart +@freezed +class TodayStats with _$TodayStats { + const factory TodayStats({ + required int totalMinutes, + required int sessionCount, + required int streak, + required int lifetimeMinutes, + required int lifetimeSessionCount, + required int monthlyMinutes, + }) = _TodayStats; + + factory TodayStats.empty() => const TodayStats( + totalMinutes: 0, + sessionCount: 0, + streak: 0, + lifetimeMinutes: 0, + lifetimeSessionCount: 0, + monthlyMinutes: 0, + ); +} +``` + +### 4.3 Provider 리팩터링 — `study_stats_provider.dart` + +3개 함수에 인증/게스트 분기 추가. 시그니처는 **동기 `int` 그대로 유지** — 인증 모드에서 `todayStatsNotifierProvider` 가 로딩 중이거나 에러일 때는 `valueOrNull?.X ?? 0` 패턴으로 0 fallback. + +```dart +import '../../../auth/presentation/providers/auth_provider.dart'; +import 'today_stats_provider.dart'; + +@riverpod +int totalStudyMinutes(Ref ref) { + if (ref.watch(isAuthenticatedProvider)) { + return ref.watch(todayStatsNotifierProvider).valueOrNull?.lifetimeMinutes ?? 0; + } + final sessions = + ref.watch(timerSessionListNotifierProvider).valueOrNull ?? []; + return sessions.fold(0, (sum, s) => sum + s.durationMinutes); +} + +@riverpod +int totalSessionCount(Ref ref) { + if (ref.watch(isAuthenticatedProvider)) { + return ref.watch(todayStatsNotifierProvider).valueOrNull?.lifetimeSessionCount ?? 0; + } + final sessions = + ref.watch(timerSessionListNotifierProvider).valueOrNull ?? []; + return sessions.length; +} + +@riverpod +int monthlyStudyMinutes(Ref ref) { + if (ref.watch(isAuthenticatedProvider)) { + return ref.watch(todayStatsNotifierProvider).valueOrNull?.monthlyMinutes ?? 0; + } + final sessions = + ref.watch(timerSessionListNotifierProvider).valueOrNull ?? []; + final now = DateTime.now(); + final monthStart = DateTime(now.year, now.month, 1); + return sessions + .where((s) => !s.startedAt.isBefore(monthStart)) + .fold(0, (sum, s) => sum + s.durationMinutes); +} +``` + +**손대지 않는 함수**: `todayStudyMinutes`, `weeklyStudyMinutes`, `currentStreak`. 이슈가 명시한 3개만 변경. + +### 4.4 무효화 트리거 — 변경 없음 + +`todayStatsNotifierProvider` 는 이미 4곳에서 invalidate 된다: + +- `lib/features/timer/presentation/providers/timer_provider.dart:174` — 세션 저장 후 +- `lib/features/auth/presentation/providers/auth_provider.dart:334` — 로그인 후 +- `lib/features/auth/presentation/providers/auth_provider.dart:369` — 로그아웃 후 +- `lib/features/auth/presentation/providers/auth_provider.dart:463` — 탈퇴 후 + +신규 누적 필드도 동일 시점에 자동 갱신됨. 추가 invalidate 트리거 불필요. + +--- + +## 5. 호출처 영향 + +### 5.1 `profile_screen.dart:83` + +```dart +final totalMinutes = ref.watch(totalStudyMinutesProvider); +``` + +시그니처 동일 → 코드 변경 없음. +**동작 변화**: 인증 사용자는 최근 20세션 → 전체 누적값으로 정확해짐. +**과도기 UX**: 첫 진입 / invalidate 직후 `todayStatsNotifierProvider` 로딩 동안 0 표시 가능 (기존 코드도 빈 상태에서 0 표시였으므로 신규 깜빡임 아님). + +### 5.2 `badge_provider.dart:53,55` + +```dart +final totalMinutes = ref.read(totalStudyMinutesProvider); +final sessionCount = ref.read(totalSessionCountProvider); +``` + +시그니처 동일 → 코드 변경 없음. +**동작 변화**: "총 100시간 공부", "총 50회 세션" 류 뱃지가 정확한 누적값으로 평가됨 → 정상 해금 가능. + +--- + +## 6. 테스트 계획 + +### 6.1 DTO 테스트 — `today_stats_response_model_test.dart` + +기존 케이스에 새 필드 3개 직렬화·엔티티 매핑 추가: + +```dart +test('fromJson + toEntity — 6필드 모두', () { + final m = TodayStatsResponseModel.fromJson({ + 'totalMinutes': 180, + 'sessionCount': 3, + 'streak': 7, + 'lifetimeMinutes': 12450, + 'lifetimeSessionCount': 287, + 'monthlyMinutes': 1820, + }); + expect(m.lifetimeMinutes, 12450); + expect(m.lifetimeSessionCount, 287); + expect(m.monthlyMinutes, 1820); + + final e = m.toEntity(); + expect(e.lifetimeMinutes, 12450); + expect(e.lifetimeSessionCount, 287); + expect(e.monthlyMinutes, 1820); +}); +``` + +### 6.2 Entity 테스트 — `today_stats_test.dart` + +`TodayStats.empty()` 가 6필드 모두 0 반환하는지 검증: + +```dart +test('empty() — 6 필드 모두 0', () { + final e = TodayStats.empty(); + expect(e.totalMinutes, 0); + expect(e.sessionCount, 0); + expect(e.streak, 0); + expect(e.lifetimeMinutes, 0); + expect(e.lifetimeSessionCount, 0); + expect(e.monthlyMinutes, 0); +}); +``` + +### 6.3 Provider 테스트 — `study_stats_provider_test.dart` (신규) + +`isAuthenticatedProvider` overrride + `todayStatsNotifierProvider` override + `timerSessionListNotifierProvider` override 로 4가지 시나리오 검증: + +1. **인증 + todayStats 로드 완료** → `totalStudyMinutes = lifetimeMinutes`, `totalSessionCount = lifetimeSessionCount`, `monthlyStudyMinutes = monthlyMinutes` +2. **인증 + todayStats 로딩 중** (AsyncLoading) → 세 provider 모두 0 반환 +3. **인증 + todayStats 에러** (AsyncError) → 세 provider 모두 0 반환 (valueOrNull fallback) +4. **게스트 + 로컬 세션 N개** → 기존 합산 로직 결과 +5. **게스트 + 로컬 세션 비어있음** → 0 반환 + +### 6.4 회귀 가드 + +`today_stats_provider_test.dart` 의 기존 2개 케이스 (인증 모드, 게스트 모드) 가 그대로 통과해야 한다. `TodayStats` 생성자에 필드 3개가 추가되므로 테스트 픽스처 업데이트 필요 (모두 0 으로) — 컴파일 통과만 확보. + +--- + +## 7. 명시적 비범위(Out of Scope) + +다음은 이 이슈에서 **건드리지 않는다**. 별도 이슈로 다룰 사안: + +- `todayStudyMinutes`, `weeklyStudyMinutes`, `currentStreak` provider — 이슈가 지목한 3개만 변경. +- `today_stats_provider.dart` 의 게스트 분기 — 현재 로컬 계산 그대로 유지. +- `today_stats_provider.dart` 의 인증 분기 — 이미 `repository.getTodayStats()` 위임 패턴 동작 중. +- 새 invalidate 트리거 — 기존 4곳으로 충분. +- UI 신규 컴포넌트 — 시그니처가 동일해서 호출처 수정 불필요. +- 백엔드 작업 — 이미 완료, `docs/api-docs.json` 동기화 끝남. + +--- + +## 8. 검증 체크리스트 + +- [ ] `flutter pub run build_runner build --delete-conflicting-outputs` 성공 +- [ ] `flutter analyze` 경고 0 +- [ ] `flutter test test/features/timer/` 전부 통과 +- [ ] 기존 `today_stats_provider_test.dart` 회귀 없음 (TodayStats 생성자 인자 추가만 반영) +- [ ] `git grep -n 'TodayStats('` 결과의 모든 호출처 — 신규 6필드 또는 `.empty()` 사용 일관 From b33a7918ec0bb83ab014cdab0490dc9ef3a5913b Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Wed, 27 May 2026 00:35:37 +0900 Subject: [PATCH 02/10] =?UTF-8?q?docs=20:=20today-stats=20spec=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84=20=E2=80=94=20=EA=B2=8C=EC=8A=A4=ED=8A=B8=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0=206=ED=95=84=EB=93=9C=20fold=20+=20study=5Fstats=5Fpr?= =?UTF-8?q?ovider=20=EB=8B=A8=EC=88=9C=ED=99=94=20#83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...stats-cumulative-fields-frontend-design.md | 135 ++++++++++++------ 1 file changed, 89 insertions(+), 46 deletions(-) diff --git a/docs/superpowers/specs/2026-05-27-today-stats-cumulative-fields-frontend-design.md b/docs/superpowers/specs/2026-05-27-today-stats-cumulative-fields-frontend-design.md index 1756f86..66bf524 100644 --- a/docs/superpowers/specs/2026-05-27-today-stats-cumulative-fields-frontend-design.md +++ b/docs/superpowers/specs/2026-05-27-today-stats-cumulative-fields-frontend-design.md @@ -26,9 +26,12 @@ ## 2. 솔루션 개요 -서버가 반환하기 시작한 누적 필드 3개를 클라이언트가 수신·노출한다. 인증 모드는 서버 값을 신뢰하고, 게스트 모드는 현재 로컬 합산 로직을 그대로 유지한다. +서버가 반환하기 시작한 누적 필드 3개를 클라이언트가 수신·노출한다. `todayStatsNotifierProvider` 가 **단일 진실 소스(single source of truth)** 가 되도록 정리한다. -`today_stats_provider.dart` 에 이미 존재하는 인증/게스트 분기 패턴을 그대로 활용한다 — 새 provider 를 만들지 않고 기존 `todayStatsNotifierProvider` 결과를 `study_stats_provider` 에서 끌어 쓴다. +- **인증 모드**: 서버 응답을 그대로 신뢰 — 이미 동작 중. +- **게스트 모드**: 기존에는 3필드만 채웠지만, 이제 로컬 세션을 fold 해서 6필드 모두 채운다. 이슈 명시("게스트 모드는 로컬 전체 세션이 메모리에 있어 정확함")와 일치. + +`study_stats_provider.dart` 의 누적 통계 3개 함수는 단순히 `todayStatsNotifierProvider.valueOrNull?.X ?? 0` 한 줄로 위임 — 자체 분기 로직 제거. 더 이상 `isAuthenticatedProvider` 를 watch 하지 않는다. --- @@ -38,12 +41,14 @@ |---|---| | `lib/features/timer/data/models/today_stats_response_model.dart` | 필드 3개 (`lifetimeMinutes`, `lifetimeSessionCount`, `monthlyMinutes`) 추가 + `toEntity()` 매핑 갱신 | | `lib/features/timer/domain/entities/today_stats.dart` | 동일 3필드 추가 + `TodayStats.empty()` 의 0 초기값 6필드로 확장 | -| `lib/features/timer/presentation/providers/study_stats_provider.dart` | `totalStudyMinutes`, `totalSessionCount`, `monthlyStudyMinutes` 에 인증/게스트 분기 추가 | +| `lib/features/timer/presentation/providers/today_stats_provider.dart` | **게스트 분기에서 6필드 모두 로컬 fold 로 채우도록 확장**. 컴파일 에러 회피 + 단일 진실 소스 보장 | +| `lib/features/timer/presentation/providers/study_stats_provider.dart` | `totalStudyMinutes`, `totalSessionCount`, `monthlyStudyMinutes` 를 `todayStatsNotifierProvider` 위임 한 줄로 단순화. `isAuthenticatedProvider` watch 제거 | | `*.freezed.dart`, `*.g.dart` | `flutter pub run build_runner build --delete-conflicting-outputs` 로 재생성 | | `test/features/timer/data/models/today_stats_response_model_test.dart` | 새 필드 직렬화 + `toEntity()` 매핑 검증 추가 | -| `test/features/timer/domain/entities/today_stats_test.dart` | 새 필드 + `empty()` 검증 추가 | -| `test/features/timer/presentation/providers/study_stats_provider_test.dart` | **신규 파일**. 3개 provider 의 인증/게스트 분기 검증 | -| `test/features/timer/presentation/providers/today_stats_provider_test.dart` | 회귀 가드 — 기존 케이스 통과 유지 (변경 없음) | +| `test/features/timer/domain/entities/today_stats_test.dart` | 새 필드 + `empty()` 검증 추가, 기존 fixture 6필드로 확장 | +| `test/features/timer/data/repositories/timer_session_repository_impl_test.dart` | **회귀 가드** — `TodayStatsResponseModel(...)` 직접 생성 fixture 6필드로 확장 | +| `test/features/timer/presentation/providers/today_stats_provider_test.dart` | **회귀 가드** — `TodayStats(...)` 직접 생성 fixture 6필드로 확장 + 게스트 분기 새 필드 검증 보강 | +| `test/features/timer/presentation/providers/study_stats_provider_test.dart` | **신규 파일**. `todayStatsNotifierProvider` override 만으로 3개 provider 검증 | --- @@ -106,52 +111,79 @@ class TodayStats with _$TodayStats { } ``` -### 4.3 Provider 리팩터링 — `study_stats_provider.dart` +### 4.3 today_stats_provider 게스트 분기 — 6필드 fold 로 확장 -3개 함수에 인증/게스트 분기 추가. 시그니처는 **동기 `int` 그대로 유지** — 인증 모드에서 `todayStatsNotifierProvider` 가 로딩 중이거나 에러일 때는 `valueOrNull?.X ?? 0` 패턴으로 0 fallback. +Entity 가 6필드 `required` 가 되면서 현재 게스트 분기(`today_stats_provider.dart:39`) 가 컴파일 에러. 게스트 모드에서는 로컬 세션 전체가 메모리에 있으므로 fold 로 정확하게 계산 가능 — 이슈가 명시한 의도와 일치. + +```dart +@override +Future build() async { + final isAuthenticated = ref.watch(isAuthenticatedProvider); + final repository = ref.watch(timerSessionRepositoryProvider); + + if (isAuthenticated) { + return repository.getTodayStats(); + } + + // 게스트 모드: 로컬 세션 fold + final sessions = repository.getSessions(); + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final tomorrow = today.add(const Duration(days: 1)); + final monthStart = DateTime(now.year, now.month, 1); + + final todaySessions = sessions.where((s) { + return s.startedAt.isAfter(today.subtract(const Duration(seconds: 1))) && + s.startedAt.isBefore(tomorrow); + }).toList(); + + final todayMinutes = + todaySessions.fold(0, (sum, s) => sum + s.durationMinutes); + final lifetimeMinutes = + sessions.fold(0, (sum, s) => sum + s.durationMinutes); + final monthlyMinutes = sessions + .where((s) => !s.startedAt.isBefore(monthStart)) + .fold(0, (sum, s) => sum + s.durationMinutes); + + return TodayStats( + totalMinutes: todayMinutes, + sessionCount: todaySessions.length, + streak: todaySessions.isEmpty ? 0 : 1, + lifetimeMinutes: lifetimeMinutes, + lifetimeSessionCount: sessions.length, + monthlyMinutes: monthlyMinutes, + ); +} +``` + +기존 streak 계산("오늘 세션 있으면 1, 없으면 0") 은 변경하지 않음 — 이슈 범위 밖. + +### 4.4 study_stats_provider 단순화 — `todayStatsNotifierProvider` 위임 + +3개 함수를 한 줄로 단순화. 더 이상 `isAuthenticatedProvider` 를 watch 하지 않고, 분기 책임을 전부 `todayStatsNotifierProvider` 에 위임. 시그니처는 동기 `int` 그대로 유지 — 로딩/에러시 `valueOrNull?.X ?? 0` fallback. ```dart -import '../../../auth/presentation/providers/auth_provider.dart'; import 'today_stats_provider.dart'; @riverpod int totalStudyMinutes(Ref ref) { - if (ref.watch(isAuthenticatedProvider)) { - return ref.watch(todayStatsNotifierProvider).valueOrNull?.lifetimeMinutes ?? 0; - } - final sessions = - ref.watch(timerSessionListNotifierProvider).valueOrNull ?? []; - return sessions.fold(0, (sum, s) => sum + s.durationMinutes); + return ref.watch(todayStatsNotifierProvider).valueOrNull?.lifetimeMinutes ?? 0; } @riverpod int totalSessionCount(Ref ref) { - if (ref.watch(isAuthenticatedProvider)) { - return ref.watch(todayStatsNotifierProvider).valueOrNull?.lifetimeSessionCount ?? 0; - } - final sessions = - ref.watch(timerSessionListNotifierProvider).valueOrNull ?? []; - return sessions.length; + return ref.watch(todayStatsNotifierProvider).valueOrNull?.lifetimeSessionCount ?? 0; } @riverpod int monthlyStudyMinutes(Ref ref) { - if (ref.watch(isAuthenticatedProvider)) { - return ref.watch(todayStatsNotifierProvider).valueOrNull?.monthlyMinutes ?? 0; - } - final sessions = - ref.watch(timerSessionListNotifierProvider).valueOrNull ?? []; - final now = DateTime.now(); - final monthStart = DateTime(now.year, now.month, 1); - return sessions - .where((s) => !s.startedAt.isBefore(monthStart)) - .fold(0, (sum, s) => sum + s.durationMinutes); + return ref.watch(todayStatsNotifierProvider).valueOrNull?.monthlyMinutes ?? 0; } ``` **손대지 않는 함수**: `todayStudyMinutes`, `weeklyStudyMinutes`, `currentStreak`. 이슈가 명시한 3개만 변경. -### 4.4 무효화 트리거 — 변경 없음 +### 4.5 무효화 트리거 — 변경 없음 `todayStatsNotifierProvider` 는 이미 4곳에서 invalidate 된다: @@ -233,17 +265,26 @@ test('empty() — 6 필드 모두 0', () { ### 6.3 Provider 테스트 — `study_stats_provider_test.dart` (신규) -`isAuthenticatedProvider` overrride + `todayStatsNotifierProvider` override + `timerSessionListNotifierProvider` override 로 4가지 시나리오 검증: +`todayStatsNotifierProvider` override 만으로 3가지 시나리오 검증 — 인증/게스트 분기 책임이 todayStatsNotifierProvider 로 이동했으므로 study_stats_provider 자체는 위임만 검증: + +1. **todayStats 로드 완료** → `totalStudyMinutes = lifetimeMinutes`, `totalSessionCount = lifetimeSessionCount`, `monthlyStudyMinutes = monthlyMinutes` +2. **todayStats 로딩 중** (`AsyncValue.loading()` override) → 세 provider 모두 0 반환 +3. **todayStats 에러** (`AsyncValue.error()` override) → 세 provider 모두 0 반환 + +### 6.4 회귀 가드 — 컴파일 통과 위한 fixture 갱신 + +DTO/Entity 가 6필드 `required` 가 되면서 직접 생성자 호출하는 기존 테스트 파일들이 모두 컴파일 에러. 다음 4개 파일에서 fixture 를 6필드로 확장 — 추가 필드는 모두 0: -1. **인증 + todayStats 로드 완료** → `totalStudyMinutes = lifetimeMinutes`, `totalSessionCount = lifetimeSessionCount`, `monthlyStudyMinutes = monthlyMinutes` -2. **인증 + todayStats 로딩 중** (AsyncLoading) → 세 provider 모두 0 반환 -3. **인증 + todayStats 에러** (AsyncError) → 세 provider 모두 0 반환 (valueOrNull fallback) -4. **게스트 + 로컬 세션 N개** → 기존 합산 로직 결과 -5. **게스트 + 로컬 세션 비어있음** → 0 반환 +| 파일:라인 | 현재 | 갱신 | +|---|---|---| +| `today_stats_test.dart:6` | `TodayStats(totalMinutes: 180, sessionCount: 3, streak: 7)` | 6필드. 추가 3필드는 0 또는 의미있는 값 | +| `today_stats_test.dart:15` | `TodayStats(totalMinutes: 0, sessionCount: 0, streak: 0)` | 6필드 모두 0 — `empty()` 검증 케이스 | +| `today_stats_provider_test.dart:26` | `TodayStats(totalMinutes: 180, sessionCount: 3, streak: 7)` | 인증 모드 케이스. 6필드 의미있는 값 | +| `timer_session_repository_impl_test.dart:173-177` | `TodayStatsResponseModel(...)` 3필드 | 6필드 | -### 6.4 회귀 가드 +추가로 `today_stats_provider_test.dart` 의 게스트 분기 케이스에 lifetime/monthly 검증 보강: -`today_stats_provider_test.dart` 의 기존 2개 케이스 (인증 모드, 게스트 모드) 가 그대로 통과해야 한다. `TodayStats` 생성자에 필드 3개가 추가되므로 테스트 픽스처 업데이트 필요 (모두 0 으로) — 컴파일 통과만 확보. +- 오늘 세션 2개(60+30분) + 어제 세션 1개(40분) 시나리오 → `totalMinutes=90`, `lifetimeMinutes=130`, `lifetimeSessionCount=3`, `monthlyMinutes` (이번 달 시작 이후 합산값) --- @@ -252,10 +293,10 @@ test('empty() — 6 필드 모두 0', () { 다음은 이 이슈에서 **건드리지 않는다**. 별도 이슈로 다룰 사안: - `todayStudyMinutes`, `weeklyStudyMinutes`, `currentStreak` provider — 이슈가 지목한 3개만 변경. -- `today_stats_provider.dart` 의 게스트 분기 — 현재 로컬 계산 그대로 유지. -- `today_stats_provider.dart` 의 인증 분기 — 이미 `repository.getTodayStats()` 위임 패턴 동작 중. -- 새 invalidate 트리거 — 기존 4곳으로 충분. -- UI 신규 컴포넌트 — 시그니처가 동일해서 호출처 수정 불필요. +- `today_stats_provider.dart` 게스트 분기의 **streak 계산 로직** — 현재 "오늘 있으면 1, 없으면 0" 단순 로직 그대로. lifetime/monthly 필드 추가만 진행. +- `today_stats_provider.dart` 의 인증 분기 — 이미 `repository.getTodayStats()` 위임 패턴 동작 중. 변경 없음. +- 새 invalidate 트리거 — 기존 4곳(timer save 1, auth 3) 으로 충분. +- UI 신규 컴포넌트 — 시그니처가 동일해서 호출처(profile_screen, badge_provider) 수정 불필요. - 백엔드 작업 — 이미 완료, `docs/api-docs.json` 동기화 끝남. --- @@ -265,5 +306,7 @@ test('empty() — 6 필드 모두 0', () { - [ ] `flutter pub run build_runner build --delete-conflicting-outputs` 성공 - [ ] `flutter analyze` 경고 0 - [ ] `flutter test test/features/timer/` 전부 통과 -- [ ] 기존 `today_stats_provider_test.dart` 회귀 없음 (TodayStats 생성자 인자 추가만 반영) -- [ ] `git grep -n 'TodayStats('` 결과의 모든 호출처 — 신규 6필드 또는 `.empty()` 사용 일관 +- [ ] `git grep -n 'TodayStats('` 결과의 모든 호출처 — 신규 6필드 또는 `.empty()` 사용 일관 (§6.4 4개 파일 갱신 완료 여부 확인) +- [ ] `git grep -n 'TodayStatsResponseModel('` 결과 — `timer_session_repository_impl_test.dart` 픽스처 6필드 갱신 완료 +- [ ] profile_screen / badge_provider 호출처 — 코드 미수정 상태로 정상 동작 (시그니처 동일) +- [ ] flutter run 으로 인증 사용자 프로필 화면 진입 → "공부 시간" 카드가 누적값 표시 (서버 정확값 검증) From 38cbf5ad9a280823a1827ecf67ea652fb14e96a6 Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Wed, 27 May 2026 00:39:58 +0900 Subject: [PATCH 03/10] =?UTF-8?q?docs=20:=20today-stats=20spec=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B3=84=ED=9A=8D=20=EA=B0=84=EC=86=8C?= =?UTF-8?q?=ED=99=94=20=E2=80=94=20=ED=9A=8C=EA=B7=80=20=EA=B0=80=EB=93=9C?= =?UTF-8?q?=20fixture=20=EA=B0=B1=EC=8B=A0=20=EC=9C=84=EC=A3=BC=20#83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...stats-cumulative-fields-frontend-design.md | 79 +++++-------------- 1 file changed, 21 insertions(+), 58 deletions(-) diff --git a/docs/superpowers/specs/2026-05-27-today-stats-cumulative-fields-frontend-design.md b/docs/superpowers/specs/2026-05-27-today-stats-cumulative-fields-frontend-design.md index 66bf524..263a301 100644 --- a/docs/superpowers/specs/2026-05-27-today-stats-cumulative-fields-frontend-design.md +++ b/docs/superpowers/specs/2026-05-27-today-stats-cumulative-fields-frontend-design.md @@ -44,11 +44,11 @@ | `lib/features/timer/presentation/providers/today_stats_provider.dart` | **게스트 분기에서 6필드 모두 로컬 fold 로 채우도록 확장**. 컴파일 에러 회피 + 단일 진실 소스 보장 | | `lib/features/timer/presentation/providers/study_stats_provider.dart` | `totalStudyMinutes`, `totalSessionCount`, `monthlyStudyMinutes` 를 `todayStatsNotifierProvider` 위임 한 줄로 단순화. `isAuthenticatedProvider` watch 제거 | | `*.freezed.dart`, `*.g.dart` | `flutter pub run build_runner build --delete-conflicting-outputs` 로 재생성 | -| `test/features/timer/data/models/today_stats_response_model_test.dart` | 새 필드 직렬화 + `toEntity()` 매핑 검증 추가 | -| `test/features/timer/domain/entities/today_stats_test.dart` | 새 필드 + `empty()` 검증 추가, 기존 fixture 6필드로 확장 | -| `test/features/timer/data/repositories/timer_session_repository_impl_test.dart` | **회귀 가드** — `TodayStatsResponseModel(...)` 직접 생성 fixture 6필드로 확장 | -| `test/features/timer/presentation/providers/today_stats_provider_test.dart` | **회귀 가드** — `TodayStats(...)` 직접 생성 fixture 6필드로 확장 + 게스트 분기 새 필드 검증 보강 | -| `test/features/timer/presentation/providers/study_stats_provider_test.dart` | **신규 파일**. `todayStatsNotifierProvider` override 만으로 3개 provider 검증 | +| `test/features/timer/data/models/today_stats_response_model_test.dart` | 기존 케이스에 새 필드 3개 직렬화·매핑 검증 추가 | +| `test/features/timer/domain/entities/today_stats_test.dart` | fixture 6필드로 확장 (회귀 가드) | +| `test/features/timer/data/repositories/timer_session_repository_impl_test.dart` | fixture 6필드로 확장 (회귀 가드) | +| `test/features/timer/presentation/providers/today_stats_provider_test.dart` | fixture 6필드로 확장 (회귀 가드) | +| `test/features/timer/presentation/providers/study_stats_provider_test.dart` | **신규 파일**. 위임 동작 1 케이스 | --- @@ -222,69 +222,32 @@ final sessionCount = ref.read(totalSessionCountProvider); ## 6. 테스트 계획 -### 6.1 DTO 테스트 — `today_stats_response_model_test.dart` +작업 난이도가 낮으므로 **이슈가 명시한 테스트 + 회귀 가드 (fixture 갱신)** 만 진행. trivial 한 분기는 케이스 추가하지 않는다. -기존 케이스에 새 필드 3개 직렬화·엔티티 매핑 추가: +### 6.1 DTO — `today_stats_response_model_test.dart` -```dart -test('fromJson + toEntity — 6필드 모두', () { - final m = TodayStatsResponseModel.fromJson({ - 'totalMinutes': 180, - 'sessionCount': 3, - 'streak': 7, - 'lifetimeMinutes': 12450, - 'lifetimeSessionCount': 287, - 'monthlyMinutes': 1820, - }); - expect(m.lifetimeMinutes, 12450); - expect(m.lifetimeSessionCount, 287); - expect(m.monthlyMinutes, 1820); - - final e = m.toEntity(); - expect(e.lifetimeMinutes, 12450); - expect(e.lifetimeSessionCount, 287); - expect(e.monthlyMinutes, 1820); -}); -``` - -### 6.2 Entity 테스트 — `today_stats_test.dart` - -`TodayStats.empty()` 가 6필드 모두 0 반환하는지 검증: +기존 테스트의 JSON 픽스처에 새 필드 3개 추가하고 6필드 모두 `toEntity()` 매핑이 동작하는지 한 케이스로 검증. -```dart -test('empty() — 6 필드 모두 0', () { - final e = TodayStats.empty(); - expect(e.totalMinutes, 0); - expect(e.sessionCount, 0); - expect(e.streak, 0); - expect(e.lifetimeMinutes, 0); - expect(e.lifetimeSessionCount, 0); - expect(e.monthlyMinutes, 0); -}); -``` +### 6.2 신규 — `study_stats_provider_test.dart` -### 6.3 Provider 테스트 — `study_stats_provider_test.dart` (신규) +`todayStatsNotifierProvider` override 한 줄로 위임 동작만 검증. **로드 완료 1 케이스만**: -`todayStatsNotifierProvider` override 만으로 3가지 시나리오 검증 — 인증/게스트 분기 책임이 todayStatsNotifierProvider 로 이동했으므로 study_stats_provider 자체는 위임만 검증: +- `lifetimeMinutes=12450`, `lifetimeSessionCount=287`, `monthlyMinutes=1820` 인 `TodayStats` 주입 → 3 provider 가 각각 일치하는 값 반환. -1. **todayStats 로드 완료** → `totalStudyMinutes = lifetimeMinutes`, `totalSessionCount = lifetimeSessionCount`, `monthlyStudyMinutes = monthlyMinutes` -2. **todayStats 로딩 중** (`AsyncValue.loading()` override) → 세 provider 모두 0 반환 -3. **todayStats 에러** (`AsyncValue.error()` override) → 세 provider 모두 0 반환 +loading/error 분기는 Riverpod 표준 동작 (`valueOrNull` 이 null 반환) 이므로 별도 케이스 불필요. -### 6.4 회귀 가드 — 컴파일 통과 위한 fixture 갱신 +### 6.3 회귀 가드 — fixture 6필드 갱신 -DTO/Entity 가 6필드 `required` 가 되면서 직접 생성자 호출하는 기존 테스트 파일들이 모두 컴파일 에러. 다음 4개 파일에서 fixture 를 6필드로 확장 — 추가 필드는 모두 0: +DTO/Entity 가 6필드 `required` 가 되면서 기존 픽스처는 컴파일 에러. 다음 4개 파일에서 6필드로 확장 (추가 필드는 모두 0): -| 파일:라인 | 현재 | 갱신 | -|---|---|---| -| `today_stats_test.dart:6` | `TodayStats(totalMinutes: 180, sessionCount: 3, streak: 7)` | 6필드. 추가 3필드는 0 또는 의미있는 값 | -| `today_stats_test.dart:15` | `TodayStats(totalMinutes: 0, sessionCount: 0, streak: 0)` | 6필드 모두 0 — `empty()` 검증 케이스 | -| `today_stats_provider_test.dart:26` | `TodayStats(totalMinutes: 180, sessionCount: 3, streak: 7)` | 인증 모드 케이스. 6필드 의미있는 값 | -| `timer_session_repository_impl_test.dart:173-177` | `TodayStatsResponseModel(...)` 3필드 | 6필드 | - -추가로 `today_stats_provider_test.dart` 의 게스트 분기 케이스에 lifetime/monthly 검증 보강: +| 파일:라인 | 갱신 | +|---|---| +| `today_stats_test.dart:6` | `TodayStats(...)` 6필드 | +| `today_stats_test.dart:15` | `TodayStats(...)` 6필드 모두 0 (`empty()` 비교 대상) | +| `today_stats_provider_test.dart:26` | `TodayStats(...)` 6필드 | +| `timer_session_repository_impl_test.dart:173-177` | `TodayStatsResponseModel(...)` 6필드 | -- 오늘 세션 2개(60+30분) + 어제 세션 1개(40분) 시나리오 → `totalMinutes=90`, `lifetimeMinutes=130`, `lifetimeSessionCount=3`, `monthlyMinutes` (이번 달 시작 이후 합산값) +기존 테스트 케이스 자체는 추가하지 않음. 컴파일 통과만 확보. --- From a58182dae2d07c6512782862133375a497a2dd2f Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Wed, 27 May 2026 00:44:57 +0900 Subject: [PATCH 04/10] =?UTF-8?q?docs=20:=20today-stats=20=EB=88=84?= =?UTF-8?q?=EC=A0=81=20=ED=86=B5=EA=B3=84=20=ED=95=84=EB=93=9C=20=ED=81=B4?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EC=97=B0=EB=8F=99=20imp?= =?UTF-8?q?lementation=20plan=20#83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-today-stats-cumulative-fields-frontend.md | 515 ++++++++++++++++++ 1 file changed, 515 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-27-today-stats-cumulative-fields-frontend.md diff --git a/docs/superpowers/plans/2026-05-27-today-stats-cumulative-fields-frontend.md b/docs/superpowers/plans/2026-05-27-today-stats-cumulative-fields-frontend.md new file mode 100644 index 0000000..22d0fe2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-today-stats-cumulative-fields-frontend.md @@ -0,0 +1,515 @@ +# 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:** 백엔드가 추가한 `lifetimeMinutes`/`lifetimeSessionCount`/`monthlyMinutes` 필드를 클라이언트 DTO/Entity 에 노출하고, `todayStatsNotifierProvider` 가 단일 진실 소스가 되도록 누적 통계 provider 를 위임 패턴으로 정리한다. + +**Architecture:** Entity 가 6필드 `required` 가 되면 `TodayStats(...)` / `TodayStatsResponseModel(...)` 직접 호출 5곳이 동시에 컴파일 에러 → Task 1 에서 일괄 처리해 중간 상태 컴파일을 보존한다. Task 2 에서 `study_stats_provider` 의 3개 함수를 `todayStatsNotifierProvider.valueOrNull?.X ?? 0` 한 줄로 단순화하여 인증/게스트 분기 책임을 한 곳으로 모은다. + +**Tech Stack:** Flutter 3.9, Riverpod 2.6 (riverpod_annotation), Freezed 2.5, build_runner, mocktail, flutter_test + +**Spec:** `docs/superpowers/specs/2026-05-27-today-stats-cumulative-fields-frontend-design.md` + +--- + +## File Structure + +| 파일 | 작업 | Task | +|---|---|---| +| `lib/features/timer/data/models/today_stats_response_model.dart` | M — 3필드 + `toEntity()` | 1 | +| `lib/features/timer/domain/entities/today_stats.dart` | M — 3필드 + `empty()` | 1 | +| `lib/features/timer/presentation/providers/today_stats_provider.dart` | M — 게스트 분기 6필드 fold | 1 | +| `lib/features/timer/presentation/providers/study_stats_provider.dart` | M — 위임 한 줄로 단순화, `isAuthenticatedProvider` 제거 | 2 | +| `*.freezed.dart`, `*.g.dart` | regenerate via build_runner | 1, 2 | +| `test/features/timer/data/models/today_stats_response_model_test.dart` | M — 6필드 JSON 픽스처 | 1 | +| `test/features/timer/domain/entities/today_stats_test.dart` | M — fixture 2건 6필드 | 1 | +| `test/features/timer/data/repositories/timer_session_repository_impl_test.dart` | M — DTO 픽스처 6필드 | 1 | +| `test/features/timer/presentation/providers/today_stats_provider_test.dart` | M — fixture 6필드 | 1 | +| `test/features/timer/presentation/providers/study_stats_provider_test.dart` | C — 신규, 위임 동작 검증 | 2 | + +--- + +## Task 1: Entity·DTO 6필드 확장 + 모든 호출처 컴파일 에러 해소 + +**Files:** +- Modify: `lib/features/timer/domain/entities/today_stats.dart` (전체 재작성) +- Modify: `lib/features/timer/data/models/today_stats_response_model.dart` (전체 재작성) +- Modify: `lib/features/timer/presentation/providers/today_stats_provider.dart:14-44` (게스트 분기 fold) +- Modify: `test/features/timer/data/models/today_stats_response_model_test.dart` (전체 재작성) +- Modify: `test/features/timer/domain/entities/today_stats_test.dart` (전체 재작성) +- Modify: `test/features/timer/data/repositories/timer_session_repository_impl_test.dart:170-183` (fixture 갱신) +- Modify: `test/features/timer/presentation/providers/today_stats_provider_test.dart:23-37` (fixture 갱신) + +### Step 1: DTO 테스트 갱신 (RED) + +`test/features/timer/data/models/today_stats_response_model_test.dart` 를 다음으로 교체. + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:space_study_ship/features/timer/data/models/today_stats_response_model.dart'; + +void main() { + test('fromJson + toEntity — 6필드 모두 매핑', () { + final m = TodayStatsResponseModel.fromJson({ + 'totalMinutes': 180, + 'sessionCount': 3, + 'streak': 7, + 'lifetimeMinutes': 12450, + 'lifetimeSessionCount': 287, + 'monthlyMinutes': 1820, + }); + expect(m.totalMinutes, 180); + expect(m.sessionCount, 3); + expect(m.streak, 7); + expect(m.lifetimeMinutes, 12450); + expect(m.lifetimeSessionCount, 287); + expect(m.monthlyMinutes, 1820); + + final e = m.toEntity(); + expect(e.totalMinutes, 180); + expect(e.sessionCount, 3); + expect(e.streak, 7); + expect(e.lifetimeMinutes, 12450); + expect(e.lifetimeSessionCount, 287); + expect(e.monthlyMinutes, 1820); + }); +} +``` + +### Step 2: Entity 테스트 갱신 (RED) + +`test/features/timer/domain/entities/today_stats_test.dart` 를 다음으로 교체. + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:space_study_ship/features/timer/domain/entities/today_stats.dart'; + +void main() { + test('TodayStats — 6필드 생성', () { + const stats = TodayStats( + totalMinutes: 180, + sessionCount: 3, + streak: 7, + lifetimeMinutes: 12450, + lifetimeSessionCount: 287, + monthlyMinutes: 1820, + ); + expect(stats.totalMinutes, 180); + expect(stats.lifetimeMinutes, 12450); + expect(stats.monthlyMinutes, 1820); + }); + + test('TodayStats.empty(): 6필드 모두 0', () { + expect( + TodayStats.empty(), + const TodayStats( + totalMinutes: 0, + sessionCount: 0, + streak: 0, + lifetimeMinutes: 0, + lifetimeSessionCount: 0, + monthlyMinutes: 0, + ), + ); + }); +} +``` + +### Step 3: 회귀 가드 fixture 갱신 — `today_stats_provider_test.dart` + +`test/features/timer/presentation/providers/today_stats_provider_test.dart:23-37` 의 인증 모드 케이스에서 `TodayStats` fixture 를 6필드로 확장. 다음 블록을 찾아 교체. + +```dart + test('인증 모드: repo.getTodayStats() 결과 그대로 반환', () async { + when(() => repo.getTodayStats()).thenAnswer( + (_) async => const TodayStats( + totalMinutes: 180, + sessionCount: 3, + streak: 7, + lifetimeMinutes: 12450, + lifetimeSessionCount: 287, + monthlyMinutes: 1820, + ), + ); + container = ProviderContainer( + overrides: [ + isAuthenticatedProvider.overrideWith((ref) => true), + timerSessionRepositoryProvider.overrideWith((ref) => repo), + ], + ); + final stats = await container.read(todayStatsNotifierProvider.future); + expect(stats.totalMinutes, 180); + expect(stats.streak, 7); + expect(stats.lifetimeMinutes, 12450); + }); +``` + +게스트 모드 케이스 (line 39-69 부근) 는 `repository.getSessions()` mock 만 사용하므로 fixture 변경 불필요. 단, 마지막 assertion 에 신규 필드 1개만 추가해서 fold 결과 확인: + +```dart + expect(stats.sessionCount, 2); + expect(stats.totalMinutes, 90); + expect(stats.streak, 1); + expect(stats.lifetimeMinutes, 90); // 게스트는 전체 = today 합산 + expect(stats.lifetimeSessionCount, 2); +``` + +### Step 4: 회귀 가드 fixture 갱신 — `timer_session_repository_impl_test.dart` + +`test/features/timer/data/repositories/timer_session_repository_impl_test.dart:170-183` 의 `getTodayStats` 그룹에서 DTO fixture 를 6필드로 확장. + +```dart + group('getTodayStats', () { + test('성공: TodayStats 반환', () async { + when(() => remote.getTodayStats()).thenAnswer( + (_) async => const TodayStatsResponseModel( + totalMinutes: 180, + sessionCount: 3, + streak: 7, + lifetimeMinutes: 12450, + lifetimeSessionCount: 287, + monthlyMinutes: 1820, + ), + ); + final stats = await repo.getTodayStats(); + expect(stats.totalMinutes, 180); + expect(stats.streak, 7); + expect(stats.lifetimeMinutes, 12450); + }); + }); +``` + +### Step 5: Entity 필드 추가 + +`lib/features/timer/domain/entities/today_stats.dart` 를 다음으로 교체. + +```dart +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'today_stats.freezed.dart'; + +/// KST 기준 오늘 공부 통계 + 누적 통계 (GET /api/timer-sessions/today-stats). +@freezed +class TodayStats with _$TodayStats { + const factory TodayStats({ + required int totalMinutes, + required int sessionCount, + required int streak, + required int lifetimeMinutes, + required int lifetimeSessionCount, + required int monthlyMinutes, + }) = _TodayStats; + + factory TodayStats.empty() => const TodayStats( + totalMinutes: 0, + sessionCount: 0, + streak: 0, + lifetimeMinutes: 0, + lifetimeSessionCount: 0, + monthlyMinutes: 0, + ); +} +``` + +### Step 6: DTO 필드 추가 + toEntity 매핑 + +`lib/features/timer/data/models/today_stats_response_model.dart` 를 다음으로 교체. + +```dart +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../domain/entities/today_stats.dart'; + +part 'today_stats_response_model.freezed.dart'; +part 'today_stats_response_model.g.dart'; + +@freezed +class TodayStatsResponseModel with _$TodayStatsResponseModel { + const TodayStatsResponseModel._(); + + const factory TodayStatsResponseModel({ + required int totalMinutes, + required int sessionCount, + required int streak, + required int lifetimeMinutes, + required int lifetimeSessionCount, + required int monthlyMinutes, + }) = _TodayStatsResponseModel; + + factory TodayStatsResponseModel.fromJson(Map json) => + _$TodayStatsResponseModelFromJson(json); + + TodayStats toEntity() => TodayStats( + totalMinutes: totalMinutes, + sessionCount: sessionCount, + streak: streak, + lifetimeMinutes: lifetimeMinutes, + lifetimeSessionCount: lifetimeSessionCount, + monthlyMinutes: monthlyMinutes, + ); +} +``` + +### Step 7: today_stats_provider 게스트 분기 6필드 fold + +`lib/features/timer/presentation/providers/today_stats_provider.dart` 의 `build()` 메서드 전체를 다음으로 교체 (전체 파일 line 14-44 영역). + +```dart + @override + Future build() async { + final isAuthenticated = ref.watch(isAuthenticatedProvider); + final repository = ref.watch(timerSessionRepositoryProvider); + + if (isAuthenticated) { + return repository.getTodayStats(); + } + + // 게스트 모드: 로컬 세션 fold (전체가 메모리에 있어 정확) + final sessions = repository.getSessions(); + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final tomorrow = today.add(const Duration(days: 1)); + final monthStart = DateTime(now.year, now.month, 1); + + final todaySessions = sessions.where((s) { + return s.startedAt.isAfter(today.subtract(const Duration(seconds: 1))) && + s.startedAt.isBefore(tomorrow); + }).toList(); + + final todayMinutes = + todaySessions.fold(0, (sum, s) => sum + s.durationMinutes); + final lifetimeMinutes = + sessions.fold(0, (sum, s) => sum + s.durationMinutes); + final monthlyMinutes = sessions + .where((s) => !s.startedAt.isBefore(monthStart)) + .fold(0, (sum, s) => sum + s.durationMinutes); + + return TodayStats( + totalMinutes: todayMinutes, + sessionCount: todaySessions.length, + streak: todaySessions.isEmpty ? 0 : 1, + lifetimeMinutes: lifetimeMinutes, + lifetimeSessionCount: sessions.length, + monthlyMinutes: monthlyMinutes, + ); + } +``` + +### Step 8: build_runner 재생성 + +Run: +```bash +flutter pub run build_runner build --delete-conflicting-outputs +``` + +Expected: `[INFO] Succeeded` 로 종료. `today_stats.freezed.dart`, `today_stats_response_model.freezed.dart`, `today_stats_response_model.g.dart` 가 6필드 반영하여 재생성됨. + +### Step 9: 분석·테스트 통과 확인 (GREEN) + +Run: +```bash +flutter analyze lib/features/timer test/features/timer +``` +Expected: `No issues found!` + +```bash +flutter test test/features/timer +``` +Expected: 모든 케이스 PASS. 특히: +- `today_stats_response_model_test.dart` — 1 case PASS +- `today_stats_test.dart` — 2 cases PASS +- `today_stats_provider_test.dart` — 2 cases PASS (인증·게스트 분기) +- `timer_session_repository_impl_test.dart` — 기존 케이스 회귀 없음 + +### Step 10: 커밋 + +```bash +git add lib/features/timer/domain/entities/today_stats.dart \ + lib/features/timer/domain/entities/today_stats.freezed.dart \ + lib/features/timer/data/models/today_stats_response_model.dart \ + lib/features/timer/data/models/today_stats_response_model.freezed.dart \ + lib/features/timer/data/models/today_stats_response_model.g.dart \ + lib/features/timer/presentation/providers/today_stats_provider.dart \ + test/features/timer/data/models/today_stats_response_model_test.dart \ + test/features/timer/domain/entities/today_stats_test.dart \ + test/features/timer/data/repositories/timer_session_repository_impl_test.dart \ + test/features/timer/presentation/providers/today_stats_provider_test.dart +git commit -m "feat : TodayStats 누적 통계 3필드 추가 + 게스트 분기 6필드 fold #83" +``` + +--- + +## Task 2: study_stats_provider 위임 단순화 + 신규 테스트 + +**Files:** +- Create: `test/features/timer/presentation/providers/study_stats_provider_test.dart` +- Modify: `lib/features/timer/presentation/providers/study_stats_provider.dart:36-65` (3 함수 단순화) + +### Step 1: 신규 테스트 작성 (RED) + +`test/features/timer/presentation/providers/study_stats_provider_test.dart` 신규 생성: + +```dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:space_study_ship/features/timer/domain/entities/today_stats.dart'; +import 'package:space_study_ship/features/timer/presentation/providers/study_stats_provider.dart'; +import 'package:space_study_ship/features/timer/presentation/providers/today_stats_provider.dart'; + +void main() { + test('todayStats 로드 완료 → 3 provider 가 lifetime/monthly 값 위임', () { + final stats = const TodayStats( + totalMinutes: 180, + sessionCount: 3, + streak: 7, + lifetimeMinutes: 12450, + lifetimeSessionCount: 287, + monthlyMinutes: 1820, + ); + + final container = ProviderContainer( + overrides: [ + todayStatsNotifierProvider.overrideWith(() { + return _StubTodayStatsNotifier(stats); + }), + ], + ); + addTearDown(container.dispose); + + expect(container.read(totalStudyMinutesProvider), 12450); + expect(container.read(totalSessionCountProvider), 287); + expect(container.read(monthlyStudyMinutesProvider), 1820); + }); +} + +class _StubTodayStatsNotifier extends TodayStatsNotifier { + _StubTodayStatsNotifier(this._stats); + final TodayStats _stats; + + @override + Future build() async => _stats; +} +``` + +### Step 2: 테스트 실패 확인 (RED 검증) + +Run: +```bash +flutter test test/features/timer/presentation/providers/study_stats_provider_test.dart +``` +Expected: FAIL. `totalStudyMinutesProvider` 가 현재 `timerSessionListNotifierProvider` 의존이라 0 반환 → expected 12450 mismatch. + +### Step 3: study_stats_provider 위임 패턴 적용 + +`lib/features/timer/presentation/providers/study_stats_provider.dart` 의 `// === 월별/전체 통계 ===` 섹션(line 36 이후) ~ `// === Streak ===` 직전 까지의 3개 함수 (`monthlyStudyMinutes`, `totalStudyMinutes`, `totalSessionCount`) 를 다음으로 교체. + +```dart +// === 월별/전체 통계 === + +/// 이번 달 공부 시간 (분) — todayStats 위임 +@riverpod +int monthlyStudyMinutes(Ref ref) { + return ref.watch(todayStatsNotifierProvider).valueOrNull?.monthlyMinutes ?? 0; +} + +/// 전체 총 공부 시간 (분) — todayStats 위임 +@riverpod +int totalStudyMinutes(Ref ref) { + return ref.watch(todayStatsNotifierProvider).valueOrNull?.lifetimeMinutes ?? 0; +} + +/// 전체 세션 수 — todayStats 위임 +@riverpod +int totalSessionCount(Ref ref) { + return ref.watch(todayStatsNotifierProvider).valueOrNull?.lifetimeSessionCount ?? 0; +} +``` + +추가로 파일 상단 import 에 `today_stats_provider.dart` 가 없으면 추가: + +```dart +import 'today_stats_provider.dart'; +``` + +`todayStudyMinutes`, `weeklyStudyMinutes`, `currentStreak` 는 변경 없음. + +### Step 4: build_runner 재생성 + +Run: +```bash +flutter pub run build_runner build --delete-conflicting-outputs +``` +Expected: `[INFO] Succeeded`. `study_stats_provider.g.dart` 의존성 해시 갱신. + +### Step 5: 테스트 통과 확인 (GREEN) + +Run: +```bash +flutter test test/features/timer/presentation/providers/study_stats_provider_test.dart +``` +Expected: PASS — 1 case. + +```bash +flutter test test/features/timer +``` +Expected: 전체 PASS (회귀 없음). + +```bash +flutter analyze lib/features/timer test/features/timer +``` +Expected: `No issues found!` + +### Step 6: 커밋 + +```bash +git add lib/features/timer/presentation/providers/study_stats_provider.dart \ + lib/features/timer/presentation/providers/study_stats_provider.g.dart \ + test/features/timer/presentation/providers/study_stats_provider_test.dart +git commit -m "refactor : study_stats_provider 누적 통계 3개 todayStats 위임 단순화 #83" +``` + +--- + +## Final Verification + +### Step 1: 전체 분석 통과 + +Run: +```bash +flutter analyze +``` +Expected: `No issues found!` (프로젝트 전체) + +### Step 2: 전체 테스트 통과 + +Run: +```bash +flutter test +``` +Expected: 전체 PASS, 회귀 없음. + +### Step 3: 호출처 동작 수동 검증 + +Run: +```bash +flutter run +``` +- 인증 사용자로 로그인 → 프로필 화면 진입 → "공부 시간" 카드가 서버 누적값 표시 확인. +- 게스트 모드 → 프로필 화면 진입 → 로컬 세션 합산값 표시 확인 (회귀 없음). + +### Step 4: grep 회귀 가드 + +Run: +```bash +git grep -n 'TodayStats(' -- ':!*.freezed.dart' ':!*.g.dart' +git grep -n 'TodayStatsResponseModel(' -- ':!*.freezed.dart' ':!*.g.dart' +``` +Expected: 모든 호출처가 6필드 또는 `.empty()` 사용. 3필드 잔존 호출 없음. + +--- + +## Plan Self-Review + +- **Spec coverage**: §3 의 10행 파일 변경표 → 모두 Task 1·2 에 매핑됨. §4.1~4.5 의 코드 블록 → Task 1 Step 5~7 + Task 2 Step 3 에 그대로 반영. §6.1~6.3 의 테스트 → Task 1 Step 1·2·3·4 + Task 2 Step 1 에 반영. §6.4 회귀 가드 4개 파일 → Task 1 Step 1·2·3·4 모두 처리. §8 검증 체크리스트 → Final Verification 4 step 에 반영. +- **Placeholder scan**: TBD/TODO/생략 없음. 모든 코드 블록 완전. import 경로 명시. +- **Type consistency**: `TodayStats` 의 필드명 (`lifetimeMinutes`, `lifetimeSessionCount`, `monthlyMinutes`) 이 Task 1·2 전체에서 일관. `valueOrNull` 패턴 통일. provider 명 (`totalStudyMinutesProvider`, `totalSessionCountProvider`, `monthlyStudyMinutesProvider`) 일치. From 7882ae31579c3233a2a7c65f62ebcf20b03d6521 Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Wed, 27 May 2026 01:44:30 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat=20:=20TodayStats=20=EB=88=84?= =?UTF-8?q?=EC=A0=81=20=ED=86=B5=EA=B3=84=203=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20+=20=EA=B2=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=206=ED=95=84=EB=93=9C=20fold=20#83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../models/today_stats_response_model.dart | 6 ++ .../today_stats_response_model.freezed.dart | 90 +++++++++++++++++-- .../models/today_stats_response_model.g.dart | 6 ++ .../timer/domain/entities/today_stats.dart | 15 +++- .../domain/entities/today_stats.freezed.dart | 90 +++++++++++++++++-- .../providers/today_stats_provider.dart | 19 ++-- .../timer_session_remote_datasource_test.dart | 7 +- .../today_stats_response_model_test.dart | 22 +++-- .../timer_session_repository_impl_test.dart | 4 + .../domain/entities/today_stats_test.dart | 26 ++++-- .../providers/today_stats_provider_test.dart | 13 ++- 11 files changed, 262 insertions(+), 36 deletions(-) diff --git a/lib/features/timer/data/models/today_stats_response_model.dart b/lib/features/timer/data/models/today_stats_response_model.dart index 99b2360..b1ba70d 100644 --- a/lib/features/timer/data/models/today_stats_response_model.dart +++ b/lib/features/timer/data/models/today_stats_response_model.dart @@ -13,6 +13,9 @@ class TodayStatsResponseModel with _$TodayStatsResponseModel { required int totalMinutes, required int sessionCount, required int streak, + required int lifetimeMinutes, + required int lifetimeSessionCount, + required int monthlyMinutes, }) = _TodayStatsResponseModel; factory TodayStatsResponseModel.fromJson(Map json) => @@ -22,5 +25,8 @@ class TodayStatsResponseModel with _$TodayStatsResponseModel { totalMinutes: totalMinutes, sessionCount: sessionCount, streak: streak, + lifetimeMinutes: lifetimeMinutes, + lifetimeSessionCount: lifetimeSessionCount, + monthlyMinutes: monthlyMinutes, ); } diff --git a/lib/features/timer/data/models/today_stats_response_model.freezed.dart b/lib/features/timer/data/models/today_stats_response_model.freezed.dart index 9da2e0d..062af31 100644 --- a/lib/features/timer/data/models/today_stats_response_model.freezed.dart +++ b/lib/features/timer/data/models/today_stats_response_model.freezed.dart @@ -26,6 +26,9 @@ mixin _$TodayStatsResponseModel { int get totalMinutes => throw _privateConstructorUsedError; int get sessionCount => throw _privateConstructorUsedError; int get streak => throw _privateConstructorUsedError; + int get lifetimeMinutes => throw _privateConstructorUsedError; + int get lifetimeSessionCount => throw _privateConstructorUsedError; + int get monthlyMinutes => throw _privateConstructorUsedError; /// Serializes this TodayStatsResponseModel to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -44,7 +47,14 @@ abstract class $TodayStatsResponseModelCopyWith<$Res> { $Res Function(TodayStatsResponseModel) then, ) = _$TodayStatsResponseModelCopyWithImpl<$Res, TodayStatsResponseModel>; @useResult - $Res call({int totalMinutes, int sessionCount, int streak}); + $Res call({ + int totalMinutes, + int sessionCount, + int streak, + int lifetimeMinutes, + int lifetimeSessionCount, + int monthlyMinutes, + }); } /// @nodoc @@ -68,6 +78,9 @@ class _$TodayStatsResponseModelCopyWithImpl< Object? totalMinutes = null, Object? sessionCount = null, Object? streak = null, + Object? lifetimeMinutes = null, + Object? lifetimeSessionCount = null, + Object? monthlyMinutes = null, }) { return _then( _value.copyWith( @@ -83,6 +96,18 @@ class _$TodayStatsResponseModelCopyWithImpl< ? _value.streak : streak // ignore: cast_nullable_to_non_nullable as int, + lifetimeMinutes: null == lifetimeMinutes + ? _value.lifetimeMinutes + : lifetimeMinutes // ignore: cast_nullable_to_non_nullable + as int, + lifetimeSessionCount: null == lifetimeSessionCount + ? _value.lifetimeSessionCount + : lifetimeSessionCount // ignore: cast_nullable_to_non_nullable + as int, + monthlyMinutes: null == monthlyMinutes + ? _value.monthlyMinutes + : monthlyMinutes // ignore: cast_nullable_to_non_nullable + as int, ) as $Val, ); @@ -98,7 +123,14 @@ abstract class _$$TodayStatsResponseModelImplCopyWith<$Res> ) = __$$TodayStatsResponseModelImplCopyWithImpl<$Res>; @override @useResult - $Res call({int totalMinutes, int sessionCount, int streak}); + $Res call({ + int totalMinutes, + int sessionCount, + int streak, + int lifetimeMinutes, + int lifetimeSessionCount, + int monthlyMinutes, + }); } /// @nodoc @@ -122,6 +154,9 @@ class __$$TodayStatsResponseModelImplCopyWithImpl<$Res> Object? totalMinutes = null, Object? sessionCount = null, Object? streak = null, + Object? lifetimeMinutes = null, + Object? lifetimeSessionCount = null, + Object? monthlyMinutes = null, }) { return _then( _$TodayStatsResponseModelImpl( @@ -137,6 +172,18 @@ class __$$TodayStatsResponseModelImplCopyWithImpl<$Res> ? _value.streak : streak // ignore: cast_nullable_to_non_nullable as int, + lifetimeMinutes: null == lifetimeMinutes + ? _value.lifetimeMinutes + : lifetimeMinutes // ignore: cast_nullable_to_non_nullable + as int, + lifetimeSessionCount: null == lifetimeSessionCount + ? _value.lifetimeSessionCount + : lifetimeSessionCount // ignore: cast_nullable_to_non_nullable + as int, + monthlyMinutes: null == monthlyMinutes + ? _value.monthlyMinutes + : monthlyMinutes // ignore: cast_nullable_to_non_nullable + as int, ), ); } @@ -149,6 +196,9 @@ class _$TodayStatsResponseModelImpl extends _TodayStatsResponseModel { required this.totalMinutes, required this.sessionCount, required this.streak, + required this.lifetimeMinutes, + required this.lifetimeSessionCount, + required this.monthlyMinutes, }) : super._(); factory _$TodayStatsResponseModelImpl.fromJson(Map json) => @@ -160,10 +210,16 @@ class _$TodayStatsResponseModelImpl extends _TodayStatsResponseModel { final int sessionCount; @override final int streak; + @override + final int lifetimeMinutes; + @override + final int lifetimeSessionCount; + @override + final int monthlyMinutes; @override String toString() { - return 'TodayStatsResponseModel(totalMinutes: $totalMinutes, sessionCount: $sessionCount, streak: $streak)'; + return 'TodayStatsResponseModel(totalMinutes: $totalMinutes, sessionCount: $sessionCount, streak: $streak, lifetimeMinutes: $lifetimeMinutes, lifetimeSessionCount: $lifetimeSessionCount, monthlyMinutes: $monthlyMinutes)'; } @override @@ -175,13 +231,26 @@ class _$TodayStatsResponseModelImpl extends _TodayStatsResponseModel { other.totalMinutes == totalMinutes) && (identical(other.sessionCount, sessionCount) || other.sessionCount == sessionCount) && - (identical(other.streak, streak) || other.streak == streak)); + (identical(other.streak, streak) || other.streak == streak) && + (identical(other.lifetimeMinutes, lifetimeMinutes) || + other.lifetimeMinutes == lifetimeMinutes) && + (identical(other.lifetimeSessionCount, lifetimeSessionCount) || + other.lifetimeSessionCount == lifetimeSessionCount) && + (identical(other.monthlyMinutes, monthlyMinutes) || + other.monthlyMinutes == monthlyMinutes)); } @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => - Object.hash(runtimeType, totalMinutes, sessionCount, streak); + int get hashCode => Object.hash( + runtimeType, + totalMinutes, + sessionCount, + streak, + lifetimeMinutes, + lifetimeSessionCount, + monthlyMinutes, + ); /// Create a copy of TodayStatsResponseModel /// with the given fields replaced by the non-null parameter values. @@ -205,6 +274,9 @@ abstract class _TodayStatsResponseModel extends TodayStatsResponseModel { required final int totalMinutes, required final int sessionCount, required final int streak, + required final int lifetimeMinutes, + required final int lifetimeSessionCount, + required final int monthlyMinutes, }) = _$TodayStatsResponseModelImpl; const _TodayStatsResponseModel._() : super._(); @@ -217,6 +289,12 @@ abstract class _TodayStatsResponseModel extends TodayStatsResponseModel { int get sessionCount; @override int get streak; + @override + int get lifetimeMinutes; + @override + int get lifetimeSessionCount; + @override + int get monthlyMinutes; /// Create a copy of TodayStatsResponseModel /// with the given fields replaced by the non-null parameter values. diff --git a/lib/features/timer/data/models/today_stats_response_model.g.dart b/lib/features/timer/data/models/today_stats_response_model.g.dart index 74088de..990cfcb 100644 --- a/lib/features/timer/data/models/today_stats_response_model.g.dart +++ b/lib/features/timer/data/models/today_stats_response_model.g.dart @@ -12,6 +12,9 @@ _$TodayStatsResponseModelImpl _$$TodayStatsResponseModelImplFromJson( totalMinutes: (json['totalMinutes'] as num).toInt(), sessionCount: (json['sessionCount'] as num).toInt(), streak: (json['streak'] as num).toInt(), + lifetimeMinutes: (json['lifetimeMinutes'] as num).toInt(), + lifetimeSessionCount: (json['lifetimeSessionCount'] as num).toInt(), + monthlyMinutes: (json['monthlyMinutes'] as num).toInt(), ); Map _$$TodayStatsResponseModelImplToJson( @@ -20,4 +23,7 @@ Map _$$TodayStatsResponseModelImplToJson( 'totalMinutes': instance.totalMinutes, 'sessionCount': instance.sessionCount, 'streak': instance.streak, + 'lifetimeMinutes': instance.lifetimeMinutes, + 'lifetimeSessionCount': instance.lifetimeSessionCount, + 'monthlyMinutes': instance.monthlyMinutes, }; diff --git a/lib/features/timer/domain/entities/today_stats.dart b/lib/features/timer/domain/entities/today_stats.dart index dae3249..e96e947 100644 --- a/lib/features/timer/domain/entities/today_stats.dart +++ b/lib/features/timer/domain/entities/today_stats.dart @@ -2,15 +2,24 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'today_stats.freezed.dart'; -/// KST 기준 오늘 공부 통계 (GET /api/timer-sessions/today-stats). +/// KST 기준 오늘 공부 통계 + 누적 통계 (GET /api/timer-sessions/today-stats). @freezed class TodayStats with _$TodayStats { const factory TodayStats({ required int totalMinutes, required int sessionCount, required int streak, + required int lifetimeMinutes, + required int lifetimeSessionCount, + required int monthlyMinutes, }) = _TodayStats; - factory TodayStats.empty() => - const TodayStats(totalMinutes: 0, sessionCount: 0, streak: 0); + factory TodayStats.empty() => const TodayStats( + totalMinutes: 0, + sessionCount: 0, + streak: 0, + lifetimeMinutes: 0, + lifetimeSessionCount: 0, + monthlyMinutes: 0, + ); } diff --git a/lib/features/timer/domain/entities/today_stats.freezed.dart b/lib/features/timer/domain/entities/today_stats.freezed.dart index 62c50e0..7b3f8d3 100644 --- a/lib/features/timer/domain/entities/today_stats.freezed.dart +++ b/lib/features/timer/domain/entities/today_stats.freezed.dart @@ -20,6 +20,9 @@ mixin _$TodayStats { int get totalMinutes => throw _privateConstructorUsedError; int get sessionCount => throw _privateConstructorUsedError; int get streak => throw _privateConstructorUsedError; + int get lifetimeMinutes => throw _privateConstructorUsedError; + int get lifetimeSessionCount => throw _privateConstructorUsedError; + int get monthlyMinutes => throw _privateConstructorUsedError; /// Create a copy of TodayStats /// with the given fields replaced by the non-null parameter values. @@ -35,7 +38,14 @@ abstract class $TodayStatsCopyWith<$Res> { $Res Function(TodayStats) then, ) = _$TodayStatsCopyWithImpl<$Res, TodayStats>; @useResult - $Res call({int totalMinutes, int sessionCount, int streak}); + $Res call({ + int totalMinutes, + int sessionCount, + int streak, + int lifetimeMinutes, + int lifetimeSessionCount, + int monthlyMinutes, + }); } /// @nodoc @@ -56,6 +66,9 @@ class _$TodayStatsCopyWithImpl<$Res, $Val extends TodayStats> Object? totalMinutes = null, Object? sessionCount = null, Object? streak = null, + Object? lifetimeMinutes = null, + Object? lifetimeSessionCount = null, + Object? monthlyMinutes = null, }) { return _then( _value.copyWith( @@ -71,6 +84,18 @@ class _$TodayStatsCopyWithImpl<$Res, $Val extends TodayStats> ? _value.streak : streak // ignore: cast_nullable_to_non_nullable as int, + lifetimeMinutes: null == lifetimeMinutes + ? _value.lifetimeMinutes + : lifetimeMinutes // ignore: cast_nullable_to_non_nullable + as int, + lifetimeSessionCount: null == lifetimeSessionCount + ? _value.lifetimeSessionCount + : lifetimeSessionCount // ignore: cast_nullable_to_non_nullable + as int, + monthlyMinutes: null == monthlyMinutes + ? _value.monthlyMinutes + : monthlyMinutes // ignore: cast_nullable_to_non_nullable + as int, ) as $Val, ); @@ -86,7 +111,14 @@ abstract class _$$TodayStatsImplCopyWith<$Res> ) = __$$TodayStatsImplCopyWithImpl<$Res>; @override @useResult - $Res call({int totalMinutes, int sessionCount, int streak}); + $Res call({ + int totalMinutes, + int sessionCount, + int streak, + int lifetimeMinutes, + int lifetimeSessionCount, + int monthlyMinutes, + }); } /// @nodoc @@ -106,6 +138,9 @@ class __$$TodayStatsImplCopyWithImpl<$Res> Object? totalMinutes = null, Object? sessionCount = null, Object? streak = null, + Object? lifetimeMinutes = null, + Object? lifetimeSessionCount = null, + Object? monthlyMinutes = null, }) { return _then( _$TodayStatsImpl( @@ -121,6 +156,18 @@ class __$$TodayStatsImplCopyWithImpl<$Res> ? _value.streak : streak // ignore: cast_nullable_to_non_nullable as int, + lifetimeMinutes: null == lifetimeMinutes + ? _value.lifetimeMinutes + : lifetimeMinutes // ignore: cast_nullable_to_non_nullable + as int, + lifetimeSessionCount: null == lifetimeSessionCount + ? _value.lifetimeSessionCount + : lifetimeSessionCount // ignore: cast_nullable_to_non_nullable + as int, + monthlyMinutes: null == monthlyMinutes + ? _value.monthlyMinutes + : monthlyMinutes // ignore: cast_nullable_to_non_nullable + as int, ), ); } @@ -133,6 +180,9 @@ class _$TodayStatsImpl implements _TodayStats { required this.totalMinutes, required this.sessionCount, required this.streak, + required this.lifetimeMinutes, + required this.lifetimeSessionCount, + required this.monthlyMinutes, }); @override @@ -141,10 +191,16 @@ class _$TodayStatsImpl implements _TodayStats { final int sessionCount; @override final int streak; + @override + final int lifetimeMinutes; + @override + final int lifetimeSessionCount; + @override + final int monthlyMinutes; @override String toString() { - return 'TodayStats(totalMinutes: $totalMinutes, sessionCount: $sessionCount, streak: $streak)'; + return 'TodayStats(totalMinutes: $totalMinutes, sessionCount: $sessionCount, streak: $streak, lifetimeMinutes: $lifetimeMinutes, lifetimeSessionCount: $lifetimeSessionCount, monthlyMinutes: $monthlyMinutes)'; } @override @@ -156,12 +212,25 @@ class _$TodayStatsImpl implements _TodayStats { other.totalMinutes == totalMinutes) && (identical(other.sessionCount, sessionCount) || other.sessionCount == sessionCount) && - (identical(other.streak, streak) || other.streak == streak)); + (identical(other.streak, streak) || other.streak == streak) && + (identical(other.lifetimeMinutes, lifetimeMinutes) || + other.lifetimeMinutes == lifetimeMinutes) && + (identical(other.lifetimeSessionCount, lifetimeSessionCount) || + other.lifetimeSessionCount == lifetimeSessionCount) && + (identical(other.monthlyMinutes, monthlyMinutes) || + other.monthlyMinutes == monthlyMinutes)); } @override - int get hashCode => - Object.hash(runtimeType, totalMinutes, sessionCount, streak); + int get hashCode => Object.hash( + runtimeType, + totalMinutes, + sessionCount, + streak, + lifetimeMinutes, + lifetimeSessionCount, + monthlyMinutes, + ); /// Create a copy of TodayStats /// with the given fields replaced by the non-null parameter values. @@ -177,6 +246,9 @@ abstract class _TodayStats implements TodayStats { required final int totalMinutes, required final int sessionCount, required final int streak, + required final int lifetimeMinutes, + required final int lifetimeSessionCount, + required final int monthlyMinutes, }) = _$TodayStatsImpl; @override @@ -185,6 +257,12 @@ abstract class _TodayStats implements TodayStats { int get sessionCount; @override int get streak; + @override + int get lifetimeMinutes; + @override + int get lifetimeSessionCount; + @override + int get monthlyMinutes; /// Create a copy of TodayStats /// with the given fields replaced by the non-null parameter values. diff --git a/lib/features/timer/presentation/providers/today_stats_provider.dart b/lib/features/timer/presentation/providers/today_stats_provider.dart index f50c4e1..cf049f9 100644 --- a/lib/features/timer/presentation/providers/today_stats_provider.dart +++ b/lib/features/timer/presentation/providers/today_stats_provider.dart @@ -20,26 +20,33 @@ class TodayStatsNotifier extends _$TodayStatsNotifier { return repository.getTodayStats(); } - // 게스트 모드: 로컬 계산 + // 게스트 모드: 로컬 세션 fold (전체가 메모리에 있어 정확) final sessions = repository.getSessions(); final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); final tomorrow = today.add(const Duration(days: 1)); + final monthStart = DateTime(now.year, now.month, 1); final todaySessions = sessions.where((s) { return s.startedAt.isAfter(today.subtract(const Duration(seconds: 1))) && s.startedAt.isBefore(tomorrow); }).toList(); - final totalMinutes = todaySessions.fold( - 0, - (sum, s) => sum + s.durationMinutes, - ); + final todayMinutes = + todaySessions.fold(0, (sum, s) => sum + s.durationMinutes); + final lifetimeMinutes = + sessions.fold(0, (sum, s) => sum + s.durationMinutes); + final monthlyMinutes = sessions + .where((s) => !s.startedAt.isBefore(monthStart)) + .fold(0, (sum, s) => sum + s.durationMinutes); return TodayStats( - totalMinutes: totalMinutes, + totalMinutes: todayMinutes, sessionCount: todaySessions.length, streak: todaySessions.isEmpty ? 0 : 1, + lifetimeMinutes: lifetimeMinutes, + lifetimeSessionCount: sessions.length, + monthlyMinutes: monthlyMinutes, ); } } diff --git a/test/features/timer/data/datasources/timer_session_remote_datasource_test.dart b/test/features/timer/data/datasources/timer_session_remote_datasource_test.dart index bb5a4b1..8f0fa85 100644 --- a/test/features/timer/data/datasources/timer_session_remote_datasource_test.dart +++ b/test/features/timer/data/datasources/timer_session_remote_datasource_test.dart @@ -82,12 +82,15 @@ void main() { test('getTodayStats: GET 호출', () async { when(() => adapter.fetch(any(), any(), any())).thenAnswer( - (inv) async => - jsonBody(200, '{"totalMinutes":180,"sessionCount":3,"streak":7}'), + (inv) async => jsonBody( + 200, + '{"totalMinutes":180,"sessionCount":3,"streak":7,"lifetimeMinutes":12450,"lifetimeSessionCount":287,"monthlyMinutes":1820}', + ), ); final res = await api.getTodayStats(); expect(res.totalMinutes, 180); expect(res.streak, 7); + expect(res.lifetimeMinutes, 12450); }); } diff --git a/test/features/timer/data/models/today_stats_response_model_test.dart b/test/features/timer/data/models/today_stats_response_model_test.dart index f084ef9..7a31dfa 100644 --- a/test/features/timer/data/models/today_stats_response_model_test.dart +++ b/test/features/timer/data/models/today_stats_response_model_test.dart @@ -2,16 +2,28 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:space_study_ship/features/timer/data/models/today_stats_response_model.dart'; void main() { - test('fromJson + toEntity', () { + test('fromJson + toEntity — 6필드 모두 매핑', () { final m = TodayStatsResponseModel.fromJson({ 'totalMinutes': 180, 'sessionCount': 3, 'streak': 7, + 'lifetimeMinutes': 12450, + 'lifetimeSessionCount': 287, + 'monthlyMinutes': 1820, }); expect(m.totalMinutes, 180); - final entity = m.toEntity(); - expect(entity.totalMinutes, 180); - expect(entity.sessionCount, 3); - expect(entity.streak, 7); + expect(m.sessionCount, 3); + expect(m.streak, 7); + expect(m.lifetimeMinutes, 12450); + expect(m.lifetimeSessionCount, 287); + expect(m.monthlyMinutes, 1820); + + final e = m.toEntity(); + expect(e.totalMinutes, 180); + expect(e.sessionCount, 3); + expect(e.streak, 7); + expect(e.lifetimeMinutes, 12450); + expect(e.lifetimeSessionCount, 287); + expect(e.monthlyMinutes, 1820); }); } diff --git a/test/features/timer/data/repositories/timer_session_repository_impl_test.dart b/test/features/timer/data/repositories/timer_session_repository_impl_test.dart index 32fc7c3..c3ad2d4 100644 --- a/test/features/timer/data/repositories/timer_session_repository_impl_test.dart +++ b/test/features/timer/data/repositories/timer_session_repository_impl_test.dart @@ -174,11 +174,15 @@ void main() { totalMinutes: 180, sessionCount: 3, streak: 7, + lifetimeMinutes: 12450, + lifetimeSessionCount: 287, + monthlyMinutes: 1820, ), ); final stats = await repo.getTodayStats(); expect(stats.totalMinutes, 180); expect(stats.streak, 7); + expect(stats.lifetimeMinutes, 12450); }); }); diff --git a/test/features/timer/domain/entities/today_stats_test.dart b/test/features/timer/domain/entities/today_stats_test.dart index 4f1f39f..6af70fa 100644 --- a/test/features/timer/domain/entities/today_stats_test.dart +++ b/test/features/timer/domain/entities/today_stats_test.dart @@ -2,17 +2,31 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:space_study_ship/features/timer/domain/entities/today_stats.dart'; void main() { - test('TodayStats 필드 보유', () { - const stats = TodayStats(totalMinutes: 180, sessionCount: 3, streak: 7); + test('TodayStats — 6필드 생성', () { + const stats = TodayStats( + totalMinutes: 180, + sessionCount: 3, + streak: 7, + lifetimeMinutes: 12450, + lifetimeSessionCount: 287, + monthlyMinutes: 1820, + ); expect(stats.totalMinutes, 180); - expect(stats.sessionCount, 3); - expect(stats.streak, 7); + expect(stats.lifetimeMinutes, 12450); + expect(stats.monthlyMinutes, 1820); }); - test('TodayStats.empty: 모두 0', () { + test('TodayStats.empty(): 6필드 모두 0', () { expect( TodayStats.empty(), - const TodayStats(totalMinutes: 0, sessionCount: 0, streak: 0), + const TodayStats( + totalMinutes: 0, + sessionCount: 0, + streak: 0, + lifetimeMinutes: 0, + lifetimeSessionCount: 0, + monthlyMinutes: 0, + ), ); }); } diff --git a/test/features/timer/presentation/providers/today_stats_provider_test.dart b/test/features/timer/presentation/providers/today_stats_provider_test.dart index 9d43e87..b321f44 100644 --- a/test/features/timer/presentation/providers/today_stats_provider_test.dart +++ b/test/features/timer/presentation/providers/today_stats_provider_test.dart @@ -22,8 +22,14 @@ void main() { test('인증 모드: repo.getTodayStats() 결과 그대로 반환', () async { when(() => repo.getTodayStats()).thenAnswer( - (_) async => - const TodayStats(totalMinutes: 180, sessionCount: 3, streak: 7), + (_) async => const TodayStats( + totalMinutes: 180, + sessionCount: 3, + streak: 7, + lifetimeMinutes: 12450, + lifetimeSessionCount: 287, + monthlyMinutes: 1820, + ), ); container = ProviderContainer( overrides: [ @@ -34,6 +40,7 @@ void main() { final stats = await container.read(todayStatsNotifierProvider.future); expect(stats.totalMinutes, 180); expect(stats.streak, 7); + expect(stats.lifetimeMinutes, 12450); }); test('게스트 모드: 로컬 세션으로 직접 계산 (오늘 KST 기준)', () async { @@ -66,5 +73,7 @@ void main() { expect(stats.sessionCount, 2); expect(stats.totalMinutes, 90); expect(stats.streak, 1); + expect(stats.lifetimeMinutes, 90); // 게스트는 전체 = today 합산 + expect(stats.lifetimeSessionCount, 2); }); } From ec74ce9a7592e0120420f4cf7b3e3a0cda9d9b7b Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Wed, 27 May 2026 01:49:40 +0900 Subject: [PATCH 06/10] =?UTF-8?q?test=20:=20TodayStats=20monthlyMinutes=20?= =?UTF-8?q?/=20sessionCount=20assertion=20=EB=B3=B4=EA=B0=95=20#83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/features/timer/domain/entities/today_stats_test.dart | 3 +++ .../presentation/providers/today_stats_provider_test.dart | 1 + 2 files changed, 4 insertions(+) diff --git a/test/features/timer/domain/entities/today_stats_test.dart b/test/features/timer/domain/entities/today_stats_test.dart index 6af70fa..0988961 100644 --- a/test/features/timer/domain/entities/today_stats_test.dart +++ b/test/features/timer/domain/entities/today_stats_test.dart @@ -14,6 +14,9 @@ void main() { expect(stats.totalMinutes, 180); expect(stats.lifetimeMinutes, 12450); expect(stats.monthlyMinutes, 1820); + expect(stats.sessionCount, 3); + expect(stats.streak, 7); + expect(stats.lifetimeSessionCount, 287); }); test('TodayStats.empty(): 6필드 모두 0', () { diff --git a/test/features/timer/presentation/providers/today_stats_provider_test.dart b/test/features/timer/presentation/providers/today_stats_provider_test.dart index b321f44..2547433 100644 --- a/test/features/timer/presentation/providers/today_stats_provider_test.dart +++ b/test/features/timer/presentation/providers/today_stats_provider_test.dart @@ -75,5 +75,6 @@ void main() { expect(stats.streak, 1); expect(stats.lifetimeMinutes, 90); // 게스트는 전체 = today 합산 expect(stats.lifetimeSessionCount, 2); + expect(stats.monthlyMinutes, 90); }); } From 25bf987c1e4c2020ae187ea4ea8653bcd45e1cf2 Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Wed, 27 May 2026 01:54:12 +0900 Subject: [PATCH 07/10] =?UTF-8?q?refactor=20:=20study=5Fstats=5Fprovider?= =?UTF-8?q?=20=EB=88=84=EC=A0=81=20=ED=86=B5=EA=B3=84=203=EA=B0=9C=20today?= =?UTF-8?q?Stats=20=EC=9C=84=EC=9E=84=20=EB=8B=A8=EC=88=9C=ED=99=94=20#83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../providers/study_stats_provider.dart | 24 ++++------- .../providers/study_stats_provider.g.dart | 12 +++--- .../providers/study_stats_provider_test.dart | 42 +++++++++++++++++++ 3 files changed, 55 insertions(+), 23 deletions(-) create mode 100644 test/features/timer/presentation/providers/study_stats_provider_test.dart diff --git a/lib/features/timer/presentation/providers/study_stats_provider.dart b/lib/features/timer/presentation/providers/study_stats_provider.dart index 9d949d9..24a8113 100644 --- a/lib/features/timer/presentation/providers/study_stats_provider.dart +++ b/lib/features/timer/presentation/providers/study_stats_provider.dart @@ -3,6 +3,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../utils/timer_format_utils.dart'; import 'timer_session_provider.dart'; +import 'today_stats_provider.dart'; part 'study_stats_provider.g.dart'; @@ -35,33 +36,22 @@ int weeklyStudyMinutes(Ref ref) { // === 월별/전체 통계 === -/// 이번 달 공부 시간 (분) +/// 이번 달 공부 시간 (분) — todayStats 위임 @riverpod int monthlyStudyMinutes(Ref ref) { - final sessions = - ref.watch(timerSessionListNotifierProvider).valueOrNull ?? []; - final now = DateTime.now(); - final monthStart = DateTime(now.year, now.month, 1); - - return sessions - .where((s) => !s.startedAt.isBefore(monthStart)) - .fold(0, (sum, s) => sum + s.durationMinutes); + return ref.watch(todayStatsNotifierProvider).valueOrNull?.monthlyMinutes ?? 0; } -/// 전체 총 공부 시간 (분) +/// 전체 총 공부 시간 (분) — todayStats 위임 @riverpod int totalStudyMinutes(Ref ref) { - final sessions = - ref.watch(timerSessionListNotifierProvider).valueOrNull ?? []; - return sessions.fold(0, (sum, s) => sum + s.durationMinutes); + return ref.watch(todayStatsNotifierProvider).valueOrNull?.lifetimeMinutes ?? 0; } -/// 전체 세션 수 +/// 전체 세션 수 — todayStats 위임 @riverpod int totalSessionCount(Ref ref) { - final sessions = - ref.watch(timerSessionListNotifierProvider).valueOrNull ?? []; - return sessions.length; + return ref.watch(todayStatsNotifierProvider).valueOrNull?.lifetimeSessionCount ?? 0; } // === Streak === diff --git a/lib/features/timer/presentation/providers/study_stats_provider.g.dart b/lib/features/timer/presentation/providers/study_stats_provider.g.dart index 1d104c6..8d157a0 100644 --- a/lib/features/timer/presentation/providers/study_stats_provider.g.dart +++ b/lib/features/timer/presentation/providers/study_stats_provider.g.dart @@ -46,9 +46,9 @@ final weeklyStudyMinutesProvider = AutoDisposeProvider.internal( // ignore: unused_element typedef WeeklyStudyMinutesRef = AutoDisposeProviderRef; String _$monthlyStudyMinutesHash() => - r'0848eb2ad73d324eaf87dd350f6b857fd05b08fa'; + r'46281f77813a3b09026ab3aa09dbc3733e907a72'; -/// 이번 달 공부 시간 (분) +/// 이번 달 공부 시간 (분) — todayStats 위임 /// /// Copied from [monthlyStudyMinutes]. @ProviderFor(monthlyStudyMinutes) @@ -65,9 +65,9 @@ final monthlyStudyMinutesProvider = AutoDisposeProvider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef MonthlyStudyMinutesRef = AutoDisposeProviderRef; -String _$totalStudyMinutesHash() => r'8477df0e108d2a18ac8dcd546bd05ecf93fd2adb'; +String _$totalStudyMinutesHash() => r'bd91b30decff70dbb57e5649252cff18b02607b9'; -/// 전체 총 공부 시간 (분) +/// 전체 총 공부 시간 (분) — todayStats 위임 /// /// Copied from [totalStudyMinutes]. @ProviderFor(totalStudyMinutes) @@ -84,9 +84,9 @@ final totalStudyMinutesProvider = AutoDisposeProvider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef TotalStudyMinutesRef = AutoDisposeProviderRef; -String _$totalSessionCountHash() => r'dee88fc7f314f89bad35757ae90d9f066372b401'; +String _$totalSessionCountHash() => r'76529512582e8e028fb000f1a4791f4095cb80a7'; -/// 전체 세션 수 +/// 전체 세션 수 — todayStats 위임 /// /// Copied from [totalSessionCount]. @ProviderFor(totalSessionCount) diff --git a/test/features/timer/presentation/providers/study_stats_provider_test.dart b/test/features/timer/presentation/providers/study_stats_provider_test.dart new file mode 100644 index 0000000..3e7ee5a --- /dev/null +++ b/test/features/timer/presentation/providers/study_stats_provider_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:space_study_ship/features/timer/domain/entities/today_stats.dart'; +import 'package:space_study_ship/features/timer/presentation/providers/study_stats_provider.dart'; +import 'package:space_study_ship/features/timer/presentation/providers/today_stats_provider.dart'; + +void main() { + test('todayStats 로드 완료 → 3 provider 가 lifetime/monthly 값 위임', () async { + const stats = TodayStats( + totalMinutes: 180, + sessionCount: 3, + streak: 7, + lifetimeMinutes: 12450, + lifetimeSessionCount: 287, + monthlyMinutes: 1820, + ); + + final container = ProviderContainer( + overrides: [ + todayStatsNotifierProvider.overrideWith(() { + return _StubTodayStatsNotifier(stats); + }), + ], + ); + addTearDown(container.dispose); + + // todayStats 비동기 로드 완료 대기 + await container.read(todayStatsNotifierProvider.future); + + expect(container.read(totalStudyMinutesProvider), 12450); + expect(container.read(totalSessionCountProvider), 287); + expect(container.read(monthlyStudyMinutesProvider), 1820); + }); +} + +class _StubTodayStatsNotifier extends TodayStatsNotifier { + _StubTodayStatsNotifier(this._stats); + final TodayStats _stats; + + @override + Future build() async => _stats; +} From 006291951c419afe63494aeb3b4e69d1500667d9 Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Wed, 27 May 2026 11:59:07 +0900 Subject: [PATCH 08/10] =?UTF-8?q?refactor=20:=20=ED=83=80=EC=9D=B4?= =?UTF-8?q?=EB=A8=B8=20=ED=86=B5=EA=B3=84=20=ED=8C=A8=EB=84=90=20todayStat?= =?UTF-8?q?s=20API=20=EC=9C=84=EC=9E=84=20+=20=EC=9D=B4=EB=B2=88=20?= =?UTF-8?q?=EC=A3=BC=20=E2=86=92=20=EC=9D=B4=EB=B2=88=20=EB=8B=AC=20#83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../providers/study_stats_provider.dart | 55 ++----------------- .../providers/study_stats_provider.g.dart | 28 ++-------- .../providers/today_stats_provider.g.dart | 2 +- .../presentation/screens/timer_screen.dart | 8 +-- .../providers/study_stats_provider_test.dart | 6 +- 5 files changed, 18 insertions(+), 81 deletions(-) diff --git a/lib/features/timer/presentation/providers/study_stats_provider.dart b/lib/features/timer/presentation/providers/study_stats_provider.dart index 24a8113..ff62549 100644 --- a/lib/features/timer/presentation/providers/study_stats_provider.dart +++ b/lib/features/timer/presentation/providers/study_stats_provider.dart @@ -1,37 +1,16 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../utils/timer_format_utils.dart'; -import 'timer_session_provider.dart'; import 'today_stats_provider.dart'; part 'study_stats_provider.g.dart'; -// === 일별/주별 통계 === +// === 일별 통계 === -/// 오늘 공부 시간 (분) +/// 오늘 공부 시간 (분) — todayStats 위임 @riverpod int todayStudyMinutes(Ref ref) { - final sessions = - ref.watch(timerSessionListNotifierProvider).valueOrNull ?? []; - final today = normalizeDate(DateTime.now()); - - return sessions - .where((s) => normalizeDate(s.startedAt) == today) - .fold(0, (sum, s) => sum + s.durationMinutes); -} - -/// 이번 주 공부 시간 (분) — 최근 7일 -@riverpod -int weeklyStudyMinutes(Ref ref) { - final sessions = - ref.watch(timerSessionListNotifierProvider).valueOrNull ?? []; - final now = DateTime.now(); - final weekStart = normalizeDate(now.subtract(const Duration(days: 6))); - - return sessions - .where((s) => !normalizeDate(s.startedAt).isBefore(weekStart)) - .fold(0, (sum, s) => sum + s.durationMinutes); + return ref.watch(todayStatsNotifierProvider).valueOrNull?.totalMinutes ?? 0; } // === 월별/전체 통계 === @@ -56,32 +35,8 @@ int totalSessionCount(Ref ref) { // === Streak === -/// 연속 공부 일수 (streak) +/// 연속 공부 일수 (streak) — todayStats 위임 @riverpod int currentStreak(Ref ref) { - final sessions = - ref.watch(timerSessionListNotifierProvider).valueOrNull ?? []; - if (sessions.isEmpty) return 0; - - // 세션이 있는 날짜 집합 - final studyDates = - sessions.map((s) => normalizeDate(s.startedAt)).toSet().toList() - ..sort((a, b) => b.compareTo(a)); // 최신순 - - final today = normalizeDate(DateTime.now()); - final yesterday = today.subtract(const Duration(days: 1)); - - // 오늘 또는 어제 공부하지 않았으면 streak 0 - if (studyDates.first != today && studyDates.first != yesterday) return 0; - - int streak = 1; - for (int i = 1; i < studyDates.length; i++) { - final diff = studyDates[i - 1].difference(studyDates[i]).inDays; - if (diff == 1) { - streak++; - } else { - break; - } - } - return streak; + return ref.watch(todayStatsNotifierProvider).valueOrNull?.streak ?? 0; } diff --git a/lib/features/timer/presentation/providers/study_stats_provider.g.dart b/lib/features/timer/presentation/providers/study_stats_provider.g.dart index 8d157a0..962d888 100644 --- a/lib/features/timer/presentation/providers/study_stats_provider.g.dart +++ b/lib/features/timer/presentation/providers/study_stats_provider.g.dart @@ -6,9 +6,9 @@ part of 'study_stats_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$todayStudyMinutesHash() => r'945b739e7bb40dbb04d6d397d4cecb7a2664bf79'; +String _$todayStudyMinutesHash() => r'c9211d1880bad4ccae60eca4b69c8ed6f0ba0b00'; -/// 오늘 공부 시간 (분) +/// 오늘 공부 시간 (분) — todayStats 위임 /// /// Copied from [todayStudyMinutes]. @ProviderFor(todayStudyMinutes) @@ -25,26 +25,6 @@ final todayStudyMinutesProvider = AutoDisposeProvider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef TodayStudyMinutesRef = AutoDisposeProviderRef; -String _$weeklyStudyMinutesHash() => - r'f972b83f62e0641552e1fe32d5d04482e9f55074'; - -/// 이번 주 공부 시간 (분) — 최근 7일 -/// -/// Copied from [weeklyStudyMinutes]. -@ProviderFor(weeklyStudyMinutes) -final weeklyStudyMinutesProvider = AutoDisposeProvider.internal( - weeklyStudyMinutes, - name: r'weeklyStudyMinutesProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$weeklyStudyMinutesHash, - dependencies: null, - allTransitiveDependencies: null, -); - -@Deprecated('Will be removed in 3.0. Use Ref instead') -// ignore: unused_element -typedef WeeklyStudyMinutesRef = AutoDisposeProviderRef; String _$monthlyStudyMinutesHash() => r'46281f77813a3b09026ab3aa09dbc3733e907a72'; @@ -103,9 +83,9 @@ final totalSessionCountProvider = AutoDisposeProvider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef TotalSessionCountRef = AutoDisposeProviderRef; -String _$currentStreakHash() => r'695c3e5cc1b3eef210917de2240ab38f7a862007'; +String _$currentStreakHash() => r'e7f3905bd5bd21ca744b530a63022031b5d2ad03'; -/// 연속 공부 일수 (streak) +/// 연속 공부 일수 (streak) — todayStats 위임 /// /// Copied from [currentStreak]. @ProviderFor(currentStreak) diff --git a/lib/features/timer/presentation/providers/today_stats_provider.g.dart b/lib/features/timer/presentation/providers/today_stats_provider.g.dart index a074c3b..f177d58 100644 --- a/lib/features/timer/presentation/providers/today_stats_provider.g.dart +++ b/lib/features/timer/presentation/providers/today_stats_provider.g.dart @@ -7,7 +7,7 @@ part of 'today_stats_provider.dart'; // ************************************************************************** String _$todayStatsNotifierHash() => - r'36b4b93510cf2738ac95809c6573d3a881c4410b'; + r'fc66cbacb28f5be7cfe7947a9a6499f882c80921'; /// 오늘 공부 통계 provider. /// 인증 모드: 서버 GET /api/timer-sessions/today-stats 결과 신뢰. diff --git a/lib/features/timer/presentation/screens/timer_screen.dart b/lib/features/timer/presentation/screens/timer_screen.dart index 02120b1..a474311 100644 --- a/lib/features/timer/presentation/screens/timer_screen.dart +++ b/lib/features/timer/presentation/screens/timer_screen.dart @@ -113,7 +113,7 @@ class _TimerScreenState extends ConsumerState { child: Consumer( builder: (context, ref, _) { final todayMinutes = ref.watch(todayStudyMinutesProvider); - final weeklyMinutes = ref.watch(weeklyStudyMinutesProvider); + final monthlyMinutes = ref.watch(monthlyStudyMinutesProvider); final streak = ref.watch(currentStreakProvider); return Padding( padding: AppPadding.horizontal20, @@ -134,9 +134,9 @@ class _TimerScreenState extends ConsumerState { color: AppColors.spaceDivider, ), SpaceStatItem( - icon: Icons.date_range_rounded, - label: '이번 주', - value: formatMinutes(weeklyMinutes), + icon: Icons.calendar_month_rounded, + label: '이번 달', + value: formatMinutes(monthlyMinutes), ), Container( width: 1, diff --git a/test/features/timer/presentation/providers/study_stats_provider_test.dart b/test/features/timer/presentation/providers/study_stats_provider_test.dart index 3e7ee5a..aabc73d 100644 --- a/test/features/timer/presentation/providers/study_stats_provider_test.dart +++ b/test/features/timer/presentation/providers/study_stats_provider_test.dart @@ -5,7 +5,7 @@ import 'package:space_study_ship/features/timer/presentation/providers/study_sta import 'package:space_study_ship/features/timer/presentation/providers/today_stats_provider.dart'; void main() { - test('todayStats 로드 완료 → 3 provider 가 lifetime/monthly 값 위임', () async { + test('todayStats 로드 완료 → 5 provider 가 모두 todayStats 값 위임', () async { const stats = TodayStats( totalMinutes: 180, sessionCount: 3, @@ -27,9 +27,11 @@ void main() { // todayStats 비동기 로드 완료 대기 await container.read(todayStatsNotifierProvider.future); + expect(container.read(todayStudyMinutesProvider), 180); + expect(container.read(monthlyStudyMinutesProvider), 1820); expect(container.read(totalStudyMinutesProvider), 12450); expect(container.read(totalSessionCountProvider), 287); - expect(container.read(monthlyStudyMinutesProvider), 1820); + expect(container.read(currentStreakProvider), 7); }); } From 45ddad25980d9547ad173e1767e38ebde9b906f7 Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Wed, 27 May 2026 12:05:57 +0900 Subject: [PATCH 09/10] fix : CI format --- docs/api-docs.json | 660 +++++++++--------- .../providers/study_stats_provider.dart | 9 +- .../providers/today_stats_provider.dart | 12 +- 3 files changed, 354 insertions(+), 327 deletions(-) diff --git a/docs/api-docs.json b/docs/api-docs.json index 6d5dbf7..f15919d 100644 --- a/docs/api-docs.json +++ b/docs/api-docs.json @@ -70,6 +70,19 @@ } ], "responses": { + "200": { + "description": "조회 성공", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TodoResponse" + } + } + } + } + }, "401": { "description": "인증 실패", "content": { @@ -83,19 +96,6 @@ } } } - }, - "200": { - "description": "조회 성공", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TodoResponse" - } - } - } - } } } }, @@ -115,20 +115,6 @@ "required": true }, "responses": { - "401": { - "description": "인증 실패", - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "example": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." - } - } - } - }, "409": { "description": "동일 ID 중복", "content": { @@ -157,40 +143,54 @@ } } }, - "400": { - "description": "입력값 검증 실패", + "201": { + "description": "생성 성공", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/TodoResponse" + } + } + } + }, + "404": { + "description": "카테고리 없음", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "INVALID_INPUT_VALUE", - "message": "title: 비어있을 수 없습니다." + "code": "CATEGORY_NOT_FOUND", + "message": "해당 카테고리를 찾을 수 없습니다." } } } }, - "201": { - "description": "생성 성공", + "400": { + "description": "입력값 검증 실패", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/TodoResponse" + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "INVALID_INPUT_VALUE", + "message": "title: 비어있을 수 없습니다." } } } }, - "404": { - "description": "카테고리 없음", + "401": { + "description": "인증 실패", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "CATEGORY_NOT_FOUND", - "message": "해당 카테고리를 찾을 수 없습니다." + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } @@ -205,19 +205,6 @@ "description": "createdAt 오름차순", "operationId": "findAll_1", "responses": { - "200": { - "description": "조회 성공", - "content": { - "*/*": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CategoryResponse" - } - } - } - } - }, "401": { "description": "인증 실패", "content": { @@ -231,6 +218,19 @@ } } } + }, + "200": { + "description": "조회 성공", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CategoryResponse" + } + } + } + } } } }, @@ -249,16 +249,16 @@ "required": true }, "responses": { - "401": { - "description": "인증 실패", + "500": { + "description": "서버 내부 오류", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." } } } @@ -273,16 +273,16 @@ } } }, - "500": { - "description": "서버 내부 오류", + "409": { + "description": "동일 ID 중복", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "INTERNAL_SERVER_ERROR", - "message": "서버 내부 오류가 발생했습니다." + "code": "CATEGORY_ALREADY_EXISTS", + "message": "동일 ID의 카테고리가 이미 존재합니다." } } } @@ -301,16 +301,16 @@ } } }, - "409": { - "description": "동일 ID 중복", + "401": { + "description": "인증 실패", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "CATEGORY_ALREADY_EXISTS", - "message": "동일 ID의 카테고리가 이미 존재합니다." + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } @@ -371,20 +371,20 @@ } ], "responses": { - "401": { - "description": "인증 필요", + "200": { + "description": "조회 성공", "content": { - "*/*": { + "application/json": { "schema": { "$ref": "#/components/schemas/TimerSessionListResponse" } } } }, - "200": { - "description": "조회 성공", + "401": { + "description": "인증 필요", "content": { - "application/json": { + "*/*": { "schema": { "$ref": "#/components/schemas/TimerSessionListResponse" } @@ -445,41 +445,6 @@ } } }, - "404": { - "description": "연결된 Todo가 본인 소유 아님 / 존재하지 않음", - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "example": { - "code": "TODO_NOT_FOUND", - "message": "해당 할 일을 찾을 수 없습니다." - } - } - } - }, - "201": { - "description": "저장 성공", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TimerSessionCreateResponse" - }, - "example": { - "session": { - "id": "sess-uuid", - "todoId": "todo-1", - "todoTitle": "수학", - "startedAt": "2026-05-25T01:00:00Z", - "endedAt": "2026-05-25T02:30:00Z", - "durationMinutes": 90 - }, - "fuelCharged": 90 - } - } - } - }, "500": { "description": "서버 오류", "content": { @@ -536,6 +501,41 @@ } } } + }, + "201": { + "description": "저장 성공", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TimerSessionCreateResponse" + }, + "example": { + "session": { + "id": "sess-uuid", + "todoId": "todo-1", + "todoTitle": "수학", + "startedAt": "2026-05-25T01:00:00Z", + "endedAt": "2026-05-25T02:30:00Z", + "durationMinutes": 90 + }, + "fuelCharged": 90 + } + } + } + }, + "404": { + "description": "연결된 Todo가 본인 소유 아님 / 존재하지 않음", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "TODO_NOT_FOUND", + "message": "해당 할 일을 찾을 수 없습니다." + } + } + } } } } @@ -576,6 +576,25 @@ } } }, + "401": { + "description": "Refresh Token 이 만료되었거나, DB의 저장 해시와 불일치(탈취 의심)이거나, 변조된 경우. 클라이언트는 로그아웃 처리 후 로그인 화면으로 이동해야 합니다.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidToken": { + "description": "InvalidToken", + "value": { + "code": "INVALID_TOKEN", + "message": "인증 정보가 올바르지 않습니다." + } + } + } + } + } + }, "200": { "description": "토큰 재발급 성공. 클라이언트는 두 토큰 모두 교체 저장해야 합니다.", "content": { @@ -615,25 +634,6 @@ } } } - }, - "401": { - "description": "Refresh Token 이 만료되었거나, DB의 저장 해시와 불일치(탈취 의심)이거나, 변조된 경우. 클라이언트는 로그아웃 처리 후 로그인 화면으로 이동해야 합니다.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "InvalidToken": { - "description": "InvalidToken", - "value": { - "code": "INVALID_TOKEN", - "message": "인증 정보가 올바르지 않습니다." - } - } - } - } - } } } } @@ -674,6 +674,9 @@ } } }, + "204": { + "description": "로그아웃 처리 완료. 응답 본문 없음." + }, "400": { "description": "요청 본문 형식 오류 (refreshToken 누락 등).", "content": { @@ -692,9 +695,6 @@ } } } - }, - "204": { - "description": "로그아웃 처리 완료. 응답 본문 없음." } } } @@ -735,34 +735,6 @@ } } }, - "400": { - "description": "요청 본문 형식 오류 (필수 필드 누락, socialType 이 유효하지 않은 값 등).", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "InvalidInputValue": { - "summary": "필수 필드 누락", - "description": "InvalidInputValue", - "value": { - "code": "INVALID_INPUT_VALUE", - "message": "idToken: 소셜 인증 토큰(ID Token)은 필수입니다." - } - }, - "UnsupportedSocialType": { - "summary": "지원하지 않는 소셜 타입", - "description": "UnsupportedSocialType", - "value": { - "code": "UNSUPPORTED_SOCIAL_TYPE", - "message": "지원하지 않는 소셜 로그인 방식입니다." - } - } - } - } - } - }, "200": { "description": "기존 회원 로그인 성공.", "content": { @@ -831,6 +803,34 @@ } } } + }, + "400": { + "description": "요청 본문 형식 오류 (필수 필드 누락, socialType 이 유효하지 않은 값 등).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidInputValue": { + "summary": "필수 필드 누락", + "description": "InvalidInputValue", + "value": { + "code": "INVALID_INPUT_VALUE", + "message": "idToken: 소셜 인증 토큰(ID Token)은 필수입니다." + } + }, + "UnsupportedSocialType": { + "summary": "지원하지 않는 소셜 타입", + "description": "UnsupportedSocialType", + "value": { + "code": "UNSUPPORTED_SOCIAL_TYPE", + "message": "지원하지 않는 소셜 로그인 방식입니다." + } + } + } + } + } } } } @@ -853,23 +853,20 @@ } ], "responses": { - "401": { - "description": "인증 실패", + "404": { + "description": "Todo 없음", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." + "code": "TODO_NOT_FOUND", + "message": "해당 할 일을 찾을 수 없습니다." } } } }, - "204": { - "description": "삭제 성공" - }, "500": { "description": "서버 내부 오류", "content": { @@ -884,16 +881,19 @@ } } }, - "404": { - "description": "Todo 없음", + "204": { + "description": "삭제 성공" + }, + "401": { + "description": "인증 실패", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "TODO_NOT_FOUND", - "message": "해당 할 일을 찾을 수 없습니다." + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } @@ -927,40 +927,44 @@ "required": true }, "responses": { - "401": { - "description": "인증 실패", + "500": { + "description": "서버 내부 오류", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." } } } }, - "500": { - "description": "서버 내부 오류", + "400": { + "description": "입력값 검증 실패", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "INTERNAL_SERVER_ERROR", - "message": "서버 내부 오류가 발생했습니다." + "code": "INVALID_INPUT_VALUE", + "message": "..." } } } }, - "200": { - "description": "수정 성공", + "401": { + "description": "인증 실패", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/TodoResponse" + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } @@ -979,16 +983,12 @@ } } }, - "400": { - "description": "입력값 검증 실패", + "200": { + "description": "수정 성공", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "example": { - "code": "INVALID_INPUT_VALUE", - "message": "..." + "$ref": "#/components/schemas/TodoResponse" } } } @@ -1010,27 +1010,10 @@ "schema": { "type": "string", "pattern": "[a-zA-Z0-9-]+" - } - } - ], - "responses": { - "401": { - "description": "인증 실패", - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "example": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." - } - } - } - }, - "204": { - "description": "삭제 성공" - }, + } + } + ], + "responses": { "500": { "description": "서버 내부 오류", "content": { @@ -1058,6 +1041,23 @@ } } } + }, + "204": { + "description": "삭제 성공" + }, + "401": { + "description": "인증 실패", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." + } + } + } } } }, @@ -1087,68 +1087,68 @@ "required": true }, "responses": { - "401": { - "description": "인증 실패", + "500": { + "description": "서버 내부 오류", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." } } } }, - "200": { - "description": "수정 성공", + "400": { + "description": "입력값 검증 실패", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/CategoryResponse" + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "INVALID_INPUT_VALUE", + "message": "..." } } } }, - "500": { - "description": "서버 내부 오류", + "404": { + "description": "카테고리 없음", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "INTERNAL_SERVER_ERROR", - "message": "서버 내부 오류가 발생했습니다." + "code": "CATEGORY_NOT_FOUND", + "message": "해당 카테고리를 찾을 수 없습니다." } } } }, - "400": { - "description": "입력값 검증 실패", + "401": { + "description": "인증 실패", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "INVALID_INPUT_VALUE", - "message": "..." + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } }, - "404": { - "description": "카테고리 없음", + "200": { + "description": "수정 성공", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "example": { - "code": "CATEGORY_NOT_FOUND", - "message": "해당 카테고리를 찾을 수 없습니다." + "$ref": "#/components/schemas/CategoryResponse" } } } @@ -1192,75 +1192,75 @@ } } }, - "200": { - "description": "닉네임 변경 성공. 응답 본문에 변경된 닉네임 포함.", + "409": { + "description": "이미 다른 사용자가 사용 중인 닉네임.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateNicknameResponse" + "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "UpdateSuccess": { - "description": "UpdateSuccess", + "DuplicatedNickname": { + "description": "DuplicatedNickname", "value": { - "nickname": "우주탐험가" + "code": "DUPLICATED_NICKNAME", + "message": "이미 사용 중인 닉네임입니다." } } } } } }, - "400": { - "description": "닉네임 형식 오류 (길이 미달/초과, 허용되지 않은 문자).", + "401": { + "description": "인증 실패 — Access Token 이 헤더에 없거나, 만료되었거나, 유효하지 않은 경우.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "InvalidInputValue": { - "description": "InvalidInputValue", + "UnauthenticatedRequest": { + "description": "UnauthenticatedRequest", "value": { - "code": "INVALID_INPUT_VALUE", - "message": "nickname: 닉네임은 2자 이상 10자 이하여야 합니다." + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } } } }, - "401": { - "description": "인증 실패 — Access Token 이 헤더에 없거나, 만료되었거나, 유효하지 않은 경우.", + "200": { + "description": "닉네임 변경 성공. 응답 본문에 변경된 닉네임 포함.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/UpdateNicknameResponse" }, "examples": { - "UnauthenticatedRequest": { - "description": "UnauthenticatedRequest", + "UpdateSuccess": { + "description": "UpdateSuccess", "value": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." + "nickname": "우주탐험가" } } } } } }, - "409": { - "description": "이미 다른 사용자가 사용 중인 닉네임.", + "400": { + "description": "닉네임 형식 오류 (길이 미달/초과, 허용되지 않은 문자).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "DuplicatedNickname": { - "description": "DuplicatedNickname", + "InvalidInputValue": { + "description": "InvalidInputValue", "value": { - "code": "DUPLICATED_NICKNAME", - "message": "이미 사용 중인 닉네임입니다." + "code": "INVALID_INPUT_VALUE", + "message": "nickname: 닉네임은 2자 이상 10자 이하여야 합니다." } } } @@ -1273,8 +1273,8 @@ "/api/timer-sessions/today-stats": { "get": { "tags": ["Timer"], - "summary": "오늘 공부 통계", - "description": "KST(Asia/Seoul) 기준 오늘의 총 분 / 세션 수 / 연속 일수(streak)", + "summary": "오늘 공부 통계 + 누적 통계", + "description": "KST(Asia/Seoul) 기준 통계.\n\n### 응답 필드\n- `totalMinutes`, `sessionCount`: 오늘 (KST)\n- `streak`: 연속 공부 일수 (오늘 포함, KST)\n- `lifetimeMinutes`, `lifetimeSessionCount`: 회원의 전체 누적\n- `monthlyMinutes`: 이번 달 누적 (KST 1일 00:00 ~ 다음 달 1일 00:00)\n\n세션 0건 회원도 6개 필드 모두 `0`을 반환합니다 (null 금지).\n", "operationId": "getTodayStats", "responses": { "401": { @@ -1297,7 +1297,10 @@ "example": { "totalMinutes": 180, "sessionCount": 3, - "streak": 7 + "streak": 7, + "lifetimeMinutes": 12450, + "lifetimeSessionCount": 287, + "monthlyMinutes": 1820 } } } @@ -1334,26 +1337,26 @@ } } }, - "500": { - "description": "서버 오류", + "401": { + "description": "인증 필요", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } }, - "401": { - "description": "인증 필요", + "500": { + "description": "서버 오류", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" - }, - "example": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." } } } @@ -1414,40 +1417,6 @@ } ], "responses": { - "401": { - "description": "인증 필요", - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/FuelTransactionListResponse" - } - } - } - }, - "500": { - "description": "서버 오류", - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/FuelTransactionListResponse" - } - } - } - }, - "400": { - "description": "잘못된 query parameter", - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "example": { - "code": "INVALID_INPUT_VALUE", - "message": "type은 charge 또는 consume이어야 합니다." - } - } - } - }, "200": { "description": "조회 성공", "content": { @@ -1479,6 +1448,40 @@ } } } + }, + "400": { + "description": "잘못된 query parameter", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "INVALID_INPUT_VALUE", + "message": "type은 charge 또는 consume이어야 합니다." + } + } + } + }, + "401": { + "description": "인증 필요", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/FuelTransactionListResponse" + } + } + } + }, + "500": { + "description": "서버 오류", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/FuelTransactionListResponse" + } + } + } } } } @@ -1533,38 +1536,38 @@ } } }, - "401": { - "description": "인증 실패 — Access Token 이 헤더에 없거나, 만료되었거나, 유효하지 않은 경우. 클라이언트는 `/api/auth/reissue` 로 재발급을 시도해야 합니다.", + "400": { + "description": "닉네임 형식 오류 (길이 미달/초과, 허용되지 않은 문자 포함 등). `message` 필드에 어떤 필드의 어떤 제약을 어겼는지 상세 표기됩니다.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "UnauthenticatedRequest": { - "description": "UnauthenticatedRequest", + "InvalidInputValue": { + "description": "InvalidInputValue", "value": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." + "code": "INVALID_INPUT_VALUE", + "message": "nickname: 닉네임은 한글, 영문, 숫자만 사용할 수 있습니다." } } } } } }, - "400": { - "description": "닉네임 형식 오류 (길이 미달/초과, 허용되지 않은 문자 포함 등). `message` 필드에 어떤 필드의 어떤 제약을 어겼는지 상세 표기됩니다.", + "401": { + "description": "인증 실패 — Access Token 이 헤더에 없거나, 만료되었거나, 유효하지 않은 경우. 클라이언트는 `/api/auth/reissue` 로 재발급을 시도해야 합니다.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "InvalidInputValue": { - "description": "InvalidInputValue", + "UnauthenticatedRequest": { + "description": "UnauthenticatedRequest", "value": { - "code": "INVALID_INPUT_VALUE", - "message": "nickname: 닉네임은 한글, 영문, 숫자만 사용할 수 있습니다." + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } @@ -1603,38 +1606,38 @@ "204": { "description": "탈퇴 성공. 응답 본문 없음. (이미 탈퇴된 상태 / Firebase 측 사용자 부재 / 외부 시스템 일시 오류 등 모두 포함)" }, - "401": { - "description": "인증 실패 — Access Token 이 헤더에 없거나, 만료되었거나, 유효하지 않은 경우. 클라이언트는 `/api/auth/reissue` 로 재발급을 시도해야 합니다.", + "500": { + "description": "서버 내부 오류. 주로 DB 통신 실패 시. 사용자에게는 \"잠시 후 다시 시도해주세요\" 안내가 적절합니다.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "UnauthenticatedRequest": { - "description": "UnauthenticatedRequest", + "InternalServerError": { + "description": "InternalServerError", "value": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." } } } } } }, - "500": { - "description": "서버 내부 오류. 주로 DB 통신 실패 시. 사용자에게는 \"잠시 후 다시 시도해주세요\" 안내가 적절합니다.", + "401": { + "description": "인증 실패 — Access Token 이 헤더에 없거나, 만료되었거나, 유효하지 않은 경우. 클라이언트는 `/api/auth/reissue` 로 재발급을 시도해야 합니다.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "InternalServerError": { - "description": "InternalServerError", + "UnauthenticatedRequest": { + "description": "UnauthenticatedRequest", "value": { - "code": "INTERNAL_SERVER_ERROR", - "message": "서버 내부 오류가 발생했습니다." + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } @@ -2169,7 +2172,7 @@ }, "TodayStatsResponse": { "type": "object", - "description": "오늘 공부 통계 (KST 기준)", + "description": "오늘 공부 통계 + 누적 통계 (KST 기준)", "properties": { "totalMinutes": { "type": "integer", @@ -2185,6 +2188,21 @@ "type": "integer", "format": "int32", "description": "연속 공부 일수 (오늘 포함, KST 기준)" + }, + "lifetimeMinutes": { + "type": "integer", + "format": "int32", + "description": "회원의 전체 누적 공부 시간 (분)" + }, + "lifetimeSessionCount": { + "type": "integer", + "format": "int32", + "description": "회원의 전체 세션 수" + }, + "monthlyMinutes": { + "type": "integer", + "format": "int32", + "description": "이번 달 누적 공부 시간 (분, KST 기준)" } } }, diff --git a/lib/features/timer/presentation/providers/study_stats_provider.dart b/lib/features/timer/presentation/providers/study_stats_provider.dart index ff62549..8ffd34c 100644 --- a/lib/features/timer/presentation/providers/study_stats_provider.dart +++ b/lib/features/timer/presentation/providers/study_stats_provider.dart @@ -24,13 +24,18 @@ int monthlyStudyMinutes(Ref ref) { /// 전체 총 공부 시간 (분) — todayStats 위임 @riverpod int totalStudyMinutes(Ref ref) { - return ref.watch(todayStatsNotifierProvider).valueOrNull?.lifetimeMinutes ?? 0; + return ref.watch(todayStatsNotifierProvider).valueOrNull?.lifetimeMinutes ?? + 0; } /// 전체 세션 수 — todayStats 위임 @riverpod int totalSessionCount(Ref ref) { - return ref.watch(todayStatsNotifierProvider).valueOrNull?.lifetimeSessionCount ?? 0; + return ref + .watch(todayStatsNotifierProvider) + .valueOrNull + ?.lifetimeSessionCount ?? + 0; } // === Streak === diff --git a/lib/features/timer/presentation/providers/today_stats_provider.dart b/lib/features/timer/presentation/providers/today_stats_provider.dart index cf049f9..e8b3917 100644 --- a/lib/features/timer/presentation/providers/today_stats_provider.dart +++ b/lib/features/timer/presentation/providers/today_stats_provider.dart @@ -32,10 +32,14 @@ class TodayStatsNotifier extends _$TodayStatsNotifier { s.startedAt.isBefore(tomorrow); }).toList(); - final todayMinutes = - todaySessions.fold(0, (sum, s) => sum + s.durationMinutes); - final lifetimeMinutes = - sessions.fold(0, (sum, s) => sum + s.durationMinutes); + final todayMinutes = todaySessions.fold( + 0, + (sum, s) => sum + s.durationMinutes, + ); + final lifetimeMinutes = sessions.fold( + 0, + (sum, s) => sum + s.durationMinutes, + ); final monthlyMinutes = sessions .where((s) => !s.startedAt.isBefore(monthStart)) .fold(0, (sum, s) => sum + s.durationMinutes); From bd1e1c5a239eec72b29318087fa1957f6ce31304 Mon Sep 17 00:00:00 2001 From: EM-H20 Date: Wed, 27 May 2026 12:40:12 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix=20:=20CodeRabbit=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EB=B0=98=EC=98=81=20=E2=80=94=20=EA=B2=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20streak=20=EB=8B=A4=EC=9D=BC=20=EB=B3=B5=EC=9B=90=20+=20OpenA?= =?UTF-8?q?PI=20=EC=97=90=EB=9F=AC=20=EC=9D=91=EB=8B=B5=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20+=20assertion=20=EB=B3=B4=EA=B0=95=20#83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - today_stats_provider 게스트 분기 streak 0/1 고정 회귀 → 로컬 세션 날짜 fold 로 다일 연속 계산 복원 - docs/api-docs.json 의 에러 상태(401/500) 응답 스키마가 성공 DTO 를 참조하던 4곳을 ErrorResponse 로 교정: * GET /api/timer-sessions 401 (TimerSessionListResponse -> ErrorResponse) * POST /api/timer-sessions 500 (TimerSessionCreateResponse -> ErrorResponse) * GET /api/timer-sessions/today-stats 401 (TodayStatsResponse -> ErrorResponse) * GET /api/fuel/transactions 401/500 (FuelTransactionListResponse -> ErrorResponse) - timer_session_remote_datasource_test / repository_impl_test: lifetimeSessionCount, monthlyMinutes assertion 보강 - 게스트 다일 streak 회귀 가드 테스트 추가 (3일 연속 -> streak=3) --- docs/api-docs.json | 30 +++++++++++--- .../providers/today_stats_provider.dart | 29 +++++++++++++- .../timer_session_remote_datasource_test.dart | 2 + .../timer_session_repository_impl_test.dart | 2 + .../providers/today_stats_provider_test.dart | 39 +++++++++++++++++++ 5 files changed, 96 insertions(+), 6 deletions(-) diff --git a/docs/api-docs.json b/docs/api-docs.json index f15919d..1d296f5 100644 --- a/docs/api-docs.json +++ b/docs/api-docs.json @@ -386,7 +386,11 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/TimerSessionListResponse" + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } @@ -450,7 +454,11 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/TimerSessionCreateResponse" + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." } } } @@ -1282,7 +1290,11 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/TodayStatsResponse" + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } @@ -1468,7 +1480,11 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/FuelTransactionListResponse" + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } @@ -1478,7 +1494,11 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/FuelTransactionListResponse" + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." } } } diff --git a/lib/features/timer/presentation/providers/today_stats_provider.dart b/lib/features/timer/presentation/providers/today_stats_provider.dart index e8b3917..fc0fb08 100644 --- a/lib/features/timer/presentation/providers/today_stats_provider.dart +++ b/lib/features/timer/presentation/providers/today_stats_provider.dart @@ -1,7 +1,9 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../auth/presentation/providers/auth_provider.dart'; +import '../../domain/entities/timer_session_entity.dart'; import '../../domain/entities/today_stats.dart'; +import '../utils/timer_format_utils.dart'; import 'timer_session_provider.dart'; part 'today_stats_provider.g.dart'; @@ -47,10 +49,35 @@ class TodayStatsNotifier extends _$TodayStatsNotifier { return TodayStats( totalMinutes: todayMinutes, sessionCount: todaySessions.length, - streak: todaySessions.isEmpty ? 0 : 1, + streak: _calculateStreak(sessions), lifetimeMinutes: lifetimeMinutes, lifetimeSessionCount: sessions.length, monthlyMinutes: monthlyMinutes, ); } + + /// 연속 공부 일수 계산 (오늘 또는 어제 공부했어야 streak 유지). + int _calculateStreak(List sessions) { + if (sessions.isEmpty) return 0; + + final studyDates = + sessions.map((s) => normalizeDate(s.startedAt)).toSet().toList() + ..sort((a, b) => b.compareTo(a)); + + final today = normalizeDate(DateTime.now()); + final yesterday = today.subtract(const Duration(days: 1)); + + if (studyDates.first != today && studyDates.first != yesterday) return 0; + + int streak = 1; + for (int i = 1; i < studyDates.length; i++) { + final diff = studyDates[i - 1].difference(studyDates[i]).inDays; + if (diff == 1) { + streak++; + } else { + break; + } + } + return streak; + } } diff --git a/test/features/timer/data/datasources/timer_session_remote_datasource_test.dart b/test/features/timer/data/datasources/timer_session_remote_datasource_test.dart index 8f0fa85..e11ce11 100644 --- a/test/features/timer/data/datasources/timer_session_remote_datasource_test.dart +++ b/test/features/timer/data/datasources/timer_session_remote_datasource_test.dart @@ -92,5 +92,7 @@ void main() { expect(res.totalMinutes, 180); expect(res.streak, 7); expect(res.lifetimeMinutes, 12450); + expect(res.lifetimeSessionCount, 287); + expect(res.monthlyMinutes, 1820); }); } diff --git a/test/features/timer/data/repositories/timer_session_repository_impl_test.dart b/test/features/timer/data/repositories/timer_session_repository_impl_test.dart index c3ad2d4..ab069d9 100644 --- a/test/features/timer/data/repositories/timer_session_repository_impl_test.dart +++ b/test/features/timer/data/repositories/timer_session_repository_impl_test.dart @@ -183,6 +183,8 @@ void main() { expect(stats.totalMinutes, 180); expect(stats.streak, 7); expect(stats.lifetimeMinutes, 12450); + expect(stats.lifetimeSessionCount, 287); + expect(stats.monthlyMinutes, 1820); }); }); diff --git a/test/features/timer/presentation/providers/today_stats_provider_test.dart b/test/features/timer/presentation/providers/today_stats_provider_test.dart index 2547433..21ade2b 100644 --- a/test/features/timer/presentation/providers/today_stats_provider_test.dart +++ b/test/features/timer/presentation/providers/today_stats_provider_test.dart @@ -77,4 +77,43 @@ void main() { expect(stats.lifetimeSessionCount, 2); expect(stats.monthlyMinutes, 90); }); + + test('게스트 모드: 다일 streak 계산 — 오늘·어제·그저께 연속 공부', () async { + final now = DateTime.now(); + final todayNoon = DateTime(now.year, now.month, now.day, 12, 0); + when(() => repo.getSessions()).thenReturn([ + TimerSessionEntity( + id: 's-today', + startedAt: todayNoon, + endedAt: todayNoon.add(const Duration(hours: 1)), + durationMinutes: 60, + ), + TimerSessionEntity( + id: 's-yesterday', + startedAt: todayNoon.subtract(const Duration(days: 1)), + endedAt: todayNoon + .subtract(const Duration(days: 1)) + .add(const Duration(hours: 1)), + durationMinutes: 60, + ), + TimerSessionEntity( + id: 's-day-before', + startedAt: todayNoon.subtract(const Duration(days: 2)), + endedAt: todayNoon + .subtract(const Duration(days: 2)) + .add(const Duration(hours: 1)), + durationMinutes: 60, + ), + ]); + + container = ProviderContainer( + overrides: [ + isAuthenticatedProvider.overrideWith((ref) => false), + timerSessionRepositoryProvider.overrideWith((ref) => repo), + ], + ); + + final stats = await container.read(todayStatsNotifierProvider.future); + expect(stats.streak, 3); + }); }