[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>
This commit is contained in:
2026-06-11 16:57:31 +09:00
parent bcfc6b2402
commit b8e563176b
13 changed files with 2458 additions and 0 deletions

View File

@@ -0,0 +1,189 @@
# 함수 설계서: `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<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 가 잠정 정의, 사용자 확정 필요.