Files
life-helper/docs/design/204-flutter-bootstrap/fn-compute-streak.md
joungmin b8e563176b [Architect] #204 design spec — Flutter bootstrap + 18 tables
Phase 1 설계서 작성 완료. docs/design/204-flutter-bootstrap/ 13 개 파일:
- README.md (12 섹션 모두 채움, 함수 19 개 명세, AC 16 항)
- 01-project-structure.md (feature-first + layer-first 하이브리드)
- 02-drift-schema-catalog.md (Catalog 7 테이블 Dart 정의)
- 03-drift-schema-user.md (User 11 테이블 + R1~R10 강제 매트릭스)
- 04-migrations.md (schemaVersion v1 + 인덱스 17 개)
- 05-seed-data.md (assets/seed/*.json + first-run import)
- 06-ux-contracts.md (체크인 R8 ≤ 60 초 흐름)
- fn-recommend-variant / fn-compute-streak / fn-weekly-minimum-ratio
- fn-validate-frame-level / fn-active-habit-quota / fn-seed-importer

핵심 결정: dose_variants 는 별도 habit_dose_variant 테이블로 정규화 (FK
무결성 + recommendVariant SQL 단순성). ADR-0002 승격 권장.

Refs #204

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-11 16:57:31 +09:00

7.9 KiB

함수 설계서: computeStreak (#204)

부모 설계서: README.md · 상태: Draft 작성: [AI] Architect · 구현: lib/domain/streak/compute_streak.dart::computeStreak (TBD) 테스트: test/domain/streak/compute_streak_test.dart (TBD)

1. 시그니처

Future<StreakState> computeStreak({
  required String habitId,
  required DateTime asOf,                    // 기준 날짜 (보통 today)
  required Iterable<TrackerEntry> entries,   // habit 의 entries (또는 DAO 에서 pre-load)
  Iterable<RewardDeclaration>? 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<TrackerEntry> habit 의 전체 entries 호출자가 DAO 로 load
declarations Iterable<RewardDeclaration>? 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 가 잠정 정의, 사용자 확정 필요.