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>
190 lines
7.9 KiB
Markdown
190 lines
7.9 KiB
Markdown
# 함수 설계서: `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 가 잠정 정의, 사용자 확정 필요.
|