# 함수 설계서: `computeStreak` (#204) > 부모 설계서: [README.md](./README.md) · 상태: Draft > 작성: [AI] Architect · 구현: `lib/domain/streak/compute_streak.dart::computeStreak` (TBD) > 테스트: `test/domain/streak/compute_streak_test.dart` (TBD) ## 1. 시그니처 ```dart Future computeStreak({ required String habitId, required DateTime asOf, // 기준 날짜 (보통 today) required Iterable entries, // habit 의 entries (또는 DAO 에서 pre-load) Iterable? declarations, // (선택) 진입 tier 의 reward_text 동봉용 }); class StreakState { final int currentStreak; // 현재 연속 done 일수 (Never miss twice 적용 후) final int longestStreak; // 역대 최장 final int doneCountInPhase42; // 최근 42 일 done 카운트 final int doneCountInWindow30; // 최근 30 일 done 카운트 final RewardTier currentTier; // T0/T1/T2/T3/T4 final RewardTier? newlyEntered; // 이번 호출에서 새로 진입한 tier (없으면 null) final bool neverMissTwiceBroken; // 직전 2 일 연속 blank 발생 여부 } enum RewardTier { T0, T1, T2, T3, T4 } ``` ## 2. 책임 habit 의 tracker_entries 를 입력으로 받아 현재 스트릭 + 5-Tier milestone 진입 상태를 계산한다 (Never miss twice 규칙 포함). 순수 함수. ## 3. 입력 | 파라미터 | 타입 | 제약/검증 | 설명 | |----------|------|-----------|------| | `habitId` | `String` | not null | 로그/디버그용 | | `asOf` | `DateTime` | not null. local timezone (KST) | 계산 기준일 | | `entries` | `Iterable` | habit 의 전체 entries | 호출자가 DAO 로 load | | `declarations` | `Iterable?` | optional | newlyEntered tier 의 reward_text 라우팅용 | ## 4. 출력 - **반환**: `StreakState` — 위 구조. - **부수효과**: 없음. **순수 함수**. > 호출자가 결과를 보고 reward_claim insert 여부, UI 갱신, celebration 모달 표시를 결정. ## 5. 동작 / 알고리즘 ### 5.1 정의 - **Never miss twice 규칙**: "연속 2 일 blank → 스트릭 0 으로 리셋. 1 회 blank 는 스트릭을 보존하지 않고 break 하지만 (즉 streak 0 부터 재계산), 다음 날 done 이면 다시 카운트 시작." → **수정**: 본 시스템은 더 관대한 정의 채택. 정확한 정의는 아래 5.2. ### 5.2 Never miss twice 정확 정의 (본 시스템) ``` 연속 2 일 이상 blank → tier 강등 (T3 → T2, T2 → T1, T1 → T0). currentStreak = 0. 연속 1 일 blank → currentStreak 끊김 (0 으로 리셋). tier 는 유지. 다음 done 부터 다시 1 부터 카운트. ``` 근거: 사용자 메모리 `feedback_sustainable_minimal.md` "지속가능성 + 짧고 간단" + `feedback_reward_ladder.md` "누적 milestone 은 한 번 진입하면 강제 박탈하지 않는다". 단 2 일 연속 blank 는 명백한 abandonment 신호 → tier 강등. > **OQ-5** 에서 사용자 확정 필요. 본 설계서는 위 정의를 기본. ### 5.3 milestone (5-Tier Reward Ladder) | Tier | 조건 (machine 정의) | |------|---------------------| | T0 | currentStreak ≥ 1 | | T1 | currentStreak ≥ 3 (3 회 스트릭) | | T2 | currentStreak ≥ 7 | | T3 | 직전 30 일 중 done ≥ 24 | | T4 | habit.started_at + 42 일 도달 + 그 42 일 중 done ≥ 30 (약 71%) | > T3/T4 는 streak 가 아니라 비율 기반. T3 진입 후 T1/T2 는 자동 유지 (강등 X). T4 진입 후 phase status='completed' 토글 권장. ### 5.4 알고리즘 ``` function computeStreak(habitId, asOf, entries, declarations) -> StreakState: 1. byDate = entries.toMap(e -> e.date) (date 기준 dict) 2. sortedDates = byDate.keys.sorted ascending 3. # ---- currentStreak (Never miss twice 적용) ---- streak = 0 consecutiveBlank = 0 dateCursor = asOf while true: entry = byDate[dateCursor] if entry == null OR entry.value == 'blank': consecutiveBlank += 1 if consecutiveBlank >= 2: # 강등 + streak 0 break else: # 1 일 blank — streak 종결 (0 으로) 하고 종료 streak = 0 break else: # done streak += 1 consecutiveBlank = 0 dateCursor = dateCursor.minusDays(1) # 안전: 시작일 이전으로 가면 종료 if dateCursor < habit.started_at: break 4. # ---- longestStreak ---- longest = 0 run = 0 for d in sortedDates: if byDate[d].value == 'done': run += 1; longest = max(longest, run) else: run = 0 5. # ---- 30 일 done 카운트 ---- window30 = byDate.entries.filter(d in [asOf-29 .. asOf] && value=='done').count window42 = byDate.entries.filter(d in [asOf-41 .. asOf] && value=='done').count 6. # ---- tier 판정 ---- tier = T0 if streak >= 3: tier = T1 if streak >= 7: tier = T2 if window30 >= 24: tier = max(tier, T3) phaseDay = asOf.diff(habit.started_at).inDays + 1 if phaseDay >= 42 && window42 >= 30: tier = T4 7. # ---- newlyEntered ---- # 호출자가 prior tier 를 알고 있어야 newlyEntered 비교 가능. # 본 함수는 newlyEntered 계산 X (책임 분리), 호출자가 prior 와 비교. newlyEntered = null # caller-side 8. neverMissTwiceBroken = (consecutiveBlank >= 2 안에 들어와서 streak 가 0 으로 강등된 경우) 9. return StreakState(streak, longest, window42, window30, tier, null, neverMissTwiceBroken) ``` > `newlyEntered` 는 호출자에서 prior tier 와 비교해 결정. 본 함수 책임은 "현재 상태" 까지. ## 6. 에러 & 실패 모드 | 조건 | 처리 | 반환 | |------|------|------| | `entries` 빈 컬렉션 | streak=0, tier=T0 | empty StreakState | | `asOf < habit.started_at` | streak=0, tier=T0 | (논리적으로 의미 없음) | | 미래 entries 존재 (`date > asOf`) | 무시 (필터링) | | | 중복 entries (동일 date) | UNIQUE 제약 위반이므로 도달 불가. 도달 시 첫 1 개만 사용 | | ## 7. 엣지케이스 - 시작일에 첫 done 1 개 → streak=1, tier=T0 (T1 미진입). - 3 일 연속 done → tier=T1 진입. - 7 일 연속 done → T2. - 30 일 중 24 일 done (스트릭은 끊겨도) → T3. - 30 일 done 모두 → T2 + T3 동시. tier = T3. - 1 일 blank 후 다시 done → streak 1 부터. - 2 일 blank → 강등 (T3 → T2 등). - T4 진입: started_at 부터 42 일 경과 + 30/42 done. - 동일 date 의 done → blank 변경 (사용자 수정): 호출 시점에 byDate 갱신된 상태로 호출 — 함수는 그 view 로 계산. ## 8. 복잡도 / 성능 - 시간: O(N log N) — sortedDates 정렬 (N = entries 수). currentStreak 루프는 worst O(days_since_start). - 공간: O(N). - 호출 빈도: 체크인 직후 1 회 + 화면 진입 시 1 회. N ≤ 42 (6 주 phase) → < 1 ms. ## 9. 의존성 - `TrackerEntry` 모델 (domain/models). - `Habit` 모델 (started_at, phase_id 참조). - `RewardDeclaration` 모델 (optional). - Drift 미참조 — 순수. ## 10. 테스트 케이스 - [ ] 정상 1: entries=[D-2 done, D-1 done, D done] → streak=3, tier=T1 - [ ] 정상 2: 7 연속 done → tier=T2 - [ ] T3: 30 일 entries 중 24 done (스트릭은 5 라 가정) → tier=T3, currentStreak=5 - [ ] T4: started_at = D-41, 42 일 중 30 done → tier=T4 - [ ] Never miss twice 강등: T1 상태 + 직전 2 일 blank → streak=0, neverMissTwiceBroken=true - [ ] 1 일 blank: T1 상태 + 직전 1 일 blank, 그 전 3 일 done → streak=0 (T1 진입 이력은 longest 로만 남음) - [ ] 빈 entries → streak=0, tier=T0 - [ ] 시작일 첫날 done 1 회 → streak=1, tier=T0 (T1 미진입) - [ ] 미래 entries 무시 (date > asOf) - [ ] longestStreak: [D, B, D, D, D, B] → longest=3, current 는 별도 ## 11. 추적성 - 인수조건: #204 AC-12. - 관련 메모리: `feedback_reward_ladder.md` (5-Tier 정의), `feedback_sustainable_minimal.md` (Never miss twice). - OQ-5: Never miss twice 정확 정의 — 본 설계서 §5.2 가 잠정 정의, 사용자 확정 필요.