[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:
225
docs/design/204-flutter-bootstrap/01-project-structure.md
Normal file
225
docs/design/204-flutter-bootstrap/01-project-structure.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# 01 — Flutter 프로젝트 구조 (#204)
|
||||
|
||||
> 부모 설계서: [README.md](./README.md)
|
||||
|
||||
## 1. 결정: feature-first + layer-first 하이브리드
|
||||
|
||||
| 레이어 | 위치 | 원칙 |
|
||||
|--------|------|------|
|
||||
| 화면 단위 (UI + 화면 상태) | `lib/features/<feature>/` | **feature-first** — 한 화면을 만들 때 보는 코드는 한 폴더 안 |
|
||||
| 도메인 로직 (순수 함수) | `lib/domain/<area>/` | **layer-first** — Drift 미참조, 테스트 가능 |
|
||||
| 데이터 I/O (Drift + assets) | `lib/data/` | **layer-first** — DAO 패턴 |
|
||||
| 공통 유틸 | `lib/core/` | id 생성, datetime, Result type 등 |
|
||||
|
||||
**이유**: feature-first 만 가면 R1~R10 같은 cross-cutting 규칙이 여러 feature 폴더에 흩어진다. layer-first 만 가면 작은 vertical slice 가 4 폴더로 흩어진다. 둘 다 채택하되 경계를 분명히 한다 (domain ↔ data ↔ features).
|
||||
|
||||
## 2. 최종 디렉토리 트리
|
||||
|
||||
```
|
||||
app/
|
||||
├── pubspec.yaml
|
||||
├── analysis_options.yaml
|
||||
├── build.yaml # build_runner 옵션
|
||||
├── README.md # Developer 용 간단 가이드 (Phase 1 종료 후 작성)
|
||||
├── lib/
|
||||
│ ├── main.dart # 앱 진입점 (Riverpod ProviderScope + AppDatabase wire)
|
||||
│ ├── app.dart # MaterialApp + routes
|
||||
│ ├── core/
|
||||
│ │ ├── id.dart # generateUlid, isValidUlid
|
||||
│ │ ├── result.dart # sealed class Result<Ok, Err>
|
||||
│ │ ├── time.dart # nowKst, dateOnly, weekStart
|
||||
│ │ └── constants.dart # USER_LOCAL_DEFAULT 등
|
||||
│ ├── data/
|
||||
│ │ ├── db/
|
||||
│ │ │ ├── app_database.dart # @DriftDatabase 진입 + onCreate/onUpgrade
|
||||
│ │ │ ├── tables/
|
||||
│ │ │ │ ├── catalog/
|
||||
│ │ │ │ │ ├── protocols.dart
|
||||
│ │ │ │ │ ├── break_protocols.dart
|
||||
│ │ │ │ │ ├── common_frames.dart
|
||||
│ │ │ │ │ ├── methodologies.dart
|
||||
│ │ │ │ │ ├── frame_patterns.dart
|
||||
│ │ │ │ │ ├── reward_menu_items.dart
|
||||
│ │ │ │ │ └── references.dart
|
||||
│ │ │ │ ├── user/
|
||||
│ │ │ │ │ ├── users.dart
|
||||
│ │ │ │ │ ├── phases.dart
|
||||
│ │ │ │ │ ├── habits.dart
|
||||
│ │ │ │ │ ├── habit_dose_variants.dart # 정규화 산출
|
||||
│ │ │ │ │ ├── if_then_rules.dart
|
||||
│ │ │ │ │ ├── tracker_entries.dart
|
||||
│ │ │ │ │ ├── lapse_logs.dart
|
||||
│ │ │ │ │ ├── urge_logs.dart
|
||||
│ │ │ │ │ ├── reward_declarations.dart
|
||||
│ │ │ │ │ ├── reward_claims.dart
|
||||
│ │ │ │ │ └── reflections.dart
|
||||
│ │ │ │ └── meta_kv.dart # 시드 완료 플래그 등 보조 KV
|
||||
│ │ │ ├── converters/ # TypeConverter (enum, list<string>, datetime)
|
||||
│ │ │ ├── daos/
|
||||
│ │ │ │ ├── habit_dao.dart
|
||||
│ │ │ │ ├── tracker_dao.dart
|
||||
│ │ │ │ ├── phase_dao.dart
|
||||
│ │ │ │ ├── reward_dao.dart
|
||||
│ │ │ │ ├── reflection_dao.dart
|
||||
│ │ │ │ └── catalog_dao.dart
|
||||
│ │ │ └── migrations/
|
||||
│ │ │ └── v1_initial.dart # schemaVersion=1 진입점
|
||||
│ │ └── seed/
|
||||
│ │ ├── seed_importer.dart # importIfNeeded
|
||||
│ │ ├── adapters/
|
||||
│ │ │ ├── protocol_seed_adapter.dart
|
||||
│ │ │ ├── break_protocol_seed_adapter.dart
|
||||
│ │ │ └── ... (시드별 어댑터)
|
||||
│ │ └── seed_models.dart # freezed + json_serializable
|
||||
│ ├── domain/
|
||||
│ │ ├── models/ # plain Dart 모델 (Drift 미참조)
|
||||
│ │ │ ├── habit.dart
|
||||
│ │ │ ├── tracker_entry.dart
|
||||
│ │ │ ├── frame.dart
|
||||
│ │ │ └── ...
|
||||
│ │ ├── rules/
|
||||
│ │ │ ├── active_habit_quota.dart # R1/R2
|
||||
│ │ │ ├── reward_window.dart # R4
|
||||
│ │ │ ├── tracker_value.dart # R5
|
||||
│ │ │ ├── phase_anchor_change.dart # R6
|
||||
│ │ │ └── xor_protocol.dart
|
||||
│ │ ├── frame/
|
||||
│ │ │ ├── validate_frame_level.dart # R3
|
||||
│ │ │ └── detect_avoidance_keywords.dart # R7
|
||||
│ │ ├── recommend/
|
||||
│ │ │ ├── recommend_variant.dart # fn-recommend-variant
|
||||
│ │ │ └── scoring.dart
|
||||
│ │ ├── streak/
|
||||
│ │ │ ├── compute_streak.dart # fn-compute-streak
|
||||
│ │ │ └── weekly_minimum_ratio.dart # fn-weekly-minimum-ratio
|
||||
│ │ └── checkin/
|
||||
│ │ └── checkin_timer.dart # R8 측정
|
||||
│ └── features/
|
||||
│ ├── habit_create/
|
||||
│ │ ├── habit_create_screen.dart
|
||||
│ │ ├── habit_create_controller.dart # Riverpod StateNotifier
|
||||
│ │ └── widgets/
|
||||
│ ├── daily_checkin/
|
||||
│ │ ├── daily_checkin_sheet.dart
|
||||
│ │ ├── daily_checkin_controller.dart
|
||||
│ │ └── widgets/
|
||||
│ └── tracker_view/
|
||||
│ ├── tracker_view_screen.dart
|
||||
│ └── widgets/
|
||||
├── assets/
|
||||
│ └── seed/
|
||||
│ ├── protocols.json
|
||||
│ ├── break_protocols.json
|
||||
│ ├── common_frames.json
|
||||
│ ├── methodologies.json
|
||||
│ ├── frame_patterns.json
|
||||
│ ├── reward_menu_items.json
|
||||
│ └── references.json
|
||||
└── test/
|
||||
├── data/
|
||||
│ ├── db/
|
||||
│ └── seed/
|
||||
├── domain/
|
||||
│ ├── rules/
|
||||
│ ├── frame/
|
||||
│ ├── recommend/
|
||||
│ └── streak/
|
||||
├── features/
|
||||
└── fixtures/
|
||||
└── seed/ # 작은 테스트용 seed
|
||||
```
|
||||
|
||||
## 3. pubspec.yaml 의존성 (요약)
|
||||
|
||||
```yaml
|
||||
name: life_helper
|
||||
description: Huberman + Atomic Habits + Tiny Habits + If-Then. Local-first.
|
||||
publish_to: none
|
||||
version: 0.1.0+1
|
||||
|
||||
environment:
|
||||
sdk: ">=3.4.0 <4.0.0"
|
||||
flutter: ">=3.22.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_riverpod: ^2.5.1
|
||||
drift: ^2.18.0
|
||||
sqlite3_flutter_libs: ^0.5.0
|
||||
path_provider: ^2.1.0
|
||||
path: ^1.9.0
|
||||
freezed_annotation: ^2.4.0
|
||||
json_annotation: ^4.9.0
|
||||
ulid: ^2.0.0 # 또는 uuid + 자체 base32
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
drift_dev: ^2.18.0
|
||||
build_runner: ^2.4.0
|
||||
freezed: ^2.5.0
|
||||
json_serializable: ^6.8.0
|
||||
flutter_lints: ^4.0.0
|
||||
|
||||
flutter:
|
||||
assets:
|
||||
- assets/seed/
|
||||
```
|
||||
|
||||
## 4. build_runner / Drift 코드 생성 흐름
|
||||
|
||||
```
|
||||
.dart (source) ──► build_runner ──► .g.dart (generated)
|
||||
│
|
||||
├─ data/db/tables/**/*.dart → app_database.g.dart (Drift)
|
||||
├─ data/seed/seed_models.dart → seed_models.g.dart / .freezed.dart
|
||||
└─ domain/models/*.dart → *.freezed.dart (선택)
|
||||
|
||||
명령:
|
||||
$ dart run build_runner build --delete-conflicting-outputs
|
||||
(watch 모드: dart run build_runner watch)
|
||||
```
|
||||
|
||||
- `.g.dart` / `.freezed.dart` 는 commit. 이유: 새 환경에서 codegen 실패해도 일단 빌드 가능 + diff 추적 (스키마 변화를 PR diff 로 검토).
|
||||
- `build.yaml` 에 Drift 옵션: `null_aware_type_converters: true`, `use_sql_column_name_as_json_key: false`.
|
||||
|
||||
## 5. main.dart 의존성 와이어링 (스케치)
|
||||
|
||||
```dart
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
final dbDir = await getApplicationDocumentsDirectory();
|
||||
final db = AppDatabase(NativeDatabase.createInBackground(
|
||||
File('${dbDir.path}/life_helper.sqlite')));
|
||||
await SeedImporter(db).importIfNeeded();
|
||||
runApp(ProviderScope(
|
||||
overrides: [appDatabaseProvider.overrideWithValue(db)],
|
||||
child: const LifeHelperApp(),
|
||||
));
|
||||
}
|
||||
```
|
||||
|
||||
## 6. analysis_options.yaml 핵심
|
||||
|
||||
```yaml
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
analyzer:
|
||||
language:
|
||||
strict-casts: true
|
||||
strict-raw-types: true
|
||||
errors:
|
||||
invalid_annotation_target: ignore # freezed 호환
|
||||
linter:
|
||||
rules:
|
||||
- prefer_const_constructors
|
||||
- require_trailing_commas
|
||||
- avoid_print
|
||||
```
|
||||
|
||||
## 7. CI 훅 (Phase 1 종료 시점 작업으로 권장, 본 Phase 는 명세만)
|
||||
|
||||
- `flutter analyze`
|
||||
- `flutter test`
|
||||
- `dart run build_runner build --delete-conflicting-outputs` (smoke)
|
||||
- Drift schema dump → `tool/schema/v1.json` 과 diff (regression 방지)
|
||||
197
docs/design/204-flutter-bootstrap/02-drift-schema-catalog.md
Normal file
197
docs/design/204-flutter-bootstrap/02-drift-schema-catalog.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# 02 — Catalog 테이블 7 개 Drift 정의 (#204)
|
||||
|
||||
> 부모 설계서: [README.md](./README.md)
|
||||
> SoT: `schema/protocol.schema.json`, `break_protocol.schema.json`, `common_frame.schema.json`, `methodology.schema.json`, `frame_pattern.schema.json`, `reward_menu_item.schema.json`, `reference.schema.json`.
|
||||
|
||||
## 공통 규약
|
||||
|
||||
- 모든 카탈로그 테이블 PK = `TEXT id` (ULID 또는 의미 있는 식별자).
|
||||
- 모든 enum 값은 `TextColumn().named('xxx').withCheckConstraint("xxx IN ('...', ...)")` 로 CHECK 박는다.
|
||||
- 다차원 배열·중첩 객체 (procedure[], phases[], frame_examples[], etc.) 는 **JSON TEXT** 단일 컬럼에 저장. 카탈로그는 read-only 라서 JSON 쿼리 불필요 (단순 read → freezed 모델로 deserialize).
|
||||
- `reference_ids[]` 도 JSON TEXT. M:N 조인 테이블은 본 Phase 에서 만들지 않음 (실용 카운트 작음, Phase 2 에서 join 필요 시 추가).
|
||||
- `additionalProperties: false` 인 schema 의 모든 명시 필드는 컬럼화. 단 nested object 는 JSON.
|
||||
|
||||
## 1. `protocols` (← `protocol.schema.json`)
|
||||
|
||||
```dart
|
||||
class Protocols extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get category => text().withCheckConstraint(
|
||||
"category IN ('health','meditation','motivation','habit','learning','diet')")();
|
||||
TextColumn get title => text()();
|
||||
TextColumn get titleEn => text().nullable()();
|
||||
TextColumn get what => text()();
|
||||
TextColumn get whenText => text().named('when')();
|
||||
TextColumn get dose => text()();
|
||||
TextColumn get why => text()();
|
||||
TextColumn get howJson => text()(); // List<String> JSON
|
||||
TextColumn get check => text()();
|
||||
TextColumn get caution => text().nullable()();
|
||||
TextColumn get defaultAnchorJson => text().nullable()(); // {when,after_what,where}
|
||||
TextColumn get minDoseForStart => text().nullable()();
|
||||
TextColumn get referenceIdsJson => text().nullable()(); // List<String> JSON
|
||||
TextColumn get evidenceStrength => text().nullable().withCheckConstraint(
|
||||
"evidence_strength IS NULL OR evidence_strength IN "
|
||||
"('strong_rct','meta_analysis','observational','mechanistic','expert_opinion')")();
|
||||
TextColumn get sourceDoc => text().nullable().withCheckConstraint(
|
||||
"source_doc IS NULL OR source_doc IN "
|
||||
"('huberman-protocols.md','diet-protocols.md')")();
|
||||
@override Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
인덱스:
|
||||
- `IDX_protocols_category(category)` — UI 의 카테고리 탭 필터.
|
||||
|
||||
## 2. `break_protocols` (← `break_protocol.schema.json`)
|
||||
|
||||
```dart
|
||||
class BreakProtocols extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get category => text().withCheckConstraint(
|
||||
"category IN ('alcohol','nicotine','porn_masturbation','social_media',"
|
||||
"'sugar','caffeine','cannabis','behavioral')")();
|
||||
TextColumn get title => text()();
|
||||
TextColumn get hubermanSummary => text()();
|
||||
TextColumn get frameExamplesJson => text().nullable()(); // [{level,text}]
|
||||
TextColumn get phasesJson => text()(); // [{week,goal,...}]
|
||||
TextColumn get defaultCommonFramesJson => text()(); // ['dopamine_reset',...]
|
||||
TextColumn get toolsJson => text().nullable()();
|
||||
TextColumn get medicalWarning => text().nullable()();
|
||||
TextColumn get referenceIdsJson => text().nullable()();
|
||||
@override Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
인덱스: `IDX_break_protocols_category(category)` UNIQUE — break protocol 은 카테고리당 1 개.
|
||||
|
||||
## 3. `common_frames` (← `common_frame.schema.json`)
|
||||
|
||||
```dart
|
||||
class CommonFrames extends Table {
|
||||
TextColumn get id => text().withCheckConstraint(
|
||||
"id IN ('dopamine_reset','urge_surf','environment_design',"
|
||||
"'relapse_recovery','recovery_stack')")();
|
||||
TextColumn get title => text()();
|
||||
TextColumn get what => text()();
|
||||
TextColumn get why => text()();
|
||||
TextColumn get dose => text().nullable()();
|
||||
TextColumn get howJson => text().nullable()();
|
||||
TextColumn get check => text()();
|
||||
TextColumn get applicableBreakCategoriesJson => text().nullable()();
|
||||
TextColumn get referenceIdsJson => text().nullable()();
|
||||
@override Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
행 수 시드 = 5 (CommonFrameId enum 5 값).
|
||||
|
||||
## 4. `methodologies` (← `methodology.schema.json`)
|
||||
|
||||
```dart
|
||||
class Methodologies extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get name => text()();
|
||||
TextColumn get originator => text()();
|
||||
TextColumn get oneLineDefinition => text()();
|
||||
TextColumn get coreUnit => text()();
|
||||
TextColumn get procedureJson => text().nullable()();
|
||||
TextColumn get toolsJson => text().nullable()();
|
||||
TextColumn get strengthsJson => text().nullable()();
|
||||
TextColumn get weaknessesJson => text().nullable()();
|
||||
TextColumn get goodFor => text().nullable()();
|
||||
IntColumn get hubermanFitScore => integer().withCheckConstraint(
|
||||
"huberman_fit_score BETWEEN 1 AND 5")();
|
||||
BoolColumn get isCoreEngine => boolean().withDefault(const Constant(false))();
|
||||
TextColumn get referenceIdsJson => text().nullable()();
|
||||
@override Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
인덱스: `IDX_methodologies_core(is_core_engine)` partial WHERE true — 핵심 엔진 3 개 (atomic_habits, tiny_habits, implementation_intentions) 빠른 조회.
|
||||
|
||||
## 5. `frame_patterns` (← `frame_pattern.schema.json`)
|
||||
|
||||
```dart
|
||||
class FramePatterns extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get domain => text().nullable().withCheckConstraint(
|
||||
"domain IS NULL OR domain IN ('food','drink','smoking','screen','porn',"
|
||||
"'sleep','exercise','general')")();
|
||||
TextColumn get avoidanceKeyword => text()();
|
||||
TextColumn get l0Example => text()();
|
||||
TextColumn get l1SimpleReplace => text().nullable()();
|
||||
TextColumn get l2Suggestion => text()();
|
||||
TextColumn get l3Identity => text().nullable()();
|
||||
@override Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
인덱스: `IDX_frame_patterns_keyword(avoidance_keyword)` — `detectAvoidanceKeywords` 가 사전 검색.
|
||||
|
||||
## 6. `reward_menu_items` (← `reward_menu_item.schema.json`)
|
||||
|
||||
```dart
|
||||
class RewardMenuItems extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get tierRecommended => text().withCheckConstraint(
|
||||
"tier_recommended IN ('T0','T1','T2','T3','T4')")();
|
||||
TextColumn get title => text()();
|
||||
TextColumn get description => text().nullable()();
|
||||
IntColumn get estimatedCostKrwMin => integer().nullable()();
|
||||
IntColumn get estimatedCostKrwMax => integer().nullable()();
|
||||
BoolColumn get isEffortTied => boolean().nullable()();
|
||||
TextColumn get tagsJson => text().nullable()();
|
||||
TextColumn get avoidForBreakHabitsJson => text().nullable()();
|
||||
@override Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
인덱스: `IDX_reward_menu_tier(tier_recommended)`.
|
||||
|
||||
## 7. `references` (← `reference.schema.json`)
|
||||
|
||||
```dart
|
||||
class References extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get kind => text().withCheckConstraint(
|
||||
"kind IN ('paper','podcast_episode','book','url','korean_explainer')")();
|
||||
TextColumn get title => text()();
|
||||
TextColumn get authorsJson => text().nullable()();
|
||||
IntColumn get year => integer().nullable().withCheckConstraint(
|
||||
"year IS NULL OR (year BETWEEN 1900 AND 2100)")();
|
||||
TextColumn get journal => text().nullable()();
|
||||
TextColumn get doi => text().nullable()();
|
||||
TextColumn get url => text().nullable()();
|
||||
IntColumn get episodeNumber => integer().nullable()();
|
||||
TextColumn get publisher => text().nullable()();
|
||||
TextColumn get evidenceStrength => text().nullable().withCheckConstraint(
|
||||
"evidence_strength IS NULL OR evidence_strength IN "
|
||||
"('strong_rct','meta_analysis','observational','mechanistic','expert_opinion')")();
|
||||
BoolColumn get verified => boolean().nullable()();
|
||||
TextColumn get note => text().nullable()();
|
||||
@override Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
인덱스: `IDX_references_kind(kind)`, `IDX_references_doi(doi)` partial WHERE doi IS NOT NULL.
|
||||
|
||||
## 시드 카운트 추산 (05-seed-data.md 와 sync)
|
||||
|
||||
| 테이블 | 추산 행 수 | 비고 |
|
||||
|--------|-----------|------|
|
||||
| `references` | ~50 | huberman §8 + methodologies §출처 + breaking §5 + diet §7 합계 |
|
||||
| `protocols` | ~34 | huberman 28 (§1.9 stub 제외) + diet 1.1~1.6 (6) |
|
||||
| `break_protocols` | 8 | 알코올/니코틴/포르노/SNS/설탕/카페인/대마/행동 |
|
||||
| `common_frames` | 5 | dopamine_reset/urge_surf/environment_design/relapse_recovery/recovery_stack |
|
||||
| `methodologies` | 21 | habit-todo-methodologies.md §1~§21 |
|
||||
| `frame_patterns` | ~30 | habit-todo-methodologies.md "흔한 끊기 목표 변환 30선" |
|
||||
| `reward_menu_items` | ~30 | habit-todo-methodologies.md "권장 리워드 메뉴 30선" |
|
||||
|
||||
> diet §2 (식이 패턴 5 개) 는 OQ-3 에서 결정 후 별도 분류 가능. 본 설계서는 `protocol(category='diet')` 로 동일 테이블에 포함 가정.
|
||||
|
||||
## 카탈로그는 read-only
|
||||
|
||||
- 모든 카탈로그 테이블에 대한 INSERT 는 `SeedImporter` 와 마이그레이션만 수행.
|
||||
- App 코드 (features/) 에서는 SELECT 만.
|
||||
- 사용자 커스텀 protocol 은 v2 의 `user_protocol` 별도 테이블 (out of scope).
|
||||
320
docs/design/204-flutter-bootstrap/03-drift-schema-user.md
Normal file
320
docs/design/204-flutter-bootstrap/03-drift-schema-user.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# 03 — User-Data 테이블 11 개 Drift 정의 + R 강제 매트릭스 (#204)
|
||||
|
||||
> 부모 설계서: [README.md](./README.md)
|
||||
> SoT: `schema/user.schema.json`, `phase.schema.json`, `habit.schema.json`, `if_then_rule.schema.json`, `tracker_entry.schema.json`, `lapse_log.schema.json`, `urge_log.schema.json`, `reward_declaration.schema.json`, `reward_claim.schema.json`, `reflection.schema.json` + (정규화) `habit_dose_variant`.
|
||||
|
||||
## 0. 공통 규약
|
||||
|
||||
- 모든 user-data 테이블은 `TEXT user_id` (또는 간접 참조). 단일 사용자 default = `'u_local_default'`.
|
||||
- 일/일시 컬럼은 모두 ISO 8601 string (`TextColumn`). Drift `TypeConverter<DateTime, String>` 로 변환.
|
||||
- enum 강제는 (1) Dart enum + TypeConverter (1 차) (2) CHECK 제약 (2 차) 두 층.
|
||||
- FK 는 `customConstraint('REFERENCES habit(id) ON DELETE RESTRICT')` 로 박는다. 본 Phase 는 hard delete 없음 (soft delete only). RESTRICT 가 안전.
|
||||
|
||||
## 1. `users` (← `user.schema.json`)
|
||||
|
||||
```dart
|
||||
class Users extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get displayName => text().nullable()();
|
||||
TextColumn get locale => text().withDefault(const Constant('ko-KR'))();
|
||||
TextColumn get timezone => text().withDefault(const Constant('Asia/Seoul'))();
|
||||
TextColumn get createdAt => text()(); // ISO 8601
|
||||
TextColumn get preferencesJson => text().nullable()(); // celebration_style 등 nested
|
||||
@override Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
## 2. `phases` (← `phase.schema.json`)
|
||||
|
||||
```dart
|
||||
class Phases extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get userId => text().customConstraint('REFERENCES users(id)')();
|
||||
TextColumn get title => text().nullable()();
|
||||
TextColumn get startedAt => text()(); // YYYY-MM-DD
|
||||
TextColumn get endedAt => text().nullable()();
|
||||
IntColumn get durationWeeks => integer().withDefault(const Constant(6))
|
||||
.withCheckConstraint("duration_weeks >= 1")();
|
||||
TextColumn get status => text().withCheckConstraint(
|
||||
"status IN ('active','completed','abandoned')")();
|
||||
TextColumn get intentionText => text().nullable()();
|
||||
BoolColumn get rewardDeclarationsLocked => boolean().withDefault(const Constant(false))();
|
||||
@override Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
인덱스:
|
||||
- `IDX_phases_user_status(user_id, status)` — active 1 개 조회 hot path.
|
||||
|
||||
## 3. `habits` (← `habit.schema.json`, dose_variants 제외)
|
||||
|
||||
```dart
|
||||
class Habits extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get userId => text().customConstraint('REFERENCES users(id)')();
|
||||
TextColumn get phaseId => text().nullable().customConstraint(
|
||||
'REFERENCES phases(id) ON DELETE SET NULL')();
|
||||
TextColumn get type => text().withCheckConstraint("type IN ('build','break')")();
|
||||
TextColumn get status => text().withCheckConstraint(
|
||||
"status IN ('active','paused','completed','abandoned')")();
|
||||
TextColumn get title => text()();
|
||||
TextColumn get protocolId => text().nullable().customConstraint(
|
||||
'REFERENCES protocols(id)')();
|
||||
TextColumn get breakProtocolId => text().nullable().customConstraint(
|
||||
'REFERENCES break_protocols(id)')();
|
||||
TextColumn get commonFrameIdsJson => text().nullable()();
|
||||
TextColumn get frameLevel => text().withCheckConstraint(
|
||||
"frame_level IN ('L2','L3')")(); // R3 here
|
||||
TextColumn get frameOriginalText => text().nullable()();
|
||||
TextColumn get frameFramedText => text()();
|
||||
TextColumn get anchorWhen => text().nullable()(); // HH:MM
|
||||
TextColumn get anchorAfterWhat => text().nullable()();
|
||||
TextColumn get anchorWhere => text().nullable()();
|
||||
IntColumn get stackPosition => integer().nullable().withCheckConstraint(
|
||||
"stack_position IS NULL OR stack_position >= 1")();
|
||||
TextColumn get minDose => text().nullable()(); // deprecated when variants used
|
||||
TextColumn get targetDose => text().nullable()(); // deprecated when variants used
|
||||
TextColumn get startedAt => text()(); // YYYY-MM-DD
|
||||
TextColumn get endedAt => text().nullable()();
|
||||
TextColumn get tagsJson => text().nullable()();
|
||||
@override Set<Column> get primaryKey => {id};
|
||||
@override List<String> get customConstraints => [
|
||||
// XOR — application layer 추가 검증 (assertXorProtocol). CHECK 도 박는다.
|
||||
"CHECK ( (type = 'build' AND protocol_id IS NOT NULL AND break_protocol_id IS NULL) "
|
||||
" OR (type = 'break' AND break_protocol_id IS NOT NULL AND protocol_id IS NULL) )"
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
인덱스:
|
||||
- `IDX_habits_user_status_type(user_id, status, type)` — R1/R2 빠른 카운트.
|
||||
- `IDX_habits_phase(phase_id)`.
|
||||
|
||||
## 4. `habit_dose_variants` (정규화 — ADR 후보)
|
||||
|
||||
```dart
|
||||
class HabitDoseVariants extends Table {
|
||||
TextColumn get variantId => text()(); // habit 안에서 unique. tracker_entry.variant_id 가 참조.
|
||||
TextColumn get habitId => text().customConstraint(
|
||||
'REFERENCES habits(id) ON DELETE CASCADE')();
|
||||
TextColumn get label => text()();
|
||||
TextColumn get doseText => text()();
|
||||
TextColumn get contextTagsJson => text().nullable()();
|
||||
TextColumn get conditionTagsJson => text().nullable()();
|
||||
BoolColumn get isMinimum => boolean().withDefault(const Constant(false))();
|
||||
IntColumn get sortOrder => integer().withDefault(const Constant(0))();
|
||||
@override Set<Column> get primaryKey => {variantId};
|
||||
}
|
||||
```
|
||||
|
||||
인덱스:
|
||||
- `IDX_habit_dose_variants_habit(habit_id)`.
|
||||
- `IDX_habit_dose_variants_habit_minimum(habit_id, is_minimum)` — recommendVariant fallback 빠른 조회.
|
||||
|
||||
> CASCADE delete OK — variant 는 habit 의 부속. 단 habit 자체는 soft delete (status='abandoned'). variant 만 단독 삭제는 사용자가 명시적으로 한 경우만.
|
||||
|
||||
## 5. `if_then_rules` (← `if_then_rule.schema.json`)
|
||||
|
||||
```dart
|
||||
class IfThenRules extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get habitId => text().customConstraint(
|
||||
'REFERENCES habits(id) ON DELETE CASCADE')();
|
||||
TextColumn get ifCondition => text()();
|
||||
TextColumn get thenAction => text()();
|
||||
TextColumn get triggerType => text().nullable().withCheckConstraint(
|
||||
"trigger_type IS NULL OR trigger_type IN "
|
||||
"('time','location','emotion','preceding_action','urge')")();
|
||||
IntColumn get priority => integer().withDefault(const Constant(1))
|
||||
.withCheckConstraint("priority BETWEEN 1 AND 3")();
|
||||
TextColumn get createdAt => text().nullable()();
|
||||
@override Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
인덱스: `IDX_if_then_habit(habit_id)`.
|
||||
|
||||
## 6. `tracker_entries` (← `tracker_entry.schema.json`)
|
||||
|
||||
```dart
|
||||
class TrackerEntries extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get habitId => text().customConstraint(
|
||||
'REFERENCES habits(id) ON DELETE RESTRICT')();
|
||||
TextColumn get date => text()(); // YYYY-MM-DD
|
||||
TextColumn get value => text().withCheckConstraint(
|
||||
"value IN ('done','blank')")(); // R5 here
|
||||
TextColumn get loggedAt => text().nullable()();
|
||||
TextColumn get note => text().nullable().withCheckConstraint(
|
||||
"note IS NULL OR length(note) <= 200")();
|
||||
TextColumn get variantId => text().nullable().customConstraint(
|
||||
'REFERENCES habit_dose_variants(variant_id) ON DELETE SET NULL')();
|
||||
TextColumn get ctxLocation => text().nullable()(); // context_snapshot.location
|
||||
TextColumn get ctxCondition => text().nullable()(); // context_snapshot.condition
|
||||
@override Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
인덱스 / 제약:
|
||||
- `UNIQUE INDEX UQ_tracker_habit_date(habit_id, date)` — (habit_id, date) unique.
|
||||
- `IDX_tracker_habit_date(habit_id, date)` — computeStreak 윈도우 쿼리.
|
||||
- `IDX_tracker_date(date)` — 주간 집계 (weeklyMinimumRatio).
|
||||
|
||||
## 7. `lapse_logs` (← `lapse_log.schema.json`)
|
||||
|
||||
```dart
|
||||
class LapseLogs extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get habitId => text().customConstraint('REFERENCES habits(id)')();
|
||||
TextColumn get date => text()();
|
||||
TextColumn get labelText => text()();
|
||||
TextColumn get examineHaltJson => text()(); // ['hungry','tired',...]
|
||||
TextColumn get antecedentJson => text()(); // 1..5 strings
|
||||
TextColumn get replan => text()();
|
||||
TextColumn get nextAction => text()();
|
||||
TextColumn get createdAt => text().nullable()();
|
||||
@override Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
인덱스: `IDX_lapse_habit_date(habit_id, date)`.
|
||||
|
||||
## 8. `urge_logs` (← `urge_log.schema.json`)
|
||||
|
||||
```dart
|
||||
class UrgeLogs extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get habitId => text().customConstraint('REFERENCES habits(id)')();
|
||||
TextColumn get occurredAt => text()();
|
||||
IntColumn get intensityBefore => integer().nullable().withCheckConstraint(
|
||||
"intensity_before IS NULL OR intensity_before BETWEEN 0 AND 10")();
|
||||
IntColumn get intensityAfter => integer().nullable().withCheckConstraint(
|
||||
"intensity_after IS NULL OR intensity_after BETWEEN 0 AND 10")();
|
||||
IntColumn get durationSeconds => integer().nullable().withCheckConstraint(
|
||||
"duration_seconds IS NULL OR duration_seconds >= 0")();
|
||||
TextColumn get bodyLocationJson => text().nullable()();
|
||||
BoolColumn get passed => boolean()();
|
||||
TextColumn get methodUsed => text().nullable().withCheckConstraint(
|
||||
"method_used IS NULL OR method_used IN "
|
||||
"('cyclic_sighing','walk','water','social_contact','if_then_action','other')")();
|
||||
@override Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
인덱스: `IDX_urge_habit_occurred(habit_id, occurred_at)`.
|
||||
|
||||
## 9. `reward_declarations` (← `reward_declaration.schema.json`)
|
||||
|
||||
```dart
|
||||
class RewardDeclarations extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get phaseId => text().customConstraint('REFERENCES phases(id)')();
|
||||
TextColumn get habitId => text().customConstraint('REFERENCES habits(id)')();
|
||||
TextColumn get tier => text().withCheckConstraint(
|
||||
"tier IN ('T0','T1','T2','T3','T4')")();
|
||||
TextColumn get milestoneRule => text()();
|
||||
TextColumn get milestoneMachineRuleJson => text().nullable()(); // {window_days,min_done,require_consecutive}
|
||||
TextColumn get rewardText => text()();
|
||||
TextColumn get rewardMenuItemId => text().nullable().customConstraint(
|
||||
'REFERENCES reward_menu_items(id)')();
|
||||
IntColumn get estimatedCostKrw => integer().nullable().withCheckConstraint(
|
||||
"estimated_cost_krw IS NULL OR estimated_cost_krw >= 0")();
|
||||
BoolColumn get isEffortTied => boolean().nullable()();
|
||||
TextColumn get declaredAt => text()();
|
||||
@override Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
> R4 (phase.started_at + 7 일 이내만 insert) 는 CHECK 로 박을 수 없음 (다른 테이블 참조). **app layer 에서 강제** + phase.reward_declarations_locked 보조.
|
||||
|
||||
## 10. `reward_claims` (← `reward_claim.schema.json`)
|
||||
|
||||
```dart
|
||||
class RewardClaims extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get declarationId => text().customConstraint(
|
||||
'REFERENCES reward_declarations(id)')();
|
||||
TextColumn get milestoneReachedAt => text()();
|
||||
BoolColumn get fulfilled => boolean()();
|
||||
TextColumn get fulfilledAt => text().nullable()();
|
||||
TextColumn get reflection => text().nullable().withCheckConstraint(
|
||||
"reflection IS NULL OR length(reflection) <= 500")();
|
||||
@override Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
## 11. `reflections` (← `reflection.schema.json`)
|
||||
|
||||
```dart
|
||||
class Reflections extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get userId => text().customConstraint('REFERENCES users(id)')();
|
||||
TextColumn get scope => text().withCheckConstraint(
|
||||
"scope IN ('weekly','monthly','phase_end')")();
|
||||
TextColumn get periodStart => text()();
|
||||
TextColumn get periodEnd => text()();
|
||||
TextColumn get phaseId => text().nullable().customConstraint(
|
||||
'REFERENCES phases(id) ON DELETE SET NULL')();
|
||||
TextColumn get kept => text().nullable()();
|
||||
TextColumn get missed => text().nullable()();
|
||||
TextColumn get adjust => text().nullable()();
|
||||
TextColumn get identityNote => text().nullable()();
|
||||
RealColumn get minimumRatio => real().nullable().withCheckConstraint(
|
||||
"minimum_ratio IS NULL OR (minimum_ratio BETWEEN 0 AND 1)")();
|
||||
TextColumn get createdAt => text().nullable()();
|
||||
@override Set<Column> get primaryKey => {id};
|
||||
}
|
||||
```
|
||||
|
||||
인덱스: `IDX_reflections_user_scope(user_id, scope)`.
|
||||
|
||||
## 보조: `meta_kv` (시드 플래그, 기타)
|
||||
|
||||
```dart
|
||||
class MetaKv extends Table {
|
||||
TextColumn get key => text()();
|
||||
TextColumn get value => text()();
|
||||
@override Set<Column> get primaryKey => {key};
|
||||
}
|
||||
```
|
||||
|
||||
> AC-2 의 "18 테이블 모두 생성" 검증 시 본 보조 테이블은 제외. 단 실제 DB 에는 존재.
|
||||
|
||||
---
|
||||
|
||||
## R 강제 매트릭스 (필수)
|
||||
|
||||
| R | 규칙 | Schema CHECK | Index/Constraint | Trigger | App Layer | 위치 |
|
||||
|---|------|--------------|------------------|---------|-----------|------|
|
||||
| **R1** | active build ≤ 3 | ✗ | `IDX_habits_user_status_type` (조회 가속) | ✗ | ✓ `domain/rules/active_habit_quota.dart::checkActiveHabitQuota` | habit insert/status 변경 직전 |
|
||||
| **R2** | active break ≤ 1 | ✗ | 동일 인덱스 | ✗ | ✓ 동일 함수 | 동일 |
|
||||
| **R3** | frame.level ∈ {L2,L3} | ✓ `CHECK frame_level IN ('L2','L3')` | ✗ | ✗ | ✓ `validateFrameLevel` (UX 변환 제안) | 입력 단계 + DB 마지막 방어 |
|
||||
| **R4** | reward_declaration 은 phase 시작 +7 일 이내 | ✗ (cross-table) | ✗ | ✗ (트리거 가능하나 가독성↓) | ✓ `domain/rules/reward_window.dart::validateRewardDeclarationWindow` + `phase.reward_declarations_locked` 자동 토글 | reward_declaration insert 직전 |
|
||||
| **R5** | tracker_entry.value ∈ {done,blank} | ✓ `CHECK value IN ('done','blank')` | ✗ | ✗ | ✓ `validateTrackerValue` (UI 가드) | 입력 + DB 방어 |
|
||||
| **R6** | phase 중간 anchor 변경 → warning | ✗ | ✗ | ✗ | ✓ `phaseAnchorChangeWarning` (warning only, 차단 X) | habit.anchor* update 시 |
|
||||
| **R7** | if_then_rule.then_action 회피 키워드 → warning | ✗ | ✗ | ✗ | ✓ `detectAvoidanceKeywords` + `frame_patterns` 카탈로그 사전 검색 | UI 입력 시 실시간 |
|
||||
| **R8** | 일일 운영 ≤ 2 분 (UX) | ✗ | ✗ | ✗ | ✓ `CheckInTimer.elapsed` + UX 디자인 (06-ux-contracts.md) | UI 만 |
|
||||
| **R9** | dose_variants 개수 무제한, is_minimum=true 권장 | ✗ | ✗ | ✗ | ✓ — onboarding hint only (강제 없음) | habit_create 폼 |
|
||||
| **R10** | reflection.scope='weekly' 의 minimum_ratio hint | ✗ (RealColumn CHECK 만) | ✗ | ✗ | ✓ `weeklyMinimumRatio` 계산 + 표시 only | reflection 생성 시 |
|
||||
|
||||
### XOR (habit.protocol_id vs break_protocol_id) 강제
|
||||
|
||||
- Schema CHECK 박음 (위 §3 `customConstraints`). + `domain/rules/xor_protocol.dart::assertXorProtocol` 추가 검증 (clearer error).
|
||||
|
||||
### (habit_id, date) UNIQUE 강제
|
||||
|
||||
- `UNIQUE INDEX UQ_tracker_habit_date` 가 1 차. UI 가 중복 진입 시 readable 메시지.
|
||||
|
||||
---
|
||||
|
||||
## 12. dose_variants 저장 형태 — JSON vs 정규화 결정 (반복)
|
||||
|
||||
> README.md §11 의 결정. 본 문서에서 다시 명시.
|
||||
|
||||
**결정**: 별도 `habit_dose_variants` 테이블로 **정규화**.
|
||||
|
||||
**근거 한 줄**: `tracker_entry.variant_id` FK 무결성 + `recommendVariant` SQL 조회 + variant 단위 row CRUD 가 모두 standard SQL 로 가능하다 (JSON1 extension 의존성 회피).
|
||||
|
||||
**비용**: `habit.schema.json` 의 nested 표현 ↔ 정규화 row 변환 어댑터 1 개 (`data/seed/adapters/habit_variant_adapter.dart` — Phase 1 vertical slice 에는 사용자 입력 변환만 필요).
|
||||
|
||||
**ADR 승격 권장**: Phase 1 종료 후 ADR-0002 로 이력 보존.
|
||||
157
docs/design/204-flutter-bootstrap/04-migrations.md
Normal file
157
docs/design/204-flutter-bootstrap/04-migrations.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# 04 — Drift 마이그레이션 전략 (#204)
|
||||
|
||||
> 부모 설계서: [README.md](./README.md)
|
||||
|
||||
## 1. schemaVersion 정책
|
||||
|
||||
| version | 범위 | 작성자 | 비고 |
|
||||
|---------|------|--------|------|
|
||||
| **v1** | 18 테이블 + `habit_dose_variants` + `meta_kv` 초기 생성 | Architect (이 설계서) → Developer (구현) | Phase 1 종료 시점 = production v1 |
|
||||
| v2+ | TBD (Phase 2+) | 해당 시점 Architect | data-model.md §8 의 후속 엔티티 (habit_stack, notification_rule 등) |
|
||||
|
||||
규칙:
|
||||
- schema 가 바뀌면 **반드시 schemaVersion 증가** + `onUpgrade` 콜백 작성.
|
||||
- 컬럼 추가만은 가능하나, drop/rename 은 destructive — `m.alterTable(TableMigration(...))` 사용.
|
||||
- production user 가 0 명인 본 Phase (v1 미배포 상태) 에서만 destructive recreate 허용. v1 이 디바이스에 올라간 후엔 데이터 보존 마이그레이션 필수.
|
||||
|
||||
## 2. v1 진입점 (Drift `MigrationStrategy`)
|
||||
|
||||
```dart
|
||||
@DriftDatabase(tables: [
|
||||
// catalog
|
||||
Protocols, BreakProtocols, CommonFrames, Methodologies,
|
||||
FramePatterns, RewardMenuItems, References,
|
||||
// user
|
||||
Users, Phases, Habits, HabitDoseVariants, IfThenRules,
|
||||
TrackerEntries, LapseLogs, UrgeLogs,
|
||||
RewardDeclarations, RewardClaims, Reflections,
|
||||
// meta
|
||||
MetaKv,
|
||||
])
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase(QueryExecutor e) : super(e);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
onCreate: (m) async {
|
||||
await m.createAll();
|
||||
await _createIndexes(m);
|
||||
},
|
||||
onUpgrade: (m, from, to) async {
|
||||
// Phase 1 한정: v1 만 존재. 도달 불가 보장.
|
||||
assert(false, 'Phase 1 has no upgrade path. from=$from to=$to');
|
||||
},
|
||||
beforeOpen: (details) async {
|
||||
if (details.wasCreated) {
|
||||
// SeedImporter 는 main.dart 에서 별도 호출 (트랜잭션 분리 위해)
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Future<void> _createIndexes(Migrator m) async {
|
||||
// catalog
|
||||
await m.createIndex(Index('IDX_protocols_category',
|
||||
'CREATE INDEX IDX_protocols_category ON protocols(category)'));
|
||||
await m.createIndex(Index('IDX_break_protocols_category',
|
||||
'CREATE UNIQUE INDEX IDX_break_protocols_category ON break_protocols(category)'));
|
||||
await m.createIndex(Index('IDX_methodologies_core',
|
||||
'CREATE INDEX IDX_methodologies_core ON methodologies(is_core_engine) '
|
||||
'WHERE is_core_engine = 1'));
|
||||
await m.createIndex(Index('IDX_frame_patterns_keyword',
|
||||
'CREATE INDEX IDX_frame_patterns_keyword ON frame_patterns(avoidance_keyword)'));
|
||||
await m.createIndex(Index('IDX_reward_menu_tier',
|
||||
'CREATE INDEX IDX_reward_menu_tier ON reward_menu_items(tier_recommended)'));
|
||||
await m.createIndex(Index('IDX_references_kind',
|
||||
'CREATE INDEX IDX_references_kind ON "references"(kind)'));
|
||||
await m.createIndex(Index('IDX_references_doi',
|
||||
'CREATE INDEX IDX_references_doi ON "references"(doi) WHERE doi IS NOT NULL'));
|
||||
// user
|
||||
await m.createIndex(Index('IDX_phases_user_status',
|
||||
'CREATE INDEX IDX_phases_user_status ON phases(user_id, status)'));
|
||||
await m.createIndex(Index('IDX_habits_user_status_type',
|
||||
'CREATE INDEX IDX_habits_user_status_type ON habits(user_id, status, type)'));
|
||||
await m.createIndex(Index('IDX_habits_phase',
|
||||
'CREATE INDEX IDX_habits_phase ON habits(phase_id)'));
|
||||
await m.createIndex(Index('IDX_habit_dose_variants_habit',
|
||||
'CREATE INDEX IDX_habit_dose_variants_habit ON habit_dose_variants(habit_id)'));
|
||||
await m.createIndex(Index('IDX_habit_dose_variants_habit_min',
|
||||
'CREATE INDEX IDX_habit_dose_variants_habit_min '
|
||||
'ON habit_dose_variants(habit_id, is_minimum)'));
|
||||
await m.createIndex(Index('IDX_if_then_habit',
|
||||
'CREATE INDEX IDX_if_then_habit ON if_then_rules(habit_id)'));
|
||||
await m.createIndex(Index('UQ_tracker_habit_date',
|
||||
'CREATE UNIQUE INDEX UQ_tracker_habit_date ON tracker_entries(habit_id, date)'));
|
||||
await m.createIndex(Index('IDX_tracker_habit_date',
|
||||
'CREATE INDEX IDX_tracker_habit_date ON tracker_entries(habit_id, date)'));
|
||||
await m.createIndex(Index('IDX_tracker_date',
|
||||
'CREATE INDEX IDX_tracker_date ON tracker_entries(date)'));
|
||||
await m.createIndex(Index('IDX_lapse_habit_date',
|
||||
'CREATE INDEX IDX_lapse_habit_date ON lapse_logs(habit_id, date)'));
|
||||
await m.createIndex(Index('IDX_urge_habit_occurred',
|
||||
'CREATE INDEX IDX_urge_habit_occurred ON urge_logs(habit_id, occurred_at)'));
|
||||
await m.createIndex(Index('IDX_reflections_user_scope',
|
||||
'CREATE INDEX IDX_reflections_user_scope ON reflections(user_id, scope)'));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `references` 는 SQL reserved-ish (특히 일부 dialects). 본 SQLite 에선 안전하나 Drift 가 안전한 quoting 자동 처리. 직접 SQL 쓸 땐 따옴표 사용.
|
||||
|
||||
## 3. 향후 변경 패턴 (v1 → v2 가이드)
|
||||
|
||||
### 3.1 컬럼 추가 (안전, 권장)
|
||||
|
||||
```dart
|
||||
onUpgrade: (m, from, to) async {
|
||||
if (from < 2) {
|
||||
await m.addColumn(habits, habits.newColumnName);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 새 테이블 추가 (안전)
|
||||
|
||||
```dart
|
||||
if (from < 2) await m.createTable(newTable);
|
||||
```
|
||||
|
||||
### 3.3 컬럼 drop / rename (위험 — `TableMigration` 사용)
|
||||
|
||||
```dart
|
||||
if (from < 2) {
|
||||
await m.alterTable(TableMigration(
|
||||
habits,
|
||||
columnTransformer: { habits.oldCol: const Constant(null) },
|
||||
newColumns: [habits.replacementCol],
|
||||
));
|
||||
}
|
||||
```
|
||||
|
||||
- 데이터 보존 마이그레이션은 Drift 의 `TableMigration` 으로 한 번에 처리.
|
||||
- 컬럼 의미 변경 (예: timezone 의미 변경) 은 마이그레이션 + 데이터 정규화 스크립트 별도.
|
||||
|
||||
### 3.4 enum 값 추가 (안전)
|
||||
|
||||
- CHECK 제약 문자열 업데이트 = `alterTable` 안에서 다시 만들거나, Drift 의 enum CHECK 재생성.
|
||||
|
||||
### 3.5 enum 값 제거 / rename (위험)
|
||||
|
||||
- 기존 데이터 변환 필요. 마이그레이션 + 별도 코드 경로.
|
||||
|
||||
## 4. 회귀 방지 — schema dump diff
|
||||
|
||||
- v1 통과 시점에 `tool/schema/v1.json` 으로 Drift schema dump (`dart run drift_dev schema dump`).
|
||||
- CI 가 schema dump 와 v1.json 의 diff 를 비교 — schemaVersion 증가 없이 schema 가 변경되면 fail.
|
||||
|
||||
## 5. 본 Phase 의 운영 규칙
|
||||
|
||||
- v1 출시 전이므로 schema 변경은 자유롭게 가능. 단 **이 설계서를 먼저 수정한 뒤** 코드 변경 (Design-First, CLAUDE.md §2).
|
||||
- 첫 디바이스 배포 (Architect → QA → Designer → Release) 이후엔 schemaVersion 증가 + onUpgrade 명시 필수.
|
||||
|
||||
## 6. 도구
|
||||
|
||||
- `dart run drift_dev schema dump lib/data/db/app_database.dart tool/schema/`
|
||||
- `dart run drift_dev schema generate tool/schema/ test/generated_migrations/` — 마이그레이션 step 검증용 generated test.
|
||||
129
docs/design/204-flutter-bootstrap/05-seed-data.md
Normal file
129
docs/design/204-flutter-bootstrap/05-seed-data.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# 05 — 시드 데이터 전략 (#204)
|
||||
|
||||
> 부모 설계서: [README.md](./README.md)
|
||||
|
||||
## 1. 전략 결정: build-time 번들 + first-run JSON import
|
||||
|
||||
### 검토한 대안
|
||||
|
||||
| 옵션 | 장점 | 단점 | 채택 |
|
||||
|------|------|------|------|
|
||||
| A. build-time codegen (`*.dart` 생성) | 컴파일 타임 안전, JSON parse 실패 0 | 시드 변경 시 codegen 강제. PR diff 가 dart 자동 생성. | ✗ |
|
||||
| **B. asset 번들 JSON + 첫 실행 import** | SoT `schema/*.json` 호환 형식 그대로. 사람이 읽기 쉬움. diff 명료. | 첫 실행 시 ~1 초 추가 시간. parse 실패 가능성 (테스트로 커버) | **✓** |
|
||||
| C. 원격 fetch | 동적 업데이트 | 네트워크 의존 (Phase 1 out of scope) | ✗ |
|
||||
|
||||
## 2. 파일 배치
|
||||
|
||||
```
|
||||
app/assets/seed/
|
||||
├── protocols.json # Array<Protocol> — ~34 항목
|
||||
├── break_protocols.json # Array<BreakProtocol> — 8 항목
|
||||
├── common_frames.json # Array<CommonFrame> — 5 항목
|
||||
├── methodologies.json # Array<Methodology> — 21 항목
|
||||
├── frame_patterns.json # Array<FramePattern> — ~30 항목
|
||||
├── reward_menu_items.json # Array<RewardMenuItem> — ~30 항목
|
||||
└── references.json # Array<Reference> — ~50 항목
|
||||
```
|
||||
|
||||
- 각 파일 형식 = `schema/<entity>.schema.json` 을 만족하는 객체 배열.
|
||||
- 인코딩 = UTF-8 (한국어 텍스트).
|
||||
- `pubspec.yaml` 의 `flutter.assets:` 에 `assets/seed/` 등록.
|
||||
|
||||
## 3. 로딩 순서 (참조 무결성)
|
||||
|
||||
```
|
||||
1. references.json ← 다른 시드가 reference_ids 로 참조
|
||||
2. protocols.json
|
||||
3. break_protocols.json
|
||||
4. common_frames.json
|
||||
5. methodologies.json
|
||||
6. frame_patterns.json
|
||||
7. reward_menu_items.json
|
||||
```
|
||||
|
||||
- 각 파일 batch insert 는 단일 트랜잭션.
|
||||
- 전체 시드 import 도 단일 transaction. 1 파일이라도 실패하면 rollback.
|
||||
|
||||
## 4. 첫 실행 감지
|
||||
|
||||
`meta_kv` 테이블에 `key='seeded_v1'` 행 존재 여부로 판단.
|
||||
|
||||
```dart
|
||||
Future<bool> _alreadySeeded(AppDatabase db) async {
|
||||
final row = await db.metaKvDao.find('seeded_v1');
|
||||
return row?.value == 'true';
|
||||
}
|
||||
```
|
||||
|
||||
## 5. import 시퀀스 (의사코드 — fn-seed-importer.md 참조)
|
||||
|
||||
```dart
|
||||
Future<void> importIfNeeded() async {
|
||||
if (await _alreadySeeded(db)) return;
|
||||
await db.transaction(() async {
|
||||
for (final entry in _seedOrder) {
|
||||
final raw = await rootBundle.loadString(entry.assetPath);
|
||||
final list = jsonDecode(raw) as List;
|
||||
for (final json in list) {
|
||||
final adapted = entry.adapter(json as Map<String, dynamic>);
|
||||
await entry.dao.insertAll(adapted);
|
||||
}
|
||||
}
|
||||
await _ensureLocalDefaultUser();
|
||||
await db.metaKvDao.put('seeded_v1', 'true');
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 시드 데이터 출처 ↔ 카탈로그 매핑 (검증용)
|
||||
|
||||
| 시드 파일 | SoT 마크다운 | 추출 단위 | 추산 행 |
|
||||
|-----------|-------------|----------|---------|
|
||||
| protocols.json | huberman-protocols.md §1~§5 + nutrition/diet-protocols.md §1 | `###` 헤더 단위 | ~34 |
|
||||
| break_protocols.json | habit-breaking-protocols.md §2 | `###` 헤더 단위 (8 카테고리) | 8 |
|
||||
| common_frames.json | habit-breaking-protocols.md §1 | `###` 5 개 | 5 |
|
||||
| methodologies.json | habit-todo-methodologies.md §1~§21 | `###` 헤더 단위 | 21 |
|
||||
| frame_patterns.json | habit-todo-methodologies.md "흔한 끊기 목표 변환 30선" | 표 row 단위 | ~30 |
|
||||
| reward_menu_items.json | habit-todo-methodologies.md "권장 리워드 메뉴 30선" | 표 row 단위 | ~30 |
|
||||
| references.json | 4 SoT 의 각 출처 섹션 합계 | 인용 1 건 | ~50 |
|
||||
|
||||
## 7. 손 작성 vs 자동 추출
|
||||
|
||||
본 Phase 는 **손 작성** (Developer 가 SoT 마크다운을 보고 JSON 작성). 이유:
|
||||
- 자동 추출 파서가 또 다른 코드 (스크립트) 의 SoT 가 됨 — 본 Phase 범위 초과.
|
||||
- 손 작성으로 카탈로그 schema 의 비논리적 누락을 발견할 수 있음 (피드백 루프).
|
||||
|
||||
Phase 2 에서 `scripts/extract_seed.py` 검토 (OQ-2).
|
||||
|
||||
## 8. user 기본 row 생성
|
||||
|
||||
```dart
|
||||
Future<void> _ensureLocalDefaultUser() async {
|
||||
await db.users.insertOnConflictUpdate(UsersCompanion.insert(
|
||||
id: 'u_local_default',
|
||||
createdAt: DateTime.now().toIso8601String(),
|
||||
locale: const Value('ko-KR'),
|
||||
timezone: const Value('Asia/Seoul'),
|
||||
));
|
||||
}
|
||||
```
|
||||
|
||||
## 9. 테스트 fixture
|
||||
|
||||
```
|
||||
test/fixtures/seed/
|
||||
├── protocols_small.json # 2 항목 (1 health + 1 diet)
|
||||
├── break_protocols_small.json # 1 항목
|
||||
├── common_frames_small.json # 1 항목
|
||||
├── methodologies_small.json # 3 항목 (core engine 3 개)
|
||||
├── frame_patterns_small.json # 2 항목
|
||||
├── reward_menu_items_small.json # 5 항목 (T0~T4 각 1)
|
||||
└── references_small.json # 2 항목
|
||||
```
|
||||
|
||||
- `SeedImporter` 의 단위 테스트는 fixture 디렉토리로 path override.
|
||||
|
||||
## 10. 운영 메모
|
||||
|
||||
- 시드 변경 시 (v1 출시 후 카탈로그 한 행 추가): asset JSON 수정 → schemaVersion 그대로 → migration 으로 신규 row insert 별도 처리. 자동 import 는 `seeded_v1` flag 가 true 라 다시 안 돔.
|
||||
- 카탈로그 갱신은 별도 메커니즘 (v2 또는 `seeded_v2` 플래그 신설) — Phase 2 결정.
|
||||
195
docs/design/204-flutter-bootstrap/06-ux-contracts.md
Normal file
195
docs/design/204-flutter-bootstrap/06-ux-contracts.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# 06 — UX Contracts: 일일 체크인 · 추천 · 주간 reflection (#204)
|
||||
|
||||
> 부모 설계서: [README.md](./README.md)
|
||||
> 참조: R8 (≤ 60 초), R9 (variant 무제한), R10 (minimum_ratio hint), ADR-0001.
|
||||
|
||||
## 1. 일일 체크인 화면 (R8 ≤ 60 초 보장)
|
||||
|
||||
### 1.1 화면 계약
|
||||
|
||||
```
|
||||
┌────────────────────────────────┐
|
||||
│ 오늘 · hb_… 아침 햇빛 10분 │ ← AppBar (≤ 1 줄)
|
||||
├────────────────────────────────┤
|
||||
│ │
|
||||
│ 장소 │ ← chip row 1 (수평 스크롤)
|
||||
│ [집] [짐] [출장] [외부] + │
|
||||
│ │
|
||||
│ 컨디션 │ ← chip row 2
|
||||
│ [좋음] [보통] [나쁨] │
|
||||
│ │
|
||||
│ ─────────────────────────────│
|
||||
│ 추천 도즈 │ ← variant card (자동 추천)
|
||||
│ 📍 짐-메인 │
|
||||
│ 데드 리프트 60kg 5x5 │
|
||||
│ [○ 완료] [다른 옵션 ▾] │
|
||||
│ │
|
||||
└────────────────────────────────┘
|
||||
```
|
||||
|
||||
탭 수:
|
||||
1. 장소 chip 1 탭
|
||||
2. 컨디션 chip 1 탭
|
||||
3. ○ 완료 1 탭 (또는 다른 옵션 → override 1 탭 → ○ 완료 1 탭)
|
||||
|
||||
→ 최소 3 탭, 최대 5 탭. R8 (≤ 60 초) 자연스럽게 달성.
|
||||
|
||||
### 1.2 R8 측정
|
||||
|
||||
`CheckInTimer` 가 화면 진입 시 시작, ○ 완료 탭 시 종료.
|
||||
- `elapsed > 60s` 면 dev console warning.
|
||||
- `elapsed > 120s` 면 사용자에게 toast "체크인이 오래 걸렸어요. UI 개선 피드백" (선택, A/B).
|
||||
|
||||
### 1.3 데이터 흐름
|
||||
|
||||
```
|
||||
화면 진입
|
||||
└─ CheckInTimer.start()
|
||||
└─ DAO.loadVariantsForHabit(habitId) → List<HabitDoseVariant>
|
||||
|
||||
장소 chip 선택
|
||||
└─ controller.setLocation(s)
|
||||
└─ recommendVariant(habit, ctx(loc, null)) → 부분 추천 (location 만)
|
||||
|
||||
컨디션 chip 선택
|
||||
└─ controller.setCondition(s)
|
||||
└─ recommendVariant(habit, ctx(loc, cond)) → 최종 추천 + score
|
||||
|
||||
[다른 옵션] 탭
|
||||
└─ 모달 시트: 모든 variants (score 내림차순) + 사용자 선택
|
||||
|
||||
[○ 완료]
|
||||
└─ TrackerDao.recordCheckIn(
|
||||
habitId, today, 'done', variantId, ctx(loc, cond))
|
||||
└─ computeStreak(habitId, today) → StreakState
|
||||
└─ UI: 오늘 셀 ○ + tier badge 갱신
|
||||
└─ if T1~T4 진입 → Celebration 모달
|
||||
└─ CheckInTimer.stop()
|
||||
```
|
||||
|
||||
### 1.4 blank 처리
|
||||
|
||||
- "○ 완료" 안 누르고 화면 종료 → tracker_entry insert 안 됨. `blank` 는 자동 — 명시적 insert 불요. (`computeStreak` 은 entry 부재 = blank 로 해석.)
|
||||
|
||||
## 2. 추천 매칭 알고리즘 (의사코드)
|
||||
|
||||
> 상세는 [fn-recommend-variant.md](./fn-recommend-variant.md).
|
||||
|
||||
```
|
||||
function recommendVariant(habit, ctx) -> {variant, score, reason}
|
||||
variants = habit.dose_variants
|
||||
if variants.isEmpty: return null
|
||||
|
||||
scored = []
|
||||
for v in variants:
|
||||
s = scoreVariant(v, ctx)
|
||||
scored.push({v, s})
|
||||
|
||||
scored.sortByDesc(s.score, breakTies = v.sortOrder)
|
||||
|
||||
best = scored[0]
|
||||
if best.score > 0:
|
||||
return best
|
||||
else:
|
||||
# fallback: is_minimum=true 첫 variant
|
||||
minimum = variants.firstWhere(v -> v.is_minimum, orElse: variants.first)
|
||||
return {minimum, score: 0, reason: 'fallback_minimum'}
|
||||
|
||||
function scoreVariant(v, ctx) -> int
|
||||
score = 0
|
||||
if ctx.location in v.context_tags: score += 2
|
||||
if ctx.condition in v.condition_tags: score += 2
|
||||
if v.is_minimum and ctx.condition == 'bad': score += 1
|
||||
return score
|
||||
```
|
||||
|
||||
복잡도: O(N) per call, N = variants 개수. R9 무제한이지만 실용 N ≤ 약 10 → < 50 µs.
|
||||
|
||||
## 3. 사용자 override 흐름
|
||||
|
||||
```
|
||||
[다른 옵션 ▾] 탭
|
||||
↓
|
||||
모달 sheet (전체 variants 표시, score 표시)
|
||||
↓
|
||||
사용자 선택 → controller.overrideVariant(variantId)
|
||||
↓
|
||||
변경 후에도 ○ 완료 가능. tracker_entry.variant_id = overridden.
|
||||
```
|
||||
|
||||
> override 시에도 score 0 의 fallback variant 도 선택 가능 — 사용자 autonomy (SDT).
|
||||
|
||||
## 4. habit 생성 폼 (vertical slice — 최소)
|
||||
|
||||
```
|
||||
[1] protocol 선택 (목록 — category 탭)
|
||||
↓
|
||||
[2] frame.framed_text 입력 (L2 default, L3 toggle)
|
||||
↓ validateFrameLevel
|
||||
(L0/L1 감지 시 변환 제안 모달)
|
||||
↓
|
||||
[3] anchor.when, after_what, where 입력 (Tiny Habits)
|
||||
↓
|
||||
[4] 기본 variant 자동 생성 (label='기본', dose_text=protocol.min_dose_for_start, is_minimum=true)
|
||||
↓ checkActiveHabitQuota (R1/R2)
|
||||
↓
|
||||
[5] [habit 생성] → HabitDao.insertWithVariants
|
||||
```
|
||||
|
||||
> dose_variants 추가 입력은 v1 vertical slice 에선 생략 가능 (기본 1 개 자동). 추가 입력 UI 는 같은 Phase 안의 후속 sub-task 로 둘 수 있음 (Developer 가 시간 보고 분리).
|
||||
|
||||
## 5. 주간 reflection 화면
|
||||
|
||||
### 5.1 진입
|
||||
|
||||
수동 — Tracker view 의 "이번 주 회고" 버튼.
|
||||
|
||||
### 5.2 minimum_ratio 표시
|
||||
|
||||
```
|
||||
이번 주 (6/8 ~ 6/14)
|
||||
── kept (잘 한 것): _________
|
||||
── missed (못 한 것): _________
|
||||
── adjust (다음 주 변경 1 개): _________
|
||||
|
||||
[hint] 이번 주 done 중 'tiny' (최소 도즈) 비율: 43%
|
||||
↑ 강제 임계값 없음. 정보 hint only (R10).
|
||||
```
|
||||
|
||||
### 5.3 계산식
|
||||
|
||||
```
|
||||
minimum_ratio = (done && is_minimum_true 카운트) / (done 카운트)
|
||||
done = 0 → null (UI: "이번 주 done 없음")
|
||||
```
|
||||
|
||||
상세는 [fn-weekly-minimum-ratio.md](./fn-weekly-minimum-ratio.md).
|
||||
|
||||
### 5.4 표시 트리거
|
||||
|
||||
- minimum_ratio ≥ 0.7 → UI hint: "tiny 가 자주 고정되고 있어요. 컨디션 좋은 날 메인 도즈를 시도해보세요." (이것도 강제 X, 단순 메시지)
|
||||
- minimum_ratio ≤ 0.2 → UI hint: "본 도즈를 잘 유지하고 있어요." (긍정 강화)
|
||||
- 그 외 → 표시만, 메시지 없음.
|
||||
|
||||
## 6. Celebration (T0~T4)
|
||||
|
||||
| 진입 | UI |
|
||||
|------|-----|
|
||||
| T0 (매일 완료 직후) | 작은 ○ 애니메이션 + user.preferences.celebration_style 적용 (verbal/gesture/emoji/silent) |
|
||||
| T1 (3 연속 done) | 모달 1 회: "3 회 스트릭. 작은 변화의 시작." + (선언된 reward_text 있으면 표시) |
|
||||
| T2 (7 일) | 모달 1 회: 주간 축하 + reward_text |
|
||||
| T3 (30 일 중 24 일) | 모달 + reward_claim insert 유도 (사용자가 fulfilled=true 체크 가능) |
|
||||
| T4 (42 일 = 6 주) | 모달 + phase 종료 안내 (status='completed' 토글) |
|
||||
|
||||
> celebration 은 한 번만. 동일 tier 재진입은 표시 X (per-tier flag).
|
||||
|
||||
## 7. R8 보장 검증
|
||||
|
||||
- AC: 체크인 평균 elapsed < 30 초 (수동 QA + 측정).
|
||||
- 변동성: 새 variant 만들 때 (최초) 는 90 초 허용 (도즈 입력 자체가 사용자 의도적 작업).
|
||||
|
||||
## 8. 접근성 / 한국어
|
||||
|
||||
- 최소 폰트 16 sp.
|
||||
- 모든 chip text 한국어. 카테고리는 SoT 의 ko 제목 그대로.
|
||||
- 다크모드 대응은 Phase 2 (out of scope).
|
||||
407
docs/design/204-flutter-bootstrap/README.md
Normal file
407
docs/design/204-flutter-bootstrap/README.md
Normal file
@@ -0,0 +1,407 @@
|
||||
# 설계서: Phase 1 — Flutter + Drift Bootstrap + 18 Schema (#204)
|
||||
|
||||
> **상태**: Draft
|
||||
> **작성**: [AI] Architect · **최종수정**: 2026-06-11
|
||||
> **추적성** — Redmine: #204 · 관련 ADR: [ADR-0001 dose-variants](../../adr/0001-dose-variants.md)
|
||||
> · 구현 파일: `app/` (TBD by Developer) · 테스트: `app/test/` (TBD by Developer)
|
||||
> **하위 문서**:
|
||||
> - [01-project-structure.md](./01-project-structure.md) — Flutter 레이아웃 + pubspec + codegen
|
||||
> - [02-drift-schema-catalog.md](./02-drift-schema-catalog.md) — Catalog 테이블 7개
|
||||
> - [03-drift-schema-user.md](./03-drift-schema-user.md) — User-data 테이블 11개 + R1~R10 강제 위치 표
|
||||
> - [04-migrations.md](./04-migrations.md) — Drift schemaVersion 전략
|
||||
> - [05-seed-data.md](./05-seed-data.md) — 시드 로딩 전략
|
||||
> - [06-ux-contracts.md](./06-ux-contracts.md) — 체크인 / 추천 / 주간 reflection UX 계약
|
||||
> - [fn-recommend-variant.md](./fn-recommend-variant.md) — variant 매칭 점수 함수
|
||||
> - [fn-compute-streak.md](./fn-compute-streak.md) — 5-Tier milestone + Never miss twice
|
||||
> - [fn-weekly-minimum-ratio.md](./fn-weekly-minimum-ratio.md) — 주간 minimum_ratio 집계
|
||||
> - [fn-validate-frame-level.md](./fn-validate-frame-level.md) — R3 + R7 코끼리 회피 린터
|
||||
> - [fn-active-habit-quota.md](./fn-active-habit-quota.md) — R1/R2 한도 검사
|
||||
> - [fn-seed-importer.md](./fn-seed-importer.md) — 시드 JSON → Drift 첫 부팅 import
|
||||
|
||||
---
|
||||
|
||||
## 1. 목적 (Why)
|
||||
|
||||
> Planner 목표: "Huberman/방법론/끊기/식이 SoT 를 앱에서 쓸 수 있는 로컬-우선 Flutter 앱의 데이터 계층 + 최소 vertical slice 를 만든다."
|
||||
|
||||
Phase 1 의 단일 과제는 **"머릿속/마크다운에만 있는 18 개 엔티티 schema 를 실제 디바이스에서 굴러가는 Drift DB 로 내려놓는 것"** 이다. 그 위에서 vertical slice (habit 1 개 생성 → 1 회 체크인 → 화면 확인) 가 동작하면, 이후의 모든 페르소나 작업 (UI 확장, 시드 보강, ADR 적용) 은 이 데이터 계층 위에서 점진적으로 진행된다. 동시에 **R1~R10 운영 규칙이 schema 가 아닌 어디서 강제되는지** 를 명확히 박아 두어, 향후 어떤 페르소나도 "이 규칙은 어디서 검사하지?" 라는 표류를 만들지 않게 한다.
|
||||
|
||||
## 2. 범위 (Scope)
|
||||
|
||||
### 포함
|
||||
|
||||
- Flutter 프로젝트 부트스트랩 (`app/` 서브디렉토리, iOS + Android 빌드 타깃).
|
||||
- Drift (sqlite) ORM 도입 + 코드 생성 파이프라인 (`build_runner`).
|
||||
- 18 개 테이블 Dart 정의 (Catalog 7 + User 11) — `schema/*.json` 19 개 SoT 와 1:1 매핑.
|
||||
- `dose_variants[]` 저장 형태 결정 — **별도 `habit_dose_variant` 테이블로 정규화** (근거는 §11, 상세는 03-drift-schema-user.md).
|
||||
- `tracker_entry.variant_id` + `context_snapshot` 저장.
|
||||
- R1~R10 운영 규칙별 강제 위치 (schema CHECK · index · trigger · app layer) 매트릭스.
|
||||
- Drift `schemaVersion = 1` 마이그레이션 진입점 + 향후 변경 패턴.
|
||||
- 시드 로딩 전략: build 시 `assets/seed/*.json` 번들 + 첫 부팅 시 import.
|
||||
- 단일 vertical slice: `habit` 1 개 생성 폼 + 일일 체크인 1 회 + tracker 그리드 1 일 셀 표시.
|
||||
- ULID 생성 + ID prefix 규약.
|
||||
- 핵심 함수 6 개에 대한 단위 테스트 케이스 명세.
|
||||
|
||||
### 제외 (out of scope)
|
||||
|
||||
- **클라우드 동기화 / 인증 / 멀티 디바이스** — Phase 2+ (단, `user_id` 컬럼은 호환성 유지).
|
||||
- **알림 / cron / 백그라운드 스케줄러** — Phase 3+.
|
||||
- **Apple Health / Google Fit / Notion 통합** — Phase 4+.
|
||||
- **고급 UI** (대시보드 위젯, 통계 그래프, 캘린더 히트맵) — Phase 2.
|
||||
- **`habit_stack`, `dashboard_preference`, `notification_rule`, `social_link` 등 v2 엔티티** — data-model.md §8 참조.
|
||||
- **시드 SoT 본문 (Huberman 마크다운 텍스트)** 의 앱 내 풀텍스트 검색 — Phase 3+.
|
||||
- **회피 키워드 자동 변환기 (L0→L2 자동 생성)** — 본 Phase 는 키워드 감지 + warning 까지만.
|
||||
- **앱 스토어 출시 절차** — Phase 5 (Release 페르소나).
|
||||
|
||||
## 3. 인수조건 (Acceptance Criteria)
|
||||
|
||||
QA 가 다음 항목을 모두 통과 표시할 수 있어야 한다.
|
||||
|
||||
- [ ] **AC-1**: `flutter pub get && dart run build_runner build` 가 새 환경에서 에러 없이 통과한다 (Drift codegen 포함).
|
||||
- [ ] **AC-2**: 앱 실행 직후 첫 부팅 시 18 개 테이블이 모두 생성되고 (`sqlite_master` 조회로 검증), 카탈로그 7 개 테이블에 시드 행이 0 개 이상 들어가 있다 (Huberman protocol ≥ 28, break_protocol = 8, common_frame = 5, methodology = 21, reward_menu_item ≥ 10).
|
||||
- [ ] **AC-3**: `user_id = 'u_local_default'` user 1 행이 자동 생성된다.
|
||||
- [ ] **AC-4**: `habit` 생성 폼에서 build 타입 1 개를 만들고 (protocol_id 참조, frame.level = L2, anchor.when 입력), `habit` 테이블 + `habit_dose_variant` 테이블에 행이 들어간다.
|
||||
- [ ] **AC-5**: 만든 habit 에 대해 오늘 날짜로 체크인 (장소 1 탭 + 컨디션 1 탭) 시 `tracker_entry` 1 행이 생성되고 `variant_id` + `context_snapshot.location` + `context_snapshot.condition` 이 모두 채워진다.
|
||||
- [ ] **AC-6**: 같은 habit 에 대해 같은 날짜로 두 번째 `tracker_entry` 를 insert 하면 unique 위반으로 거부된다 (UNIQUE INDEX `(habit_id, date)`).
|
||||
- [ ] **AC-7**: build habit 을 3 개 active 상태로 가진 user 가 4 번째 build habit 을 active 로 생성하려 하면 app layer 가 거부하고 사용자 메시지를 반환한다 (R1).
|
||||
- [ ] **AC-8**: `frame.level = L0` 으로 habit 을 생성하려는 시도는 app layer 가 거부한다 (R3).
|
||||
- [ ] **AC-9**: `tracker_entry.value` 에 `done`/`blank` 이외 값을 넣으려는 시도는 거부된다 (R5, CHECK 제약).
|
||||
- [ ] **AC-10**: phase 시작 후 8 일째에 `reward_declaration` insert 를 시도하면 app layer 가 거부한다 (R4).
|
||||
- [ ] **AC-11**: `fn-recommend-variant` 가 `context_tags`/`condition_tags` 매칭 점수에 따라 variant 1 개를 반환하고, 매칭 0 점일 때 `is_minimum=true` variant 를 fallback 으로 반환한다.
|
||||
- [ ] **AC-12**: `fn-compute-streak` 가 3 연속 done → T1, 7/7 → T2, 24/30 → T3, 42 일 6 주 통과 → T4 를 각각 정확히 판정한다 + "Never miss twice" 규칙 (연속 blank ≥ 2 → 스트릭 0 으로 리셋, 단 1 회 blank 는 스트릭을 끊지 않음) 이 동작한다.
|
||||
- [ ] **AC-13**: `fn-weekly-minimum-ratio` 가 주어진 주에 대해 `is_minimum=true` variant 비율을 0.0~1.0 으로 반환하며, 해당 주 done = 0 일 때 `null` 을 반환한다.
|
||||
- [ ] **AC-14**: 모든 `*.dart` 단위 테스트가 `flutter test` 로 통과한다 (각 함수 §7 표의 "테스트 케이스" 와 1:1).
|
||||
- [ ] **AC-15**: 첫 부팅 후 앱을 강제 종료 → 재실행 시 데이터가 유지된다 (Drift 파일 영속).
|
||||
- [ ] **AC-16**: schema 파일 하나를 임의로 추가했을 때 schemaVersion 을 v1 그대로 두면 Developer 가 즉시 "마이그레이션 누락" 컴파일 경고를 받도록 04-migrations.md 의 가이드대로 lint 설정이 켜져 있다 (또는 README 에 절차 명시).
|
||||
|
||||
## 4. 컨텍스트 & 제약
|
||||
|
||||
### 의존성
|
||||
|
||||
- **Flutter SDK** ≥ 3.22 (Dart 3.4+).
|
||||
- **Drift** (sqlite ORM) — `drift`, `drift_dev`, `sqlite3_flutter_libs`, `path_provider`, `path`.
|
||||
- **build_runner** — Drift `.g.dart` 코드 생성.
|
||||
- **ulid** — Dart 패키지 (typed ULID 생성). 없으면 `uuid` + base32 변환으로 자체 구현.
|
||||
- **flutter_riverpod** (또는 `provider`) — 상태 관리. 본 Phase 는 Riverpod 으로 결정 (테스트 가능성).
|
||||
- **freezed** + `json_serializable` — 시드 JSON ↔ Dart 모델 변환.
|
||||
- **NO 네트워크 의존** — 모든 SoT 는 `assets/seed/*.json` 으로 번들.
|
||||
|
||||
### 제약
|
||||
|
||||
- **R8 (≤ 60 초 체크인)**: UI 두 탭 이상 금지. 추천 매칭 알고리즘은 클라이언트 메모리에서 < 50ms 안에 결과를 내야 한다 (habit 당 variant ≤ 약 10 개 가정 — R9 가 제한 없음이지만 실용 상한).
|
||||
- **데이터 손실 금지**: schema 변경 시 destructive migration (drop & recreate) 는 production user 가 0 명인 본 Phase 에서만 허용. Phase 2 부터는 metadata 보존 migration.
|
||||
- **로컬 only**: 모든 비밀 (없음) 은 `.env` 가 아니라 디바이스 secure storage. 본 Phase 는 비밀 없음.
|
||||
- **단일 사용자 우선**: 모든 쿼리에서 `user_id = 'u_local_default'` 가 default. 다만 user_id 컬럼은 모든 user-data 테이블에 살아있어야 한다 (멀티 호환).
|
||||
- **i18n**: ko-KR 만. 다국어는 v2.
|
||||
- **시간대**: `Asia/Seoul` 고정 default. `user.timezone` 컬럼은 살리되 본 Phase 는 무시.
|
||||
|
||||
### 가정
|
||||
|
||||
- 사용자는 본인 한 명 (joungmin). 따라서 race condition / 락 우려 없음.
|
||||
- 시드 SoT 의 한국어 텍스트 인코딩은 UTF-8.
|
||||
- iOS/Android 모두 sqlite 3.39+ (Flutter sqlite3_flutter_libs 가 번들). JSON1 extension 미사용 (정규화 결정으로 회피 — §11 참조).
|
||||
- 첫 릴리스 전이므로 schemaVersion = 1 부터 시작, downgrade 처리 없음.
|
||||
|
||||
## 5. 아키텍처 개요
|
||||
|
||||
### 디렉토리 구조 (요약, 상세는 01-project-structure.md)
|
||||
|
||||
```
|
||||
life-helper/
|
||||
├── app/ # Flutter 프로젝트 root
|
||||
│ ├── pubspec.yaml
|
||||
│ ├── lib/
|
||||
│ │ ├── main.dart
|
||||
│ │ ├── core/ # 공통 유틸 (id, datetime, result)
|
||||
│ │ ├── data/
|
||||
│ │ │ ├── db/ # Drift database + tables
|
||||
│ │ │ │ ├── app_database.dart
|
||||
│ │ │ │ ├── tables/
|
||||
│ │ │ │ │ ├── catalog/ # 7 개 catalog 테이블
|
||||
│ │ │ │ │ └── user/ # 11 개 user-data 테이블
|
||||
│ │ │ │ ├── daos/
|
||||
│ │ │ │ └── migrations/
|
||||
│ │ │ └── seed/ # JSON import 코드
|
||||
│ │ ├── domain/ # 순수 도메인 로직 (Drift 미참조)
|
||||
│ │ │ ├── rules/ # R1~R10 검증기
|
||||
│ │ │ ├── recommend/ # fn-recommend-variant
|
||||
│ │ │ ├── streak/ # fn-compute-streak
|
||||
│ │ │ └── frame/ # fn-validate-frame-level
|
||||
│ │ └── features/ # feature-first UI
|
||||
│ │ ├── habit_create/
|
||||
│ │ ├── daily_checkin/
|
||||
│ │ └── tracker_view/
|
||||
│ ├── assets/
|
||||
│ │ └── seed/ # 카탈로그 JSON (빌드 시 번들)
|
||||
│ └── test/
|
||||
└── (기존 docs/, schema/, ... 그대로)
|
||||
```
|
||||
|
||||
### 데이터 흐름
|
||||
|
||||
```
|
||||
[App start]
|
||||
│
|
||||
▼
|
||||
[main.dart] ──► [AppDatabase 초기화]
|
||||
│
|
||||
├─ 첫 실행 감지 (meta_kv 테이블의 seeded_v1 flag)
|
||||
│
|
||||
├─ if 첫 실행:
|
||||
│ ├─ 18 테이블 CREATE (Drift schemaVersion=1)
|
||||
│ ├─ assets/seed/*.json 로드
|
||||
│ ├─ 카탈로그 7 테이블에 batch insert
|
||||
│ ├─ user('u_local_default') 1 행 insert
|
||||
│ └─ meta_kv['seeded_v1'] = true
|
||||
│
|
||||
└─ else: noop
|
||||
│
|
||||
▼
|
||||
[features/habit_create] ──► [domain/rules/active_quota] (R1/R2 검사)
|
||||
│ │
|
||||
│ ▼
|
||||
│ [domain/frame/validate_frame_level] (R3 + R7)
|
||||
│ │
|
||||
│ ▼
|
||||
│ [data/db/daos/HabitDao.insertWithVariants]
|
||||
│ │
|
||||
│ ▼ (insert 트랜잭션)
|
||||
│ habit + habit_dose_variant 동시 commit
|
||||
▼
|
||||
[features/daily_checkin] ──► location + condition 1 탭씩
|
||||
│
|
||||
▼
|
||||
[domain/recommend/recommend_variant] (장소·컨디션 → variant + score)
|
||||
│
|
||||
▼
|
||||
사용자 override 여부 (1 탭)
|
||||
│
|
||||
▼
|
||||
[TrackerDao.insert] (habit_id + date + value=done + variant_id + context_snapshot)
|
||||
│
|
||||
▼
|
||||
[domain/streak/compute_streak] 재계산
|
||||
│
|
||||
▼
|
||||
UI 갱신 (오늘 ○ + 현재 tier 표시)
|
||||
```
|
||||
|
||||
### I/O ↔ 순수 로직 경계
|
||||
|
||||
- **I/O 계층** (Drift, FileSystem, asset bundle): `lib/data/`. 외부 부수효과를 가진 모든 코드.
|
||||
- **순수 도메인 로직**: `lib/domain/`. **Drift 타입 import 금지**. 입력은 plain Dart 모델 (freezed), 출력도 plain Dart. → 테스트가 sqlite 없이 가능.
|
||||
- **DAO** 는 I/O 와 도메인 사이의 변환기. DAO 안에서 Drift `Companion` ↔ 도메인 모델 매핑.
|
||||
- **UI** (`features/`) 는 Riverpod provider 를 통해서만 데이터를 받고, 비즈니스 결정은 모두 domain 함수 호출로 위임.
|
||||
|
||||
## 6. 데이터 모델
|
||||
|
||||
### 18 개 테이블 한눈에
|
||||
|
||||
| # | 테이블 | 분류 | SoT JSON | 행 수 (시드) | Drift 정의 위치 |
|
||||
|---|--------|------|----------|-------------|-----------------|
|
||||
| 1 | `protocol` | catalog | protocol.schema.json | ~34 (Huberman 28 + diet 6) | 02-catalog |
|
||||
| 2 | `break_protocol` | catalog | break_protocol.schema.json | 8 | 02-catalog |
|
||||
| 3 | `common_frame` | catalog | common_frame.schema.json | 5 | 02-catalog |
|
||||
| 4 | `methodology` | catalog | methodology.schema.json | 21 | 02-catalog |
|
||||
| 5 | `frame_pattern` | catalog | frame_pattern.schema.json | ~30 (32 변환표) | 02-catalog |
|
||||
| 6 | `reward_menu_item` | catalog | reward_menu_item.schema.json | ~30 (메뉴 30선) | 02-catalog |
|
||||
| 7 | `reference` | catalog | reference.schema.json | ~50 (출처 통합 합계) | 02-catalog |
|
||||
| 8 | `user` | user | user.schema.json | 1 (자동) | 03-user |
|
||||
| 9 | `phase` | user | phase.schema.json | 0 | 03-user |
|
||||
| 10 | `habit` | user | habit.schema.json | 0 | 03-user |
|
||||
| 11 | `habit_dose_variant` | user (정규화 산출) | habit.schema.json `dose_variants[]` 분해 | 0 | 03-user |
|
||||
| 12 | `if_then_rule` | user | if_then_rule.schema.json | 0 | 03-user |
|
||||
| 13 | `tracker_entry` | user | tracker_entry.schema.json | 0 | 03-user |
|
||||
| 14 | `lapse_log` | user | lapse_log.schema.json | 0 | 03-user |
|
||||
| 15 | `urge_log` | user | urge_log.schema.json | 0 | 03-user |
|
||||
| 16 | `reward_declaration` | user | reward_declaration.schema.json | 0 | 03-user |
|
||||
| 17 | `reward_claim` | user | reward_claim.schema.json | 0 | 03-user |
|
||||
| 18 | `reflection` | user | reflection.schema.json | 0 | 03-user |
|
||||
|
||||
> **총 18 테이블 = 11 user-data + 7 catalog**. `dose_variants[]` 가 `habit_dose_variant` 로 정규화되므로 user-data 수가 schema/*.json 의 10 개에서 11 개로 증가한다. 메타 `meta_kv` (seed 완료 플래그 저장) 는 Drift 내부 관리용 보조 테이블로 18 개에 포함하지 않는다 (04-migrations 참조).
|
||||
|
||||
### 경계 검증 규칙 (요약)
|
||||
|
||||
- 입력 검증은 모두 domain 계층에서 (Drift insert 진입 전).
|
||||
- ULID 형식 검사는 `core/id.dart::isValidUlid()` — `enums.schema.json#/$defs/Ulid` 의 정규식 적용.
|
||||
- 한국어 텍스트 normalize: NFC.
|
||||
- timezone-aware DateTime 만 받음. Drift 저장은 ISO 8601 string + (ms epoch) 두 컬럼 병행 ❌ — string 단일.
|
||||
|
||||
### enum ↔ Drift 매핑
|
||||
|
||||
`enums.schema.json` 의 모든 enum 은 Dart `enum` 으로 정의 + Drift `TypeConverter` 로 string ↔ enum 변환. CHECK 제약은 Drift 의 `customConstraint('CHECK (value IN (...))')` 로 박는다 (R3/R5 강제 — 03-user §"R 강제 매트릭스" 참조).
|
||||
|
||||
## 7. 함수 명세 (Function Specs)
|
||||
|
||||
> 단순 = 본 표만으로 충분 / **복잡** = `fn-*.md` 별도 작성.
|
||||
|
||||
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|
||||
|------|-----------|----------------|------|------|-----------|-------|
|
||||
| `AppDatabase()` | Drift DB 진입점 (lazy open + migration) | `class AppDatabase extends _$AppDatabase` | path | DB instance | path 권한 실패 → fatal | 단순 |
|
||||
| `AppDatabase.migration` | schemaVersion 1 초기 생성 + 향후 onUpgrade dispatch | `MigrationStrategy get migration` | from,to | void | migration 실패 → 예외 | 단순 |
|
||||
| `generateUlid(prefix)` | typed ULID 1 개 생성 | `String generateUlid(String prefix)` | prefix | `<prefix>_<26-char>` | prefix 빈 문자열 → ArgumentError | 단순 |
|
||||
| `isValidUlid(s)` | ULID 형식 검사 | `bool isValidUlid(String s)` | string | bool | 없음 | 단순 |
|
||||
| `SeedImporter.importIfNeeded` | 첫 부팅 시 카탈로그 시드 import | `Future<void> importIfNeeded()` | DB | void (idempotent) | JSON parse 실패 → 예외 + rollback | **복잡** → [fn-seed-importer.md](./fn-seed-importer.md) |
|
||||
| `validateFrameLevel(input)` | R3 (L0/L1 차단) + R7 (회피 키워드 warning) | `FrameValidationResult validateFrameLevel(FrameInput)` | FrameInput | result(ok/warn/err) | L0 입력 → reject | **복잡** → [fn-validate-frame-level.md](./fn-validate-frame-level.md) |
|
||||
| `checkActiveHabitQuota(uid, type)` | R1/R2 — active build ≤ 3, break ≤ 1 | `Future<QuotaResult> checkActiveHabitQuota(...)` | userId, HabitType | QuotaResult | 초과 → reject 메시지 | **복잡** → [fn-active-habit-quota.md](./fn-active-habit-quota.md) |
|
||||
| `HabitDao.insertWithVariants` | habit + dose_variant 트랜잭션 insert | `Future<String> insertWithVariants(HabitDraft)` | HabitDraft | habitId | 트랜잭션 실패 → rollback | 단순 |
|
||||
| `recommendVariant(habit, ctx)` | location/condition 매칭 점수 → variant 1 개 | `VariantPick recommendVariant(Habit, CheckInContext)` | habit, ctx | VariantPick | variants 없음 → null | **복잡** → [fn-recommend-variant.md](./fn-recommend-variant.md) |
|
||||
| `TrackerDao.recordCheckIn` | tracker_entry insert + UNIQUE 위반 처리 | `Future<TrackerEntry> recordCheckIn(...)` | habitId, date, value, variantId, ctx | entry | (habit_id,date) 중복 → 사용자 메시지 | 단순 |
|
||||
| `computeStreak(habitId, asOf)` | T0~T4 milestone 진입 판정 + Never miss twice | `Future<StreakState> computeStreak(...)` | habitId, asOfDate | StreakState | 데이터 0 → empty | **복잡** → [fn-compute-streak.md](./fn-compute-streak.md) |
|
||||
| `weeklyMinimumRatio(userId, weekStart)` | 주간 done 중 is_minimum=true 비율 | `Future<double?> weeklyMinimumRatio(...)` | userId, weekStart | 0..1 or null | done=0 → null | **복잡** → [fn-weekly-minimum-ratio.md](./fn-weekly-minimum-ratio.md) |
|
||||
| `validateRewardDeclarationWindow(phase, now)` | R4 — phase 시작 +7 일 이내만 허용 | `bool validateRewardDeclarationWindow(Phase, DateTime)` | phase, now | bool | 초과 시 false + 메시지 | 단순 |
|
||||
| `validateTrackerValue(s)` | R5 — done/blank 외 거부 | `bool validateTrackerValue(String)` | string | bool | 없음 | 단순 |
|
||||
| `detectAvoidanceKeywords(text)` | R7 — 회피 키워드 list 반환 | `List<String> detectAvoidanceKeywords(String)` | string | list | 없음 | 단순 |
|
||||
| `phaseAnchorChangeWarning(phase, now)` | R6 — 6 주 사이클 중간 anchor 변경 시 warning | `bool phaseAnchorChangeWarning(Phase, DateTime)` | phase, now | bool | 없음 | 단순 |
|
||||
| `CheckInTimer.elapsed` | R8 — 체크인 화면 시작~완료 시간 측정 | `Duration get elapsed` | timestamp | Duration | 없음 | 단순 |
|
||||
| `assertXorProtocol(habit)` | habit type=build → protocol_id, type=break → break_protocol_id (XOR) | `void assertXorProtocol(Habit)` | habit | void | XOR 깨짐 → 예외 | 단순 |
|
||||
| `toggleFrameLevelL2L3(current)` | UI 헬퍼: L2 ↔ L3 변환 제안 텍스트 반환 | `String toggleFrameLevelL2L3(FrameLevel)` | level | text | 없음 | 단순 |
|
||||
|
||||
> **복잡 함수 6 개**: `SeedImporter.importIfNeeded`, `validateFrameLevel`, `checkActiveHabitQuota`, `recommendVariant`, `computeStreak`, `weeklyMinimumRatio`. 각각 `fn-*.md` 있음.
|
||||
|
||||
## 8. 흐름 / 알고리즘
|
||||
|
||||
### 시나리오 A: 첫 부팅 + 시드 import
|
||||
|
||||
1. `main.dart` → `AppDatabase` 인스턴스화 (lazy open).
|
||||
2. Drift `onCreate` 콜백에서 18 테이블 + `meta_kv` 생성.
|
||||
3. `SeedImporter.importIfNeeded` 호출 — `meta_kv['seeded_v1']` 확인.
|
||||
4. flag 가 false 면 `assets/seed/*.json` 7 파일을 순서대로 읽어 batch insert.
|
||||
- 순서: `reference` → `protocol` → `break_protocol` → `common_frame` → `methodology` → `frame_pattern` → `reward_menu_item` (참조 무결성 순).
|
||||
5. `user('u_local_default')` 1 행 insert.
|
||||
6. `meta_kv['seeded_v1'] = true` 기록.
|
||||
7. 트랜잭션 commit. 실패 시 전체 rollback → 다음 부팅에 재시도.
|
||||
|
||||
### 시나리오 B: build habit 생성 (vertical slice)
|
||||
|
||||
1. UI: 사용자가 protocol 1 개 선택 → 제목, anchor.when, frame.framed_text 입력.
|
||||
2. domain: `validateFrameLevel({level:L2, original_text, framed_text})` 호출.
|
||||
- L0/L1 → reject + 변환 제안 표시.
|
||||
- L2/L3 + 회피 키워드 감지 시 warning + 계속 진행 가능.
|
||||
3. domain: `checkActiveHabitQuota('u_local_default', 'build')` 호출. active build ≥ 3 면 reject.
|
||||
4. UI: dose_variants 입력 (최소 1 개, 강제 X. 본 vertical slice 는 기본 variant 1 개 자동 생성: label='기본', dose_text=protocol.min_dose_for_start, is_minimum=true).
|
||||
5. `HabitDao.insertWithVariants` 트랜잭션:
|
||||
- `habit` insert.
|
||||
- `habit_dose_variant` 다중 insert.
|
||||
- `if_then_rule` 다중 insert (있다면).
|
||||
6. commit → UI 가 habit list 갱신.
|
||||
|
||||
### 시나리오 C: 일일 체크인 (R8 ≤ 60 초 보장)
|
||||
|
||||
1. UI: 오늘 habit 카드 탭 → 체크인 화면 진입. `CheckInTimer.start()`.
|
||||
2. UI: 장소 chip 1 탭 (예: '집') — `context.location` 채워짐.
|
||||
3. UI: 컨디션 chip 1 탭 (예: '보통') — `context.condition` 채워짐.
|
||||
4. domain: `recommendVariant(habit, ctx)` 호출 → variant 1 개 + score.
|
||||
5. UI: 추천 variant 카드 표시 + (선택) "다른 옵션" 펼치기 → override.
|
||||
6. 사용자가 "○ 완료" 탭 → `TrackerDao.recordCheckIn` 호출.
|
||||
7. domain: `computeStreak(habitId, today)` 호출 → 현재 tier 갱신.
|
||||
8. UI: 오늘 셀이 ○ 로 채워짐. T1~T4 진입했다면 축하 모달 (5-tier Reward Ladder).
|
||||
9. `CheckInTimer.elapsed` 가 60 초 초과 시 dev log 경고 (production 에선 silent).
|
||||
|
||||
### 시나리오 D: 주간 reflection
|
||||
|
||||
1. 일요일 22:00 (수동 진입) UI 에서 "이번 주 회고" 진입.
|
||||
2. domain: `weeklyMinimumRatio('u_local_default', weekStart)` 호출.
|
||||
3. UI: kept / missed / adjust 3 필드 입력. minimum_ratio 가 결과로 hint 표시 (강제 X — R10).
|
||||
4. `ReflectionDao.insert`.
|
||||
|
||||
## 9. 엣지케이스 & 에러 처리
|
||||
|
||||
| 상황 | 처리 | 비고 |
|
||||
|------|------|------|
|
||||
| 시드 JSON parse 실패 | rollback + 다음 부팅 재시도 + 사용자에게 "시드 로딩 실패, 앱 재시작 권장" 메시지 | seeded_v1 flag 미설정 유지 |
|
||||
| Drift open 권한 실패 (Android scoped storage) | fatal — 앱 시작 차단 + 사용자 안내 | rare |
|
||||
| 같은 날짜 중복 체크인 | UNIQUE INDEX 위반 → "오늘은 이미 체크함" 토스트, 기존 row 표시 | tracker_entry.UNIQUE(habit_id,date) |
|
||||
| habit 삭제 시 자식 row | hard delete 금지 (R6 시기 변경 warning 정신). 본 Phase 는 status='abandoned' 로 soft delete | cascade X |
|
||||
| ULID 충돌 | 26 자리 randomness 로 사실상 불가능. 단 unit test 에서 mock ULID 충돌 케이스 1 개 검증 | |
|
||||
| 시간대 변경 (해외 출장) | 본 Phase 는 Asia/Seoul 고정. 날짜 경계는 사용자 local timezone 기준 — `DateTime.now().toLocal()` | v2 에서 timezone 컬럼 활용 |
|
||||
| variant 0 개 habit 에 체크인 | 추천 결과 null → UI 가 "도즈 입력 후 체크" 안내. tracker_entry.variant_id = null 허용 | recommendVariant 가 null 반환 |
|
||||
| phase 없이 habit 만 운영 | habit.phase_id = null 허용. computeStreak 는 phase 무관 동작 | data-model.md §6 단일 사용자 예시와 동일 |
|
||||
| Drift `onUpgrade` 호출 (Phase 2 진입) | Phase 1 종료 후 04-migrations.md 의 v1→v2 패턴 적용. 본 Phase 는 onUpgrade 진입 시 assert false (도달 불가 보장) | |
|
||||
| 회피 키워드 false positive (예: '안전한 식단') | warning 만 띄우고 사용자가 '계속' 선택 가능 (R7 = soft) | |
|
||||
|
||||
### 안전한 기본값
|
||||
|
||||
- 시드 import 실패 시 → 카탈로그 빈 상태로 진행 (사용자가 catalog 선택 못 함). hard fail 아님.
|
||||
- recommendVariant 결과 score 동률 → variant 의 정의 순서 (변경 안정성) 로 첫번째 선택.
|
||||
- Streak 데이터 누락 (DB 손상) → tier='T0', streak=0 으로 표시. 자동 복구 X.
|
||||
|
||||
## 10. 테스트 계획
|
||||
|
||||
### 단위 테스트 (각 AC 1:1)
|
||||
|
||||
| AC | 테스트 | 위치 |
|
||||
|----|--------|------|
|
||||
| AC-1 | `flutter pub get` 통과 + `dart run build_runner build` 통과 (CI smoke) | scripts/ci |
|
||||
| AC-2 | `seed_importer_test.dart` — 첫 부팅 후 카탈로그 행 수 ≥ 시드 카운트 | test/data/seed |
|
||||
| AC-3 | `user_default_test.dart` — `u_local_default` 존재 | test/data/db |
|
||||
| AC-4 | `habit_create_test.dart` — habit + variant insert 성공 | test/features/habit_create |
|
||||
| AC-5 | `daily_checkin_test.dart` — tracker_entry 행 + variant_id + ctx 채워짐 | test/features/daily_checkin |
|
||||
| AC-6 | `tracker_unique_test.dart` — 중복 insert 거부 | test/data/db |
|
||||
| AC-7 | `active_habit_quota_test.dart` — 4 번째 build 거부 | test/domain/rules |
|
||||
| AC-8 | `validate_frame_level_test.dart` — L0 reject + 변환 제안 | test/domain/frame |
|
||||
| AC-9 | `tracker_value_check_test.dart` — invalid value 거부 | test/data/db |
|
||||
| AC-10 | `reward_window_test.dart` — D+8 reject | test/domain/rules |
|
||||
| AC-11 | `recommend_variant_test.dart` — context 매칭 + fallback | test/domain/recommend |
|
||||
| AC-12 | `compute_streak_test.dart` — T1~T4 + Never miss twice | test/domain/streak |
|
||||
| AC-13 | `weekly_min_ratio_test.dart` — null + 0..1 케이스 | test/domain/streak |
|
||||
| AC-14 | `flutter test` 전체 green | CI |
|
||||
| AC-15 | 통합: 앱 재시작 후 db 영속 (golden test 또는 manual QA) | test/integration |
|
||||
| AC-16 | schema 변경 lint — 04-migrations.md 가이드 준수 (manual) | docs check |
|
||||
|
||||
### 모킹 / 드라이런
|
||||
|
||||
- Drift in-memory mode (`NativeDatabase.memory()`) — 모든 domain 테스트는 in-memory DB 로 동작.
|
||||
- Asset bundle mocking — `assets/seed/` 대신 test fixture (`test/fixtures/seed/`).
|
||||
- DateTime 주입 — domain 함수는 `DateTime now` 를 파라미터로 받아 테스트 가능.
|
||||
|
||||
## 11. 리스크 & 대안 검토
|
||||
|
||||
### 핵심 결정: `dose_variants[]` 저장 형태
|
||||
|
||||
| 옵션 | 장점 | 단점 | 채택 |
|
||||
|------|------|------|------|
|
||||
| **A. JSON 단일 컬럼** (`habit.dose_variants_json TEXT`) | 코드 적음, `habit.schema.json` 과 1:1 매핑. ULID 같은 inner id 그대로. | (1) `tracker_entry.variant_id` 참조 무결성 SQL 강제 불가. (2) variant 별 집계 (recommendVariant, weeklyMinimumRatio) 가 JSON1 extension 필요. iOS/Android 빌드 sqlite 의 JSON1 보장 흔들림. (3) 부분 업데이트 (variant 1 개 수정) 가 read-modify-write. | ✗ |
|
||||
| **B. 별도 `habit_dose_variant` 테이블** (정규화) | (1) FK + INDEX 로 참조 무결성 + 빠른 조회. (2) recommendVariant 가 standard SQL 로 가능. (3) variant CRUD 가 row 단위. (4) Drift type-safe codegen 그대로 활용. | (1) schema/*.json 의 nested 구조와 1:1 안 됨 → 변환 어댑터 필요. (2) 테이블 1 개 추가 (총 18 → 사실상 19). | **✓ 채택** |
|
||||
|
||||
**결정 근거**: B 의 장점이 R8 (≤ 60 초) 와 직결되는 recommendVariant 의 쿼리 단순성 + AC-5/AC-11 의 테스트 가능성을 모두 달성한다. JSON 옵션의 "schema 1:1" 장점은 `core/seed/adapters/` 의 가벼운 어댑터로 회수 가능. data-model.md 와 `habit.schema.json` 은 nested 표현을 유지하되, Drift 계층에서 정규화 한다. ADR-0001 의 결정 (R9 무제한 / R10 hint) 은 그대로 보존.
|
||||
|
||||
> 단, 테이블 수 count 는 "사용자 외부 schema 18" 그대로 표기한다 (`habit_dose_variant` 는 `habit` 의 정규화 부속). AC-2 의 18 테이블 검증 시 `habit_dose_variant` 도 포함하여 실제 19 테이블이 생성됨을 허용한다.
|
||||
|
||||
### 그 외 검토 대안
|
||||
|
||||
- **Drift vs sqflite + SQL 직접 작성** — Drift 채택. 이유: type-safe + migration 도구 + DAO 패턴. sqflite 는 boilerplate 큼.
|
||||
- **Riverpod vs Provider vs Bloc** — Riverpod 채택. 이유: 테스트 가능성 (provider override), 컴파일 타임 의존성 검증.
|
||||
- **시드 import: build 시 generated Dart 코드 vs 첫 부팅 시 JSON read** — 첫 부팅 채택. 이유: SoT JSON 이 곧 검증 가능한 단일 진실. generated dart 는 변경마다 codegen 강제 → 마찰. 자세히는 05-seed-data.md.
|
||||
- **feature-first vs layer-first** — feature-first 채택 (`features/`), 단 공용 도메인은 layer-first (`domain/`, `data/`). 자세히는 01-project-structure.md.
|
||||
- **enum 강제: CHECK 제약 vs app layer 만** — 둘 다. CHECK 는 마지막 방어선, app layer 가 1 차. R3/R5 는 CHECK 까지 박는다.
|
||||
|
||||
### 되돌리기 어려운 결정 → ADR 후보
|
||||
|
||||
- `habit_dose_variant` 정규화: 본 설계서에 명시했고 schema/*.json 은 변경하지 않으므로 ADR 별도 분리는 옵션. **Phase 1 종료 후 ADR-0002 로 승격 권장** (DB 형태 결정 = 멀티 디바이스 sync 시 갈등 가능).
|
||||
- Riverpod 채택: 코드 분량 큼. **ADR-0003 후보**.
|
||||
|
||||
## 12. 미해결 질문 (Open Questions)
|
||||
|
||||
> Developer 단계로 넘기기 전 사용자에게 확인이 필요한 항목.
|
||||
|
||||
1. **OQ-1**: `habit_dose_variant` 정규화 결정을 ADR-0002 로 분리할지 (권장) — 본 설계서에 박혀 있지만 ADR 이 명시적이라 추적 유리.
|
||||
2. **OQ-2**: 시드 JSON 의 출처 — `huberman-protocols.md` 등 마크다운에서 자동 추출 스크립트를 짤 것인가 (`scripts/` 신규), 아니면 손으로 JSON 작성? 본 설계서는 후자 (손 작성) 를 가정. Phase 1 끝에서 Developer 가 손 작성하면 Phase 2 에서 자동화로 전환.
|
||||
3. **OQ-3**: `protocol.category` 에 'diet' 가 들어가는데 nutrition/diet-protocols.md §2 의 "식이 패턴" (지중해/케토/TRE/식물성/한식) 5 개를 `protocol` 로 저장할 것인가 vs 별도 `diet_pattern` 카탈로그를 신설? 본 설계서는 전자 (단순화). 다른 의견 있으면 v1 안에 반영 필요.
|
||||
4. **OQ-4**: `frame_pattern.schema.json` 에 ko/en 두 언어 컬럼 없음 — 한국어만으로 충분한지. 본 설계서는 한국어 only 가정.
|
||||
5. **OQ-5**: 5-Tier 의 "Never miss twice" 정의 — "연속 2 일 blank → 스트릭 0" 으로 본 설계서가 박았는데, 사용자가 "주 단위 2 회 blank" 같은 다른 해석을 원하는지 확인 필요. 본 설계서의 정의는 fn-compute-streak.md §"동작" 에 상세.
|
||||
6. **OQ-6**: `tracker_entry.variant_id` 가 nullable 인가? — 본 설계서는 nullable (variant 없는 habit 도 허용). 다만 dose_variants 권장이라면 not-null 강제도 가능. 결정 필요.
|
||||
7. **OQ-7**: vertical slice UI 의 화면 갯수 — habit 생성 폼 + 오늘 카드 + 체크인 시트 + (선택) habit 목록 화면. 본 설계서는 4 개. Designer 단계에서 줄이거나 늘릴지 결정.
|
||||
8. **OQ-8**: 앱 ID, 패키지명 — `kr.cloud_handson.life_helper` 가정. iOS bundle id 도 동일. 사용자 확정 필요.
|
||||
|
||||
---
|
||||
|
||||
## 부록: 자가 점검 (Architect 가 작업 종료 시 검증)
|
||||
|
||||
- [x] `_TEMPLATE.md` 12 개 섹션 모두 비어있지 않음
|
||||
- [x] 18 테이블 (catalog 7 + user 11) 전부 §6 표 + 03-drift-schema-user.md 의 Drift 컬럼/타입/제약 정의 완료
|
||||
- [x] R1~R10 강제 위치 매트릭스 = 03-drift-schema-user.md §"R 강제 매트릭스"
|
||||
- [x] dose_variants 결정 = §11 "B. 별도 `habit_dose_variant` 테이블" + 근거
|
||||
- [x] 복잡 함수 6 개 각각 fn-*.md 존재 (recommend-variant, compute-streak, weekly-minimum-ratio, validate-frame-level, active-habit-quota, seed-importer)
|
||||
- [x] §7 함수 명세 표에 모든 함수 등재 (단순 13 + 복잡 6)
|
||||
- [x] AC §3 — QA 판정 가능한 17 항목 (AC-1 ~ AC-16)
|
||||
109
docs/design/204-flutter-bootstrap/fn-active-habit-quota.md
Normal file
109
docs/design/204-flutter-bootstrap/fn-active-habit-quota.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# 함수 설계서: `checkActiveHabitQuota` (#204)
|
||||
|
||||
> 부모 설계서: [README.md](./README.md) · 상태: Draft
|
||||
> 작성: [AI] Architect · 구현: `lib/domain/rules/active_habit_quota.dart::checkActiveHabitQuota` (TBD)
|
||||
> 테스트: `test/domain/rules/active_habit_quota_test.dart` (TBD)
|
||||
|
||||
## 1. 시그니처
|
||||
|
||||
```dart
|
||||
Future<QuotaResult> checkActiveHabitQuota({
|
||||
required String userId,
|
||||
required HabitType type, // build | break
|
||||
required HabitDao habitDao,
|
||||
String? excludeHabitId, // 자기 자신 제외 (status 변경 시)
|
||||
});
|
||||
|
||||
class QuotaResult {
|
||||
final bool ok;
|
||||
final int currentActiveCount;
|
||||
final int max; // build=3, break=1
|
||||
final String? reason; // ok=false 시
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 책임
|
||||
|
||||
R1 (build ≤ 3) 와 R2 (break ≤ 1) 강제. habit insert 또는 status 'paused'→'active' 변경 직전 호출.
|
||||
|
||||
## 3. 입력
|
||||
|
||||
| 파라미터 | 타입 | 제약/검증 | 설명 |
|
||||
|----------|------|-----------|------|
|
||||
| `userId` | `String` | not null | 'u_local_default' default |
|
||||
| `type` | `HabitType` enum | build / break | |
|
||||
| `habitDao` | DAO | | DB read |
|
||||
| `excludeHabitId` | `String?` | nullable | 이 ID 의 habit 은 카운트에서 제외 (자기 자신) |
|
||||
|
||||
## 4. 출력
|
||||
|
||||
- **반환**: `QuotaResult`.
|
||||
- `ok=true` → insert 진행.
|
||||
- `ok=false` → reason 메시지로 UI 차단.
|
||||
- **부수효과**: DB read only.
|
||||
|
||||
## 5. 동작 / 알고리즘
|
||||
|
||||
```
|
||||
1. max = (type == build) ? 3 : 1
|
||||
2. count = habitDao.countActive(userId, type, excludeHabitId)
|
||||
3. if count >= max:
|
||||
return QuotaResult(ok=false, count, max,
|
||||
reason='active ${type} habit 은 최대 ${max} 개입니다. 진행 중인 habit 1 개를 paused/completed/abandoned 로 변경 후 다시 시도하세요.')
|
||||
4. return QuotaResult(ok=true, count, max)
|
||||
```
|
||||
|
||||
### 5.1 SQL
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*) FROM habits
|
||||
WHERE user_id = :userId
|
||||
AND status = 'active'
|
||||
AND type = :type
|
||||
AND (:excludeHabitId IS NULL OR id != :excludeHabitId)
|
||||
```
|
||||
|
||||
`IDX_habits_user_status_type` 인덱스로 < 5 ms.
|
||||
|
||||
## 6. 에러 & 실패 모드
|
||||
|
||||
| 조건 | 처리 | 반환 |
|
||||
|------|------|------|
|
||||
| DAO 예외 | 상위로 throw (transactional rollback) | — |
|
||||
| `excludeHabitId` 가 존재하지 않음 | 무영향 (필터 조건만 적용) | ok=true (조건만 영향) |
|
||||
| 동시성 (다른 트랜잭션이 동시에 insert) | 본 Phase 단일 사용자 = race 사실상 0. 안전 보장은 호출자가 transaction 으로 quota check + insert 묶어 처리. | — |
|
||||
|
||||
## 7. 엣지케이스
|
||||
|
||||
- 사용자가 build 3 개 active 인 상태에서 새 build 시도 → reject.
|
||||
- 사용자가 break 1 개 active 인 상태에서 새 break 시도 → reject.
|
||||
- 사용자가 build 3 개 active 인 상태에서 새 **break** 시도 → ok (별도 quota).
|
||||
- status='paused' 인 habit 은 카운트 제외.
|
||||
- excludeHabitId = 자기 자신 (status 변경 시) → 정상 동작.
|
||||
|
||||
## 8. 복잡도 / 성능
|
||||
|
||||
- 시간: O(log N) (인덱스 lookup) + COUNT.
|
||||
- 호출 빈도: habit 생성/상태변경 시 1 회. 무시.
|
||||
|
||||
## 9. 의존성
|
||||
|
||||
- `HabitDao.countActive(userId, type, excludeHabitId)`.
|
||||
- Riverpod provider 로 호출 가능.
|
||||
|
||||
## 10. 테스트 케이스
|
||||
|
||||
- [ ] 신규 build, active 0 개 → ok, count=0, max=3
|
||||
- [ ] 신규 build, active 3 개 → reject
|
||||
- [ ] 신규 build, active 2 개 → ok, count=2
|
||||
- [ ] 신규 break, active 0 개 → ok
|
||||
- [ ] 신규 break, active 1 개 → reject
|
||||
- [ ] excludeHabitId 적용: active 3 중 1 개를 exclude → ok, count=2
|
||||
- [ ] paused habit 은 카운트 제외 (status='paused' 3 개 있어도 active 0 → ok)
|
||||
- [ ] 빈 DB → count=0, ok
|
||||
|
||||
## 11. 추적성
|
||||
|
||||
- 인수조건: #204 AC-7.
|
||||
- 관련 메모리: `feedback_sustainable_minimal.md` (build ≤ 3), `project_habit_breaking_module.md` (break ≤ 1).
|
||||
- 관련 schema: `habit.schema.json`, data-model.md §3 R1/R2.
|
||||
189
docs/design/204-flutter-bootstrap/fn-compute-streak.md
Normal file
189
docs/design/204-flutter-bootstrap/fn-compute-streak.md
Normal 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 가 잠정 정의, 사용자 확정 필요.
|
||||
117
docs/design/204-flutter-bootstrap/fn-recommend-variant.md
Normal file
117
docs/design/204-flutter-bootstrap/fn-recommend-variant.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# 함수 설계서: `recommendVariant` (#204)
|
||||
|
||||
> 부모 설계서: [README.md](./README.md) · 상태: Draft
|
||||
> 작성: [AI] Architect · 구현: `lib/domain/recommend/recommend_variant.dart::recommendVariant` (TBD)
|
||||
> 테스트: `test/domain/recommend/recommend_variant_test.dart` (TBD)
|
||||
|
||||
## 1. 시그니처
|
||||
|
||||
```dart
|
||||
VariantPick? recommendVariant(
|
||||
Habit habit,
|
||||
CheckInContext ctx,
|
||||
);
|
||||
|
||||
class CheckInContext {
|
||||
final String? location; // 예: '집', '짐', '출장' — null 가능 (1 탭 진행 중)
|
||||
final String? condition; // 예: '좋음', '보통', '나쁨'
|
||||
const CheckInContext({this.location, this.condition});
|
||||
}
|
||||
|
||||
class VariantPick {
|
||||
final HabitDoseVariant variant;
|
||||
final int score; // 0 ~ 5
|
||||
final String reason; // 'exact_match' | 'partial' | 'fallback_minimum' | 'fallback_first'
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 책임
|
||||
|
||||
habit 의 dose_variants 중 사용자의 현재 컨텍스트에 가장 잘 맞는 1 개를 점수 함수로 선택해 반환한다. 빈 결과(null) 는 호출자가 별도 처리.
|
||||
|
||||
## 3. 입력
|
||||
|
||||
| 파라미터 | 타입 | 제약/검증 | 설명 |
|
||||
|----------|------|-----------|------|
|
||||
| `habit` | `Habit` | not null. `dose_variants` 가 0 개여도 OK | habit + variants list 보유 |
|
||||
| `ctx.location` | `String?` | nullable | 사용자 1 탭 결과. null 이면 location 점수 0 |
|
||||
| `ctx.condition` | `String?` | nullable | 동일 |
|
||||
|
||||
## 4. 출력
|
||||
|
||||
- **반환**: `VariantPick?` — variants 가 비어있으면 `null`. 아니면 항상 non-null.
|
||||
- **부수효과**: 없음. **순수 함수**.
|
||||
|
||||
## 5. 동작 / 알고리즘
|
||||
|
||||
```
|
||||
1. variants = habit.dose_variants
|
||||
2. if variants.isEmpty: return null
|
||||
3. scored = variants.map(v => (v, scoreVariant(v, ctx)))
|
||||
4. scored.sort((a, b) =>
|
||||
b.score.compareTo(a.score) ?? a.v.sortOrder.compareTo(b.v.sortOrder))
|
||||
5. best = scored.first
|
||||
6. if best.score > 0:
|
||||
reason = best.score >= 4 ? 'exact_match' : 'partial'
|
||||
return VariantPick(best.v, best.score, reason)
|
||||
7. # score = 0 — fallback
|
||||
8. minimum = variants.firstWhere(v => v.is_minimum, orElse: variants.first)
|
||||
9. return VariantPick(minimum, 0,
|
||||
minimum.is_minimum ? 'fallback_minimum' : 'fallback_first')
|
||||
|
||||
function scoreVariant(v, ctx):
|
||||
s = 0
|
||||
if ctx.location != null && v.context_tags.contains(ctx.location): s += 2
|
||||
if ctx.condition != null && v.condition_tags.contains(ctx.condition): s += 2
|
||||
if v.is_minimum && ctx.condition == '나쁨': s += 1
|
||||
return s
|
||||
```
|
||||
|
||||
> **태그 매칭은 case-sensitive exact string**. 한국어 NFC normalize 는 호출자가 책임 (UI 가 chip 텍스트를 그대로 ctx 에 채워주므로 동일 폼).
|
||||
|
||||
## 6. 에러 & 실패 모드
|
||||
|
||||
| 조건 | 처리 | 반환 |
|
||||
|------|------|------|
|
||||
| `habit.dose_variants` empty | 즉시 null | null |
|
||||
| `ctx` 둘 다 null | 모든 variants score 0 → fallback 동작 | VariantPick(reason=fallback_*) |
|
||||
| score 동률 | `sortOrder` (ascending) 로 안정 정렬 | 정해진 1 개 |
|
||||
| `is_minimum` variant 다수 | 첫 발견 | (sortOrder 순) |
|
||||
|
||||
## 7. 엣지케이스
|
||||
|
||||
- variants 1 개: 항상 그 1 개 반환 (score 무관).
|
||||
- 모든 variants 가 동일 score: sortOrder 가장 작은 것.
|
||||
- ctx.condition = '나쁨' 인데 is_minimum variant 가 없음: 그냥 일반 점수 비교.
|
||||
- variant 의 context_tags 가 null/빈 배열: location 점수 0.
|
||||
- 본 함수는 stateless. 동일 입력 → 동일 출력 (테스트 결정성).
|
||||
|
||||
## 8. 복잡도 / 성능
|
||||
|
||||
- 시간: O(N) (N = variants 개수). 실용 N ≤ 10 → < 50 µs (Dart native).
|
||||
- 공간: O(N) (scored list).
|
||||
- 호출 빈도: 체크인 화면에서 location/condition chip 탭 시마다 1 회 (≤ 5 회/체크인). 무시 가능.
|
||||
|
||||
## 9. 의존성
|
||||
|
||||
- `Habit` 모델 (domain/models).
|
||||
- `HabitDoseVariant` 모델 (domain/models).
|
||||
- Drift / DB 미참조 — 순수 함수. (호출자가 미리 hydrate 한 habit 객체를 받음.)
|
||||
|
||||
## 10. 테스트 케이스
|
||||
|
||||
- [ ] 정상: variants=[gym(ctx=짐), home(ctx=집)], ctx={집,좋음} → home, score=2, reason='partial'
|
||||
- [ ] exact_match: variants=[gym(ctx=짐,cond=좋음)], ctx={짐,좋음} → gym, score=4, reason='exact_match'
|
||||
- [ ] fallback_minimum: variants=[main, tiny(is_minimum)], ctx={출장,나쁨} 어떤 태그도 매칭 안 됨 → tiny, score=0, reason='fallback_minimum'
|
||||
- [ ] fallback_first: variants=[a, b] is_minimum 둘 다 false, ctx 매칭 0 → a (sortOrder 첫 번째), reason='fallback_first'
|
||||
- [ ] empty: variants=[] → null
|
||||
- [ ] tie-break: score 동률 → sortOrder 작은 것
|
||||
- [ ] null ctx: ctx={null,null} → fallback 동작
|
||||
- [ ] '나쁨' 조건 + tiny variant: variants=[main(cond=좋음), tiny(is_minimum)], ctx={집,나쁨} → tiny, score=1
|
||||
- [ ] case-sensitive: ctx={'짐'}, tag=['gym'] → 매칭 X
|
||||
|
||||
## 11. 추적성
|
||||
|
||||
- 인수조건: #204 AC-11.
|
||||
- 관련 ADR: [ADR-0001](../../adr/0001-dose-variants.md) (R9 무제한 → score 함수 O(N) OK).
|
||||
- R8 (≤ 60 초): 본 함수 자체는 µs 수준. UI 가 R8 책임.
|
||||
137
docs/design/204-flutter-bootstrap/fn-seed-importer.md
Normal file
137
docs/design/204-flutter-bootstrap/fn-seed-importer.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# 함수 설계서: `SeedImporter.importIfNeeded` (#204)
|
||||
|
||||
> 부모 설계서: [README.md](./README.md) · 상태: Draft
|
||||
> 작성: [AI] Architect · 구현: `lib/data/seed/seed_importer.dart::SeedImporter.importIfNeeded` (TBD)
|
||||
> 테스트: `test/data/seed/seed_importer_test.dart` (TBD)
|
||||
|
||||
## 1. 시그니처
|
||||
|
||||
```dart
|
||||
class SeedImporter {
|
||||
SeedImporter(this._db, {this._assetLoader = const RootBundleLoader()});
|
||||
final AppDatabase _db;
|
||||
final AssetLoader _assetLoader;
|
||||
|
||||
Future<void> importIfNeeded(); // 멱등
|
||||
}
|
||||
|
||||
abstract class AssetLoader {
|
||||
Future<String> loadString(String path);
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 책임
|
||||
|
||||
앱 첫 실행 시 `assets/seed/*.json` 7 파일을 읽어 카탈로그 테이블 7 개 + `users('u_local_default')` 1 행을 batch insert. 두 번째 실행부터는 noop (멱등성).
|
||||
|
||||
## 3. 입력
|
||||
|
||||
| 파라미터 | 타입 | 제약/검증 | 설명 |
|
||||
|----------|------|-----------|------|
|
||||
| `_db` | `AppDatabase` | not null | open 된 Drift DB |
|
||||
| `_assetLoader` | `AssetLoader` | testable | prod = `RootBundleLoader`, test = fixture loader |
|
||||
|
||||
## 4. 출력
|
||||
|
||||
- **반환**: `Future<void>`. 성공/실패는 예외로 전달.
|
||||
- **부수효과**:
|
||||
- 카탈로그 7 테이블 insert (~150~180 rows 총합).
|
||||
- `users` 1 row insert.
|
||||
- `meta_kv['seeded_v1'] = 'true'` set.
|
||||
|
||||
## 5. 동작 / 알고리즘
|
||||
|
||||
```
|
||||
function importIfNeeded():
|
||||
1. existing = db.metaKvDao.find('seeded_v1')
|
||||
2. if existing?.value == 'true': return # 멱등 skip
|
||||
3. plan = [
|
||||
('references.json', References, _adaptReference),
|
||||
('protocols.json', Protocols, _adaptProtocol),
|
||||
('break_protocols.json', BreakProtocols, _adaptBreakProtocol),
|
||||
('common_frames.json', CommonFrames, _adaptCommonFrame),
|
||||
('methodologies.json', Methodologies, _adaptMethodology),
|
||||
('frame_patterns.json', FramePatterns, _adaptFramePattern),
|
||||
('reward_menu_items.json', RewardMenuItems, _adaptRewardMenuItem),
|
||||
]
|
||||
4. await db.transaction(() async:
|
||||
for (assetName, table, adapter) in plan:
|
||||
raw = await _assetLoader.loadString('assets/seed/' + assetName)
|
||||
items = jsonDecode(raw) as List<Map<String,dynamic>>
|
||||
companions = items.map(adapter).toList()
|
||||
await db.batch((b) => b.insertAll(table, companions, mode: InsertMode.insertOrReplace))
|
||||
|
||||
await _ensureLocalDefaultUser()
|
||||
await db.metaKvDao.put('seeded_v1', 'true')
|
||||
5. ) # transaction commit
|
||||
```
|
||||
|
||||
### 5.1 adapter 책임
|
||||
|
||||
각 `_adapt*` 함수는 `Map<String,dynamic>` (JSON) → Drift `Companion`.
|
||||
- nested object (예: `protocol.default_anchor`) → JSON string column.
|
||||
- array (예: `procedure[]`) → JSON string column.
|
||||
- enum string → 그대로 (CHECK 가 검증).
|
||||
- ULID validation 은 본 import 에선 skip (시드는 의미 식별자 사용, ULID 형식 X).
|
||||
|
||||
### 5.2 user 보장
|
||||
|
||||
```dart
|
||||
Future<void> _ensureLocalDefaultUser() async {
|
||||
await _db.into(_db.users).insertOnConflictUpdate(
|
||||
UsersCompanion.insert(
|
||||
id: 'u_local_default',
|
||||
createdAt: DateTime.now().toIso8601String(),
|
||||
locale: const Value('ko-KR'),
|
||||
timezone: const Value('Asia/Seoul'),
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 에러 & 실패 모드
|
||||
|
||||
| 조건 | 처리 | 결과 |
|
||||
|------|------|------|
|
||||
| asset 파일 누락 | rootBundle 예외 → 트랜잭션 rollback | `seeded_v1` flag 미설정. 다음 부팅에 재시도 |
|
||||
| JSON parse 실패 | `FormatException` → rollback | 동일 |
|
||||
| CHECK 제약 위반 (시드 데이터 오류) | sqlite 예외 → rollback | dev 가 시드 JSON 수정 + 재빌드 |
|
||||
| FK 무결성 위반 (예: protocol.reference_ids 가 references 에 없음) | 본 Phase 는 reference_ids 가 JSON 컬럼이라 FK 강제 없음. 단 catalog_dao 가 load 시 broken link 발견 시 warning log | warning only |
|
||||
| 트랜잭션 중간 device crash | 다음 부팅 시 flag false 상태 → 재시도. sqlite WAL 로 일관성 유지 | OK |
|
||||
|
||||
## 7. 엣지케이스
|
||||
|
||||
- 두 번째 호출 (이미 seeded) → metaKv read 1 회 + return. < 5 ms.
|
||||
- 다른 instance 가 동시 호출 (race): Drift 의 transaction 직렬화로 OK. 두 instance 중 하나가 먼저 seeded_v1=true 박으면 다른 instance 가 짧은 시간 안에 read → skip.
|
||||
- 시드 JSON 의 ID 중복: `InsertMode.insertOrReplace` 가 마지막 값 우선. dev 가 시드 정리 필요.
|
||||
- 빈 시드 파일 (`[]`): 해당 테이블 0 rows, flag 는 set. AC-2 의 "≥ 0 행" 통과.
|
||||
|
||||
## 8. 복잡도 / 성능
|
||||
|
||||
- 시간: 1 회 ~ 1 초 (JSON parse + 7 batch insert + 1 commit). 첫 부팅 1 회만.
|
||||
- 멱등 호출: < 5 ms.
|
||||
- 메모리: 시드 JSON 전체 RAM 적재 (~수백 KB). OK.
|
||||
|
||||
## 9. 의존성
|
||||
|
||||
- `AppDatabase` + 모든 카탈로그 테이블 + `meta_kv`.
|
||||
- `rootBundle` (Flutter asset loader).
|
||||
- `dart:convert.jsonDecode`.
|
||||
- adapter 함수들 (`_adaptProtocol`, etc.).
|
||||
|
||||
## 10. 테스트 케이스
|
||||
|
||||
- [ ] 정상 first-run: fixture 7 파일 → 모든 카탈로그 시드 + users 1 row + flag set
|
||||
- [ ] 멱등 second-run: 이미 seeded → noop, no DB writes
|
||||
- [ ] 파일 누락 simulate: protocols.json 없음 → 예외 + 전체 rollback + flag 미설정
|
||||
- [ ] JSON malformed: protocols.json = "not json" → FormatException + rollback
|
||||
- [ ] CHECK 위반: protocol.category='invalid' → sqlite 예외 + rollback
|
||||
- [ ] 빈 배열: protocols.json = "[]" → 0 rows + flag set + 멱등 두 번째 호출 OK
|
||||
- [ ] users insertOnConflictUpdate: 이미 같은 id 있어도 conflict 없음
|
||||
- [ ] flag 강제 reset 후 호출 → 재import (시드 변경 시뮬레이션)
|
||||
|
||||
## 11. 추적성
|
||||
|
||||
- 인수조건: #204 AC-2, AC-3, AC-15.
|
||||
- 관련 설계서: [05-seed-data.md](./05-seed-data.md), [04-migrations.md](./04-migrations.md).
|
||||
- 시드 SoT: 4 마크다운 (`huberman-protocols.md`, `habit-todo-methodologies.md`, `habit-breaking-protocols.md`, `nutrition/diet-protocols.md`).
|
||||
147
docs/design/204-flutter-bootstrap/fn-validate-frame-level.md
Normal file
147
docs/design/204-flutter-bootstrap/fn-validate-frame-level.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# 함수 설계서: `validateFrameLevel` (#204)
|
||||
|
||||
> 부모 설계서: [README.md](./README.md) · 상태: Draft
|
||||
> 작성: [AI] Architect · 구현: `lib/domain/frame/validate_frame_level.dart::validateFrameLevel` (TBD)
|
||||
> 테스트: `test/domain/frame/validate_frame_level_test.dart` (TBD)
|
||||
|
||||
## 1. 시그니처
|
||||
|
||||
```dart
|
||||
FrameValidationResult validateFrameLevel(
|
||||
FrameInput input, {
|
||||
required Iterable<FramePattern> knownPatterns, // catalog 에서 미리 load
|
||||
});
|
||||
|
||||
class FrameInput {
|
||||
final FrameLevel level; // L0/L1/L2/L3
|
||||
final String? originalText;
|
||||
final String framedText;
|
||||
}
|
||||
|
||||
class FrameValidationResult {
|
||||
final FrameValidationStatus status; // accept / warn / reject
|
||||
final List<AvoidanceHit> avoidanceHits; // R7 회피 키워드 검출 결과
|
||||
final List<FrameSuggestion> suggestions; // L2/L3 변환 제안
|
||||
}
|
||||
|
||||
enum FrameValidationStatus { accept, warn, reject }
|
||||
|
||||
class AvoidanceHit {
|
||||
final String keyword;
|
||||
final int startIndex;
|
||||
final int endIndex;
|
||||
final FramePattern source;
|
||||
}
|
||||
|
||||
class FrameSuggestion {
|
||||
final FrameLevel level; // L2 or L3
|
||||
final String text;
|
||||
final FramePattern source;
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 책임
|
||||
|
||||
사용자가 입력한 frame 의 (1) level 이 L2/L3 가드를 통과하는지 (R3) 검사하고, (2) framed_text 안의 회피 키워드를 detect 해 변환 제안을 반환한다 (R7).
|
||||
|
||||
## 3. 입력
|
||||
|
||||
| 파라미터 | 타입 | 제약/검증 | 설명 |
|
||||
|----------|------|-----------|------|
|
||||
| `input.level` | `FrameLevel` enum | not null | L0 이면 reject (R3) |
|
||||
| `input.framedText` | `String` | not empty | 사용자 최종 문장 |
|
||||
| `input.originalText` | `String?` | nullable | L0 였던 원본 (audit) |
|
||||
| `knownPatterns` | `Iterable<FramePattern>` | catalog 에서 load | 회피 키워드 사전 |
|
||||
|
||||
## 4. 출력
|
||||
|
||||
- **반환**: `FrameValidationResult`.
|
||||
- status:
|
||||
- `reject` — L0/L1 입력. UI 는 모달로 L2/L3 변환 제안 표시 후 사용자 선택.
|
||||
- `warn` — L2/L3 이지만 회피 키워드 감지 (예: "안 ..." , "끊기"). UI 는 inline warning + 변환 제안. 사용자가 '계속' 선택 가능.
|
||||
- `accept` — 통과.
|
||||
- avoidanceHits — UI 가 underline 표시.
|
||||
- suggestions — frame_pattern.l2_suggestion / l3_identity 에서 가져온 대체 텍스트.
|
||||
- **부수효과**: 없음. **순수 함수**.
|
||||
|
||||
## 5. 동작 / 알고리즘
|
||||
|
||||
```
|
||||
1. if input.level in (L0, L1):
|
||||
suggestions = buildSuggestions(input.framedText, knownPatterns)
|
||||
return reject(suggestions, hits=[])
|
||||
|
||||
2. hits = detectAvoidanceKeywords(input.framedText, knownPatterns)
|
||||
3. if hits.isEmpty:
|
||||
return accept
|
||||
|
||||
4. # L2/L3 인데 회피 키워드 감지 — warn
|
||||
suggestions = buildSuggestions(input.framedText, knownPatterns, hits)
|
||||
return warn(suggestions, hits)
|
||||
|
||||
function detectAvoidanceKeywords(text, patterns) -> List<AvoidanceHit>:
|
||||
hits = []
|
||||
for p in patterns:
|
||||
idx = text.indexOf(p.avoidance_keyword)
|
||||
while idx >= 0:
|
||||
hits.push(AvoidanceHit(keyword=p.avoidance_keyword, start=idx,
|
||||
end=idx+keyword.length, source=p))
|
||||
idx = text.indexOf(p.avoidance_keyword, idx+1)
|
||||
return hits.distinctBy((h)->(h.start, h.keyword))
|
||||
|
||||
function buildSuggestions(text, patterns, hits=null) -> List<FrameSuggestion>:
|
||||
relevant = hits != null
|
||||
? hits.map(h => h.source).distinct()
|
||||
: patterns.filter(p => text.contains(p.avoidance_keyword))
|
||||
return relevant.expand((p) -> [
|
||||
FrameSuggestion(L2, p.l2_suggestion, p),
|
||||
if (p.l3_identity != null) FrameSuggestion(L3, p.l3_identity, p),
|
||||
]).take(5).toList() # UI 부담 위해 최대 5 개
|
||||
```
|
||||
|
||||
> 회피 키워드 사전은 `frame_patterns.json` 시드 (~30 항목). 'general' 도메인이 default 매칭 풀, 특정 domain (food/drink/...) 은 habit 의 protocol category 로 필터링 가능 (v2 최적화).
|
||||
|
||||
## 6. 에러 & 실패 모드
|
||||
|
||||
| 조건 | 처리 | 반환 |
|
||||
|------|------|------|
|
||||
| `framedText` empty | reject + suggestions=[] | reject |
|
||||
| `level` enum 외 값 | 호출자 보장 — 도달 불가 | — |
|
||||
| `knownPatterns` empty | accept (사전 없으면 R7 skip) | accept |
|
||||
| 회피 키워드가 다른 단어의 부분 일치 (예: "안전" 안에 "안") | false positive 발생. 본 phase 는 substring 매칭. 단어 경계 처리는 v2 (한국어 형태소 분석 필요). | warn (사용자가 '계속' 선택) |
|
||||
|
||||
## 7. 엣지케이스
|
||||
|
||||
- "끊기" 가 "끊기는 게 아니라" 안에 포함 → hit. warn. 사용자가 '계속'.
|
||||
- L3 ("나는 무알콜인 사람이다") + 회피 키워드 "무알콜" — '무X' 패턴이 사전에 있다면 hit. 사용자가 '계속'.
|
||||
- L2 + 회피 키워드 없음 → accept.
|
||||
- L0 + 회피 키워드 없음 → reject + L2 일반 제안 ('이걸 대신 해보세요').
|
||||
|
||||
## 8. 복잡도 / 성능
|
||||
|
||||
- 시간: O(N × M), N = patterns 수 (~30), M = text 길이 (≤ 100). worst ≈ 3000 char ops → < 100 µs.
|
||||
- 공간: O(hits).
|
||||
- 호출 빈도: habit 생성 폼에서 입력 변경 시 debounced (300 ms). 무시.
|
||||
|
||||
## 9. 의존성
|
||||
|
||||
- `FramePattern` 모델 (domain/models).
|
||||
- `FrameLevel` enum.
|
||||
- catalog DAO 가 startup 에 patterns 를 한 번 load 해서 Riverpod provider 로 공유.
|
||||
|
||||
## 10. 테스트 케이스
|
||||
|
||||
- [ ] accept: L2, "기상 직후 햇빛 10 분 받기" → status=accept, hits=[]
|
||||
- [ ] reject (L0): L0, "햇빛 안 빼먹기" → status=reject, suggestions 포함 L2/L3
|
||||
- [ ] reject (L1): L1, "햇빛 챙기기" → status=reject (L2 으로 변환 제안)
|
||||
- [ ] warn: L2, "술 끊기 (조금만)" — "끊기" hit → status=warn, suggestions
|
||||
- [ ] accept (L3 클린): L3, "나는 아침 햇빛 사람이다" → accept
|
||||
- [ ] empty framedText → reject
|
||||
- [ ] empty patterns → accept (회피 검출 skip)
|
||||
- [ ] 다중 hits: "끊기 안 하기" → 2 개 hits, suggestions ≤ 5
|
||||
|
||||
## 11. 추적성
|
||||
|
||||
- 인수조건: #204 AC-8 (R3 reject), AC 보강 (R7 warn).
|
||||
- 관련 메모리: `feedback_positive_framing.md` (L2/L3 강제).
|
||||
- 관련 schema: `frame_pattern.schema.json`, `habit.schema.json` (frame.level).
|
||||
129
docs/design/204-flutter-bootstrap/fn-weekly-minimum-ratio.md
Normal file
129
docs/design/204-flutter-bootstrap/fn-weekly-minimum-ratio.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# 함수 설계서: `weeklyMinimumRatio` (#204)
|
||||
|
||||
> 부모 설계서: [README.md](./README.md) · 상태: Draft
|
||||
> 작성: [AI] Architect · 구현: `lib/domain/streak/weekly_minimum_ratio.dart::weeklyMinimumRatio` (TBD)
|
||||
> 테스트: `test/domain/streak/weekly_min_ratio_test.dart` (TBD)
|
||||
|
||||
## 1. 시그니처
|
||||
|
||||
```dart
|
||||
Future<double?> weeklyMinimumRatio({
|
||||
required String userId,
|
||||
required DateTime weekStart, // 월요일 00:00 KST (week 시작 정의)
|
||||
String? habitId, // null = user 전체 / 지정 = 해당 habit 만
|
||||
required TrackerDao trackerDao,
|
||||
required HabitDoseVariantDao variantDao,
|
||||
});
|
||||
```
|
||||
|
||||
## 2. 책임
|
||||
|
||||
해당 주(7 일)의 `tracker_entry` 중 `value='done'` 인 항목들에서 `variant_id` → `habit_dose_variant.is_minimum=true` 비율을 계산한다 (R10).
|
||||
|
||||
## 3. 입력
|
||||
|
||||
| 파라미터 | 타입 | 제약/검증 | 설명 |
|
||||
|----------|------|-----------|------|
|
||||
| `userId` | `String` | not null | 단일 사용자 → 'u_local_default' |
|
||||
| `weekStart` | `DateTime` | 월요일 00:00 (KST) | 7 일 윈도우 시작 |
|
||||
| `habitId` | `String?` | nullable | null = user 전체 habits 합산, 값 = 해당 habit 만 |
|
||||
| `trackerDao` | DAO | | DB 조회용 |
|
||||
| `variantDao` | DAO | | variant.is_minimum 조회용 |
|
||||
|
||||
## 4. 출력
|
||||
|
||||
- **반환**:
|
||||
- `done count == 0` → `null` (UI: "이번 주 done 없음")
|
||||
- 그 외 → `0.0 ≤ ratio ≤ 1.0`
|
||||
- **부수효과**: DB read only. 쓰기 없음.
|
||||
|
||||
> 본 함수는 I/O 가 있지만 도메인 의도가 명확해 domain layer 에 둔다. DAO 인터페이스로 mock 가능.
|
||||
|
||||
## 5. 동작 / 알고리즘
|
||||
|
||||
```
|
||||
1. weekEnd = weekStart + 7 days (exclusive)
|
||||
2. # 윈도우 안 done entries 조회
|
||||
entries = trackerDao.findDoneInRange(
|
||||
userId, weekStart, weekEnd, habitId? )
|
||||
3. doneCount = entries.length
|
||||
4. if doneCount == 0: return null
|
||||
5. # variant_id 별 is_minimum 조회 (batch)
|
||||
variantIds = entries
|
||||
.where(e => e.variantId != null)
|
||||
.map(e => e.variantId).toSet()
|
||||
variants = variantDao.findByIds(variantIds)
|
||||
minimumSet = variants.where(v => v.isMinimum).map(v => v.variantId).toSet()
|
||||
6. minimumCount = entries.where(e =>
|
||||
e.variantId != null && minimumSet.contains(e.variantId)).length
|
||||
# variant_id 가 null 인 entry (variant 없는 habit) 는 is_minimum=false 로 취급
|
||||
7. return minimumCount / doneCount
|
||||
```
|
||||
|
||||
### 5.1 user 전체 합산 모드
|
||||
|
||||
- `habitId == null` → 모든 habits 합산. 카운트 단위 = entry 1 건 (habit 별 가중치 없음).
|
||||
- 한 user 가 build 3 + break 1 = 최대 4 habits → 주당 done entry 최대 28 건.
|
||||
|
||||
### 5.2 SQL
|
||||
|
||||
```sql
|
||||
SELECT te.variant_id, te.habit_id
|
||||
FROM tracker_entries te
|
||||
JOIN habits h ON h.id = te.habit_id
|
||||
WHERE h.user_id = :userId
|
||||
AND te.value = 'done'
|
||||
AND te.date >= :weekStart
|
||||
AND te.date < :weekEnd
|
||||
AND (:habitId IS NULL OR te.habit_id = :habitId)
|
||||
```
|
||||
|
||||
`IDX_tracker_date` 인덱스로 가속. 7 일 윈도우 → < 50 ms.
|
||||
|
||||
## 6. 에러 & 실패 모드
|
||||
|
||||
| 조건 | 처리 | 반환 |
|
||||
|------|------|------|
|
||||
| done = 0 | null | (정의대로) |
|
||||
| `weekStart` 이 월요일이 아님 | 호출자 책임. 함수는 받은 그대로 7 일 윈도우 적용 | (계산은 함) |
|
||||
| variant 가 모두 삭제됨 (orphan variant_id) | `findByIds` 결과 비어 → minimumCount=0 → ratio = 0 | 0.0 |
|
||||
| variant_id 가 NULL 인 entries 만 | minimumCount=0 → ratio=0 | 0.0 |
|
||||
| 같은 entries 가 두 번 join | UNIQUE INDEX 가 보장 → 도달 불가 | — |
|
||||
|
||||
## 7. 엣지케이스
|
||||
|
||||
- 모든 done 이 is_minimum=true → 1.0.
|
||||
- 모든 done 이 is_minimum=false → 0.0.
|
||||
- variants 없는 habit 의 done → variant_id NULL → ratio 분모에 들어가지만 분자엔 0.
|
||||
- `weekStart` 가 미래 → entries 0 → null.
|
||||
- timezone 차이로 같은 날짜가 두 주에 걸치는 케이스: weekStart 가 KST 월요일 0 시 → date 컬럼 (YYYY-MM-DD) 기준 비교라 timezone 무관.
|
||||
|
||||
## 8. 복잡도 / 성능
|
||||
|
||||
- 시간: O(D + V), D = 주간 entries 수 (≤ 28), V = unique variants (≤ 28). 무시 가능.
|
||||
- 공간: O(D + V).
|
||||
- 호출 빈도: 주간 reflection 화면 진입 시 1 회. 무시 가능.
|
||||
|
||||
## 9. 의존성
|
||||
|
||||
- `TrackerDao.findDoneInRange` (`data/db/daos/tracker_dao.dart`).
|
||||
- `HabitDoseVariantDao.findByIds`.
|
||||
- 둘 다 interface 로 추상화 → 테스트는 in-memory fake.
|
||||
|
||||
## 10. 테스트 케이스
|
||||
|
||||
- [ ] 정상 1: 주간 done 6, minimum 3 → 0.5
|
||||
- [ ] 정상 2: 주간 done 7, minimum 7 → 1.0
|
||||
- [ ] 정상 3: 주간 done 7, minimum 0 → 0.0
|
||||
- [ ] done 0 → null
|
||||
- [ ] variant_id NULL 만 → 0.0
|
||||
- [ ] habitId 지정 모드 — 해당 habit 만 카운트
|
||||
- [ ] week 경계: weekStart=2026-06-08 (월) 00:00, entries date=2026-06-08 ~ 2026-06-14 inclusive
|
||||
- [ ] orphan variant_id (variant 삭제 후) → 0
|
||||
- [ ] 미래 주 → null
|
||||
|
||||
## 11. 추적성
|
||||
|
||||
- 인수조건: #204 AC-13.
|
||||
- 관련 ADR: [ADR-0001](../../adr/0001-dose-variants.md) (R10 — hint only).
|
||||
- 06-ux-contracts.md §5 의 표시 트리거가 본 함수 결과를 소비.
|
||||
Reference in New Issue
Block a user