[Developer] #204 Phase 1 MVP — Flutter app skeleton complete

- Drift 21 tables (8 catalog + 11 user + habit_dose_variants + meta_kv)
  with R1~R10 CHECK constraints and 19 indexes
- 8 hand-crafted seed JSON catalogs in app/assets/seed/
  (refs 84, protocols 34, methodologies 21, frame_patterns 30,
   reward_menu_items 30, break_protocols 8, common_frames 5, diet_patterns 5)
- 6 domain functions: recommend_variant, compute_streak,
  validate_frame_level, active_habit_quota, weekly_minimum_ratio,
  seed_importer (transactional, idempotent)
- 4 vertical-slice Riverpod screens: HabitList, HabitCreate, CheckIn, Streak
- 31 unit tests passing; flutter analyze clean
- OQ-5 streak semantics: missing entry ≠ explicit blank
  (missing = end of history; only TrackerValue.blank triggers Never-miss-twice)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 10:33:03 +09:00
parent 29befe4d97
commit 8fe6a8f378
76 changed files with 29059 additions and 0 deletions

11
.gitignore vendored
View File

@@ -33,3 +33,14 @@ target/
# --- sibling repos (managed separately) --- # --- sibling repos (managed separately) ---
# nutrition/은 별도 Gitea repo (joungmin/nutrition). life-helper와 sibling 관계. # nutrition/은 별도 Gitea repo (joungmin/nutrition). life-helper와 sibling 관계.
nutrition/ nutrition/
# --- intermediate working dirs ---
# seed-staging/은 시드 JSON 작성 작업 공간. 정본은 app/assets/seed/.
seed-staging/
# --- flutter build artifacts (under app/) ---
app/.dart_tool/
app/build/
app/.flutter-plugins
app/.flutter-plugins-dependencies
app/.idea/

45
app/.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

30
app/.metadata Normal file
View File

@@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "c9a6c484230f8b5e408ec57be1ef71dee1e77020"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
- platform: android
create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

17
app/README.md Normal file
View File

@@ -0,0 +1,17 @@
# life_helper
Huberman + Atomic Habits + Tiny Habits + If-Then. Local-first habit/checklist/todo.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

28
app/analysis_options.yaml Normal file
View File

@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

14
app/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@@ -0,0 +1,45 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "kr.cloud_handson.life_helper"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "kr.cloud_handson.life_helper"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="life_helper"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,5 @@
package kr.cloud_handson.life_helper
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip

View File

@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
}
include(":app")

View File

@@ -0,0 +1,65 @@
# SEED-NOTES — life-helper seed extraction
Generated: 2026-06-12. Source-of-Truth files extracted into 8 JSON seeds at `seed-staging/`.
## Row counts
| File | Entries |
|---|---|
| `references.json` | 84 |
| `protocols.json` | 34 |
| `break_protocols.json` | 8 |
| `common_frames.json` | 5 |
| `methodologies.json` | 21 |
| `frame_patterns.json` | 30 |
| `reward_menu_items.json` | 30 |
| `diet_patterns.json` | 5 |
## Validation status
- Required-field violations: **0**
- `additionalProperties: false` violations: **0**
- Enum violations (RewardTier, FrameDomain, EvidenceStrength, huberman_fit_score 1..5, BreakCategory): **0**
- Orphaned `reference_ids[]` (pointing to non-existent reference): **0**
- Orphaned `default_common_frames[]` (break → common): **0**
- Orphaned `linked_protocol_ids[]` (diet pattern → protocol): **0**
- Orphaned `applicable_break_categories[]` (common → BreakCategory): **0**
## Unused references (5)
These references are valid but not yet cited from any entry because their hosting schemas
(`frame_pattern`, `reward_menu_item`) do not currently expose a `reference_ids[]` field.
They back the language-framing and reward-system theoretical foundation. Keep them — do
not prune.
- `ref_book_lakoff_elephant` — Lakoff (2004) *Don't Think of an Elephant!* → frame_patterns rationale
- `ref_doi_10_1037_0022_3514_53_1_5` — Wegner et al. (1987) White Bear / ironic process → frame_patterns + urge_surf
- `ref_doi_10_1037_0033_2909_125_6_627` — Deci, Koestner & Ryan (1999) overjustification meta → reward_menu_items
- `ref_doi_10_1146_annurev_psych_122414_033417` — Wood & Rünger (2016) habit conditioning → reward_menu_items
- `ref_doi_10_1207_s15326985ep3403_3` — Elliot (1999) approach vs avoidance → frame_patterns
## Decisions / notes
1. **Korean preserved verbatim.** Section symbols (§), em-dashes, full-width punctuation
left as in source. No translation or normalization of body text.
2. **`omit` not `null`.** Missing optional fields are absent (not present with `null`).
3. **Methodology `is_core_engine: true`** applied only to `atomic_habits`,
`tiny_habits`, `implementation_intentions` per project memory
(`feedback_sustainable_minimal.md`).
4. **Break protocols** mapped the 4-week SoT structure into the schema's `phases[]`
array with `week`, `goal`, `environment_design`, `if_then_examples`.
5. **`frame_patterns.json`** sourced from the "흔한 끊기 목표 변환 30선" table at
`habit-todo-methodologies.md` lines 922955. `domain` enum chosen from
{food, drink, smoking, screen, porn, sleep, exercise, general}.
6. **`reward_menu_items.json`** sourced from "권장 리워드 메뉴 30선" lines 804844.
`tier_recommended` mapped T0T4. `avoid_for_break_habits` populated where the
reward involves caffeine/sugar/alcohol so it can collide with an active break habit.
7. **`diet_patterns.json`** kept to the 5 patterns explicitly enumerated in
`diet-protocols.md` §2.1§2.5. TRE/IF body in source is a stub pointer to §1.5;
description was reconstructed from that section. `linked_protocol_ids[]` cross-links
to the six `category: "diet"` protocols in `protocols.json`.
8. **No sections skipped.** All §1§5 of every SoT are represented in some entry.
§6 (한국어 해설자) of `diet-protocols.md` is intentionally not seeded — it is a
user-fillable slot per project memory (`project_nutrition_module.md`).
9. **`reference_ids[]` ID style** used: `ref_doi_<doi-with-_>`, `ref_url_<slug>`,
`ref_book_<author>_<slug>`, `ref_podcast_<show>_<ep>_<slug>`.

View File

@@ -0,0 +1,284 @@
[
{
"id": "alcohol",
"category": "alcohol",
"title": "알코올 끊기",
"huberman_summary": "'안전한 음주량은 없다' — 주당 ≥ 2잔에서도 회백질 감소 등 측정 가능한 변화 (Topiwala 2017, Daviet 2022). 코티솔 baseline 상승, REM 수면 파괴, 장-뇌축 손상.",
"frame_examples": [
{ "level": "L2", "text": "평일 저녁 21시 이후 탄산수 + 라임 의식" },
{ "level": "L2", "text": "회식 시 첫 잔 무알콜로 시작하기" },
{ "level": "L3", "text": "나는 평일 무알콜인 사람이다" }
],
"phases": [
{
"week": 1,
"goal": "주간 잔 수 50% 감축",
"environment_design": "집에 술 1병만 보관",
"if_then_examples": ["If 18시, then 탄산수+라임"]
},
{
"week": 2,
"goal": "평일 0잔",
"environment_design": "평일용 무알콜 음료 5종 준비",
"if_then_examples": ["If 회식, then 첫잔 무알콜"]
},
{
"week": 3,
"goal": "주말 1일만, ≤ 2잔",
"environment_design": "술 → 작은 잔으로 교체",
"if_then_examples": ["If 두 잔째 충동, then 90초 urge surf"]
},
{
"week": 4,
"goal": "30일 전체 0잔 (도파민 리셋)",
"environment_design": "모든 술 집 밖으로",
"if_then_examples": ["If 결혼식 등, then 사전 계획"]
}
],
"default_common_frames": ["dopamine_reset", "urge_surf", "environment_design", "relapse_recovery", "recovery_stack"],
"medical_warning": "매일 다량 음주자(예: 일일 ≥ 4-5잔 × 수년)의 갑작스러운 중단은 진전·발작·DT로 사망 가능. 반드시 의사 감독 하 점진 감량.",
"reference_ids": ["ref_doi_10_1136_bmj_j2353", "ref_doi_10_1038_s41467_022_28735_5"]
},
{
"id": "nicotine",
"category": "nicotine",
"title": "니코틴 끊기 (담배·전자담배·파우치)",
"huberman_summary": "니코틴 자체는 인지 부스터지만 전달체(담배·vape)의 독성과 도파민 강화 곡선이 문제. Quit aids 효과 순위: Varenicline > NRT 조합 > NRT 단독 > 의지력 단독 (Cochrane 메타분석).",
"frame_examples": [
{ "level": "L2", "text": "휴식 시간엔 밖에서 5분 호흡 + 햇빛" },
{ "level": "L3", "text": "나는 폐가 깨끗한 러너다" }
],
"phases": [
{
"week": 1,
"goal": "Quit Date 정하기 (2~4주 후) + 트리거 매핑",
"if_then_examples": ["If 식후 충동, then 5분 산책"]
},
{
"week": 2,
"goal": "약물 보조 시작 (의사 상담)",
"environment_design": "Varenicline / Bupropion / NRT (패치+껌 조합)"
},
{
"week": 3,
"goal": "Quit Date — 첫 72시간 풀스택",
"environment_design": "라이터·재떨이·담배 잔여 모두 제거",
"if_then_examples": ["If 충동, then NSDR + 운동 + 햇빛"]
},
{
"week": 4,
"goal": "2주~3개월 점진 감소",
"if_then_examples": ["If 커피 시간 충동, then cyclic sighing"]
}
],
"default_common_frames": ["dopamine_reset", "urge_surf", "environment_design", "relapse_recovery", "recovery_stack"],
"tools": ["Varenicline (Champix)", "Bupropion", "NRT 패치+껌 조합"],
"medical_warning": "심혈관 질환·임신 시 약물 보조(Varenicline 등) 처방 의사 상담 우선.",
"reference_ids": ["ref_doi_10_1002_14651858_CD013229_pub2"]
},
{
"id": "porn_masturbation",
"category": "porn_masturbation",
"title": "포르노·강박적 자위 줄이기",
"huberman_summary": "초자극(supernormal stimuli)은 도파민 peak 후 baseline을 깎는다. frequency · intensity · novelty 세 축 모두를 동시에 제공하는 것이 문제. 자위 자체는 일반적으로 정상 행동, 문제는 빈도·강박성·기능 손상.",
"frame_examples": [
{ "level": "L2", "text": "주말 아침 운동 후 1회까지 (개인 합의된 빈도)" },
{ "level": "L2", "text": "혼자만의 시간엔 샤워 + 독서 30분" },
{ "level": "L3", "text": "나는 실제 친밀감을 우선하는 사람이다" }
],
"phases": [
{
"week": 1,
"goal": "DNS 필터 + 라우터 차단",
"environment_design": "NextDNS Family + Cloudflare 1.1.1.1 for Families"
},
{
"week": 2,
"goal": "침실 디바이스 분리",
"environment_design": "충전은 거실에서"
},
{
"week": 3,
"goal": "30일 dopamine reset (절제)"
},
{
"week": 4,
"goal": "novelty 자극 영구 차단, 본인 관계 또는 단순 자기자극만 의식적 재도입"
}
],
"default_common_frames": ["dopamine_reset", "urge_surf", "environment_design", "relapse_recovery"],
"tools": ["NextDNS", "Cloudflare 1.1.1.1 for Families", "CleanBrowsing", "iOS Screen Time", "Android Family Link", "Cold Turkey", "Covenant Eyes", "Accountable2You"],
"medical_warning": "no-fap 류 커뮤니티의 비과학적 주장(테스토스테론 1000% 증가 등)은 근거 없음. Huberman은 absolute abstinence가 아닌 자기조절·강박성 감소·관계 우선을 강조.",
"reference_ids": ["ref_doi_10_1111_add_13297", "ref_podcast_hl_39_dopamine"]
},
{
"id": "social_media",
"category": "social_media",
"title": "SNS·숏폼·스마트폰 강박 줄이기",
"huberman_summary": "짧은 간격의 도파민 스파이크는 daytime baseline을 가장 빠르게 깎는다. 숏폼은 unpredictable reward schedule로 가장 강한 학습 곡선.",
"frame_examples": [
{ "level": "L2", "text": "출근길 30분 오디오북 듣기" },
{ "level": "L2", "text": "취침 1시간 전 종이책 10분" },
{ "level": "L3", "text": "나는 깊이 집중하는 사람이다" }
],
"phases": [
{
"week": 1,
"goal": "앱 → 웹만 사용",
"environment_design": "Instagram·TikTok·YouTube 숏폼 앱 삭제"
},
{
"week": 2,
"goal": "홈스크린 1페이지 화이트리스트 (8개 이하)",
"environment_design": "흑백 모드 상시 (Settings → Accessibility → Color Filters)"
},
{
"week": 3,
"goal": "스크린타임 일일 30분 → 2주마다 -10분",
"if_then_examples": ["If 손이 폰으로 가면, then 3회 깊은 호흡 후 의도 라벨링"]
},
{
"week": 4,
"goal": "물리적 분리",
"environment_design": "침실 외부 충전, 책상에서 1m 이상"
}
],
"default_common_frames": ["dopamine_reset", "urge_surf", "environment_design", "relapse_recovery", "recovery_stack"],
"tools": ["iOS Screen Time", "Android Digital Wellbeing", "Cold Turkey", "Freedom", "흑백 모드"],
"reference_ids": ["ref_doi_10_1257_aer_20190658"]
},
{
"id": "sugar",
"category": "sugar",
"title": "설탕·초가공 식품 줄이기",
"huberman_summary": "액상 과당이 가장 강하게 도파민·간 대사 동시 타격. 설탕 자극은 단백질·지방으로 대체 가능 — 포만감 신호 자체가 잘 살아남.",
"frame_examples": [
{ "level": "L2", "text": "오후 3시엔 그릭요거트 + 베리 (단맛 + 단백질 + 폴리페놀)" },
{ "level": "L2", "text": "디저트는 외식 시에만 나눠 먹기" },
{ "level": "L3", "text": "나는 인슐린 감수성 좋은 사람" }
],
"phases": [
{
"week": 1,
"goal": "액상 과당 0순위 제거 (주스·소다·시럽 라떼)",
"environment_design": "집·책상 가시성 제거"
},
{
"week": 2,
"goal": "단백질-우선 30g 룰",
"if_then_examples": ["If 단맛 충동, then 단백질 30g 먼저 + 15분 대기"]
},
{
"week": 3,
"goal": "장보기 룰 정착",
"environment_design": "식후에만, 리스트만, 외곽(신선식품) 우선"
},
{
"week": 4,
"goal": "30일 reset 후 의식적 재도입 (주 1회 외식 디저트)"
}
],
"default_common_frames": ["dopamine_reset", "environment_design", "relapse_recovery"],
"reference_ids": ["ref_doi_10_1002_oby_21371", "ref_url_who_sugar_2015"]
},
{
"id": "caffeine",
"category": "caffeine",
"title": "카페인 의존 감량",
"huberman_summary": "카페인 자체는 strategic하게 쓰면 인지·운동 도움. 문제는 기상 직후 섭취 (cortisol·아데노신 mismatch)와 14시 이후 섭취 (수면 파괴).",
"frame_examples": [
{ "level": "L2", "text": "기상 후 90~120분 후 첫 커피" },
{ "level": "L2", "text": "14시 이후 디카페인" },
{ "level": "L2", "text": "오후 첫 음료 = 녹차 (L-theanine 동반)" }
],
"phases": [
{
"week": 1,
"goal": "평소 양 유지, 시간만 90분 지연 + 14시 컷오프",
"if_then_examples": ["If 기상 알람, then 물+소금 먼저"]
},
{
"week": 2,
"goal": "양 25% 감소 또는 1잔을 디카페인으로 대체"
},
{
"week": 3,
"goal": "추가 25% 감소"
},
{
"week": 4,
"goal": "안정화 (두통 등 금단은 보통 3-7일에 소실)"
}
],
"default_common_frames": ["urge_surf", "environment_design"],
"tools": ["디카페인 커피", "녹차 (L-theanine 동반)", "허브차"],
"reference_ids": ["ref_podcast_hl_101_caffeine", "ref_doi_10_5664_jcsm_3170"]
},
{
"id": "cannabis",
"category": "cannabis",
"title": "대마 끊기",
"huberman_summary": "THC는 PFC·해마·도파민 회로에 측정 가능한 영향. 청소년기 시작은 정신증 위험 증가 (Di Forti 2019). 빈도 사용자의 cessation은 첫 72시간 수면 파괴·짜증 peak.",
"frame_examples": [
{ "level": "L2", "text": "저녁 의식은 카모마일 차 + 책 30분" },
{ "level": "L3", "text": "나는 REM 수면 좋은 사람" }
],
"phases": [
{
"week": 1,
"goal": "30일 reset 시작 + 첫 72시간 풀스택",
"environment_design": "모든 사용 도구 제거",
"if_then_examples": ["If 짜증/수면 문제, then 운동 + NSDR + cyclic sighing"]
},
{
"week": 2,
"goal": "Trigger 패턴 매핑 (시간·사람·장소)"
},
{
"week": 3,
"goal": "회복 스택 정착"
},
{
"week": 4,
"goal": "30일 후 의식적 결정 — 빈도·맥락 사전 합의된 경우에만 재도입 또는 영구 중단"
}
],
"default_common_frames": ["dopamine_reset", "urge_surf", "environment_design", "relapse_recovery", "recovery_stack"],
"medical_warning": "정신증·양극성·심각한 불안 병력 있을 시 의료진 상담.",
"reference_ids": ["ref_doi_10_1016_S2215_0366_19_30048_3"]
},
{
"id": "behavioral",
"category": "behavioral",
"title": "행동 중독 (도박·쇼핑·게임)",
"huberman_summary": "가변비율 강화(variable ratio reinforcement)가 가장 강한 학습 곡선. 행동 중독은 물질 중독과 같은 도파민 회로 (Lembke).",
"frame_examples": [
{ "level": "L2", "text": "주말 오전엔 자전거 90분" },
{ "level": "L3", "text": "나는 저축률 높은 사람" },
{ "level": "L3", "text": "나는 PvE 협동 게이머" }
],
"phases": [
{
"week": 1,
"goal": "자금/계정 분리",
"environment_design": "도박 self-exclusion 등록 + 신용카드 등록 삭제 + 게임 결제 차단"
},
{
"week": 2,
"goal": "앱 삭제 + 알림 차단",
"if_then_examples": ["If 구매 충동, then 장바구니에 24시간 두기"]
},
{
"week": 3,
"goal": "30일 reset"
},
{
"week": 4,
"goal": "대체 dopamine source (운동·소셜·창작) + 재도입 시 한도 사전 선언"
}
],
"default_common_frames": ["dopamine_reset", "urge_surf", "environment_design", "relapse_recovery", "recovery_stack"],
"medical_warning": "한국도박문제예방치유원 1336.",
"reference_ids": ["ref_doi_10_1098_rstb_2008_0100", "ref_book_lembke_dopamine_nation"]
}
]

View File

@@ -0,0 +1,87 @@
[
{
"id": "dopamine_reset",
"title": "도파민 리셋 (Dopamine Fast/Reset)",
"what": "30일간 표적 행동(및 관련 자극)을 완전 제거하여 D2/D3 수용체 민감도와 도파민 baseline을 회복.",
"why": "중독성 자극은 베이스라인 도파민의 상대적 결손(deficit)을 만든다 (pleasure-pain balance). 며칠로는 부족하고 약 4주가 인간에서 baseline 복원 평균치 (Lembke). 첫 10~14일이 가장 힘들다 (withdrawal peak).",
"dose": "최소 30일 연속. 1회 lapse 시 카운트 리셋(엄격) 또는 'Never miss twice'(완화) — 사용자가 사전 선택.",
"how": [
"D-3일: 모든 트리거(앱·물건·계정·구독·접근권) 사전 제거 또는 차단.",
"D-1일: If-Then 카드 3개 작성. 예: 'If 평일 22시 충동, then 30분 산책 + 차가운 물 한 잔.'",
"D1~D14: 매일 1회 짧은 NSDR (10~20분) — withdrawal기 자율신경 안정.",
"D15~D30: T1(3회 스트릭)·T2(주) 마일스톤 보상 적용.",
"D31: 재평가. (a) 영구 단절, (b) 의식적 재도입(다른 변수·맥락에서), (c) 연장 중 선택."
],
"check": "○/공백 2값 트래커. 매일 30초.",
"applicable_break_categories": ["alcohol", "nicotine", "porn_masturbation", "social_media", "sugar", "cannabis", "behavioral"],
"reference_ids": ["ref_book_lembke_dopamine_nation", "ref_doi_10_1016_j_neuropharm_2008_05_022"]
},
{
"id": "urge_surf",
"title": "충동 서핑 (Urge Surfing)",
"what": "충동을 억누르거나 싸우지 않고 파도처럼 관찰하며 통과시킨다. 평균 충동의 신경학적 peak는 90초~몇 분이며 능동적 거부 없이 흘러간다.",
"why": "사고 억제(suppression)는 ironic process로 충동 강도를 키운다 (Wegner). 관찰 기반 수용(mindfulness-based)이 재발률을 유의하게 낮춤 (Bowen 2014).",
"dose": "1회 90초~5분. 하루 충동 발생 횟수만큼.",
"how": [
"충동 인지 → 멈춤. '지금 충동 한 파도가 왔다'고 라벨링.",
"신체 감각 스캔: 어디에 느낌이 있나? (가슴·목·복부·손) 강도 0~10.",
"호흡 5회 — cyclic sighing (이중 들숨 + 긴 날숨) 권장.",
"60~90초 관찰. 강도 재측정. 보통 절반 이하로 감소.",
"사전 If-Then 행동으로 전환 (산책, 물 마시기, 사람에게 메시지 등)."
],
"check": "트래커에 '충동 N회 / 통과 N회' 기록. 비율이 시간 따라 상승.",
"applicable_break_categories": ["alcohol", "nicotine", "porn_masturbation", "social_media", "sugar", "caffeine", "cannabis", "behavioral"],
"reference_ids": ["ref_doi_10_1001_jamapsychiatry_2013_4546", "ref_doi_10_1037_0033_295X_101_1_34", "ref_doi_10_1016_j_xcrm_2022_100895"]
},
{
"id": "environment_design",
"title": "환경 디자인 (Environment Design / Self-Binding)",
"what": "충동에 의지하지 않고 물리·디지털 환경에서 표적 행동의 마찰을 극대화 (Atomic Habits 4법칙 역적용: Invisible · Difficult).",
"why": "의지력은 한정 자원이며 환경이 행동을 결정하는 비율이 훨씬 크다 (Wood & Neal 2007). Lembke의 self-binding = 미래 자신을 보호하기 위해 현재 자신이 장벽을 설계.",
"dose": "한 번 세팅 + 분기 1회 점검.",
"how": [
"물질(술/담배/대마): 집·차에서 완전 제거 + 단골 매장 변경 + 동거인에게 알림.",
"포르노/자위: DNS 차단(NextDNS·Cloudflare Family) + 라우터 레벨 + 침실에서 단말 분리.",
"SNS/숏폼: 앱 삭제(웹만 사용) + 스크린타임 제한 + 홈스크린 1페이지 화이트리스트 + 흑백 모드.",
"설탕: 집·책상에서 제거 + 식료품은 식후에만 구매 + 충동 시 단백질 30g 먼저.",
"카페인: 디카페인 옵션 항상 가시 + 14시 이후 카페인 무자동 (kitchen rule)."
],
"check": "'환경 점수' 자가 평가 (1-5). 매주 1회.",
"applicable_break_categories": ["alcohol", "nicotine", "porn_masturbation", "social_media", "sugar", "caffeine", "cannabis", "behavioral"],
"reference_ids": ["ref_doi_10_1037_0033_295X_114_4_843", "ref_book_clear_atomic_habits", "ref_book_lembke_dopamine_nation"]
},
{
"id": "relapse_recovery",
"title": "재발 복구 프로토콜 (LEARN)",
"what": "1회 lapse를 재앙화하지 않고 데이터로 처리해 다음 24시간 안에 복귀.",
"why": "AVE(Abstinence Violation Effect, Marlatt) — '한 번 무너졌으니 끝났다' 인지가 본격 폭주로 이어지는 진짜 원인. 1회는 데이터, 2회 연속이 패턴.",
"dose": "lapse 후 24시간 안에 5단계 완료.",
"how": [
"Label — 'lapse 발생. 실패가 아닌 데이터.' (자기비판 차단)",
"Examine — 직전 HALT 체크: Hungry · Angry · Lonely · Tired 중 무엇이 있었나?",
"Antecedent — 트리거 3개 적기 (시간/장소/사람/감정/직전 행동).",
"Replan — If-Then 1개 추가 또는 환경 디자인 1개 추가.",
"Next — 다음 1회 즉시 실행(같은 시간대 또는 24시간 안). 'Never miss twice.'"
],
"check": "lapse 일지 (날짜 + LEARN 5칸). 월 1회 패턴 리뷰.",
"applicable_break_categories": ["alcohol", "nicotine", "porn_masturbation", "social_media", "sugar", "caffeine", "cannabis", "behavioral"],
"reference_ids": ["ref_book_marlatt_relapse_prevention", "ref_doi_10_1037_0003_066X_59_4_224"]
},
{
"id": "recovery_stack",
"title": "회복 스택 (Recovery Stack)",
"what": "끊기 기간 동안 도파민 baseline 회복과 자율신경 안정을 동시에 지원하는 5종 행동 묶음.",
"why": "끊기는 결손만 만들지 않게 하려면 충전원이 필요. Huberman의 자연 도파민 부스터들은 baseline을 깎지 않고 받쳐줌.",
"dose": "끊기 기간 동안 매일 5종.",
"how": [
"아침 햇빛 10~30분 — 도파민·코티솔 정상화.",
"운동 (Zone 2 30분 또는 근력 20분) — BDNF·도파민·세로토닌.",
"저녁 NSDR/Yoga Nidra 10~20분.",
"Cyclic sighing 5분/일 — 자율신경 안정·기분 개선.",
"소셜 컨택 1회 — Lembke의 'honest 1-person check-in', radical honesty가 회복률 예측인자."
],
"check": "5칸 일일 트래커. 끊기 기간 동안만.",
"applicable_break_categories": ["alcohol", "nicotine", "porn_masturbation", "social_media", "sugar", "cannabis", "behavioral"],
"reference_ids": ["ref_podcast_hl_84_sleep_toolkit", "ref_doi_10_1016_j_xcrm_2022_100895"]
}
]

View File

@@ -0,0 +1,119 @@
[
{
"id": "mediterranean",
"name": "지중해 식단",
"core": "통곡물·채소·과일·올리브유·생선·견과류·콩류 + 적정 와인(생략 가능).",
"strengths": [
"RCT 강도 최상 (PREDIMED 심혈관 사건 30% 감소).",
"한국인 식문화와 부분 호환 (생선·채소·콩).",
"장기 지속률 높음."
],
"weaknesses": [
"올리브유 비용·접근성.",
"와인 권장 항목은 알코올 끊기 사용자와 충돌."
],
"evidence_strength": "strong",
"korean_context_fit": "medium",
"starter_levers": [
"올리브유를 주 조리유로 전환.",
"주 2~3회 생선 식단.",
"간식을 견과류 한 줌으로 대체."
],
"linked_protocol_ids": ["fiber_intake", "omega3", "protein_first"],
"reference_ids": ["ref_doi_10_1056_NEJMoa1800389"]
},
{
"id": "low_carb_keto",
"name": "저탄수 / 케토",
"core": "탄수 < 20-50g/일 (keto) 또는 < 130g/일 (low-carb).",
"strengths": [
"HbA1c·중성지방 단기 개선 강함.",
"단기 체중 감량·인슐린 감수성 개선."
],
"weaknesses": [
"장기 지속률 낮음 (1~2년 후 타 식단과 큰 차이 없음).",
"식이섬유·미세영양소 부족 위험.",
"고강도 운동 사용자에게 손해."
],
"evidence_strength": "mixed",
"korean_context_fit": "low",
"starter_levers": [
"액상 과당 0순위 제거.",
"흰쌀·면을 채소·단백질로 부분 대체.",
"간식 탄수를 견과류·치즈로 전환."
],
"medical_warning": "당뇨 약물 복용자는 저혈당 위험 — 의사 상담 필수. 신장질환·간질환 사용자 금기 가능.",
"linked_protocol_ids": ["protein_first", "refined_sugar_minimize"]
},
{
"id": "tre_if",
"name": "시간 제한 식사 (TRE / IF)",
"core": "하루 식사 창을 8~12시간으로 제한하고 나머지 시간은 단식.",
"strengths": [
"취침 전 식사 차단으로 수면 질 개선.",
"체중·인슐린 감수성 개선 RCT 다수.",
"음식 종류 제한 없음 — 진입 마찰 낮음."
],
"weaknesses": [
"당뇨 약물·임신·섭식장애 병력 사용자 위험.",
"초기 1~2주 적응기 두통·집중력 저하.",
"근육량 우선 사용자는 단백질 분배 필요."
],
"evidence_strength": "moderate",
"korean_context_fit": "high",
"starter_levers": [
"취침 3시간 전 마지막 식사 컷오프.",
"14:10 시작 → 점진적 16:8.",
"단식 창에는 물·전해질만."
],
"medical_warning": "당뇨·임신·섭식장애 병력자는 의사 상담 우선.",
"linked_protocol_ids": ["meal_timing_tre"]
},
{
"id": "plant_based",
"name": "식물성 위주 (Plant-Based / 비건)",
"core": "동물성 식품 최소화, 통곡물·콩류·견과류·채소·과일 중심.",
"strengths": [
"전체 사망률·CVD 감소 (Satija 2017).",
"식이섬유·항산화 자연 풍부.",
"환경·윤리적 정합성."
],
"weaknesses": [
"B12·omega-3·아연·철·Vit D 보충 계획 필수.",
"단백질 leucine threshold 도달이 어려울 수 있음.",
"'unhealthful plant-based'(정제 곡물·당) 함정."
],
"evidence_strength": "moderate",
"korean_context_fit": "medium",
"starter_levers": [
"주 2~3끼 식물성 단백질(두부·콩·렌틸) 우선.",
"B12·omega-3 보충 시작.",
"통곡물 50% 이상 비율."
],
"linked_protocol_ids": ["fiber_intake", "omega3", "protein_first"],
"reference_ids": ["ref_doi_10_1016_j_jacc_2017_05_047"]
},
{
"id": "k_diet",
"name": "한식 기반 (Traditional Korean / K-Diet)",
"core": "밥+국+나물+발효식품(김치·된장)+생선+적정 단백질.",
"strengths": [
"발효식품 다양성으로 장 미생물 부양.",
"채소 비중·저지방 단백질(생선·두부) 우수.",
"한국 사용자 접근성 최상."
],
"weaknesses": [
"첫 식사 단백질 부족.",
"나트륨 과다 (KNHANES 평균 > 3500mg/일).",
"정제 흰쌀 비중 높음."
],
"evidence_strength": "moderate",
"korean_context_fit": "high",
"starter_levers": [
"첫 끼 단백질 +30g (계란·두부·낫토).",
"잡곡·현미 50% 이상 비율.",
"국·찌개 짠맛 ↓ + 김치 양 조절."
],
"linked_protocol_ids": ["protein_first", "fiber_intake", "refined_sugar_minimize"]
}
]

View File

@@ -0,0 +1,242 @@
[
{
"id": "snack_quit",
"domain": "food",
"avoidance_keyword": "끊기",
"l0_example": "과자 끊기",
"l2_suggestion": "오후 4시 견과류 한 줌 + 물 한 잔",
"l3_identity": "오후 4시 에너지 충전 의식"
},
{
"id": "late_night_eating_quit",
"domain": "food",
"avoidance_keyword": "끊기",
"l0_example": "야식 끊기",
"l2_suggestion": "저녁 8시 마지막 식사 종료 + 따뜻한 차",
"l3_identity": "잠을 위한 저녁 마감"
},
{
"id": "alcohol_reduce",
"domain": "drink",
"avoidance_keyword": "줄이기",
"l0_example": "술 줄이기",
"l2_suggestion": "평일 저녁 탄산수 + 라임 한 조각",
"l3_identity": "또렷한 아침을 가지는 사람"
},
{
"id": "smoking_quit",
"domain": "smoking",
"avoidance_keyword": "끊기",
"l0_example": "담배 끊기",
"l2_suggestion": "흡연 충동 시 5분 산책 + 호흡 1분",
"l3_identity": "호흡이 깊은 사람"
},
{
"id": "caffeine_reduce",
"domain": "drink",
"avoidance_keyword": "줄이기",
"l0_example": "카페인 줄이기",
"l2_suggestion": "오후 2시 이후 루이보스/허브차",
"l3_identity": "깊이 자는 사람"
},
{
"id": "oversleep_avoid",
"domain": "sleep",
"avoidance_keyword": "안",
"l0_example": "늦잠 안 자기",
"l2_suggestion": "기상 즉시 햇빛 5분 (§Huberman 1.1)",
"l3_identity": "아침을 여는 사람"
},
{
"id": "overtime_stop",
"domain": "general",
"avoidance_keyword": "그만",
"l0_example": "야근 그만하기",
"l2_suggestion": "저녁 7시 노트북 닫기 + 산책 20분",
"l3_identity": "회복을 우선하는 사람"
},
{
"id": "smartphone_stop",
"domain": "screen",
"avoidance_keyword": "그만",
"l0_example": "스마트폰 그만 보기",
"l2_suggestion": "화장실/이동 시간엔 책 1쪽 읽기",
"l3_identity": "깊이 읽는 사람"
},
{
"id": "instagram_quit",
"domain": "screen",
"avoidance_keyword": "끊기",
"l0_example": "인스타그램 끊기",
"l2_suggestion": "평일 아침 30분 일기 + 산책",
"l3_identity": "자기 삶을 사는 사람"
},
{
"id": "youtube_shorts_avoid",
"domain": "screen",
"avoidance_keyword": "안",
"l0_example": "유튜브 쇼츠 안 보기",
"l2_suggestion": "점심 후 NSDR 10분 (§Huberman 2.2)",
"l3_identity": "회복하는 사람"
},
{
"id": "gaming_reduce",
"domain": "screen",
"avoidance_keyword": "줄이기",
"l0_example": "게임 줄이기",
"l2_suggestion": "주 3회 운동 1시간 (§Huberman 1.6)",
"l3_identity": "몸을 쓰는 사람"
},
{
"id": "procrastination_stop",
"domain": "general",
"avoidance_keyword": "그만",
"l0_example": "미루기 그만",
"l2_suggestion": "매일 가장 어려운 일 1개 Phase 1 배치 (§4.1)",
"l3_identity": "먼저 끝내는 사람"
},
{
"id": "binge_eating_avoid",
"domain": "food",
"avoidance_keyword": "안",
"l0_example": "폭식 안 하기",
"l2_suggestion": "매끼 단백질 30g+ 먼저 (§1.9)",
"l3_identity": "영양을 챙기는 사람"
},
{
"id": "snacking_avoid",
"domain": "food",
"avoidance_keyword": "안",
"l0_example": "군것질 안 하기",
"l2_suggestion": "점심 식사에 단백질·채소 충분히",
"l3_identity": "든든하게 먹는 사람"
},
{
"id": "cursing_avoid",
"domain": "general",
"avoidance_keyword": "안",
"l0_example": "욕 안 하기",
"l2_suggestion": "짜증 순간 cyclic sigh 1분 (§2.3)",
"l3_identity": "차분한 사람"
},
{
"id": "anger_avoid",
"domain": "general",
"avoidance_keyword": "않기",
"l0_example": "화내지 않기",
"l2_suggestion": "욱할 때 60초 호흡 + 한 박자 쉼",
"l3_identity": "호흡으로 대응하는 사람"
},
{
"id": "anxiety_stop",
"domain": "general",
"avoidance_keyword": "멈추기",
"l0_example": "불안 멈추기",
"l2_suggestion": "불안 순간 box breathing 2분 (§2.4)",
"l3_identity": "자기 신경을 다루는 사람"
},
{
"id": "worry_avoid",
"domain": "general",
"avoidance_keyword": "안",
"l0_example": "걱정 안 하기",
"l2_suggestion": "걱정 떠오르면 종이에 한 줄 적기",
"l3_identity": "글로 정리하는 사람"
},
{
"id": "negative_thoughts_avoid",
"domain": "general",
"avoidance_keyword": "안",
"l0_example": "부정적 생각 안 하기",
"l2_suggestion": "감사 한 줄 일기 (§리워드 T0-7)",
"l3_identity": "기록하는 사람"
},
{
"id": "self_blame_avoid",
"domain": "general",
"avoidance_keyword": "안",
"l0_example": "자책 안 하기",
"l2_suggestion": "실패 후 self-compassion 발화 (Neff)",
"l3_identity": "자기에게 친절한 사람"
},
{
"id": "comparison_avoid",
"domain": "general",
"avoidance_keyword": "안",
"l0_example": "비교 안 하기",
"l2_suggestion": "아침에 자기 진척 1줄 기록",
"l3_identity": "어제의 나와 겨루는 사람"
},
{
"id": "exercise_procrastination",
"domain": "exercise",
"avoidance_keyword": "미루기",
"l0_example": "운동 미루기",
"l2_suggestion": "화·목 7시 Zone 2 30분 (캘린더 못박기)",
"l3_identity": "매주 움직이는 사람"
},
{
"id": "reading_avoid",
"domain": "general",
"avoidance_keyword": "안",
"l0_example": "책 안 읽기",
"l2_suggestion": "자기 전 1쪽 (Tiny Habits)",
"l3_identity": "1쪽씩 쌓는 사람"
},
{
"id": "english_study_procrastination",
"domain": "general",
"avoidance_keyword": "미루기",
"l0_example": "영어 공부 미루기",
"l2_suggestion": "출근길 팟캐스트 10분",
"l3_identity": "매일 듣는 사람"
},
{
"id": "tidy_avoid",
"domain": "general",
"avoidance_keyword": "안",
"l0_example": "정리 안 하기",
"l2_suggestion": "자기 전 책상 1분 리셋",
"l3_identity": "다음 날을 준비하는 사람"
},
{
"id": "late_night_sns_avoid",
"domain": "screen",
"avoidance_keyword": "안",
"l0_example": "새벽 SNS 안 보기",
"l2_suggestion": "침대 옆 책 1권 비치, 폰은 거실 충전",
"l3_identity": "침실을 자는 곳으로만 쓰는 사람"
},
{
"id": "phone_bedside_avoid",
"domain": "sleep",
"avoidance_keyword": "X",
"l0_example": "폰 충전 침대 옆 X",
"l2_suggestion": "폰은 거실 충전 + 아날로그 알람",
"l3_identity": "아침을 햇빛으로 시작하는 사람"
},
{
"id": "time_waste_stop",
"domain": "general",
"avoidance_keyword": "그만",
"l0_example": "단순 시간낭비 그만",
"l2_suggestion": "오후 슬럼프 시 NSDR 20분 (§2.2)",
"l3_identity": "회복으로 응답하는 사람"
},
{
"id": "impulse_buy_reduce",
"domain": "general",
"avoidance_keyword": "줄이기",
"l0_example": "충동구매 줄이기",
"l2_suggestion": "장바구니에 24시간 두기 룰",
"l3_identity": "하루 자고 결정하는 사람"
},
{
"id": "early_rise_fail",
"domain": "sleep",
"avoidance_keyword": "실패",
"l0_example": "일찍 일어나기 실패",
"l2_suggestion": "고정 기상 시각 + 즉시 햇빛 (§1.4, 1.1)",
"l3_identity": "빛으로 깨는 사람"
}
]

View File

@@ -0,0 +1,441 @@
[
{
"id": "franklin_planner",
"name": "Franklin Planner / 7 Habits Time Matrix",
"originator": "Hyrum W. Smith (1984) / Stephen R. Covey (1989)",
"one_line_definition": "개인 가치 → 역할 → 목표 → 일일 계획으로 내려오는 톱다운 종이 플래너 + 긴급/중요 4사분면.",
"core_unit": "일일 플래너 페이지 + 4사분면 매트릭스",
"procedure": [
"자기 핵심 가치(Governing Values) 정의.",
"역할(부모, PM, 개발자 등) 단위로 주간 목표 설정.",
"매일 아침/전날 밤 일일 페이지에 A/B/C 우선순위로 할 일 배치.",
"매 항목을 4사분면 중 하나로 분류 — Q2(중요·비긴급) 시간을 확보하는 게 핵심.",
"주간 리뷰로 가치-역할-실제 시간 사용의 정렬을 점검."
],
"tools": ["Franklin Planner 종이 양식", "FranklinCovey 앱", "Notion 템플릿"],
"strengths": ["가치/사명에서 일일 작업까지 계층 구조가 명확", "Q2 사고가 장기적 중요 활동을 의식화"],
"weaknesses": ["양식 학습 곡선이 가파름", "'긴급/중요' 판단이 주관적"],
"good_for": "다역할 리더/관리자, 장기 가치를 일일 일정과 정렬하고 싶은 사람",
"huberman_fit_score": 3,
"is_core_engine": false,
"reference_ids": ["ref_book_covey_7habits"]
},
{
"id": "gtd",
"name": "GTD — Getting Things Done",
"originator": "David Allen (2001)",
"one_line_definition": "머릿속의 모든 미결 사안을 외부 시스템에 캡처 → 의미 부여 → 정리 → 점검 → 실행으로 처리하는 5단계 워크플로우.",
"core_unit": "Inbox + Next Actions + Projects + Waiting For + Someday/Maybe 리스트 + Contexts",
"procedure": [
"Capture: 떠오르는 모든 것을 Inbox에 던진다.",
"Clarify: 항목별로 '이게 뭔가? 행동 필요한가?' 결정. 2분 이내면 즉시 처리.",
"Organize: Next Actions / Projects / Waiting For / Someday로 분류, Context 태그.",
"Reflect: 매일 Next Actions 보고, 주간 리뷰로 시스템 전체 갱신.",
"Engage: 컨텍스트·시간·에너지·우선순위 기준으로 다음 행동 선택."
],
"tools": ["Todoist", "OmniFocus", "Things 3", "Notion GTD 템플릿"],
"strengths": ["'마음의 RAM'을 비워 불안 감소 효과", "어떤 작업 유형에도 적용되는 보편적 워크플로우"],
"weaknesses": ["시스템 유지 자체에 시간이 든다", "우선순위 자체 결정 메커니즘이 약함"],
"good_for": "다양한 프로젝트·외부 요청에 시달리는 지식 노동자",
"huberman_fit_score": 3,
"is_core_engine": false,
"reference_ids": ["ref_book_allen_gtd"]
},
{
"id": "ivy_lee_method",
"name": "Ivy Lee Method",
"originator": "Ivy Lee (1918)",
"one_line_definition": "매일 저녁 내일 할 가장 중요한 6가지를 적고, 다음 날 1번부터 순서대로만 처리.",
"core_unit": "종이 메모 한 장 + 6개 항목 번호 리스트",
"procedure": [
"업무 종료 시점에 내일 가장 중요한 작업 6가지를 적는다.",
"진짜 중요도 순서로 정렬.",
"다음 날 1번부터 시작, 끝낼 때까지 다음으로 넘어가지 않는다.",
"못 끝낸 항목은 다음 날 새 6개 리스트에 다시 평가하여 넣는다.",
"매일 반복."
],
"tools": ["인덱스 카드", "포스트잇", "임의의 노트 앱"],
"strengths": ["극단적 단순성 — 학습 0분", "싱글태스킹 강제 → 컨텍스트 스위칭 감소"],
"weaknesses": ["회의 위주·인터럽트 많은 직무에는 비현실적", "우선순위 산정 자체에 대한 도움 없음"],
"good_for": "산만함이 가장 큰 적인 사람, 도구 학습 비용을 최소화하고 싶은 사람",
"huberman_fit_score": 3,
"is_core_engine": false
},
{
"id": "eat_that_frog",
"name": "Eat That Frog",
"originator": "Brian Tracy (2001)",
"one_line_definition": "가장 크고 싫은(=가장 중요한) 작업 '개구리'를 아침 첫 작업으로 처리해서 미루기를 깨라.",
"core_unit": "일일 1개 (혹은 2개) 'Frog' 작업",
"procedure": [
"전날 밤 또는 아침에 가장 중요한·미루고 싶은 1개 작업 = 'Frog'를 선정.",
"다른 일을 시작하기 전 그 작업부터 한다.",
"두 마리라면 못생긴(=더 큰) 개구리 먼저.",
"끝낼 때까지 다른 작업 시작 금지.",
"같은 패턴을 매일 반복하여 의지력 의존도를 낮춘다."
],
"tools": ["어떤 투두 앱이든"],
"strengths": ["의사결정 비용 거의 0", "도파민 회피 패턴(쉬운 일부터 처리) 차단"],
"weaknesses": ["Frog 선정 기준 자체에 대한 안내는 약함", "아침이 어려운 사람에겐 적용 시점 다를 수 있음"],
"good_for": "미루기·회피가 가장 큰 적인 사람, 아침에 인지자원이 최고인 사람",
"huberman_fit_score": 4,
"is_core_engine": false,
"reference_ids": ["ref_book_tracy_eat_that_frog"]
},
{
"id": "eisenhower_matrix",
"name": "Eisenhower Matrix",
"originator": "Dwight Eisenhower (1954) / Stephen Covey (1989)",
"one_line_definition": "모든 할 일을 긴급도 × 중요도 4사분면으로 분류해 행동 지침(Do/Schedule/Delegate/Delete)을 부여.",
"core_unit": "4사분면 매트릭스",
"procedure": [
"오늘 할 일 전체를 적는다.",
"각 항목을 긴급/비긴급, 중요/비중요로 평가.",
"Q1=즉시 / Q2=캘린더에 배치 / Q3=위임 / Q4=삭제.",
"Q2 비율을 늘리는 것이 장기 전략.",
"매주 한 번 분포 점검."
],
"tools": ["Todoist 우선순위 라벨", "Eisenhower.me", "Notion 2×2 보드"],
"strengths": ["직관적이고 시각적", "'긴급해 보여서 일하는' 함정 인식에 강함"],
"weaknesses": ["중요도 판단 기준이 없으면 모두 Q1로 몰림", "분류만 하고 실행은 다른 시스템에 의존"],
"good_for": "회의·이메일·요청이 폭주하는 사람",
"huberman_fit_score": 3,
"is_core_engine": false,
"reference_ids": ["ref_book_covey_7habits"]
},
{
"id": "pomodoro",
"name": "Pomodoro Technique",
"originator": "Francesco Cirillo (1987)",
"one_line_definition": "25분 집중 + 5분 휴식 = 1 Pomodoro, 4 Pomodoro마다 긴 휴식.",
"core_unit": "25분 타이머 블록",
"procedure": [
"작업 선택, 타이머 25분.",
"타이머가 끝날 때까지 그 작업만 한다.",
"5분 휴식.",
"4회마다 긴 휴식.",
"일일 완료 Pomodoro 수 기록."
],
"tools": ["물리 타이머", "Focus To-Do", "Forest", "Toggl Track"],
"strengths": ["시작 마찰을 낮추는 데 탁월", "작업 시간을 정량화 → 추정 능력 향상"],
"weaknesses": ["깊은 몰입(2~4시간)을 25분에 끊으면 비효율", "회의·코칭 등 외부 동기화 일에는 부적합"],
"good_for": "ADHD 경향, 시작이 어려운 사람, 학생/연구자",
"huberman_fit_score": 4,
"is_core_engine": false,
"reference_ids": ["ref_book_cirillo_pomodoro"]
},
{
"id": "atomic_habits",
"name": "Atomic Habits",
"originator": "James Clear (2018)",
"one_line_definition": "습관을 Cue → Craving → Response → Reward 4단계로 분해하고, 각각에 4가지 법칙(Obvious/Attractive/Easy/Satisfying)을 설계.",
"core_unit": "개별 미세 습관 + Habit Stack",
"procedure": [
"만들고 싶은 습관을 정의.",
"1법칙(Make it Obvious): 시간·장소 명시 + 환경 디자인.",
"2법칙(Make it Attractive): 좋아하는 활동과 묶기(temptation bundling).",
"3법칙(Make it Easy): 2분 룰로 진입 마찰 ↓.",
"4법칙(Make it Satisfying): 즉시 보상 + Habit Tracker로 시각화."
],
"tools": ["Habit Tracker (종이/Notion/Streaks)", "James Clear 공식 템플릿"],
"strengths": ["행동과학 기반의 명확한 설계 도구", "정체성 변화 프레임이 강력"],
"weaknesses": ["너무 많은 습관 동시 설계 → 운영 부담", "'1% 개선'이 만능처럼 오해되기 쉬움"],
"good_for": "습관 빌딩을 시스템적으로 접근하고 싶은 사람",
"huberman_fit_score": 5,
"is_core_engine": true,
"reference_ids": ["ref_book_clear_atomic_habits"]
},
{
"id": "tiny_habits",
"name": "Tiny Habits",
"originator": "BJ Fogg (2009 / 2019)",
"one_line_definition": "행동 = Motivation × Ability × Prompt (B=MAP). 동기에 의존하지 말고 능력을 극단적으로 쉽게 + 기존 행동을 anchor로.",
"core_unit": "Anchor → Tiny Behavior → Celebration (ABC) 한 줄 레시피",
"procedure": [
"원하는 결과를 작은 행동들로 분해.",
"각 행동을 30초 이내로 축소 (예: '푸시업 1회').",
"기존 일과(Anchor)를 찾아 'After I [anchor], I will [tiny behavior]' 문장 작성.",
"행동 직후 즉시 축하(주먹 쥐기, 'Yes!' 등) — 감정으로 습관 회로 강화.",
"자연스럽게 양/시간을 증가시키되, 작게 시작 원칙은 유지."
],
"tools": ["Tiny Habits Method 무료 5일 프로그램", "종이 레시피 카드"],
"strengths": ["동기 변동에 가장 강함 (motivation-proof)", "'감정 보상'의 신경과학적 메커니즘 명시"],
"weaknesses": ["'정말 그 정도만 해도 되나' 회의감", "큰 행동으로의 확장은 본인 재량"],
"good_for": "의지력이 약하다고 느끼는 사람",
"huberman_fit_score": 5,
"is_core_engine": true,
"reference_ids": ["ref_book_fogg_tiny_habits"]
},
{
"id": "dont_break_the_chain",
"name": "Don't Break the Chain",
"originator": "Brad Isaac (2007) / Jerry Seinfeld 일화",
"one_line_definition": "매일 한 행동을 했으면 달력에 X 표시 → 연속 streak의 시각적 압박이 동기.",
"core_unit": "1년 전체가 보이는 큰 벽 달력 + 빨간 X",
"procedure": [
"매일 할 단일 습관 1개 선택.",
"큰 벽 달력 + 굵은 마커 준비.",
"행동한 날은 X.",
"사슬이 길어질수록 끊지 않으려는 동기 ↑.",
"끊겼다면 즉시 새 사슬 시작 — 죄책감보다 재시작에 초점."
],
"tools": ["종이 달력", "Streaks (iOS)", "HabitNow", "Way of Life"],
"strengths": ["시각적 피드백이 즉각적 — 도파민 강화", "도구·학습 비용 ≈ 0"],
"weaknesses": ["1번 끊긴 날의 박탈감 → 전부 포기 위험 (what-the-hell effect)", "단일 습관만 추적"],
"good_for": "단일 습관(글쓰기, 운동, 명상 등)에 집중하는 사람",
"huberman_fit_score": 4,
"is_core_engine": false
},
{
"id": "power_of_habit",
"name": "The Power of Habit (CueRoutineReward Loop)",
"originator": "Charles Duhigg (2012)",
"one_line_definition": "모든 습관은 Cue → Routine → Reward 신경학적 루프이며, Cue와 Reward는 유지하고 Routine만 교체하는 게 변화의 핵심.",
"core_unit": "습관 루프 1세트",
"procedure": [
"바꾸고 싶은 습관의 Routine 식별.",
"Reward 실험: 무엇을 원하는 건지 5가지 대체 보상을 며칠씩 시도.",
"Cue 분리: 시간/장소/감정/사람/직전 행동 5범주로 트리거 분석.",
"같은 Cue + 같은 Reward를 유지하면서 Routine만 새 행동으로 교체.",
"(조직 변화) Keystone Habit 식별 → 파급 효과."
],
"tools": ["노트/일기 + 습관 분석 워크시트"],
"strengths": ["나쁜 습관 분해/교체에 명료한 진단 도구", "조직·사회 사례까지 확장된 케이스"],
"weaknesses": ["새 습관 형성보다 기존 습관 재구성에 가까움", "매일 무엇을 체크하나에 직접 답 없음"],
"good_for": "끊고 싶은 습관(흡연, 폭식, SNS)이 있는 사람",
"huberman_fit_score": 3,
"is_core_engine": false,
"reference_ids": ["ref_book_duhigg_power_of_habit"]
},
{
"id": "bullet_journal",
"name": "Bullet Journal",
"originator": "Ryder Carroll (2013 / 2018)",
"one_line_definition": "종이 노트 한 권에 Rapid Logging + Index + Future/Monthly/Daily Log + Migration으로 관리하는 아날로그 시스템.",
"core_unit": "빈 노트 + 6가지 표기 기호",
"procedure": [
"노트 첫 페이지를 Index로.",
"Future Log(6~12개월), Monthly Log, Daily Log를 순서대로 작성.",
"매일 Rapid Logging — 한 줄당 하나, 기호로 종류 구분.",
"완료(X), 이전(>), 일정 이동(<), 무관(취소선) 표기.",
"월말 Migration: 미완 항목을 의식적으로 옮길지/버릴지 판단."
],
"tools": ["Leuchtturm1917", "Moleskine 도트 노트", "Notion/Obsidian 디지털 변형"],
"strengths": ["디지털 알림에서 벗어남 → 깊은 사고", "Migration이 '정말 중요한 것'을 자연 선별"],
"weaknesses": ["검색·동기화·반복 알림 부재", "양식이 본인에게 맞을 때까지 시행착오 필요"],
"good_for": "손글씨로 사고가 정리되는 사람, 디지털 피로가 큰 사람",
"huberman_fit_score": 3,
"is_core_engine": false,
"reference_ids": ["ref_book_carroll_bullet_journal"]
},
{
"id": "para_method",
"name": "PARA Method",
"originator": "Tiago Forte (2017 / 2022)",
"one_line_definition": "모든 디지털 정보를 Projects / Areas / Resources / Archives 4개로 — 행동성(actionability) 기준으로 분류.",
"core_unit": "4개 최상위 폴더",
"procedure": [
"Projects: 마감 있는 단기 목표.",
"Areas: 마감 없는 장기 책임.",
"Resources: 흥미·참고 주제.",
"Archives: 비활성 항목.",
"매주 Projects 폴더를 점검, 끝난 건 Archive로."
],
"tools": ["Notion", "Obsidian", "Google Drive", "Apple Notes"],
"strengths": ["도구 불문, 앱 간 일관된 정보 구조", "'지금 행동 가능한가'로 분류 → 정리 결정 비용 ↓"],
"weaknesses": ["Areas vs Resources 경계가 모호", "시스템이 비대해지면 Archive가 무덤"],
"good_for": "여러 앱에 정보가 흩어진 지식 노동자",
"huberman_fit_score": 3,
"is_core_engine": false,
"reference_ids": ["ref_book_forte_second_brain"]
},
{
"id": "zettelkasten",
"name": "Zettelkasten",
"originator": "Niklas Luhmann (1950s / 1981)",
"one_line_definition": "카드 한 장에 한 아이디어 + 고유 번호 + 다른 카드로의 링크를 적어 지식 네트워크를 형성하는 슬립박스 노트법.",
"core_unit": "단일 아이디어 1장 = 1 Zettel",
"procedure": [
"읽으며 Fleeting Notes(즉석 메모).",
"자신의 말로 Literature Notes 정리.",
"Permanent Notes: 1카드=1아이디어, 자기 언어로 완결.",
"기존 카드와의 연결(링크) + 고유 ID 부여.",
"Structure Notes / Index로 진입점 관리."
],
"tools": ["종이 인덱스 카드 박스", "Obsidian", "Roam Research", "Zettlr"],
"strengths": ["장기적 사고의 복리 효과", "떠오르는 연결이 새 아이디어 자발 생성"],
"weaknesses": ["단기 할 일 관리와는 무관", "시스템 셋업·운영이 무거움"],
"good_for": "연구자, 작가, 장기 저작 프로젝트 보유자",
"huberman_fit_score": 2,
"is_core_engine": false,
"reference_ids": ["ref_book_ahrens_smart_notes"]
},
{
"id": "deep_work_time_blocking",
"name": "Deep Work / Time Blocking",
"originator": "Cal Newport (2016)",
"one_line_definition": "인지적으로 방해 없는 집중(Deep Work)을 매일 시간 블록 단위로 캘린더에 명시 배정.",
"core_unit": "30분 단위 시간 블록 + Deep/Shallow 라벨",
"procedure": [
"매일 아침(또는 전날 밤) 종이/캘린더에 하루 모든 시간을 블록으로 나눈다.",
"각 블록에 작업명 기입 — Deep / Shallow 구분.",
"변동 발생 시 재계획(reschedule), 죄책감 X.",
"주간 단위로 Deep hours 합계 추적.",
"분기 단위로 Deep Work 비중을 늘리는 의식적 변화."
],
"tools": ["종이 Time-Block Planner", "Google Calendar", "Sunsama", "Akiflow", "Motion"],
"strengths": ["Shallow Work 인지 → 메타인지 강화", "캘린더가 곧 실행계 — 추정 vs 실측 갭 ↓"],
"weaknesses": ["인터럽트 많은 직무엔 매일 재계획 부담 큼", "회의 위주 사람은 Deep 블록 확보 자체가 정치 문제"],
"good_for": "작가·연구자·프로그래머·디자이너",
"huberman_fit_score": 5,
"is_core_engine": false,
"reference_ids": ["ref_book_newport_deep_work"]
},
{
"id": "timeboxing",
"name": "Timeboxing",
"originator": "Marc Zao-Sanders / HBR (2018 / 2024)",
"one_line_definition": "모든 할 일을 캘린더의 특정 시간 박스로 옮긴다 — 투두 리스트 자체를 캘린더가 대체.",
"core_unit": "캘린더 이벤트 1건 = 1 task",
"procedure": [
"투두 리스트의 모든 항목에 소요 시간 추정.",
"캘린더에 가능한 시간 슬롯에 드래그/이벤트 생성.",
"시작 시각에 알람 → 그 박스만 실행.",
"끝나면 다음 박스로, 못 끝낸 건 다른 박스로 이동.",
"하루 종료 시 박스 vs 실제 시간 비교 → 추정력 개선."
],
"tools": ["Google Calendar", "Sunsama", "Reclaim.ai", "Motion", "Akiflow"],
"strengths": ["'할 일이 너무 많아 보임' 환상을 깸", "HBR 100 productivity hacks 설문에서 1위"],
"weaknesses": ["박스가 깨질 때마다 재배치 비용", "창의 작업의 비선형성(영감)을 깎을 수 있음"],
"good_for": "할 일은 많은데 실제 손이 안 가는 사람, 캘린더 의존도가 높은 직무",
"huberman_fit_score": 5,
"is_core_engine": false,
"reference_ids": ["ref_book_zao_sanders_timeboxing"]
},
{
"id": "one_three_five_rule",
"name": "1-3-5 Rule",
"originator": "Alex Cavoulacos (2013)",
"one_line_definition": "하루에 큰 일 1개 + 중간 3개 + 작은 5개 = 총 9개만 할 수 있다고 가정하고 그것만 적는다.",
"core_unit": "일일 9개 슬롯 (1/3/5)",
"procedure": [
"전날 밤 또는 아침에 큰 일 1을 정한다(2~4시간 분량).",
"중간 일 3개(30~90분).",
"작은 일 5개(<30분).",
"그 외 요청은 내일/다음 주로.",
"비상시 슬롯 1개를 비워두기."
],
"tools": ["종이", "Todoist 라벨", "Notion 템플릿"],
"strengths": ["양·크기 함께 강제 → 현실적 일일 한도 설정", "의사결정이 빠르고 단순"],
"weaknesses": ["1/3/5 비율은 경험칙", "갑작스러운 외부 요청 대응이 약함"],
"good_for": "매일 할 일을 늘 과대 추정하는 사람, 초보 PM·직장인",
"huberman_fit_score": 3,
"is_core_engine": false,
"reference_ids": ["ref_book_cavoulacos_new_rules_of_work"]
},
{
"id": "mit",
"name": "MIT — Most Important Tasks",
"originator": "Leo Babauta (2008)",
"one_line_definition": "매일 아침 3개의 가장 중요한 작업(MIT)을 정하고 다른 모든 것보다 먼저 처리.",
"core_unit": "일일 3개 MIT",
"procedure": [
"아침 처음 5분 — 오늘의 MIT 3개 선정.",
"그중 최소 1개는 장기 목표 관련.",
"다른 일 시작 전에 MIT부터.",
"끝나면 그 외 작업(이메일, 회의 등)을 처리.",
"일 종료 시 MIT 완료율 점검."
],
"tools": ["종이 한 장", "Things 3 'Today' 섹션", "Todoist 라벨"],
"strengths": ["학습 비용 0", "'큰 돌을 먼저'라는 Covey 원칙의 일일 운영판"],
"weaknesses": ["회의·외부 요청이 많으면 MIT 보호가 어려움", "MIT 선정 자체가 매일 부담일 수 있음"],
"good_for": "GTD 시스템이 부담스러운 사람, 미니멀 투두 운영 원하는 사람",
"huberman_fit_score": 4,
"is_core_engine": false,
"reference_ids": ["ref_book_babauta_zen_to_done"]
},
{
"id": "personal_kanban",
"name": "Personal Kanban",
"originator": "Jim Benson & Tonianne DeMaria Barry (2011)",
"one_line_definition": "시각화 + WIP(Work-In-Progress) 제한 단 2가지 규칙으로 개인 업무를 관리하는 칸반 보드.",
"core_unit": "To Do / Doing / Done 3열 보드 + 카드",
"procedure": [
"화이트보드/디지털 보드에 3열을 그린다.",
"모든 할 일을 카드로 To Do에 올린다.",
"WIP 한도(예: Doing ≤ 3) 설정.",
"카드를 한 장만 Doing으로 옮기고 완료 시 Done으로.",
"정기 회고(weekly retro)로 흐름·병목 점검."
],
"tools": ["화이트보드 + 포스트잇", "Trello", "KanbanFlow", "Notion 보드 뷰", "Linear"],
"strengths": ["시각화 자체의 인지 부하 감소", "WIP 제한이 멀티태스킹 자연 차단"],
"weaknesses": ["시간 차원이 약함(데드라인은 별도 표시 필요)", "카드 수가 늘면 보드가 시각적 부담"],
"good_for": "시각형 사고자, 칸반 익숙한 개발자/디자이너",
"huberman_fit_score": 3,
"is_core_engine": false,
"reference_ids": ["ref_book_benson_personal_kanban"]
},
{
"id": "okr",
"name": "OKR — Objectives & Key Results",
"originator": "Andy Grove (1970s) / John Doerr (1999)",
"one_line_definition": "정성적 Objective 1개 + 정량적 Key Result 3~5개를 분기 단위로 설정해 목표와 측정을 결합.",
"core_unit": "O 1개 + KR 3~5개",
"procedure": [
"의미 있는 Objective(영감을 주는 정성 목표) 작성.",
"측정 가능한 Key Results 3~5개 — 숫자/마감 명시.",
"주간 체크인으로 KR 진척률 갱신(0.0~1.0 또는 %).",
"분기 종료 시 0.7 내외가 이상적('stretch goal').",
"결과로 보상하지 말고 학습 자료로 회고."
],
"tools": ["Notion OKR 템플릿", "Lattice", "Workboard", "Weekdone"],
"strengths": ["정성 목표와 정량 측정의 통합", "조직·개인 공통 프레임"],
"weaknesses": ["개인 단위에선 형식이 무거울 수 있음", "KR이 측정 가능한 것에만 편중"],
"good_for": "분기 단위 큰 목표가 있는 사람, 조직 OKR과 개인 OKR을 정렬하고 싶은 직장인",
"huberman_fit_score": 3,
"is_core_engine": false,
"reference_ids": ["ref_book_doerr_measure_what_matters"]
},
{
"id": "woop",
"name": "WOOP — Wish/Outcome/Obstacle/Plan",
"originator": "Gabriele Oettingen (2014)",
"one_line_definition": "목표에 대해 소망 → 최고의 결과 → 내부 장애물 → if-then 계획을 차례로 시각화하는 4단계 자기조절 프로토콜.",
"core_unit": "WOOP 4단계 워크시트",
"procedure": [
"Wish: 의미 있고 실현 가능한 소망 1개(시간 단위 명시).",
"Outcome: 그게 이루어졌을 때 가장 좋은 결과를 생생히 상상.",
"Obstacle: 나를 가로막는 내부 장애물(감정/습관/생각) 구체화.",
"Plan: 'If [장애물 발생], then I will [구체적 행동].'",
"매일/매주 반복 가능 — 짧은 명상처럼 운용."
],
"tools": ["WOOP my life 무료 앱", "종이 워크시트(NYU 공식 PDF)"],
"strengths": ["RCT 기반 효과 검증 (학업, 건강, 운동)", "막연한 긍정 사고의 함정(fantasizing) 보완"],
"weaknesses": ["일일 운영보다는 목표/장애 진단용", "'내부 장애물' 식별 훈련이 필요"],
"good_for": "다이어트·운동·금연 등 행동 변화 목표가 있는 사람",
"huberman_fit_score": 4,
"is_core_engine": false,
"reference_ids": ["ref_book_oettingen_woop"]
},
{
"id": "implementation_intentions",
"name": "Implementation Intentions — If-Then Planning",
"originator": "Peter Gollwitzer (1999)",
"one_line_definition": "목표 달성 확률을 'If [상황 X], then I will [행동 Y]' 한 문장으로 2~3배 끌어올리는 행동 설계 기법.",
"core_unit": "If-Then 문장 1개",
"procedure": [
"목표를 정한다(예: 매일 운동).",
"행동을 촉발할 구체적 상황 단서(시간/장소/직전행동/감정) 결정.",
"'If 오전 7시에 알람이 울리면, then 즉시 운동복으로 갈아입는다' 형태로 작성.",
"가능하면 큰 소리로 반복해 신경 자동화.",
"실패 시 If 단서를 더 구체화하거나 더 작은 행동으로 교체."
],
"tools": ["종이/포스트잇", "다른 시스템 안에 삽입 가능"],
"strengths": ["메타분석상 행동 실행률 2~3배 향상", "추가 시스템 없이도 즉시 적용 가능"],
"weaknesses": ["단서가 너무 모호하면 효과 급감", "자동화된 만큼 잘못된 단서에 잘못된 행동도 강화"],
"good_for": "모든 사람 — 다른 방법론의 보조 기법으로 가장 범용",
"huberman_fit_score": 5,
"is_core_engine": true,
"reference_ids": ["ref_doi_10_1037_0003_066X_54_7_493"]
}
]

View File

@@ -0,0 +1,706 @@
[
{
"id": "morning_sunlight",
"category": "health",
"title": "아침 햇빛",
"title_en": "Morning Sunlight",
"what": "기상 후 야외에서 햇빛을 직접 눈에 받기.",
"when": "기상 후 30~60분 이내 (일출 ~ 일출 후 1시간 이상적).",
"dose": "맑은 날 5~10분 / 흐린 날 10~20분 / 비 오는 날 20~30분. 매일.",
"why": "망막 melanopsin ipRGC 자극 → cortisol 아침 분비 + 16시간 뒤 멜라토닌 타이머 설정.",
"how": [
"기상 후 가장 먼저 야외로 나간다.",
"선글라스를 벗는다 (안경/콘택트 OK).",
"해를 직시하지 말고 햇빛 들어오는 방향을 향한다.",
"가벼운 산책과 결합 권장.",
"dose 충족할 때까지 머문다."
],
"check": "기상 후 60분 이내 외출 / 선글라스 X / dose 충족",
"caution": "창문 너머 햇빛은 자외선 차단되어 효과 미미. 절대 해 직시 X.",
"default_anchor": {
"after_what": "기상 후 양치"
},
"min_dose_for_start": "햇빛 30초~2분 (Tiny Habits 시작 도즈)",
"reference_ids": ["ref_podcast_hl_2_sleep", "ref_podcast_hl_68_light", "ref_doi_10_1016_j_cub_2013_06_039"],
"evidence_strength": "strong_rct",
"source_doc": "huberman-protocols.md"
},
{
"id": "evening_sunlight",
"category": "health",
"title": "저녁 햇빛",
"title_en": "Evening Sunlight",
"what": "일몰 즈음 햇빛 보기.",
"when": "일몰 1시간 전 ~ 일몰 직후.",
"dose": "5~10분, 매일 가능 시.",
"why": "저녁 인공조명에 대한 망막 민감도 ↓ → 밤 빛의 멜라토닌 억제 정도 ↓.",
"how": [
"일몰 시각 확인.",
"일몰 30~60분 전 알람.",
"야외 5~10분 (저녁 산책과 결합)."
],
"check": "일몰 ±1시간 안에 야외 / 5분 이상",
"reference_ids": ["ref_podcast_hl_68_light"],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "night_light_avoidance",
"category": "health",
"title": "야간 빛 차단",
"title_en": "Night Light Avoidance",
"what": "밤 10시 ~ 새벽 4시 머리 위 강한 빛 차단.",
"when": "저녁 9시 ~ 취침.",
"dose": "가능한 어둡게, 간접 조명만.",
"why": "이 시간대 빛은 habenula 경로로 도파민 회로 억제 → 다음 날 기분·동기 ↓.",
"how": [
"9시 알람 = '조명 다운' 트리거.",
"천장 등 OFF, 무릎 아래 간접 조명만 ON.",
"화면 야간 모드 + 밝기 최저 + 거리 두기.",
"화장실/주방에 어두운 야간등."
],
"check": "9시 이후 머리 위 빛 OFF / 화면 야간 모드 / 침실 거의 깜깜",
"default_anchor": {
"when": "21:00"
},
"reference_ids": ["ref_podcast_hl_68_light", "ref_doi_10_1038_tp_2016_262"],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "sleep_stack",
"category": "health",
"title": "수면 스택",
"title_en": "Sleep Stack",
"what": "입면·심부 체온·자극 차단을 결합한 취침 전 90분 루틴.",
"when": "매일 같은 시각 (고정 기상 시각 기준 역산).",
"dose": "총 수면 7~9시간, 기상 시각 ±1시간.",
"why": "체온 1~3℉ 하강이 입면 신호. 카페인·식사 컷오프가 수면 깊이 결정.",
"how": [
"기상 시각 고정 → 취침 시각 자동.",
"마지막 카페인 = 기상 + 8~10h.",
"마지막 식사 = 취침 2~3h 전.",
"취침 60~90분 전 따뜻한 샤워/족욕 5~10분 → 심부 체온 하강 유도.",
"침실 18~19℃, 침대 진입 직전 화면 OFF."
],
"check": "기상 시각 ±1h / 카페인 컷오프 / 식사 2~3h 전 종료 / 침실 18~19℃",
"reference_ids": ["ref_podcast_hl_84_sleep_toolkit", "ref_podcast_hl_2_sleep"],
"evidence_strength": "expert_opinion",
"source_doc": "huberman-protocols.md"
},
{
"id": "caffeine_protocol",
"category": "health",
"title": "카페인 타이밍",
"title_en": "Caffeine Protocol",
"what": "기상 직후 카페인 회피 + 컷오프 시각 준수.",
"when": "첫 잔 = 기상 + 90~120분, 마지막 잔 = 기상 + 8~10h.",
"dose": "개인 내성 따름.",
"why": "아침 adenosine clearance 자연 진행 → 오후 crash 방지. 늦은 카페인은 수면 깊이 ↓.",
"how": [
"기상 직후: 물 + 소금 + 햇빛.",
"첫 잔 알람: 기상 + 90분.",
"마지막 잔 알람: 기상 + 10h.",
"이후 디카페인/허브차."
],
"check": "기상 후 90분 전 카페인 X / 컷오프 시각 이후 카페인 X",
"caution": "90~120분 지연은 직접 RCT 부재. adenosine 약리학 기반 추론. 근거 ⚠️.",
"reference_ids": ["ref_podcast_hl_101_caffeine", "ref_doi_10_5664_jcsm_3170"],
"evidence_strength": "expert_opinion",
"source_doc": "huberman-protocols.md"
},
{
"id": "weekly_movement_template",
"category": "health",
"title": "주간 운동 템플릿",
"title_en": "Weekly Movement Template",
"what": "유산소(Zone 2 + VO2max) + 근력의 주간 분배.",
"when": "요일 고정.",
"dose": "Zone 2 150~200분/주, VO2max 1회 4×4분, 근력 2~4회.",
"why": "심혈관·미토콘드리아·근감소·인슐린 감수성 동시 최적화.",
"how": [
"주말에 운동 요일 배치.",
"Zone 2: '말 가능, 노래 불가' (HR ≈ 180나이).",
"VO2max: 4분 매우 힘듦 + 4분 가벼움 × 4세트.",
"근력: 부위별 10~15 sets/주, 2~3 RIR.",
"운동 후 단백질 30g+."
],
"check": "Zone 2 ≥ 150분/주 / VO2max 1회 / 근력 2~4회",
"min_dose_for_start": "운동 1세트 또는 5분 산책",
"reference_ids": ["ref_doi_10_1001_jamanetworkopen_2018_3605"],
"evidence_strength": "observational",
"source_doc": "huberman-protocols.md"
},
{
"id": "deliberate_cold_exposure",
"category": "health",
"title": "의도적 냉수 노출",
"title_en": "Deliberate Cold Exposure",
"what": "찬물 샤워 또는 ice bath.",
"when": "가능하면 오전 (저녁은 수면 방해 가능).",
"dose": "주 합산 11분, 1회 1~5분.",
"why": "노르에피네프린/도파민 급상승 (Šrámek 2000: NE 530%, DA 250%) → 각성·기분·집중 1~3h 지속.",
"how": [
"가장 차가운 수도 또는 ice bath ('불편하지만 안전한' 수준).",
"진입 전 코호흡 30초.",
"1~3분 견딤.",
"마지막 30초 의도적 추가 (aMCC).",
"자연 건조 or 가벼운 움직임."
],
"check": "1회 ≥ 1분 / 주 합산 ≥ 11분",
"caution": "근비대 직후 4h 회피. 심혈관 질환자 의사 상담.",
"reference_ids": ["ref_podcast_hl_66_cold", "ref_doi_10_1007_s004210050065"],
"evidence_strength": "strong_rct",
"source_doc": "huberman-protocols.md"
},
{
"id": "deliberate_heat_exposure",
"category": "health",
"title": "사우나",
"title_en": "Deliberate Heat Exposure",
"what": "80~100℃ 사우나.",
"when": "운동 후 또는 저녁.",
"dose": "주 합산 57분+, 1회 ~20분 × 4세트.",
"why": "심혈관·전체 사망률 감소(Laukkanen 2015), 성장호르몬 일시 상승, mood/stress 회복.",
"how": [
"수분 + 소금 사전 섭취.",
"20분 입실 → 휴식(또는 짧은 냉수) → 반복.",
"끝나면 수분/전해질 보충."
],
"check": "주 합산 ≥ 57분 (선택)",
"caution": "임신/심혈관/저혈압 시 의사 상담. 알코올 결합 X.",
"reference_ids": ["ref_podcast_hl_69_heat", "ref_doi_10_1001_jamainternmed_2014_8187"],
"evidence_strength": "observational",
"source_doc": "huberman-protocols.md"
},
{
"id": "foundational_supplements",
"category": "health",
"title": "핵심 보충제",
"title_en": "Foundational Supplements",
"what": "Vitamin D3, Magnesium L-threonate, (옵션) Apigenin, Theanine.",
"when": "D3 아침, 나머지 취침 30분 전.",
"dose": "D3 2000~5000 IU / Mg L-threonate 1g / Apigenin 50mg / Theanine 100~400mg.",
"why": "수면 깊이·이완·cortisol 억제 보조.",
"how": [
"의사와 D 혈중 수치 검사 후 D3 용량 결정.",
"Mg는 식후 흡수 양호.",
"한 번에 1종씩 도입(2주 단위 관찰)."
],
"check": "처방/권장량 준수 / 신규 도입 한 번에 1종",
"caution": "의약품/임신/기저질환 시 의사 상담. Theanine은 혈압약 상호작용 가능.",
"reference_ids": ["ref_podcast_hl_84_sleep_toolkit", "ref_doi_10_1016_j_sleepx_2024_100121"],
"evidence_strength": "strong_rct",
"source_doc": "huberman-protocols.md"
},
{
"id": "focused_meditation",
"category": "meditation",
"title": "집중 명상",
"title_en": "Focused Meditation",
"what": "단일 대상(호흡/미간)에 주의 고정.",
"when": "매일 같은 시간 (아침, Phase 1).",
"dose": "13분/일, 최소 8주.",
"why": "전전두피질amygdala 연결 강화, interoception 훈련.",
"how": [
"타이머 13분.",
"척추 곧게 앉음, 눈은 감거나 1m 앞 한 점.",
"코호흡, 들숨 '1' 날숨 '2' … 10까지 카운트.",
"주의 벗어남 알아챈 순간 → 자책 없이 1로 복귀.",
"종료 후 30초 잔여감 관찰."
],
"check": "13분 완료 / 알아챔→복귀 1회 이상 의식",
"caution": "잠들기 직전 진행 시 각성 유발 가능.",
"min_dose_for_start": "명상 1분",
"reference_ids": ["ref_podcast_hl_96_meditation", "ref_doi_10_1016_j_bbr_2018_08_023"],
"evidence_strength": "strong_rct",
"source_doc": "huberman-protocols.md"
},
{
"id": "nsdr_yoga_nidra",
"category": "meditation",
"title": "NSDR / Yoga Nidra",
"title_en": "Non-Sleep Deep Rest",
"what": "누운 자세에서 가이드 보디스캔 + 긴 날숨.",
"when": "점심 후 슬럼프, 학습 직후, 자다 깼을 때.",
"dose": "10~30분.",
"why": "Yoga Nidra 중 ventral striatum 도파민 +65% (Kjaer 2002), 학습 응고화, 수면 부채 일부 보전.",
"how": [
"누울 공간 + 알람.",
"YouTube 'NSDR Huberman' 또는 'Yoga Nidra' 검색.",
"가이드 보디스캔: 발끝 → 머리.",
"코로 들이쉬고 입으로 길게 내쉼.",
"종료 후 30초 잔여감."
],
"check": "가이드 끝까지 / 종료 후 30초 잔여감",
"reference_ids": ["ref_podcast_hl_28_daily_tools", "ref_doi_10_1016_S0926_6410_01_00106_9"],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "cyclic_sighing",
"category": "meditation",
"title": "생리적 한숨",
"title_en": "Cyclic Sighing",
"what": "들숨 2회 + 긴 날숨 1회.",
"when": "스트레스 급상승 / 데일리 진정.",
"dose": "1~5분 (1일 5분 × 28~30일).",
"why": "부교감 활성화 가장 빠른 단일 호흡법. 폐포 CO₂를 한 번에 배출.",
"how": [
"편한 자세.",
"코로 들이쉼 (폐 ~80%).",
"한 번 더 짧게 코로 들이쉼 (폐 완전히).",
"입으로 천천히 길게 내쉼 (들숨 합산보다 2배+).",
"1~5분 반복."
],
"check": "패턴 유지 / 1분 이상",
"min_dose_for_start": "cyclic sighing 30초~1분",
"reference_ids": ["ref_podcast_hl_10_stress", "ref_doi_10_1016_j_xcrm_2022_100895"],
"evidence_strength": "strong_rct",
"source_doc": "huberman-protocols.md"
},
{
"id": "box_breathing",
"category": "meditation",
"title": "Box Breathing",
"title_en": "Box Breathing",
"what": "4초 들숨4초 멈춤4초 날숨4초 멈춤.",
"when": "작업 진입 전, 회의 전.",
"dose": "2~5분.",
"why": "자율신경 균형 + 주의 정렬.",
"how": [
"의자 척추 곧게.",
"코로 4초 들이쉼.",
"4초 멈춤.",
"4초 날숨.",
"4초 멈춤 — 2~5분 반복."
],
"check": "4-4-4-4 박자 / 2분 이상",
"caution": "특이성 RCT 빈약 — cyclic sighing(§2.3)보다 효과 작음.",
"reference_ids": ["ref_podcast_hl_10_stress", "ref_doi_10_3389_fnhum_2018_00353"],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "cold_sigh_combo",
"category": "meditation",
"title": "Cold + Sigh Combo",
"title_en": "Cold + Sigh Combo",
"what": "찬물 세면 + cyclic sighing.",
"when": "오후 슬럼프.",
"dose": "찬물 30초 + 호흡 1~2분.",
"why": "NE 급상승 + 부교감 안정 결합.",
"how": [
"찬물에 손목/얼굴 30초.",
"자세 정돈 후 cyclic sighing 1~2분.",
"즉시 작업 재개."
],
"check": "찬물 30초+ / 호흡 1분+",
"evidence_strength": "expert_opinion",
"source_doc": "huberman-protocols.md"
},
{
"id": "protect_dopamine_baseline",
"category": "motivation",
"title": "도파민 baseline 보호",
"title_en": "Protect Dopamine Baseline",
"what": "활동 자체에 외부 도파민 자극을 중첩(stacking)하지 않기.",
"when": "운동·공부·창작 전후.",
"dose": "주 1~2회 '자극 빼고'.",
"why": "동기 = baseline 대비 변화량. peak ↑ → baseline ↓ → 활동의 보상감 ↓.",
"how": [
"본 활동 전 동시 자극 나열 (음악/카페인/pre-workout/SNS).",
"1~2개 의도적 제거.",
"직후 5분 SNS/도파민 콘텐츠 차단.",
"주 1~2회 '맨몸' 세션으로 baseline 회복."
],
"check": "stacking ≤ 1 / 직후 5분 차단",
"reference_ids": ["ref_podcast_hl_39_dopamine", "ref_book_lembke_dopamine_nation"],
"evidence_strength": "expert_opinion",
"source_doc": "huberman-protocols.md"
},
{
"id": "reward_prediction_relabeling",
"category": "motivation",
"title": "보상 예측 재배치",
"title_en": "Reward Prediction Relabeling",
"what": "노력 자체에 보상을 결합하는 내적 라벨링.",
"when": "힘든 구간.",
"dose": "세션당 3~5회 짧게.",
"why": "전전두피질의 의식적 라벨링이 mesolimbic 도파민 분비 → 의지력 가능.",
"how": [
"힘든 구간 인지.",
"'이 불편함이 곧 도파민이다' 라벨링.",
"자세·호흡 정돈 후 다음 rep.",
"끝난 후 외적 보상 X."
],
"check": "라벨링 1회+ / 외적 보상 안 줌",
"reference_ids": ["ref_podcast_hl_39_dopamine", "ref_podcast_hl_113_dopamine_procrastination"],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "dopamine_recovery_stack",
"category": "motivation",
"title": "도파민 회복 스택",
"title_en": "Dopamine Recovery Stack",
"what": "자연적 baseline 상승 도구 묶음.",
"when": "번아웃 기미.",
"dose": "주 단위.",
"why": "햇빛·운동·냉수·NSDR 누적 baseline 상승.",
"how": [
"햇빛 데일리.",
"운동 주간 템플릿.",
"냉수 주 11분.",
"NSDR 주 3회+.",
"디지털 디톡스 주 1회 24h."
],
"check": "각 구성요소 1회+",
"reference_ids": ["ref_podcast_hl_113_dopamine_procrastination"],
"evidence_strength": "expert_opinion",
"source_doc": "huberman-protocols.md"
},
{
"id": "amcc_will_training",
"category": "motivation",
"title": "aMCC 의지력 훈련",
"title_en": "aMCC Will-Training",
"what": "'하기 싫지만 안전한' 일을 매일 1개 의도적 수행.",
"when": "매일 1회.",
"dose": "1~10분.",
"why": "anterior mid-cingulate cortex 부피 ↑ → 의지력·장수와 상관. Super-agers 보존.",
"how": [
"전날 밤/아침 '오늘의 싫은 일' 정의.",
"협상 없이 즉시 수행.",
"완료 후 'aMCC 1 rep' 라벨링."
],
"check": "오늘의 싫은 일 정의 / 수행 완료",
"reference_ids": ["ref_doi_10_1016_j_cortex_2019_09_011", "ref_doi_10_1093_braincomms_fcac163"],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "digital_dopamine_detox",
"category": "motivation",
"title": "디지털 디톡스",
"title_en": "Digital Dopamine Detox",
"what": "고자극 컨텐츠 차단 (쇼츠/릴스/포르노/SNS 스크롤).",
"when": "주 1회 24h 또는 분기 1회 48h.",
"dose": "24~48h.",
"why": "강한 peak 후 baseline 회복에 1~2일 필요.",
"how": [
"디톡스 시작 시각 캘린더 고정.",
"해당 앱 로그아웃 또는 삭제.",
"대체 행동 사전 설정.",
"종료 후 첫 사용 5분 제한."
],
"check": "24h 차단 / 첫 사용 5분 이내",
"reference_ids": ["ref_podcast_hl_39_dopamine", "ref_book_lembke_dopamine_nation"],
"evidence_strength": "expert_opinion",
"source_doc": "huberman-protocols.md"
},
{
"id": "three_phases_of_day",
"category": "habit",
"title": "하루 3 위상",
"title_en": "Three Phases of the Day",
"what": "신경전달물질 우세 시간대에 작업 배치.",
"when": "매일.",
"dose": "Phase 1 (0~8h), Phase 2 (9~15h), Phase 3 (16~24h) — 기상 기준.",
"why": "cortisol/DA/NE (Phase 1) → serotonin (Phase 2) → 수면/회상 (Phase 3).",
"how": [
"기상 시각 기준 3구간 캘린더 색칠.",
"Phase 1: 어려운/분석적/마찰 큰 새 습관.",
"Phase 2: 창의·브레인스토밍·가벼운 습관.",
"Phase 3: 회상·정리·디지털 OFF."
],
"check": "가장 어려운 일 Phase 1 배치 / Phase 3 자극적 디지털 X",
"reference_ids": ["ref_podcast_hl_28_daily_tools", "ref_podcast_hl_53_habits"],
"evidence_strength": "expert_opinion",
"source_doc": "huberman-protocols.md"
},
{
"id": "task_bracketing",
"category": "habit",
"title": "시간·맥락 브래킷",
"title_en": "Task Bracketing",
"what": "새 습관을 같은 시간 + 같은 직전/직후 행동으로 고정.",
"when": "새 습관 도입 시.",
"dose": "6주 이상.",
"why": "기저핵은 시간/맥락 신호로 자동화. 브래킷 강할수록 자동화 빠름.",
"how": [
"새 습관 1개 선택.",
"직전 브래킷 지정 (예: 양치 직후).",
"직후 브래킷 지정 (예: 햇빛 보러).",
"매일 같은 시각 3-체인 실행.",
"6주간 같은 위치 유지."
],
"check": "직전 브래킷 정의 / 직후 브래킷 정의 / 오늘 같은 시각 실행",
"reference_ids": ["ref_podcast_hl_53_habits", "ref_doi_10_1146_annurev_neuro_29_051605_112851"],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "six_week_integration_rule",
"category": "habit",
"title": "6주 자동화 규칙",
"title_en": "6-Week Integration Rule",
"what": "'6주 동안 주 6/7'을 자동화 기준으로.",
"when": "새 습관 시작부터 6주.",
"dose": "주 6회.",
"why": "21일은 myth. 신경 통합엔 더 길고 빠짐 허용이 현실적.",
"how": [
"목표를 '주 6회'로 명시.",
"6주 트래커 준비.",
"매일 ○/× 표시.",
"1회 결석 다음 날 즉시 복귀.",
"6주 후 자동화 자가 평가."
],
"check": "트래커 존재 / 이번 주 6/7 / 결석 후 다음 날 복귀",
"reference_ids": ["ref_podcast_hl_53_habits", "ref_doi_10_1002_ejsp_674"],
"evidence_strength": "observational",
"source_doc": "huberman-protocols.md"
},
{
"id": "limbic_friction_scoring",
"category": "habit",
"title": "마찰 점수화",
"title_en": "Limbic Friction Scoring",
"what": "각 습관에 0~10 마찰 점수.",
"when": "매일 수행 직전/직후.",
"dose": "30초.",
"why": "높은 점수 → Phase 1 배치 / 떨어지면 자동화 진입.",
"how": [
"트래커에 'friction 0~10' 칸.",
"수행 전 점수 기록.",
"주말 평균 추세.",
"평균 3↓ 2주 유지 → 자동화 진입."
],
"check": "friction 기록 / 주간 평균 확인",
"reference_ids": ["ref_podcast_hl_53_habits"],
"evidence_strength": "expert_opinion",
"source_doc": "huberman-protocols.md"
},
{
"id": "new_habit_onboarding",
"category": "habit",
"title": "신규 습관 도입 규칙",
"title_en": "New Habit Onboarding",
"what": "동시 1~3개, 최소 단위로 시작.",
"when": "새 습관 추가 시.",
"dose": "동시 1~3개.",
"why": "시작 행위 자체를 보상화 → 자동화 가속.",
"how": [
"신규 후보 리스트화.",
"우선순위 1~3개 선택.",
"'최소 단위' 정의.",
"브래킷 부여.",
"6주 후 평가 → 다음 1~3개."
],
"check": "현재 신규 ≤ 3개 / 각 습관 최소 단위 정의",
"reference_ids": ["ref_podcast_hl_53_habits", "ref_book_fogg_tiny_habits"],
"evidence_strength": "expert_opinion",
"source_doc": "huberman-protocols.md"
},
{
"id": "habit_breaking_via_replacement",
"category": "habit",
"title": "대체 행동으로 끊기",
"title_en": "Habit Breaking via Replacement",
"what": "트리거 직후 호환 불가능한 대체 행동 삽입.",
"when": "끊고 싶은 습관 확인 시.",
"dose": "6주.",
"why": "회로는 잘 사라지지 않음 → 같은 트리거가 다른 결과를 부르도록 재학습.",
"how": [
"트리거 식별.",
"5~30초 안의 대체 행동 정의.",
"트리거 발생 시 자동적 대체 먼저.",
"6주 평가."
],
"check": "트리거 식별 / 대체 행동 정의 / 오늘 1회+ 성공",
"reference_ids": ["ref_podcast_hl_53_habits", "ref_doi_10_1037_0033_295X_114_4_843"],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "ultradian_focus_block",
"category": "learning",
"title": "90분 Ultradian 집중 블록",
"title_en": "90-min Ultradian Focus Block",
"what": "90분 deep work + 10~20분 휴식.",
"when": "Phase 1.",
"dose": "1~3블록/일.",
"why": "ultradian 주기(BRAC) 단위.",
"how": [
"진입 전 시각 응시 30~60초.",
"box breathing 2분.",
"알림 OFF, 단일 과제.",
"90분 타이머.",
"종료 후 10~20분 NSDR 또는 산책. SNS X."
],
"check": "진입 의식 / 단일 과제 / 휴식이 도파민 자극 아님",
"reference_ids": ["ref_podcast_hl_8_learning", "ref_podcast_hl_88_focus", "ref_doi_10_1093_sleep_5_4_311"],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "visual_focus_priming",
"category": "learning",
"title": "시각 집중 점화",
"title_en": "Visual Focus Priming",
"what": "한 지점 응시로 전두엽 집중 회로 활성.",
"when": "작업 시작 직전.",
"dose": "30~60초.",
"why": "시각 집중 → 인지 집중 회로 공유 (LC-NE 매개). 좁힌 시야는 각성 ↑.",
"how": [
"책상 위 한 점 선정.",
"30~60초 응시.",
"시선 흩어지면 복귀.",
"즉시 작업 시작."
],
"check": "30초 이상 응시 후 진입",
"caution": "narrow-aperture LC 활성은 Huberman 통합 모델 — 근거 ⚠️.",
"reference_ids": ["ref_podcast_hl_6_focus_brain", "ref_podcast_hl_88_focus", "ref_doi_10_1146_annurev_neuro_28_061604_135709"],
"evidence_strength": "mechanistic",
"source_doc": "huberman-protocols.md"
},
{
"id": "post_learning_nsdr",
"category": "learning",
"title": "학습 직후 NSDR",
"title_en": "Post-Learning NSDR",
"what": "학습 직후 10분 NSDR.",
"when": "강의/집중 학습 종료 직후.",
"dose": "10분.",
"why": "학습 직후 휴식이 기억 응고화 가속.",
"how": [
"학습 종료 즉시 폰/SNS 차단.",
"NSDR 10분 가이드.",
"종료 후 5분 메모로 재진술."
],
"check": "학습 직후 SNS 안 봄 / NSDR 10분 / 메모 재진술",
"reference_ids": ["ref_podcast_hl_8_learning", "ref_doi_10_1177_0956797612441220"],
"evidence_strength": "strong_rct",
"source_doc": "huberman-protocols.md"
},
{
"id": "protein_first",
"category": "diet",
"title": "단백질 우선",
"title_en": "Protein-First",
"what": "첫 식사 + 일일 총량을 단백질 기준으로 잡는다.",
"when": "매 끼니 + 첫 식사.",
"dose": "유지 1.2~1.6 g/kg/일, 근육/노화/감량 시 1.6~2.2 g/kg/일. 첫 식사 leucine 2.5~3g (단백질 ~30g).",
"why": "만성 단백질 부족 → 골밀도·근감소 위험. 첫 식사 단백질 30g은 포만감·이후 칼로리 자연 감소 (Leidy 2015).",
"how": [
"본인 단백질 목표 g/일 계산 (체중 × 1.4 default).",
"식사 수로 나눠 끼니별 25~40g 배분.",
"첫 식사를 단백질로 시작 (계란·요거트·코티지치즈·두부·생선).",
"주 1회 일일 합계 셀프 체크."
],
"check": "첫 식사 단백질 ≥ 30g",
"min_dose_for_start": "첫 끼 단백질 +10g",
"reference_ids": ["ref_doi_10_1139_apnm_2015_0550", "ref_doi_10_1136_bjsports_2017_097608", "ref_doi_10_3945_ajcn_114_084038"],
"evidence_strength": "meta_analysis",
"source_doc": "diet-protocols.md"
},
{
"id": "refined_sugar_minimize",
"category": "diet",
"title": "정제당·액상 과당 최소화",
"title_en": "Minimize Refined / Liquid Sugar",
"what": "첨가당과 액상 과당(과일 주스 포함) 우선 줄임. 통과일·통곡물은 별개.",
"when": "모든 식사.",
"dose": "WHO 첨가당 ≤ 10% of total energy, 강한 권고는 < 5%.",
"why": "액상 과당은 간 대사 + 도파민 보상 동시 자극. 고형 동일량보다 식욕 조절 약화.",
"how": [
"액상 과당 0순위 제거: 주스·소다·시럽 라떼·가향수.",
"단맛 충동 시 단백질 30g 먼저 + 15분 대기.",
"디저트는 외식 시·주 1회 의식적으로.",
"라벨 'added sugar' 확인 (가공식품 1주일 1회 인벤토리)."
],
"check": "오늘 액상 과당 0",
"reference_ids": ["ref_url_who_sugar_2015", "ref_doi_10_1002_oby_21371", "ref_doi_10_1038_sj_ijo_0801229"],
"evidence_strength": "meta_analysis",
"source_doc": "diet-protocols.md"
},
{
"id": "fiber_intake",
"category": "diet",
"title": "식이섬유",
"title_en": "Fiber Intake",
"what": "통곡물·콩류·채소·통과일에서 일일 25~38g.",
"when": "매 끼니.",
"dose": "여성 25g/일, 남성 38g/일.",
"why": "장-뇌축 미생물 다양성 1순위 변수. SCFA 생성·염증 감소. 전체 사망률 ↓ (Reynolds 2019).",
"how": [
"끼니마다 채소 한 줌 + 콩류 또는 통곡물 한 종류.",
"주 1회 발효식품 (요거트·김치·낫토·콤부차).",
"갑자기 늘리면 가스/팽만 → 2~3주 점진 증가."
],
"check": "오늘 채소 ≥ 3 종류",
"reference_ids": ["ref_doi_10_1016_S0140_6736_18_31809_9", "ref_doi_10_1016_j_cell_2021_06_019", "ref_doi_10_1038_s41579_019_0191_8"],
"evidence_strength": "meta_analysis",
"source_doc": "diet-protocols.md"
},
{
"id": "water_electrolytes",
"category": "diet",
"title": "수분·전해질",
"title_en": "Water & Electrolytes",
"what": "기상 후 물 500ml + 소금 한 꼬집. 일일 총 수분 체중 × 30~35 ml.",
"when": "기상 직후 + 운동 전후.",
"dose": "기상 500ml + 소금 한 꼬집.",
"why": "야간 8h 무수분 → 가벼운 탈수 + cortisol·각성 둔화. sodium은 cortisol 일주기 정상화 보조.",
"how": [
"기상 직후 컵·물·소금 세팅 가시화.",
"운동 전후 추가 sodium 200~500mg.",
"카페인·알코올은 이뇨 → 보충 별도 계산."
],
"check": "기상 후 물 + 소금",
"default_anchor": {
"after_what": "기상 직후"
},
"min_dose_for_start": "기상 후 물 한 잔",
"evidence_strength": "expert_opinion",
"source_doc": "diet-protocols.md"
},
{
"id": "meal_timing_tre",
"category": "diet",
"title": "식사 시점 / 시간 제한",
"title_en": "Meal Timing / TRE",
"what": "마지막 식사를 취침 2~3시간 전 종료. (선택) 16:8 등 TRE.",
"when": "매일.",
"dose": "취침 2~3h 컷오프는 default. TRE 16:8은 선택.",
"why": "취침 전 식사 → 체온 하강 방해 + GERD + 수면 분절. 늦은 식사 자체가 체중 증가 인자.",
"how": [
"본인 취침 시간 - 3h를 식사 컷오프로 고정.",
"18~22시 카페인·알코올 동시 컷오프.",
"TRE 시작 시 14:10 → 16:8 점진."
],
"check": "마지막 식사 취침 2~3h 전 종료",
"reference_ids": ["ref_doi_10_1016_j_cmet_2020_06_018", "ref_doi_10_1038_ijo_2012_229"],
"evidence_strength": "strong_rct",
"source_doc": "diet-protocols.md"
},
{
"id": "omega3",
"category": "diet",
"title": "Omega-3 (EPA/DHA)",
"title_en": "Omega-3",
"what": "지방 생선 주 2~3회 또는 EPA 보충 1~2g/일.",
"when": "주 단위.",
"dose": "지방 생선 100~150g 주 2~3회, 보충 EPA+DHA 1~2g/일.",
"why": "심혈관 사건 감소 — 메타분석에서 일관됨. EPA-우울증은 조건부 (Mischoulon 2015 primary null).",
"how": [
"지방 생선 (연어·고등어·정어리) 100~150g 주 2~3회.",
"보충 시 EPA + DHA 합계 1~2g/일.",
"항응고제 복용 시 의사 상담."
],
"check": "주 단위 weekly reflection",
"reference_ids": ["ref_doi_10_1016_j_mayocp_2020_08_034"],
"evidence_strength": "meta_analysis",
"source_doc": "diet-protocols.md"
}
]

View File

@@ -0,0 +1,899 @@
[
{
"id": "ref_doi_10_1016_j_cub_2013_06_039",
"kind": "paper",
"title": "Entrainment of the human circadian clock to the natural light-dark cycle",
"authors": ["Wright KP", "McHill AW", "Birks BR", "Griffin BR", "Rusterholz T", "Chinoy ED"],
"year": 2013,
"journal": "Current Biology",
"doi": "10.1016/j.cub.2013.06.039",
"evidence_strength": "strong_rct",
"verified": true
},
{
"id": "ref_doi_10_1038_tp_2016_262",
"kind": "paper",
"title": "Timing of light exposure affects mood and brain circuits",
"authors": ["Bedrosian TA", "Nelson RJ"],
"year": 2017,
"journal": "Translational Psychiatry",
"doi": "10.1038/tp.2016.262",
"evidence_strength": "mechanistic",
"verified": true
},
{
"id": "ref_doi_10_1007_s004210050065",
"kind": "paper",
"title": "Human physiological responses to immersion into water of different temperatures",
"authors": ["Šrámek P", "Šimečková M", "Janský L", "Šavlíková J", "Vybíral S"],
"year": 2000,
"journal": "European Journal of Applied Physiology",
"doi": "10.1007/s004210050065",
"evidence_strength": "strong_rct",
"verified": true,
"note": "NE +530%, DA +250% 원본 출처"
},
{
"id": "ref_doi_10_1001_jamainternmed_2014_8187",
"kind": "paper",
"title": "Association Between Sauna Bathing and Fatal Cardiovascular and All-Cause Mortality Events",
"authors": ["Laukkanen T", "Khan H", "Zaccardi F", "Laukkanen JA"],
"year": 2015,
"journal": "JAMA Internal Medicine",
"doi": "10.1001/jamainternmed.2014.8187",
"evidence_strength": "observational",
"verified": true,
"note": "KIHD 코호트 n=2,315, 20.7년 추적"
},
{
"id": "ref_doi_10_1001_jamanetworkopen_2018_3605",
"kind": "paper",
"title": "Association of Cardiorespiratory Fitness With Long-term Mortality",
"authors": ["Mandsager K", "Harb S", "Cremer P", "Phelan D", "Nissen SE", "Jaber W"],
"year": 2018,
"journal": "JAMA Network Open",
"doi": "10.1001/jamanetworkopen.2018.3605",
"evidence_strength": "observational",
"verified": true
},
{
"id": "ref_doi_10_1016_S0926_6410_01_00106_9",
"kind": "paper",
"title": "Increased dopamine tone during meditation-induced change of consciousness",
"authors": ["Kjaer TW", "Bertelsen C", "Piccini P", "Brooks D", "Alving J", "Lou HC"],
"year": 2002,
"journal": "Cognitive Brain Research",
"doi": "10.1016/S0926-6410(01)00106-9",
"evidence_strength": "mechanistic",
"verified": true,
"note": "Yoga Nidra +65% dopamine 원본 (11C-raclopride PET)"
},
{
"id": "ref_doi_10_1016_j_bbr_2018_08_023",
"kind": "paper",
"title": "Brief, daily meditation enhances attention, memory, mood, and emotional regulation in non-experienced meditators",
"authors": ["Basso JC", "McHale A", "Ende V", "Oberlin DJ", "Suzuki WA"],
"year": 2019,
"journal": "Behavioural Brain Research",
"doi": "10.1016/j.bbr.2018.08.023",
"evidence_strength": "strong_rct",
"verified": true,
"note": "13분/일 × 8주 RCT 원본"
},
{
"id": "ref_doi_10_1016_j_xcrm_2022_100895",
"kind": "paper",
"title": "Brief structured respiration practices enhance mood and reduce physiological arousal",
"authors": ["Balban MY", "Neri E", "Kogon MM", "Weed L", "Nouriani B", "Jo B", "Holl G", "Zeitzer JM", "Spiegel D", "Huberman AD"],
"year": 2023,
"journal": "Cell Reports Medicine",
"doi": "10.1016/j.xcrm.2022.100895",
"evidence_strength": "strong_rct",
"verified": true,
"note": "cyclic sighing > box breathing/cyclic hyperventilation/mindfulness"
},
{
"id": "ref_doi_10_3389_fnhum_2018_00353",
"kind": "paper",
"title": "How Breath-Control Can Change Your Life: A Systematic Review on Psycho-Physiological Correlates of Slow Breathing",
"authors": ["Zaccaro A", "Piarulli A", "Laurino M", "Garbella E", "Menicucci D", "Neri B", "Gemignani A"],
"year": 2018,
"journal": "Frontiers in Human Neuroscience",
"doi": "10.3389/fnhum.2018.00353",
"evidence_strength": "meta_analysis",
"verified": true
},
{
"id": "ref_doi_10_1146_annurev_neuro_29_051605_112851",
"kind": "paper",
"title": "Habits, Rituals, and the Evaluative Brain",
"authors": ["Graybiel AM"],
"year": 2008,
"journal": "Annual Review of Neuroscience",
"doi": "10.1146/annurev.neuro.29.051605.112851",
"evidence_strength": "mechanistic",
"verified": true
},
{
"id": "ref_doi_10_1002_ejsp_674",
"kind": "paper",
"title": "How are habits formed: Modelling habit formation in the real world",
"authors": ["Lally P", "van Jaarsveld CHM", "Potts HWW", "Wardle J"],
"year": 2010,
"journal": "European Journal of Social Psychology",
"doi": "10.1002/ejsp.674",
"evidence_strength": "observational",
"verified": true,
"note": "자동화 평균 66일 (범위 18-254일)"
},
{
"id": "ref_doi_10_1016_j_cortex_2019_09_011",
"kind": "paper",
"title": "The tenacious brain: How the anterior mid-cingulate contributes to achieving goals",
"authors": ["Touroutoglou A", "Andreano J", "Dickerson BC", "Barrett LF"],
"year": 2020,
"journal": "Cortex",
"doi": "10.1016/j.cortex.2019.09.011",
"evidence_strength": "mechanistic",
"verified": true
},
{
"id": "ref_doi_10_1093_braincomms_fcac163",
"kind": "paper",
"title": "Structural integrity of the anterior mid-cingulate cortex contributes to resilience to delirium in SuperAging",
"authors": ["Katsumi Y", "Wong B", "Cavallari M", "Touroutoglou A"],
"year": 2022,
"journal": "Brain Communications",
"doi": "10.1093/braincomms/fcac163",
"evidence_strength": "observational",
"verified": true
},
{
"id": "ref_doi_10_1093_sleep_5_4_311",
"kind": "paper",
"title": "Basic Rest-Activity Cycle—22 Years Later",
"authors": ["Kleitman N"],
"year": 1982,
"journal": "Sleep",
"doi": "10.1093/sleep/5.4.311",
"evidence_strength": "expert_opinion",
"verified": true,
"note": "BRAC 90분 ultradian 원전"
},
{
"id": "ref_doi_10_1146_annurev_neuro_28_061604_135709",
"kind": "paper",
"title": "An integrative theory of locus coeruleus-norepinephrine function",
"authors": ["Aston-Jones G", "Cohen JD"],
"year": 2005,
"journal": "Annual Review of Neuroscience",
"doi": "10.1146/annurev.neuro.28.061604.135709",
"evidence_strength": "mechanistic",
"verified": true
},
{
"id": "ref_doi_10_1177_0956797612441220",
"kind": "paper",
"title": "Brief wakeful resting boosts new memories over the long term",
"authors": ["Dewar M", "Alber J", "Butler C", "Cowan N", "Della Sala S"],
"year": 2012,
"journal": "Psychological Science",
"doi": "10.1177/0956797612441220",
"evidence_strength": "strong_rct",
"verified": true
},
{
"id": "ref_doi_10_1016_j_sleepx_2024_100121",
"kind": "paper",
"title": "Magnesium-L-threonate improves sleep quality and daytime functioning in adults with self-reported sleep problems: A randomized controlled trial",
"authors": ["Hausenblas HA"],
"year": 2024,
"journal": "Sleep Medicine: X",
"doi": "10.1016/j.sleepx.2024.100121",
"evidence_strength": "strong_rct",
"verified": true,
"note": "n=80, 1g/day × 21 days, Magtein"
},
{
"id": "ref_doi_10_1136_bmj_j2353",
"kind": "paper",
"title": "Moderate alcohol consumption as risk factor for adverse brain outcomes and cognitive decline",
"authors": ["Topiwala A", "Allan CL", "Valkanova V", "Zsoldos E", "Filippini N", "Sexton C", "Mahmood A", "Fooks P", "Singh-Manoux A", "Mackay CE", "Kivimäki M", "Ebmeier KP"],
"year": 2017,
"journal": "BMJ",
"doi": "10.1136/bmj.j2353",
"evidence_strength": "observational",
"verified": true
},
{
"id": "ref_doi_10_1038_s41467_022_28735_5",
"kind": "paper",
"title": "Associations between alcohol consumption and gray and white matter volumes in the UK Biobank",
"authors": ["Daviet R", "Aydogan G", "Jagannathan K", "Spilka N", "Koellinger PD", "Kranzler HR", "Nave G", "Wetherill RR"],
"year": 2022,
"journal": "Nature Communications",
"doi": "10.1038/s41467-022-28735-5",
"evidence_strength": "observational",
"verified": true
},
{
"id": "ref_doi_10_1016_j_neuropharm_2008_05_022",
"kind": "paper",
"title": "Imaging dopamine's role in drug abuse and addiction",
"authors": ["Volkow ND", "Fowler JS", "Wang GJ", "Baler R", "Telang F"],
"year": 2009,
"journal": "Neuropharmacology",
"doi": "10.1016/j.neuropharm.2008.05.022",
"evidence_strength": "mechanistic",
"verified": true
},
{
"id": "ref_doi_10_1001_jamapsychiatry_2013_4546",
"kind": "paper",
"title": "Relative efficacy of mindfulness-based relapse prevention, standard relapse prevention, and treatment as usual for substance use disorders",
"authors": ["Bowen S", "Witkiewitz K", "Clifasefi SL", "Grow J", "Chawla N", "Hsu SH", "Carroll HA", "Harrop E", "Collins SE", "Lustyk MK", "Larimer ME"],
"year": 2014,
"journal": "JAMA Psychiatry",
"doi": "10.1001/jamapsychiatry.2013.4546",
"evidence_strength": "strong_rct",
"verified": true
},
{
"id": "ref_doi_10_1037_0003_066X_59_4_224",
"kind": "paper",
"title": "Relapse prevention for alcohol and drug problems",
"authors": ["Witkiewitz K", "Marlatt GA"],
"year": 2004,
"journal": "American Psychologist",
"doi": "10.1037/0003-066X.59.4.224",
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_doi_10_1037_0033_295X_114_4_843",
"kind": "paper",
"title": "A new look at habits and the habit-goal interface",
"authors": ["Wood W", "Neal DT"],
"year": 2007,
"journal": "Psychological Review",
"doi": "10.1037/0033-295X.114.4.843",
"evidence_strength": "mechanistic",
"verified": true
},
{
"id": "ref_doi_10_1002_14651858_CD013229_pub2",
"kind": "paper",
"title": "Pharmacological interventions for smoking cessation",
"authors": ["Hartmann-Boyce J", "Lindson N", "Butler AR", "McRobbie H", "Bullen C", "Begh R", "Theodoulou A", "Notley C", "Rigotti NA", "Turner T", "Fanshawe TR", "Hajek P"],
"year": 2023,
"journal": "Cochrane Database of Systematic Reviews",
"doi": "10.1002/14651858.CD013229.pub2",
"evidence_strength": "meta_analysis",
"verified": true
},
{
"id": "ref_doi_10_1111_add_13297",
"kind": "paper",
"title": "Should compulsive sexual behavior be considered an addiction?",
"authors": ["Kraus SW", "Voon V", "Potenza MN"],
"year": 2016,
"journal": "Addiction",
"doi": "10.1111/add.13297",
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_doi_10_1257_aer_20190658",
"kind": "paper",
"title": "The Welfare Effects of Social Media",
"authors": ["Allcott H", "Braghieri L", "Eichmeyer S", "Gentzkow M"],
"year": 2020,
"journal": "American Economic Review",
"doi": "10.1257/aer.20190658",
"evidence_strength": "strong_rct",
"verified": true
},
{
"id": "ref_doi_10_1002_oby_21371",
"kind": "paper",
"title": "Isocaloric fructose restriction and metabolic improvement in children with obesity and metabolic syndrome",
"authors": ["Lustig RH", "Mulligan K", "Noworolski SM", "Tai VW", "Wen MJ", "Erkin-Cakmak A", "Gugliucci A", "Schwarz JM"],
"year": 2016,
"journal": "Obesity",
"doi": "10.1002/oby.21371",
"evidence_strength": "strong_rct",
"verified": true
},
{
"id": "ref_doi_10_5664_jcsm_3170",
"kind": "paper",
"title": "Caffeine effects on sleep taken 0, 3, or 6 hours before going to bed",
"authors": ["Drake C", "Roehrs T", "Shambroom J", "Roth T"],
"year": 2013,
"journal": "Journal of Clinical Sleep Medicine",
"doi": "10.5664/jcsm.3170",
"evidence_strength": "strong_rct",
"verified": true
},
{
"id": "ref_doi_10_1016_S2215_0366_19_30048_3",
"kind": "paper",
"title": "The contribution of cannabis use to variation in the incidence of psychotic disorder across Europe (EU-GEI)",
"authors": ["Di Forti M", "Quattrone D", "Freeman TP", "Tripoli G", "Gayer-Anderson C"],
"year": 2019,
"journal": "Lancet Psychiatry",
"doi": "10.1016/S2215-0366(19)30048-3",
"evidence_strength": "observational",
"verified": true
},
{
"id": "ref_doi_10_1098_rstb_2008_0100",
"kind": "paper",
"title": "The neurobiology of pathological gambling and drug addiction",
"authors": ["Potenza MN"],
"year": 2008,
"journal": "Philosophical Transactions of the Royal Society B",
"doi": "10.1098/rstb.2008.0100",
"evidence_strength": "mechanistic",
"verified": true
},
{
"id": "ref_doi_10_1037_0033_295X_101_1_34",
"kind": "paper",
"title": "Ironic processes of mental control",
"authors": ["Wegner DM"],
"year": 1994,
"journal": "Psychological Review",
"doi": "10.1037/0033-295X.101.1.34",
"evidence_strength": "mechanistic",
"verified": true
},
{
"id": "ref_doi_10_1037_0022_3514_53_1_5",
"kind": "paper",
"title": "Paradoxical effects of thought suppression",
"authors": ["Wegner DM", "Schneider DJ", "Carter SR", "White TL"],
"year": 1987,
"journal": "Journal of Personality and Social Psychology",
"doi": "10.1037/0022-3514.53.1.5",
"evidence_strength": "strong_rct",
"verified": true
},
{
"id": "ref_doi_10_1207_s15326985ep3403_3",
"kind": "paper",
"title": "Approach and avoidance motivation and achievement goals",
"authors": ["Elliot AJ"],
"year": 1999,
"journal": "Educational Psychologist",
"doi": "10.1207/s15326985ep3403_3",
"evidence_strength": "mechanistic",
"verified": true
},
{
"id": "ref_doi_10_1037_0003_066X_54_7_493",
"kind": "paper",
"title": "Implementation intentions: Strong effects of simple plans",
"authors": ["Gollwitzer PM"],
"year": 1999,
"journal": "American Psychologist",
"doi": "10.1037/0003-066X.54.7.493",
"evidence_strength": "meta_analysis",
"verified": true
},
{
"id": "ref_doi_10_1037_0033_2909_125_6_627",
"kind": "paper",
"title": "A meta-analytic review of experiments examining the effects of extrinsic rewards on intrinsic motivation",
"authors": ["Deci EL", "Koestner R", "Ryan RM"],
"year": 1999,
"journal": "Psychological Bulletin",
"doi": "10.1037/0033-2909.125.6.627",
"evidence_strength": "meta_analysis",
"verified": true,
"note": "tangible reward: d=0.28~0.40 / verbal feedback: d=+0.33"
},
{
"id": "ref_doi_10_1146_annurev_psych_122414_033417",
"kind": "paper",
"title": "Psychology of Habit",
"authors": ["Wood W", "Rünger D"],
"year": 2016,
"journal": "Annual Review of Psychology",
"doi": "10.1146/annurev-psych-122414-033417",
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_doi_10_1139_apnm_2015_0550",
"kind": "paper",
"title": "Protein 'requirements' beyond the RDA: implications for optimizing health",
"authors": ["Phillips SM", "Chevalier S", "Leidy HJ"],
"year": 2016,
"journal": "Applied Physiology, Nutrition, and Metabolism",
"doi": "10.1139/apnm-2015-0550",
"evidence_strength": "meta_analysis",
"verified": true
},
{
"id": "ref_doi_10_1136_bjsports_2017_097608",
"kind": "paper",
"title": "A systematic review, meta-analysis and meta-regression of the effect of protein supplementation on resistance training-induced gains",
"authors": ["Morton RW", "Murphy KT", "McKellar SR", "Schoenfeld BJ", "Henselmans M", "Helms E", "Aragon AA", "Devries MC", "Banfield L", "Krieger JW", "Phillips SM"],
"year": 2018,
"journal": "British Journal of Sports Medicine",
"doi": "10.1136/bjsports-2017-097608",
"evidence_strength": "meta_analysis",
"verified": true
},
{
"id": "ref_doi_10_3945_ajcn_114_084038",
"kind": "paper",
"title": "The role of protein in weight loss and maintenance",
"authors": ["Leidy HJ", "Clifton PM", "Astrup A", "Wycherley TP", "Westerterp-Plantenga MS", "Luscombe-Marsh ND", "Woods SC", "Mattes RD"],
"year": 2015,
"journal": "American Journal of Clinical Nutrition",
"doi": "10.3945/ajcn.114.084038",
"evidence_strength": "meta_analysis",
"verified": true
},
{
"id": "ref_url_who_sugar_2015",
"kind": "url",
"title": "WHO Guideline: Sugars intake for adults and children",
"authors": ["World Health Organization"],
"year": 2015,
"url": "https://www.who.int/publications/i/item/9789241549028",
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_doi_10_1038_sj_ijo_0801229",
"kind": "paper",
"title": "Liquid versus solid carbohydrate: effects on food intake and body weight",
"authors": ["DiMeglio DP", "Mattes RD"],
"year": 2000,
"journal": "International Journal of Obesity",
"doi": "10.1038/sj.ijo.0801229",
"evidence_strength": "strong_rct",
"verified": true
},
{
"id": "ref_doi_10_1016_S0140_6736_18_31809_9",
"kind": "paper",
"title": "Carbohydrate quality and human health: a series of systematic reviews and meta-analyses",
"authors": ["Reynolds A", "Mann J", "Cummings J", "Winter N", "Mete E", "Te Morenga L"],
"year": 2019,
"journal": "Lancet",
"doi": "10.1016/S0140-6736(18)31809-9",
"evidence_strength": "meta_analysis",
"verified": true
},
{
"id": "ref_doi_10_1016_j_cell_2021_06_019",
"kind": "paper",
"title": "Gut-microbiota-targeted diets modulate human immune status",
"authors": ["Wastyk HC", "Fragiadakis GK", "Perelman D", "Dahan D", "Merrill BD", "Yu FB", "Topf M", "Gonzalez CG", "Van Treuren W", "Han S", "Robinson JL", "Elias JE", "Sonnenburg ED", "Gardner CD", "Sonnenburg JL"],
"year": 2021,
"journal": "Cell",
"doi": "10.1016/j.cell.2021.06.019",
"evidence_strength": "strong_rct",
"verified": true
},
{
"id": "ref_doi_10_1038_s41579_019_0191_8",
"kind": "paper",
"title": "The ancestral and industrialized gut microbiota and implications for human health",
"authors": ["Sonnenburg ED", "Sonnenburg JL"],
"year": 2019,
"journal": "Nature Reviews Microbiology",
"doi": "10.1038/s41579-019-0191-8",
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_doi_10_1016_j_cmet_2020_06_018",
"kind": "paper",
"title": "Effects of 4- and 6-h Time-Restricted Feeding on Weight and Cardiometabolic Health: A Randomized Controlled Trial",
"authors": ["Cienfuegos S", "Gabel K", "Kalam F", "Ezpeleta M", "Wiseman E", "Pavlou V", "Lin S", "Oliveira ML", "Varady KA"],
"year": 2020,
"journal": "Cell Metabolism",
"doi": "10.1016/j.cmet.2020.06.018",
"evidence_strength": "strong_rct",
"verified": true
},
{
"id": "ref_doi_10_1038_ijo_2012_229",
"kind": "paper",
"title": "Timing of food intake predicts weight loss effectiveness",
"authors": ["Garaulet M", "Gómez-Abellán P", "Alburquerque-Béjar JJ", "Lee YC", "Ordovás JM", "Scheer FA"],
"year": 2013,
"journal": "International Journal of Obesity",
"doi": "10.1038/ijo.2012.229",
"evidence_strength": "observational",
"verified": true
},
{
"id": "ref_doi_10_1016_j_mayocp_2020_08_034",
"kind": "paper",
"title": "Effect of Omega-3 Dosage on Cardiovascular Outcomes: An Updated Meta-Analysis",
"authors": ["Bernasconi AA", "Wiest MM", "Lavie CJ", "Milani RV", "Laukkanen JA"],
"year": 2021,
"journal": "Mayo Clinic Proceedings",
"doi": "10.1016/j.mayocp.2020.08.034",
"evidence_strength": "meta_analysis",
"verified": true
},
{
"id": "ref_doi_10_1056_NEJMoa1800389",
"kind": "paper",
"title": "Primary Prevention of Cardiovascular Disease with a Mediterranean Diet Supplemented with Extra-Virgin Olive Oil or Nuts (PREDIMED)",
"authors": ["Estruch R", "Ros E", "Salas-Salvadó J", "Covas MI", "Corella D", "Arós F"],
"year": 2018,
"journal": "New England Journal of Medicine",
"doi": "10.1056/NEJMoa1800389",
"evidence_strength": "strong_rct",
"verified": true
},
{
"id": "ref_doi_10_1016_j_jacc_2017_05_047",
"kind": "paper",
"title": "Healthful and Unhealthful Plant-Based Diets and the Risk of Coronary Heart Disease in U.S. Adults",
"authors": ["Satija A", "Bhupathiraju SN", "Spiegelman D", "Chiuve SE", "Manson JE", "Willett W", "Rexrode KM", "Rimm EB", "Hu FB"],
"year": 2017,
"journal": "Journal of the American College of Cardiology",
"doi": "10.1016/j.jacc.2017.05.047",
"evidence_strength": "observational",
"verified": true
},
{
"id": "ref_book_lembke_dopamine_nation",
"kind": "book",
"title": "Dopamine Nation: Finding Balance in the Age of Indulgence",
"authors": ["Lembke A"],
"year": 2021,
"publisher": "Dutton",
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_book_clear_atomic_habits",
"kind": "book",
"title": "Atomic Habits: An Easy & Proven Way to Build Good Habits & Break Bad Ones",
"authors": ["Clear J"],
"year": 2018,
"publisher": "Avery",
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_book_fogg_tiny_habits",
"kind": "book",
"title": "Tiny Habits: The Small Changes That Change Everything",
"authors": ["Fogg BJ"],
"year": 2019,
"publisher": "Houghton Mifflin Harcourt",
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_book_marlatt_relapse_prevention",
"kind": "book",
"title": "Relapse Prevention: Maintenance Strategies in the Treatment of Addictive Behaviors",
"authors": ["Marlatt GA", "Donovan DM"],
"year": 2005,
"publisher": "Guilford",
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_book_lakoff_elephant",
"kind": "book",
"title": "Don't Think of an Elephant! Know Your Values and Frame the Debate",
"authors": ["Lakoff G"],
"year": 2004,
"publisher": "Chelsea Green",
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_book_allen_gtd",
"kind": "book",
"title": "Getting Things Done: The Art of Stress-Free Productivity",
"authors": ["Allen D"],
"year": 2001,
"publisher": "Penguin",
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_book_covey_7habits",
"kind": "book",
"title": "The 7 Habits of Highly Effective People",
"authors": ["Covey SR"],
"year": 1989,
"publisher": "Free Press",
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_book_tracy_eat_that_frog",
"kind": "book",
"title": "Eat That Frog!: 21 Great Ways to Stop Procrastinating and Get More Done in Less Time",
"authors": ["Tracy B"],
"year": 2001,
"publisher": "Berrett-Koehler",
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_book_cirillo_pomodoro",
"kind": "book",
"title": "The Pomodoro Technique",
"authors": ["Cirillo F"],
"year": 2006,
"publisher": "FC Garage",
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_book_duhigg_power_of_habit",
"kind": "book",
"title": "The Power of Habit: Why We Do What We Do in Life and Business",
"authors": ["Duhigg C"],
"year": 2012,
"publisher": "Random House",
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_book_carroll_bullet_journal",
"kind": "book",
"title": "The Bullet Journal Method",
"authors": ["Carroll R"],
"year": 2018,
"publisher": "Portfolio",
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_book_forte_second_brain",
"kind": "book",
"title": "Building a Second Brain",
"authors": ["Forte T"],
"year": 2022,
"publisher": "Atria Books",
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_book_ahrens_smart_notes",
"kind": "book",
"title": "How to Take Smart Notes",
"authors": ["Ahrens S"],
"year": 2017,
"publisher": "CreateSpace",
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_book_newport_deep_work",
"kind": "book",
"title": "Deep Work: Rules for Focused Success in a Distracted World",
"authors": ["Newport C"],
"year": 2016,
"publisher": "Grand Central",
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_book_zao_sanders_timeboxing",
"kind": "book",
"title": "Timeboxing",
"authors": ["Zao-Sanders M"],
"year": 2024,
"publisher": "Penguin",
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_book_doerr_measure_what_matters",
"kind": "book",
"title": "Measure What Matters",
"authors": ["Doerr J"],
"year": 2018,
"publisher": "Portfolio",
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_book_oettingen_woop",
"kind": "book",
"title": "Rethinking Positive Thinking: Inside the New Science of Motivation",
"authors": ["Oettingen G"],
"year": 2014,
"publisher": "Current",
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_book_benson_personal_kanban",
"kind": "book",
"title": "Personal Kanban: Mapping Work | Navigating Life",
"authors": ["Benson J", "Barry TD"],
"year": 2011,
"publisher": "Modus Cooperandi Press",
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_book_babauta_zen_to_done",
"kind": "book",
"title": "Zen to Done",
"authors": ["Babauta L"],
"year": 2008,
"publisher": "Self-published",
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_book_cavoulacos_new_rules_of_work",
"kind": "book",
"title": "The New Rules of Work",
"authors": ["Cavoulacos A", "Minshew K"],
"year": 2017,
"publisher": "Crown Business",
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_podcast_hl_39_dopamine",
"kind": "podcast_episode",
"title": "Controlling Your Dopamine For Motivation, Focus & Satisfaction",
"year": 2021,
"url": "https://www.hubermanlab.com/episode/controlling-your-dopamine-for-motivation-focus-and-satisfaction",
"episode_number": 39,
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_podcast_hl_53_habits",
"kind": "podcast_episode",
"title": "The Science of Making & Breaking Habits",
"year": 2022,
"url": "https://www.hubermanlab.com/episode/the-science-of-making-and-breaking-habits",
"episode_number": 53,
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_podcast_hl_68_light",
"kind": "podcast_episode",
"title": "Using Light (Sunlight, Blue Light & Red Light) to Optimize Health",
"year": 2022,
"url": "https://www.hubermanlab.com/episode/using-light-sunlight-blue-light-and-red-light-to-optimize-health",
"episode_number": 68,
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_podcast_hl_84_sleep_toolkit",
"kind": "podcast_episode",
"title": "Sleep Toolkit: Tools for Optimizing Sleep & Sleep-Wake Timing",
"year": 2022,
"url": "https://www.hubermanlab.com/episode/sleep-toolkit-tools-for-optimizing-sleep-and-sleep-wake-timing",
"episode_number": 84,
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_podcast_hl_101_caffeine",
"kind": "podcast_episode",
"title": "Using Caffeine to Optimize Mental & Physical Performance",
"year": 2022,
"url": "https://www.hubermanlab.com/episode/using-caffeine-to-optimize-mental-and-physical-performance",
"episode_number": 101,
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_podcast_hl_66_cold",
"kind": "podcast_episode",
"title": "Using Deliberate Cold Exposure for Health and Performance",
"year": 2022,
"url": "https://www.hubermanlab.com/episode/using-deliberate-cold-exposure-for-health-and-performance",
"episode_number": 66,
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_podcast_hl_69_heat",
"kind": "podcast_episode",
"title": "The Science & Health Benefits of Deliberate Heat Exposure",
"year": 2022,
"url": "https://www.hubermanlab.com/episode/the-science-and-health-benefits-of-deliberate-heat-exposure",
"episode_number": 69,
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_podcast_hl_96_meditation",
"kind": "podcast_episode",
"title": "How Meditation Works & Science-Based Effective Meditations",
"year": 2022,
"url": "https://www.hubermanlab.com/episode/how-meditation-works-and-science-based-effective-meditations",
"episode_number": 96,
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_podcast_hl_28_daily_tools",
"kind": "podcast_episode",
"title": "Maximizing Productivity, Physical & Mental Health with Daily Tools",
"year": 2021,
"url": "https://www.hubermanlab.com/episode/maximizing-productivity-physical-and-mental-health-with-daily-tools",
"episode_number": 28,
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_podcast_hl_10_stress",
"kind": "podcast_episode",
"title": "Tools for Managing Stress & Anxiety",
"year": 2021,
"url": "https://www.hubermanlab.com/episode/tools-for-managing-stress-and-anxiety",
"episode_number": 10,
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_podcast_hl_113_dopamine_procrastination",
"kind": "podcast_episode",
"title": "Leverage Dopamine to Overcome Procrastination & Optimize Effort",
"year": 2023,
"url": "https://www.hubermanlab.com/episode/leverage-dopamine-to-overcome-procrastination-and-optimize-effort",
"episode_number": 113,
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_podcast_hl_88_focus",
"kind": "podcast_episode",
"title": "Focus Toolkit: Tools to Improve Your Focus & Concentration",
"year": 2022,
"url": "https://www.hubermanlab.com/episode/focus-toolkit-tools-to-improve-your-focus-and-concentration",
"episode_number": 88,
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_podcast_hl_8_learning",
"kind": "podcast_episode",
"title": "Optimize Your Learning & Creativity with Science-based Tools",
"year": 2021,
"url": "https://www.hubermanlab.com/episode/optimize-your-learning-and-creativity-with-science-based-tools",
"episode_number": 8,
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_podcast_hl_6_focus_brain",
"kind": "podcast_episode",
"title": "How to Focus to Change Your Brain",
"year": 2021,
"url": "https://www.hubermanlab.com/episode/how-to-focus-to-change-your-brain",
"episode_number": 6,
"evidence_strength": "expert_opinion",
"verified": true
},
{
"id": "ref_podcast_hl_2_sleep",
"kind": "podcast_episode",
"title": "Master Your Sleep & Be More Alert When Awake",
"year": 2021,
"url": "https://www.hubermanlab.com/episode/master-your-sleep-be-more-alert-when-awake",
"episode_number": 2,
"evidence_strength": "expert_opinion",
"verified": true
}
]

View File

@@ -0,0 +1,248 @@
[
{
"id": "t0_fist_yes",
"tier_recommended": "T0",
"title": "주먹 쥐고 \"좋아\" 1초",
"description": "행동 직후 1초 자기 발화. Fogg Tiny Habits의 Celebration(Shine) 핵심.",
"estimated_cost_krw_range": { "min": 0, "max": 0 },
"tags": ["experience"]
},
{
"id": "t0_self_talk_done",
"tier_recommended": "T0",
"title": "\"오늘도 해냈다\" 자기 발화 3초",
"description": "긍정 self-talk. verbal feedback의 내재 동기 강화 효과.",
"estimated_cost_krw_range": { "min": 0, "max": 0 },
"tags": ["experience"]
},
{
"id": "t0_calendar_circle",
"tier_recommended": "T0",
"title": "캘린더 ○ 그리기 5초",
"description": "Seinfeld의 Don't Break the Chain 시각 강화.",
"estimated_cost_krw_range": { "min": 0, "max": 0 },
"tags": ["experience"]
},
{
"id": "t0_shoulder_pat",
"tier_recommended": "T0",
"title": "자기 어깨 토닥 + \"수고했어\"",
"description": "Self-compassion 발화 (Neff). 신체 접촉으로 정서 강도 부여.",
"estimated_cost_krw_range": { "min": 0, "max": 0 },
"tags": ["experience"]
},
{
"id": "t0_natural_tea",
"tier_recommended": "T0",
"title": "활동에 자연스럽게 따라오는 차 1잔",
"description": "Anchoring으로 활동에 결합된 차 1잔. 별도 비용 X.",
"estimated_cost_krw_range": { "min": 0, "max": 0 },
"tags": ["rest"]
},
{
"id": "t0_window_gaze",
"tier_recommended": "T0",
"title": "60초 창밖 응시",
"description": "panoramic vision으로 stress arc 종료 (Huberman §2.3).",
"estimated_cost_krw_range": { "min": 0, "max": 0 },
"tags": ["rest"]
},
{
"id": "t0_one_line_journal",
"tier_recommended": "T0",
"title": "자기 전 한 줄 일기",
"description": "\"오늘 ___ 했다\" 1문장. 기록 의식.",
"estimated_cost_krw_range": { "min": 0, "max": 0 },
"tags": ["learning"]
},
{
"id": "t0_favorite_song",
"tier_recommended": "T0",
"title": "좋아하는 음악 1곡 의식적으로 듣기",
"description": "기존 음악을 의식적으로. 변동 보상 X.",
"estimated_cost_krw_range": { "min": 0, "max": 0 },
"tags": ["hobby"]
},
{
"id": "t0_message_one_person",
"tier_recommended": "T0",
"title": "가까운 1인에게 \"오늘 했어\" 메시지",
"description": "Social accountability. 저빈도로 운영.",
"estimated_cost_krw_range": { "min": 0, "max": 0 },
"tags": ["social"]
},
{
"id": "t0_mirror_smile",
"tier_recommended": "T0",
"title": "거울 보며 미소 5초",
"description": "표정 피드백으로 정서 강화.",
"estimated_cost_krw_range": { "min": 0, "max": 0 },
"tags": ["experience"]
},
{
"id": "t1_acknowledgement",
"tier_recommended": "T1",
"title": "\"3회 했네, 시작됐다\" 자기 인정 1분",
"description": "Lally 2010 자동화 곡선 통과 신호의 의식적 자축.",
"estimated_cost_krw_range": { "min": 0, "max": 0 },
"tags": ["experience"]
},
{
"id": "t1_careful_brew",
"tier_recommended": "T1",
"title": "좋아하는 차/커피 정성껏 한 잔",
"description": "평소 차/커피를 평소보다 정성껏. 추가 비용 거의 0.",
"estimated_cost_krw_range": { "min": 0, "max": 3000 },
"tags": ["rest"],
"avoid_for_break_habits": ["caffeine"]
},
{
"id": "t1_one_line_record",
"tier_recommended": "T1",
"title": "한 줄 기록 \"3회 통과: ___\"",
"description": "Streak 기록 노트 한 줄.",
"estimated_cost_krw_range": { "min": 0, "max": 0 },
"tags": ["learning"]
},
{
"id": "t1_new_album",
"tier_recommended": "T1",
"title": "평소 안 듣던 좋아하는 앨범 1장",
"description": "스트리밍 서비스 내 무비용 보상.",
"estimated_cost_krw_range": { "min": 0, "max": 0 },
"tags": ["hobby"]
},
{
"id": "t1_short_walk_15min",
"tier_recommended": "T1",
"title": "짧은 산책 15분",
"description": "보상 + Huberman 햇빛 시너지 (오전 권장).",
"estimated_cost_krw_range": { "min": 0, "max": 0 },
"tags": ["experience", "rest"]
},
{
"id": "t2_weekly_review_30min",
"tier_recommended": "T2",
"title": "주간 회고 노트 30분",
"description": "PDCA의 C-A 통합. 매주 일요일 자동 알림 + 수동 수행.",
"estimated_cost_krw_range": { "min": 0, "max": 0 },
"tags": ["learning"]
},
{
"id": "t2_new_cafe",
"tier_recommended": "T2",
"title": "평소 안 가본 동네 카페 1곳",
"description": "주의: 월 4회 이하로 제한 (overjustification 방지).",
"estimated_cost_krw_range": { "min": 5000, "max": 10000 },
"tags": ["experience"],
"avoid_for_break_habits": ["caffeine", "sugar"]
},
{
"id": "t2_movie_predeclared",
"tier_recommended": "T2",
"title": "좋아하는 영화 1편 사전 선언",
"description": "주 시작 시 사전 선언, 달성 시 시청.",
"estimated_cost_krw_range": { "min": 0, "max": 15000 },
"tags": ["hobby", "rest"]
},
{
"id": "t2_meal_with_close_person",
"tier_recommended": "T2",
"title": "가까운 사람과 식사",
"description": "Social bonding. SDT relatedness 충족.",
"estimated_cost_krw_range": { "min": 10000, "max": 30000 },
"tags": ["social", "experience"]
},
{
"id": "t2_favorite_walk_course",
"tier_recommended": "T2",
"title": "좋아하는 산책 코스 1회",
"description": "익숙한 코스 1회. 자연 노출 + 무비용.",
"estimated_cost_krw_range": { "min": 0, "max": 0 },
"tags": ["experience", "rest"]
},
{
"id": "t3_new_book",
"tier_recommended": "T3",
"title": "새 책 1권 구매",
"description": "30일 마일스톤 사전 선언 보상.",
"estimated_cost_krw_range": { "min": 15000, "max": 25000 },
"tags": ["object", "learning"]
},
{
"id": "t3_identity_declaration",
"tier_recommended": "T3",
"title": "\"나는 ___ 하는 사람이다\" 정체성 선언 (1명에게 공개)",
"description": "L3 정체성 프레임 강화. Atomic Habits identity-based.",
"estimated_cost_krw_range": { "min": 0, "max": 0 },
"tags": ["social", "experience"]
},
{
"id": "t3_day_trip",
"tier_recommended": "T3",
"title": "1일 근교 여행",
"description": "주의: 월 1회만. 사전 선언 필수.",
"estimated_cost_krw_range": { "min": 30000, "max": 100000 },
"is_effort_tied": false,
"tags": ["experience"]
},
{
"id": "t3_exhibition",
"tier_recommended": "T3",
"title": "전시/공연 1회",
"description": "주의: 월 1회. 사전 선언.",
"estimated_cost_krw_range": { "min": 15000, "max": 50000 },
"tags": ["experience", "hobby"]
},
{
"id": "t3_progress_review",
"tier_recommended": "T3",
"title": "한 달 진척 영상/사진 정리",
"description": "Visual progress proof. 무비용.",
"estimated_cost_krw_range": { "min": 0, "max": 0 },
"tags": ["learning"]
},
{
"id": "t3_long_meeting_friend",
"tier_recommended": "T3",
"title": "평소 못 만난 친구와 긴 만남",
"description": "Social bond 깊이 강화.",
"estimated_cost_krw_range": { "min": 10000, "max": 50000 },
"tags": ["social"]
},
{
"id": "t4_tool_upgrade",
"tier_recommended": "T4",
"title": "활동 지원 도구 업그레이드",
"description": "러닝화·노트·키보드 등. cue 강화 (effort-tied).",
"estimated_cost_krw_range": { "min": 50000, "max": 300000 },
"is_effort_tied": true,
"tags": ["object"]
},
{
"id": "t4_teach_one_person",
"tier_recommended": "T4",
"title": "배운 것 1명에게 가르치기/발표",
"description": "Protégé effect. competence + relatedness.",
"estimated_cost_krw_range": { "min": 0, "max": 0 },
"is_effort_tied": true,
"tags": ["social", "learning"]
},
{
"id": "t4_nature_exposure",
"tier_recommended": "T4",
"title": "자연 노출 1일 (등산·바다·숲)",
"description": "Huberman 친화. effort-tied 큰 경험.",
"estimated_cost_krw_range": { "min": 0, "max": 100000 },
"is_effort_tied": true,
"tags": ["experience"]
},
{
"id": "t4_small_donation",
"tier_recommended": "T4",
"title": "의미 있는 기부 1건 (소액)",
"description": "Meaning + identity 강화. 사전 선언.",
"estimated_cost_krw_range": { "min": 10000, "max": 50000 },
"tags": ["experience"]
}
]

View File

@@ -0,0 +1,4 @@
/// Default single-user id for local-only Phase 1.
const String kLocalDefaultUserId = 'u_local_default';
const String kSeededV1Flag = 'seeded_v1';

14
app/lib/core/id.dart Normal file
View File

@@ -0,0 +1,14 @@
import 'package:ulid/ulid.dart';
/// Typed ULID with prefix (e.g. `hb_01J9XYZ...`).
String generateUlid(String prefix) {
if (prefix.isEmpty) {
throw ArgumentError('prefix must be non-empty');
}
return '${prefix}_${Ulid().toString()}';
}
final RegExp _ulidRegex =
RegExp(r'^[A-Za-z]{1,8}_[0-9A-HJKMNP-TV-Z]{26}$');
bool isValidUlid(String s) => _ulidRegex.hasMatch(s);

16
app/lib/core/result.dart Normal file
View File

@@ -0,0 +1,16 @@
/// Minimal Result sum type. Use for domain operations that may fail.
sealed class Result<T, E> {
const Result();
bool get isOk => this is Ok<T, E>;
bool get isErr => this is Err<T, E>;
}
final class Ok<T, E> extends Result<T, E> {
final T value;
const Ok(this.value);
}
final class Err<T, E> extends Result<T, E> {
final E error;
const Err(this.error);
}

19
app/lib/core/time.dart Normal file
View File

@@ -0,0 +1,19 @@
/// Time helpers. KST default. ISO 8601 string for DB storage.
DateTime nowKst() => DateTime.now().toLocal();
/// Strip time → YYYY-MM-DD (DB date column).
String dateOnly(DateTime d) {
final local = d.toLocal();
return '${local.year.toString().padLeft(4, '0')}-'
'${local.month.toString().padLeft(2, '0')}-'
'${local.day.toString().padLeft(2, '0')}';
}
/// Monday 00:00 of the week containing [d].
DateTime weekStart(DateTime d) {
final local = d.toLocal();
final mondayOffset = (local.weekday - DateTime.monday) % 7;
final monday = DateTime(local.year, local.month, local.day)
.subtract(Duration(days: mondayOffset));
return monday;
}

View File

@@ -0,0 +1,144 @@
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'tables/catalog_tables.dart';
import 'tables/user_tables.dart';
part 'app_database.g.dart';
@DriftDatabase(tables: [
// Catalog 8
Protocols,
BreakProtocols,
CommonFrames,
Methodologies,
FramePatterns,
RewardMenuItems,
References,
DietPatterns,
// User 11 + 정규화 부속 1
Users,
Phases,
Habits,
HabitDoseVariants,
IfThenRules,
TrackerEntries,
LapseLogs,
UrgeLogs,
RewardDeclarations,
RewardClaims,
Reflections,
// Meta
MetaKv,
])
class AppDatabase extends _$AppDatabase {
AppDatabase(super.e);
/// In-memory for tests.
AppDatabase.memory() : super(NativeDatabase.memory());
@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 only has v1. Reaching here is a bug.
assert(false, 'Phase 1 has no upgrade path. from=$from to=$to');
},
);
Future<void> _createIndexes(Migrator m) async {
// Catalog indexes
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'));
await m.createIndex(Index(
'IDX_diet_patterns_evidence',
'CREATE INDEX IDX_diet_patterns_evidence '
'ON diet_patterns(evidence_strength)'));
await m.createIndex(Index(
'IDX_diet_patterns_kfit',
'CREATE INDEX IDX_diet_patterns_kfit '
'ON diet_patterns(korean_context_fit) '
'WHERE korean_context_fit IS NOT NULL'));
// User indexes
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)'));
}
}
Future<File> appDatabaseFile() async {
final dir = await getApplicationDocumentsDirectory();
return File(p.join(dir.path, 'life_helper.sqlite'));
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,147 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import '../../../core/id.dart';
import '../../../domain/models/habit.dart';
import '../../../domain/rules/xor_protocol.dart';
import '../app_database.dart';
import '../tables/user_tables.dart';
part 'habit_dao.g.dart';
class HabitDraft {
final String userId;
final HabitType type;
final String title;
final String? protocolId;
final String? breakProtocolId;
final FrameLevel frameLevel;
final String frameFramedText;
final String? frameOriginalText;
final String? anchorWhen;
final String? anchorAfterWhat;
final String? anchorWhere;
final String startedAt;
final String? phaseId;
final List<VariantDraft> variants;
const HabitDraft({
required this.userId,
required this.type,
required this.title,
required this.frameLevel,
required this.frameFramedText,
required this.startedAt,
this.protocolId,
this.breakProtocolId,
this.frameOriginalText,
this.anchorWhen,
this.anchorAfterWhat,
this.anchorWhere,
this.phaseId,
this.variants = const [],
});
}
class VariantDraft {
final String label;
final String doseText;
final List<String> contextTags;
final List<String> conditionTags;
final bool isMinimum;
final int sortOrder;
const VariantDraft({
required this.label,
required this.doseText,
this.contextTags = const [],
this.conditionTags = const [],
this.isMinimum = false,
this.sortOrder = 0,
});
}
@DriftAccessor(tables: [Habits, HabitDoseVariants])
class HabitDao extends DatabaseAccessor<AppDatabase> with _$HabitDaoMixin {
HabitDao(super.db);
/// Insert habit + variants atomically.
Future<String> insertWithVariants(HabitDraft draft) async {
assertXorProtocol(
type: draft.type,
protocolId: draft.protocolId,
breakProtocolId: draft.breakProtocolId,
);
final habitId = generateUlid('hb');
await transaction(() async {
await into(habits).insert(HabitsCompanion.insert(
id: habitId,
userId: draft.userId,
type: draft.type.dbValue,
status: 'active',
title: draft.title,
protocolId: Value(draft.protocolId),
breakProtocolId: Value(draft.breakProtocolId),
frameLevel: draft.frameLevel.dbValue,
frameFramedText: draft.frameFramedText,
frameOriginalText: Value(draft.frameOriginalText),
anchorWhen: Value(draft.anchorWhen),
anchorAfterWhat: Value(draft.anchorAfterWhat),
anchorWhere: Value(draft.anchorWhere),
startedAt: draft.startedAt,
phaseId: Value(draft.phaseId),
));
for (final v in draft.variants) {
await into(habitDoseVariants).insert(HabitDoseVariantsCompanion.insert(
variantId: generateUlid('dv'),
habitId: habitId,
label: v.label,
doseText: v.doseText,
contextTagsJson: Value(jsonEncode(v.contextTags)),
conditionTagsJson: Value(jsonEncode(v.conditionTags)),
isMinimum: Value(v.isMinimum),
sortOrder: Value(v.sortOrder),
));
}
});
return habitId;
}
Future<int> countActive({
required String userId,
required HabitType type,
String? excludeHabitId,
}) async {
final query = select(habits)
..where((t) => t.userId.equals(userId))
..where((t) => t.status.equals('active'))
..where((t) => t.type.equals(type.dbValue));
if (excludeHabitId != null) {
query.where((t) => t.id.isNotValue(excludeHabitId));
}
final rows = await query.get();
return rows.length;
}
Future<List<Habit>> activeHabitsForUser(String userId) {
return (select(habits)
..where((t) => t.userId.equals(userId))
..where((t) => t.status.equals('active')))
.get();
}
Future<List<HabitDoseVariant>> variantsForHabit(String habitId) {
return (select(habitDoseVariants)
..where((t) => t.habitId.equals(habitId))
..orderBy([(t) => OrderingTerm.asc(t.sortOrder)]))
.get();
}
Future<List<HabitDoseVariant>> variantsByIds(Set<String> ids) {
if (ids.isEmpty) return Future.value(const []);
return (select(habitDoseVariants)
..where((t) => t.variantId.isIn(ids)))
.get();
}
}

View File

@@ -0,0 +1,12 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'habit_dao.dart';
// ignore_for_file: type=lint
mixin _$HabitDaoMixin on DatabaseAccessor<AppDatabase> {
$UsersTable get users => attachedDatabase.users;
$PhasesTable get phases => attachedDatabase.phases;
$HabitsTable get habits => attachedDatabase.habits;
$HabitDoseVariantsTable get habitDoseVariants =>
attachedDatabase.habitDoseVariants;
}

View File

@@ -0,0 +1,23 @@
import 'package:drift/drift.dart';
import '../app_database.dart';
import '../tables/user_tables.dart';
part 'meta_dao.g.dart';
@DriftAccessor(tables: [MetaKv])
class MetaDao extends DatabaseAccessor<AppDatabase> with _$MetaDaoMixin {
MetaDao(super.db);
Future<String?> find(String key) async {
final row = await (select(metaKv)..where((t) => t.key.equals(key)))
.getSingleOrNull();
return row?.value;
}
Future<void> put(String key, String value) async {
await into(metaKv).insertOnConflictUpdate(
MetaKvCompanion.insert(key: key, value: value),
);
}
}

View File

@@ -0,0 +1,8 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'meta_dao.dart';
// ignore_for_file: type=lint
mixin _$MetaDaoMixin on DatabaseAccessor<AppDatabase> {
$MetaKvTable get metaKv => attachedDatabase.metaKv;
}

View File

@@ -0,0 +1,80 @@
import 'package:drift/drift.dart';
import '../../../core/id.dart';
import '../../../core/time.dart';
import '../app_database.dart';
import '../tables/user_tables.dart';
part 'tracker_dao.g.dart';
class TrackerEntryDraft {
final String habitId;
final String date; // YYYY-MM-DD
final String value; // done | blank
final String? variantId;
final String? ctxLocation;
final String? ctxCondition;
final String? note;
const TrackerEntryDraft({
required this.habitId,
required this.date,
required this.value,
this.variantId,
this.ctxLocation,
this.ctxCondition,
this.note,
});
}
@DriftAccessor(tables: [TrackerEntries])
class TrackerDao extends DatabaseAccessor<AppDatabase> with _$TrackerDaoMixin {
TrackerDao(super.db);
Future<String> recordCheckIn(TrackerEntryDraft draft) async {
final id = generateUlid('te');
await into(trackerEntries).insert(TrackerEntriesCompanion.insert(
id: id,
habitId: draft.habitId,
date: draft.date,
value: draft.value,
loggedAt: Value(nowKst().toIso8601String()),
variantId: Value(draft.variantId),
ctxLocation: Value(draft.ctxLocation),
ctxCondition: Value(draft.ctxCondition),
note: Value(draft.note),
));
return id;
}
Future<List<TrackerEntry>> entriesForHabit(String habitId) {
return (select(trackerEntries)
..where((t) => t.habitId.equals(habitId))
..orderBy([(t) => OrderingTerm.asc(t.date)]))
.get();
}
/// Done entries in [start, end) for one user.
/// [start]/[end] are YYYY-MM-DD strings.
Future<List<TrackerEntry>> findDoneInRangeForUser({
required String userId,
required String startDate,
required String endDate,
String? habitId,
}) async {
final habitIds = await (select(db.habits)
..where((t) => t.userId.equals(userId)))
.map((h) => h.id)
.get();
if (habitIds.isEmpty) return const [];
final query = select(trackerEntries)
..where((t) => t.habitId.isIn(habitIds))
..where((t) => t.value.equals('done'))
..where((t) =>
t.date.isBiggerOrEqualValue(startDate) & t.date.isSmallerThanValue(endDate));
if (habitId != null) {
query.where((t) => t.habitId.equals(habitId));
}
return query.get();
}
}

View File

@@ -0,0 +1,13 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'tracker_dao.dart';
// ignore_for_file: type=lint
mixin _$TrackerDaoMixin on DatabaseAccessor<AppDatabase> {
$UsersTable get users => attachedDatabase.users;
$PhasesTable get phases => attachedDatabase.phases;
$HabitsTable get habits => attachedDatabase.habits;
$HabitDoseVariantsTable get habitDoseVariants =>
attachedDatabase.habitDoseVariants;
$TrackerEntriesTable get trackerEntries => attachedDatabase.trackerEntries;
}

View File

@@ -0,0 +1,163 @@
import 'package:drift/drift.dart';
// 8 catalog tables, read-only. Source: schema/*.schema.json.
// Nested objects + arrays stored as JSON TEXT for read-only simplicity.
class Protocols extends Table {
TextColumn get id => text()();
TextColumn get category => text().check(const CustomExpression<bool>(
"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_text')();
TextColumn get dose => text()();
TextColumn get why => text()();
TextColumn get howJson => text().named('how_json')();
TextColumn get checkText => text().named('check_text')();
TextColumn get caution => text().nullable()();
TextColumn get defaultAnchorJson => text().named('default_anchor_json').nullable()();
TextColumn get minDoseForStart => text().named('min_dose_for_start').nullable()();
TextColumn get referenceIdsJson => text().named('reference_ids_json').nullable()();
TextColumn get evidenceStrength => text().named('evidence_strength').nullable().check(
const CustomExpression<bool>(
"evidence_strength IS NULL OR evidence_strength IN ('strong_rct','meta_analysis','observational','mechanistic','expert_opinion')"))();
TextColumn get sourceDoc => text().named('source_doc').nullable().check(
const CustomExpression<bool>(
"source_doc IS NULL OR source_doc IN ('huberman-protocols.md','diet-protocols.md')"))();
@override
Set<Column> get primaryKey => {id};
}
class BreakProtocols extends Table {
TextColumn get id => text()();
TextColumn get category => text().check(const CustomExpression<bool>(
"category IN ('alcohol','nicotine','porn_masturbation','social_media','sugar','caffeine','cannabis','behavioral')"))();
TextColumn get title => text()();
TextColumn get hubermanSummary => text().named('huberman_summary')();
TextColumn get frameExamplesJson => text().named('frame_examples_json').nullable()();
TextColumn get phasesJson => text().named('phases_json')();
TextColumn get defaultCommonFramesJson => text().named('default_common_frames_json')();
TextColumn get toolsJson => text().named('tools_json').nullable()();
TextColumn get medicalWarning => text().named('medical_warning').nullable()();
TextColumn get referenceIdsJson => text().named('reference_ids_json').nullable()();
@override
Set<Column> get primaryKey => {id};
}
class CommonFrames extends Table {
TextColumn get id => text().check(const CustomExpression<bool>(
"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().named('how_json').nullable()();
TextColumn get checkText => text().named('check_text')();
TextColumn get applicableBreakCategoriesJson =>
text().named('applicable_break_categories_json').nullable()();
TextColumn get referenceIdsJson => text().named('reference_ids_json').nullable()();
@override
Set<Column> get primaryKey => {id};
}
class Methodologies extends Table {
TextColumn get id => text()();
TextColumn get name => text()();
TextColumn get originator => text()();
TextColumn get oneLineDefinition => text().named('one_line_definition')();
TextColumn get coreUnit => text().named('core_unit')();
TextColumn get procedureJson => text().named('procedure_json').nullable()();
TextColumn get toolsJson => text().named('tools_json').nullable()();
TextColumn get strengthsJson => text().named('strengths_json').nullable()();
TextColumn get weaknessesJson => text().named('weaknesses_json').nullable()();
TextColumn get goodFor => text().named('good_for').nullable()();
IntColumn get hubermanFitScore => integer().named('huberman_fit_score').check(
const CustomExpression<bool>("huberman_fit_score BETWEEN 1 AND 5"))();
BoolColumn get isCoreEngine =>
boolean().named('is_core_engine').withDefault(const Constant(false))();
TextColumn get referenceIdsJson => text().named('reference_ids_json').nullable()();
@override
Set<Column> get primaryKey => {id};
}
class FramePatterns extends Table {
TextColumn get id => text()();
TextColumn get domain => text().nullable().check(const CustomExpression<bool>(
"domain IS NULL OR domain IN ('food','drink','smoking','screen','porn','sleep','exercise','general')"))();
TextColumn get avoidanceKeyword => text().named('avoidance_keyword')();
TextColumn get l0Example => text().named('l0_example')();
TextColumn get l1SimpleReplace => text().named('l1_simple_replace').nullable()();
TextColumn get l2Suggestion => text().named('l2_suggestion')();
TextColumn get l3Identity => text().named('l3_identity').nullable()();
@override
Set<Column> get primaryKey => {id};
}
class RewardMenuItems extends Table {
TextColumn get id => text()();
TextColumn get tierRecommended => text().named('tier_recommended').check(
const CustomExpression<bool>("tier_recommended IN ('T0','T1','T2','T3','T4')"))();
TextColumn get title => text()();
TextColumn get description => text().nullable()();
IntColumn get estimatedCostKrwMin => integer().named('estimated_cost_krw_min').nullable()();
IntColumn get estimatedCostKrwMax => integer().named('estimated_cost_krw_max').nullable()();
BoolColumn get isEffortTied => boolean().named('is_effort_tied').nullable()();
TextColumn get tagsJson => text().named('tags_json').nullable()();
TextColumn get avoidForBreakHabitsJson =>
text().named('avoid_for_break_habits_json').nullable()();
@override
Set<Column> get primaryKey => {id};
}
@DataClassName('ReferenceRow')
class References extends Table {
TextColumn get id => text()();
TextColumn get kind => text().check(const CustomExpression<bool>(
"kind IN ('paper','podcast_episode','book','url','korean_explainer')"))();
TextColumn get title => text()();
TextColumn get authorsJson => text().named('authors_json').nullable()();
IntColumn get year => integer().nullable().check(
const CustomExpression<bool>("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().named('episode_number').nullable()();
TextColumn get publisher => text().nullable()();
TextColumn get evidenceStrength => text().named('evidence_strength').nullable().check(
const CustomExpression<bool>(
"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};
}
class DietPatterns extends Table {
TextColumn get id => text()();
TextColumn get name => text()();
TextColumn get core => text()();
TextColumn get strengthsJson => text().named('strengths_json').nullable()();
TextColumn get weaknessesJson => text().named('weaknesses_json').nullable()();
TextColumn get evidenceStrength => text().named('evidence_strength').check(
const CustomExpression<bool>(
"evidence_strength IN ('strong','moderate','mixed','weak')"))();
TextColumn get koreanContextFit => text().named('korean_context_fit').nullable().check(
const CustomExpression<bool>(
"korean_context_fit IS NULL OR korean_context_fit IN ('high','medium','low')"))();
TextColumn get starterLeversJson => text().named('starter_levers_json').nullable()();
TextColumn get medicalWarning => text().named('medical_warning').nullable()();
TextColumn get linkedProtocolIdsJson =>
text().named('linked_protocol_ids_json').nullable()();
TextColumn get referenceIdsJson => text().named('reference_ids_json').nullable()();
@override
Set<Column> get primaryKey => {id};
}

View File

@@ -0,0 +1,265 @@
import 'package:drift/drift.dart';
// 11 user-data tables + habit_dose_variants (normalized child per ADR-0002).
class Users extends Table {
TextColumn get id => text()();
TextColumn get displayName => text().named('display_name').nullable()();
TextColumn get locale => text().withDefault(const Constant('ko-KR'))();
TextColumn get timezone => text().withDefault(const Constant('Asia/Seoul'))();
TextColumn get createdAt => text().named('created_at')();
TextColumn get preferencesJson => text().named('preferences_json').nullable()();
@override
Set<Column> get primaryKey => {id};
}
class Phases extends Table {
TextColumn get id => text()();
TextColumn get userId =>
text().named('user_id').customConstraint('REFERENCES users(id) NOT NULL')();
TextColumn get title => text().nullable()();
TextColumn get startedAt => text().named('started_at')();
TextColumn get endedAt => text().named('ended_at').nullable()();
IntColumn get durationWeeks => integer()
.named('duration_weeks')
.withDefault(const Constant(6))
.check(const CustomExpression<bool>("duration_weeks >= 1"))();
TextColumn get status => text().check(const CustomExpression<bool>(
"status IN ('active','completed','abandoned')"))();
TextColumn get intentionText => text().named('intention_text').nullable()();
BoolColumn get rewardDeclarationsLocked => boolean()
.named('reward_declarations_locked')
.withDefault(const Constant(false))();
@override
Set<Column> get primaryKey => {id};
}
class Habits extends Table {
TextColumn get id => text()();
TextColumn get userId =>
text().named('user_id').customConstraint('REFERENCES users(id) NOT NULL')();
TextColumn get phaseId => text()
.named('phase_id')
.nullable()
.customConstraint('NULL REFERENCES phases(id) ON DELETE SET NULL')();
TextColumn get type =>
text().check(const CustomExpression<bool>("type IN ('build','break')"))();
TextColumn get status => text().check(const CustomExpression<bool>(
"status IN ('active','paused','completed','abandoned')"))();
TextColumn get title => text()();
TextColumn get protocolId => text()
.named('protocol_id')
.nullable()
.customConstraint('NULL REFERENCES protocols(id)')();
TextColumn get breakProtocolId => text()
.named('break_protocol_id')
.nullable()
.customConstraint('NULL REFERENCES break_protocols(id)')();
TextColumn get commonFrameIdsJson =>
text().named('common_frame_ids_json').nullable()();
TextColumn get frameLevel => text().named('frame_level').check(
const CustomExpression<bool>("frame_level IN ('L2','L3')"))(); // R3
TextColumn get frameOriginalText => text().named('frame_original_text').nullable()();
TextColumn get frameFramedText => text().named('frame_framed_text')();
TextColumn get anchorWhen => text().named('anchor_when').nullable()();
TextColumn get anchorAfterWhat => text().named('anchor_after_what').nullable()();
TextColumn get anchorWhere => text().named('anchor_where').nullable()();
IntColumn get stackPosition => integer().named('stack_position').nullable().check(
const CustomExpression<bool>("stack_position IS NULL OR stack_position >= 1"))();
TextColumn get minDose => text().named('min_dose').nullable()();
TextColumn get targetDose => text().named('target_dose').nullable()();
TextColumn get startedAt => text().named('started_at')();
TextColumn get endedAt => text().named('ended_at').nullable()();
TextColumn get tagsJson => text().named('tags_json').nullable()();
@override
Set<Column> get primaryKey => {id};
@override
List<String> get customConstraints => [
// XOR: build → protocol_id, break → break_protocol_id
"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))",
];
}
class HabitDoseVariants extends Table {
TextColumn get variantId => text().named('variant_id')();
TextColumn get habitId => text()
.named('habit_id')
.customConstraint('REFERENCES habits(id) ON DELETE CASCADE NOT NULL')();
TextColumn get label => text()();
TextColumn get doseText => text().named('dose_text')();
TextColumn get contextTagsJson => text().named('context_tags_json').nullable()();
TextColumn get conditionTagsJson => text().named('condition_tags_json').nullable()();
BoolColumn get isMinimum =>
boolean().named('is_minimum').withDefault(const Constant(false))();
IntColumn get sortOrder =>
integer().named('sort_order').withDefault(const Constant(0))();
@override
Set<Column> get primaryKey => {variantId};
}
class IfThenRules extends Table {
TextColumn get id => text()();
TextColumn get habitId => text()
.named('habit_id')
.customConstraint('REFERENCES habits(id) ON DELETE CASCADE NOT NULL')();
TextColumn get ifCondition => text().named('if_condition')();
TextColumn get thenAction => text().named('then_action')();
TextColumn get triggerType => text().named('trigger_type').nullable().check(
const CustomExpression<bool>(
"trigger_type IS NULL OR trigger_type IN ('time','location','emotion','preceding_action','urge')"))();
IntColumn get priority => integer()
.withDefault(const Constant(1))
.check(const CustomExpression<bool>("priority BETWEEN 1 AND 3"))();
TextColumn get createdAt => text().named('created_at').nullable()();
@override
Set<Column> get primaryKey => {id};
}
class TrackerEntries extends Table {
TextColumn get id => text()();
TextColumn get habitId => text()
.named('habit_id')
.customConstraint('REFERENCES habits(id) ON DELETE RESTRICT NOT NULL')();
TextColumn get date => text()();
TextColumn get value =>
text().check(const CustomExpression<bool>("value IN ('done','blank')"))(); // R5
TextColumn get loggedAt => text().named('logged_at').nullable()();
TextColumn get note => text().nullable().check(
const CustomExpression<bool>("note IS NULL OR length(note) <= 200"))();
TextColumn get variantId => text()
.named('variant_id')
.nullable()
.customConstraint(
'NULL REFERENCES habit_dose_variants(variant_id) ON DELETE SET NULL')();
TextColumn get ctxLocation => text().named('ctx_location').nullable()();
TextColumn get ctxCondition => text().named('ctx_condition').nullable()();
@override
Set<Column> get primaryKey => {id};
}
class LapseLogs extends Table {
TextColumn get id => text()();
TextColumn get habitId => text()
.named('habit_id')
.customConstraint('REFERENCES habits(id) NOT NULL')();
TextColumn get date => text()();
TextColumn get labelText => text().named('label_text')();
TextColumn get examineHaltJson => text().named('examine_halt_json')();
TextColumn get antecedentJson => text().named('antecedent_json')();
TextColumn get replan => text()();
TextColumn get nextAction => text().named('next_action')();
TextColumn get createdAt => text().named('created_at').nullable()();
@override
Set<Column> get primaryKey => {id};
}
class UrgeLogs extends Table {
TextColumn get id => text()();
TextColumn get habitId => text()
.named('habit_id')
.customConstraint('REFERENCES habits(id) NOT NULL')();
TextColumn get occurredAt => text().named('occurred_at')();
IntColumn get intensityBefore => integer().named('intensity_before').nullable().check(
const CustomExpression<bool>(
"intensity_before IS NULL OR intensity_before BETWEEN 0 AND 10"))();
IntColumn get intensityAfter => integer().named('intensity_after').nullable().check(
const CustomExpression<bool>(
"intensity_after IS NULL OR intensity_after BETWEEN 0 AND 10"))();
IntColumn get durationSeconds => integer().named('duration_seconds').nullable().check(
const CustomExpression<bool>(
"duration_seconds IS NULL OR duration_seconds >= 0"))();
TextColumn get bodyLocationJson => text().named('body_location_json').nullable()();
BoolColumn get passed => boolean()();
TextColumn get methodUsed => text().named('method_used').nullable().check(
const CustomExpression<bool>(
"method_used IS NULL OR method_used IN ('cyclic_sighing','walk','water','social_contact','if_then_action','other')"))();
@override
Set<Column> get primaryKey => {id};
}
class RewardDeclarations extends Table {
TextColumn get id => text()();
TextColumn get phaseId => text()
.named('phase_id')
.customConstraint('REFERENCES phases(id) NOT NULL')();
TextColumn get habitId => text()
.named('habit_id')
.customConstraint('REFERENCES habits(id) NOT NULL')();
TextColumn get tier => text().check(
const CustomExpression<bool>("tier IN ('T0','T1','T2','T3','T4')"))();
TextColumn get milestoneRule => text().named('milestone_rule')();
TextColumn get milestoneMachineRuleJson =>
text().named('milestone_machine_rule_json').nullable()();
TextColumn get rewardText => text().named('reward_text')();
TextColumn get rewardMenuItemId => text()
.named('reward_menu_item_id')
.nullable()
.customConstraint('NULL REFERENCES reward_menu_items(id)')();
IntColumn get estimatedCostKrw => integer().named('estimated_cost_krw').nullable().check(
const CustomExpression<bool>(
"estimated_cost_krw IS NULL OR estimated_cost_krw >= 0"))();
BoolColumn get isEffortTied => boolean().named('is_effort_tied').nullable()();
TextColumn get declaredAt => text().named('declared_at')();
@override
Set<Column> get primaryKey => {id};
}
class RewardClaims extends Table {
TextColumn get id => text()();
TextColumn get declarationId => text()
.named('declaration_id')
.customConstraint('REFERENCES reward_declarations(id) NOT NULL')();
TextColumn get milestoneReachedAt => text().named('milestone_reached_at')();
BoolColumn get fulfilled => boolean()();
TextColumn get fulfilledAt => text().named('fulfilled_at').nullable()();
TextColumn get reflection => text().nullable().check(
const CustomExpression<bool>(
"reflection IS NULL OR length(reflection) <= 500"))();
@override
Set<Column> get primaryKey => {id};
}
class Reflections extends Table {
TextColumn get id => text()();
TextColumn get userId =>
text().named('user_id').customConstraint('REFERENCES users(id) NOT NULL')();
TextColumn get scope => text().check(const CustomExpression<bool>(
"scope IN ('weekly','monthly','phase_end')"))();
TextColumn get periodStart => text().named('period_start')();
TextColumn get periodEnd => text().named('period_end')();
TextColumn get phaseId => text()
.named('phase_id')
.nullable()
.customConstraint('NULL 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().named('identity_note').nullable()();
RealColumn get minimumRatio => real().named('minimum_ratio').nullable().check(
const CustomExpression<bool>(
"minimum_ratio IS NULL OR (minimum_ratio BETWEEN 0 AND 1)"))();
TextColumn get createdAt => text().named('created_at').nullable()();
@override
Set<Column> get primaryKey => {id};
}
class MetaKv extends Table {
TextColumn get key => text()();
TextColumn get value => text()();
@override
Set<Column> get primaryKey => {key};
}

View File

@@ -0,0 +1,232 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:flutter/services.dart' show rootBundle;
import '../../core/constants.dart';
import '../db/app_database.dart';
/// fn-seed-importer
///
/// Idempotent seed loader for the 8 catalog tables. Runs once per install
/// (gated by meta_kv[`seeded_v1`]). Transactional: either all 8 catalogs
/// import or none do.
///
/// JSON files live under `assets/seed/*.json` and ship as a top-level array.
class SeedImporter {
final AppDatabase db;
final Future<String> Function(String path) loadAsset;
SeedImporter(this.db, {Future<String> Function(String path)? loadAsset})
: loadAsset = loadAsset ?? rootBundle.loadString;
/// Import all catalogs if not already seeded. Returns true if the import
/// ran, false if it was a no-op.
Future<bool> importIfNeeded() async {
final marker = await (db.select(db.metaKv)
..where((t) => t.key.equals(kSeededV1Flag)))
.getSingleOrNull();
if (marker != null && marker.value == 'true') return false;
await db.transaction(() async {
await _importProtocols();
await _importBreakProtocols();
await _importCommonFrames();
await _importMethodologies();
await _importFramePatterns();
await _importRewardMenuItems();
await _importReferences();
await _importDietPatterns();
await db.into(db.metaKv).insertOnConflictUpdate(
MetaKvCompanion.insert(key: kSeededV1Flag, value: 'true'),
);
});
return true;
}
Future<List<dynamic>> _loadJsonArray(String fileName) async {
final raw = await loadAsset('assets/seed/$fileName');
final decoded = json.decode(raw);
if (decoded is! List) {
throw FormatException('$fileName: expected top-level JSON array');
}
return decoded;
}
String? _jsonField(Map<String, dynamic> m, String key) {
final v = m[key];
if (v == null) return null;
return json.encode(v);
}
// ---- 8 catalog adapters ----
Future<void> _importProtocols() async {
final rows = await _loadJsonArray('protocols.json');
for (final r in rows.cast<Map<String, dynamic>>()) {
await db.into(db.protocols).insertOnConflictUpdate(ProtocolsCompanion.insert(
id: r['id'] as String,
category: r['category'] as String,
title: r['title'] as String,
titleEn: Value(r['title_en'] as String?),
what: r['what'] as String,
whenText: r['when'] as String,
dose: r['dose'] as String,
why: r['why'] as String,
howJson: _jsonField(r, 'how') ?? '[]',
checkText: r['check'] as String,
caution: Value(r['caution'] as String?),
defaultAnchorJson: Value(_jsonField(r, 'default_anchor')),
minDoseForStart: Value(r['min_dose_for_start'] as String?),
referenceIdsJson: Value(_jsonField(r, 'reference_ids')),
evidenceStrength: Value(r['evidence_strength'] as String?),
sourceDoc: Value(r['source_doc'] as String?),
));
}
}
Future<void> _importBreakProtocols() async {
final rows = await _loadJsonArray('break_protocols.json');
for (final r in rows.cast<Map<String, dynamic>>()) {
await db.into(db.breakProtocols).insertOnConflictUpdate(
BreakProtocolsCompanion.insert(
id: r['id'] as String,
category: r['category'] as String,
title: r['title'] as String,
hubermanSummary: r['huberman_summary'] as String,
frameExamplesJson: Value(_jsonField(r, 'frame_examples')),
phasesJson: _jsonField(r, 'phases') ?? '[]',
defaultCommonFramesJson:
_jsonField(r, 'default_common_frames') ?? '[]',
toolsJson: Value(_jsonField(r, 'tools')),
medicalWarning: Value(r['medical_warning'] as String?),
referenceIdsJson: Value(_jsonField(r, 'reference_ids')),
),
);
}
}
Future<void> _importCommonFrames() async {
final rows = await _loadJsonArray('common_frames.json');
for (final r in rows.cast<Map<String, dynamic>>()) {
await db.into(db.commonFrames).insertOnConflictUpdate(
CommonFramesCompanion.insert(
id: r['id'] as String,
title: r['title'] as String,
what: r['what'] as String,
why: r['why'] as String,
dose: Value(r['dose'] as String?),
howJson: Value(_jsonField(r, 'how')),
checkText: r['check'] as String,
applicableBreakCategoriesJson:
Value(_jsonField(r, 'applicable_break_categories')),
referenceIdsJson: Value(_jsonField(r, 'reference_ids')),
),
);
}
}
Future<void> _importMethodologies() async {
final rows = await _loadJsonArray('methodologies.json');
for (final r in rows.cast<Map<String, dynamic>>()) {
await db.into(db.methodologies).insertOnConflictUpdate(
MethodologiesCompanion.insert(
id: r['id'] as String,
name: r['name'] as String,
originator: r['originator'] as String,
oneLineDefinition: r['one_line_definition'] as String,
coreUnit: r['core_unit'] as String,
procedureJson: Value(_jsonField(r, 'procedure')),
toolsJson: Value(_jsonField(r, 'tools')),
strengthsJson: Value(_jsonField(r, 'strengths')),
weaknessesJson: Value(_jsonField(r, 'weaknesses')),
goodFor: Value(r['good_for'] as String?),
hubermanFitScore: r['huberman_fit_score'] as int,
isCoreEngine: Value(r['is_core_engine'] as bool? ?? false),
referenceIdsJson: Value(_jsonField(r, 'reference_ids')),
),
);
}
}
Future<void> _importFramePatterns() async {
final rows = await _loadJsonArray('frame_patterns.json');
for (final r in rows.cast<Map<String, dynamic>>()) {
await db.into(db.framePatterns).insertOnConflictUpdate(
FramePatternsCompanion.insert(
id: r['id'] as String,
domain: Value(r['domain'] as String?),
avoidanceKeyword: r['avoidance_keyword'] as String,
l0Example: r['l0_example'] as String,
l1SimpleReplace: Value(r['l1_simple_replace'] as String?),
l2Suggestion: r['l2_suggestion'] as String,
l3Identity: Value(r['l3_identity'] as String?),
),
);
}
}
Future<void> _importRewardMenuItems() async {
final rows = await _loadJsonArray('reward_menu_items.json');
for (final r in rows.cast<Map<String, dynamic>>()) {
await db.into(db.rewardMenuItems).insertOnConflictUpdate(
RewardMenuItemsCompanion.insert(
id: r['id'] as String,
tierRecommended: r['tier_recommended'] as String,
title: r['title'] as String,
description: Value(r['description'] as String?),
estimatedCostKrwMin: Value(r['estimated_cost_krw_min'] as int?),
estimatedCostKrwMax: Value(r['estimated_cost_krw_max'] as int?),
isEffortTied: Value(r['is_effort_tied'] as bool?),
tagsJson: Value(_jsonField(r, 'tags')),
avoidForBreakHabitsJson:
Value(_jsonField(r, 'avoid_for_break_habits')),
),
);
}
}
Future<void> _importReferences() async {
final rows = await _loadJsonArray('references.json');
for (final r in rows.cast<Map<String, dynamic>>()) {
await db.into(db.references).insertOnConflictUpdate(
ReferencesCompanion.insert(
id: r['id'] as String,
kind: r['kind'] as String,
title: r['title'] as String,
authorsJson: Value(_jsonField(r, 'authors')),
year: Value(r['year'] as int?),
journal: Value(r['journal'] as String?),
doi: Value(r['doi'] as String?),
url: Value(r['url'] as String?),
episodeNumber: Value(r['episode_number'] as int?),
publisher: Value(r['publisher'] as String?),
evidenceStrength: Value(r['evidence_strength'] as String?),
verified: Value(r['verified'] as bool?),
note: Value(r['note'] as String?),
),
);
}
}
Future<void> _importDietPatterns() async {
final rows = await _loadJsonArray('diet_patterns.json');
for (final r in rows.cast<Map<String, dynamic>>()) {
await db.into(db.dietPatterns).insertOnConflictUpdate(
DietPatternsCompanion.insert(
id: r['id'] as String,
name: r['name'] as String,
core: r['core'] as String,
strengthsJson: Value(_jsonField(r, 'strengths')),
weaknessesJson: Value(_jsonField(r, 'weaknesses')),
evidenceStrength: r['evidence_strength'] as String,
koreanContextFit: Value(r['korean_context_fit'] as String?),
starterLeversJson: Value(_jsonField(r, 'starter_levers')),
medicalWarning: Value(r['medical_warning'] as String?),
linkedProtocolIdsJson: Value(_jsonField(r, 'linked_protocol_ids')),
referenceIdsJson: Value(_jsonField(r, 'reference_ids')),
),
);
}
}
}

View File

@@ -0,0 +1,150 @@
import '../models/frame_pattern.dart';
import '../models/habit.dart';
enum FrameValidationStatus { accept, warn, reject }
class FrameInput {
final FrameLevel level;
final String? originalText;
final String framedText;
const FrameInput({
required this.level,
required this.framedText,
this.originalText,
});
}
class AvoidanceHit {
final String keyword;
final int startIndex;
final int endIndex;
final FramePatternModel source;
const AvoidanceHit({
required this.keyword,
required this.startIndex,
required this.endIndex,
required this.source,
});
}
class FrameSuggestion {
final FrameLevel level;
final String text;
final FramePatternModel source;
const FrameSuggestion({
required this.level,
required this.text,
required this.source,
});
}
class FrameValidationResult {
final FrameValidationStatus status;
final List<AvoidanceHit> avoidanceHits;
final List<FrameSuggestion> suggestions;
const FrameValidationResult({
required this.status,
this.avoidanceHits = const [],
this.suggestions = const [],
});
}
/// fn-validate-frame-level: R3 (L0/L1 reject) + R7 (avoidance keywords).
FrameValidationResult validateFrameLevel(
FrameInput input, {
required Iterable<FramePatternModel> knownPatterns,
}) {
if (input.framedText.isEmpty) {
return const FrameValidationResult(
status: FrameValidationStatus.reject,
);
}
if (input.level == FrameLevel.l0 || input.level == FrameLevel.l1) {
final suggestions = _buildSuggestions(input.framedText, knownPatterns);
return FrameValidationResult(
status: FrameValidationStatus.reject,
suggestions: suggestions,
);
}
final hits = detectAvoidanceKeywords(input.framedText, knownPatterns);
if (hits.isEmpty) {
return const FrameValidationResult(status: FrameValidationStatus.accept);
}
final suggestions = _buildSuggestionsFromHits(hits);
return FrameValidationResult(
status: FrameValidationStatus.warn,
avoidanceHits: hits,
suggestions: suggestions,
);
}
List<AvoidanceHit> detectAvoidanceKeywords(
String text,
Iterable<FramePatternModel> patterns,
) {
final hits = <AvoidanceHit>[];
final seen = <String>{};
for (final p in patterns) {
var idx = text.indexOf(p.avoidanceKeyword);
while (idx >= 0) {
final key = '$idx:${p.avoidanceKeyword}';
if (!seen.contains(key)) {
seen.add(key);
hits.add(AvoidanceHit(
keyword: p.avoidanceKeyword,
startIndex: idx,
endIndex: idx + p.avoidanceKeyword.length,
source: p,
));
}
idx = text.indexOf(p.avoidanceKeyword, idx + 1);
}
}
return hits;
}
List<FrameSuggestion> _buildSuggestions(
String text,
Iterable<FramePatternModel> patterns,
) {
final relevant = patterns
.where((p) => text.contains(p.avoidanceKeyword))
.toList();
// If no keyword matches (L0 with no detectable avoidance), suggest from
// 'general' domain.
final pool = relevant.isEmpty
? patterns.where((p) => p.domain == 'general').toList()
: relevant;
return _expandSuggestions(pool);
}
List<FrameSuggestion> _buildSuggestionsFromHits(List<AvoidanceHit> hits) {
final unique = <String, FramePatternModel>{};
for (final h in hits) {
unique[h.source.id] = h.source;
}
return _expandSuggestions(unique.values.toList());
}
List<FrameSuggestion> _expandSuggestions(List<FramePatternModel> sources) {
final out = <FrameSuggestion>[];
for (final p in sources) {
out.add(FrameSuggestion(
level: FrameLevel.l2,
text: p.l2Suggestion,
source: p,
));
if (p.l3Identity != null) {
out.add(FrameSuggestion(
level: FrameLevel.l3,
text: p.l3Identity!,
source: p,
));
}
if (out.length >= 5) break;
}
return out.take(5).toList();
}

View File

@@ -0,0 +1,19 @@
class FramePatternModel {
final String id;
final String? domain;
final String avoidanceKeyword;
final String l0Example;
final String? l1SimpleReplace;
final String l2Suggestion;
final String? l3Identity;
const FramePatternModel({
required this.id,
this.domain,
required this.avoidanceKeyword,
required this.l0Example,
this.l1SimpleReplace,
required this.l2Suggestion,
this.l3Identity,
});
}

View File

@@ -0,0 +1,92 @@
enum HabitType { build, breakHabit }
extension HabitTypeX on HabitType {
String get dbValue => this == HabitType.build ? 'build' : 'break';
}
enum HabitStatus { active, paused, completed, abandoned }
extension HabitStatusX on HabitStatus {
String get dbValue => name;
}
enum FrameLevel { l0, l1, l2, l3 }
extension FrameLevelX on FrameLevel {
String get dbValue => name.toUpperCase();
static FrameLevel? fromDb(String s) {
switch (s) {
case 'L0':
return FrameLevel.l0;
case 'L1':
return FrameLevel.l1;
case 'L2':
return FrameLevel.l2;
case 'L3':
return FrameLevel.l3;
}
return null;
}
}
class HabitDoseVariantModel {
final String variantId;
final String habitId;
final String label;
final String doseText;
final List<String> contextTags;
final List<String> conditionTags;
final bool isMinimum;
final int sortOrder;
const HabitDoseVariantModel({
required this.variantId,
required this.habitId,
required this.label,
required this.doseText,
this.contextTags = const [],
this.conditionTags = const [],
this.isMinimum = false,
this.sortOrder = 0,
});
}
class HabitModel {
final String id;
final String userId;
final String? phaseId;
final HabitType type;
final HabitStatus status;
final String title;
final String? protocolId;
final String? breakProtocolId;
final FrameLevel frameLevel;
final String frameFramedText;
final String? frameOriginalText;
final String? anchorWhen;
final String? anchorAfterWhat;
final String? anchorWhere;
final String startedAt; // YYYY-MM-DD
final String? endedAt;
final List<HabitDoseVariantModel> doseVariants;
const HabitModel({
required this.id,
required this.userId,
this.phaseId,
required this.type,
required this.status,
required this.title,
this.protocolId,
this.breakProtocolId,
required this.frameLevel,
required this.frameFramedText,
this.frameOriginalText,
this.anchorWhen,
this.anchorAfterWhat,
this.anchorWhere,
required this.startedAt,
this.endedAt,
this.doseVariants = const [],
});
}

View File

@@ -0,0 +1,23 @@
enum PhaseStatus { active, completed, abandoned }
class PhaseModel {
final String id;
final String userId;
final String? title;
final String startedAt; // YYYY-MM-DD
final String? endedAt;
final int durationWeeks;
final PhaseStatus status;
final bool rewardDeclarationsLocked;
const PhaseModel({
required this.id,
required this.userId,
this.title,
required this.startedAt,
this.endedAt,
this.durationWeeks = 6,
required this.status,
this.rewardDeclarationsLocked = false,
});
}

View File

@@ -0,0 +1,29 @@
enum TrackerValue { done, blank }
extension TrackerValueX on TrackerValue {
String get dbValue => name;
}
class TrackerEntryModel {
final String id;
final String habitId;
final String date; // YYYY-MM-DD
final TrackerValue value;
final String? variantId;
final String? ctxLocation;
final String? ctxCondition;
final String? note;
final String? loggedAt;
const TrackerEntryModel({
required this.id,
required this.habitId,
required this.date,
required this.value,
this.variantId,
this.ctxLocation,
this.ctxCondition,
this.note,
this.loggedAt,
});
}

View File

@@ -0,0 +1,67 @@
import '../models/habit.dart';
class CheckInContext {
final String? location;
final String? condition;
const CheckInContext({this.location, this.condition});
}
enum RecommendReason { exactMatch, partial, fallbackMinimum, fallbackFirst }
class VariantPick {
final HabitDoseVariantModel variant;
final int score;
final RecommendReason reason;
const VariantPick(this.variant, this.score, this.reason);
}
/// fn-recommend-variant: returns best variant for current context.
/// O(N). Pure function. Returns null if habit has no variants.
VariantPick? recommendVariant(HabitModel habit, CheckInContext ctx) {
final variants = habit.doseVariants;
if (variants.isEmpty) return null;
final scored = variants
.map((v) => MapEntry(v, _scoreVariant(v, ctx)))
.toList();
// Stable sort: score desc, then sortOrder asc.
scored.sort((a, b) {
final byScore = b.value.compareTo(a.value);
if (byScore != 0) return byScore;
return a.key.sortOrder.compareTo(b.key.sortOrder);
});
final best = scored.first;
if (best.value > 0) {
final reason =
best.value >= 4 ? RecommendReason.exactMatch : RecommendReason.partial;
return VariantPick(best.key, best.value, reason);
}
// Fallback: first is_minimum variant (by sortOrder).
final minimum = variants
.where((v) => v.isMinimum)
.fold<HabitDoseVariantModel?>(null,
(a, v) => a == null || v.sortOrder < a.sortOrder ? v : a);
if (minimum != null) {
return VariantPick(minimum, 0, RecommendReason.fallbackMinimum);
}
// No is_minimum — fall back to first by sortOrder.
final first = [...variants]..sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
return VariantPick(first.first, 0, RecommendReason.fallbackFirst);
}
int _scoreVariant(HabitDoseVariantModel v, CheckInContext ctx) {
var s = 0;
if (ctx.location != null && v.contextTags.contains(ctx.location)) {
s += 2;
}
if (ctx.condition != null && v.conditionTags.contains(ctx.condition)) {
s += 2;
}
if (v.isMinimum && ctx.condition == '나쁨') {
s += 1;
}
return s;
}

View File

@@ -0,0 +1,42 @@
import '../models/habit.dart';
/// R1/R2: max active habits per type.
/// - build: ≤ 3
/// - break: ≤ 1
const int kMaxActiveBuild = 3;
const int kMaxActiveBreak = 1;
class QuotaResult {
final HabitType type;
final int currentCount;
final int limit;
final bool allowed;
const QuotaResult({
required this.type,
required this.currentCount,
required this.limit,
required this.allowed,
});
String get reason {
if (allowed) return 'ok';
return type == HabitType.build
? 'build habit quota reached (≤ $kMaxActiveBuild active)'
: 'break habit quota reached (≤ $kMaxActiveBreak active)';
}
}
/// Pure judgment: caller passes in the current active count.
QuotaResult judgeActiveHabitQuota({
required HabitType type,
required int currentActiveCount,
}) {
final limit = type == HabitType.build ? kMaxActiveBuild : kMaxActiveBreak;
return QuotaResult(
type: type,
currentCount: currentActiveCount,
limit: limit,
allowed: currentActiveCount < limit,
);
}

View File

@@ -0,0 +1,9 @@
/// R6: warn if anchor change happens mid-phase (>= 1 week in).
bool phaseAnchorChangeWarning({
required String phaseStartedAt,
required DateTime now,
}) {
final start = DateTime.parse(phaseStartedAt);
final daysIn = now.difference(start).inDays;
return daysIn >= 7;
}

View File

@@ -0,0 +1,9 @@
/// R4: reward_declaration must be created within phase.started_at + 7 days.
bool validateRewardDeclarationWindow({
required String phaseStartedAt, // YYYY-MM-DD
required DateTime now,
}) {
final start = DateTime.parse(phaseStartedAt);
final cutoff = start.add(const Duration(days: 7));
return !now.isAfter(cutoff);
}

View File

@@ -0,0 +1,2 @@
/// R5: tracker_entry.value must be 'done' or 'blank'.
bool validateTrackerValue(String s) => s == 'done' || s == 'blank';

View File

@@ -0,0 +1,20 @@
import '../models/habit.dart';
/// XOR: build → protocol_id (only); break → break_protocol_id (only).
void assertXorProtocol({
required HabitType type,
required String? protocolId,
required String? breakProtocolId,
}) {
if (type == HabitType.build) {
if (protocolId == null || breakProtocolId != null) {
throw ArgumentError(
'build habit requires protocol_id and no break_protocol_id');
}
} else {
if (breakProtocolId == null || protocolId != null) {
throw ArgumentError(
'break habit requires break_protocol_id and no protocol_id');
}
}
}

View File

@@ -0,0 +1,162 @@
import '../models/tracker_entry.dart';
enum RewardTier { t0, t1, t2, t3, t4 }
extension RewardTierX on RewardTier {
String get dbValue {
switch (this) {
case RewardTier.t0:
return 'T0';
case RewardTier.t1:
return 'T1';
case RewardTier.t2:
return 'T2';
case RewardTier.t3:
return 'T3';
case RewardTier.t4:
return 'T4';
}
}
int get rank => RewardTier.values.indexOf(this);
}
class StreakState {
final int currentStreak;
final int longestStreak;
final int doneCountInPhase42;
final int doneCountInWindow30;
final RewardTier currentTier;
final bool neverMissTwiceBroken;
const StreakState({
required this.currentStreak,
required this.longestStreak,
required this.doneCountInPhase42,
required this.doneCountInWindow30,
required this.currentTier,
required this.neverMissTwiceBroken,
});
static const StreakState empty = StreakState(
currentStreak: 0,
longestStreak: 0,
doneCountInPhase42: 0,
doneCountInWindow30: 0,
currentTier: RewardTier.t0,
neverMissTwiceBroken: false,
);
}
/// fn-compute-streak: computes streak + 5-tier milestone with Never miss twice.
///
/// OQ-5 decision (2026-06-11):
/// - 2+ consecutive blank → tier demoted (T3→T2, T2→T1, T1→T0), streak = 0,
/// neverMissTwiceBroken = true.
/// - 1 blank → streak = 0, tier stays. Next done starts at 1.
///
/// Pure function. [habitStartedAt] is the habit's started_at (YYYY-MM-DD).
StreakState computeStreak({
required Iterable<TrackerEntryModel> entries,
required DateTime asOf,
required String habitStartedAt,
}) {
// 1. Index by date string (YYYY-MM-DD).
final byDate = <String, TrackerEntryModel>{};
for (final e in entries) {
byDate[e.date] = e;
}
if (byDate.isEmpty) return StreakState.empty;
final startDate = DateTime.parse(habitStartedAt);
final asOfDate = DateTime(asOf.year, asOf.month, asOf.day);
// 2. currentStreak (Never miss twice).
//
// Semantics:
// - Walk back from asOf, stopping at habit start.
// - "No entry record" for a date means tracking hasn't reached that day (or
// user hasn't synced) — treat as the end of streak history, do not penalize.
// - Explicit TrackerValue.blank is the penalty signal. 1 blank zeroes the
// streak (tier stays); 2 consecutive blanks set neverMissTwiceBroken.
var streak = 0;
var consecutiveBlank = 0;
var neverMissTwiceBroken = false;
var cursor = asOfDate;
while (!cursor.isBefore(startDate)) {
final e = byDate[_ymd(cursor)];
if (e == null) {
// End of recorded history.
break;
}
if (e.value == TrackerValue.blank) {
consecutiveBlank += 1;
if (consecutiveBlank >= 2) {
neverMissTwiceBroken = true;
streak = 0;
break;
}
// 1 blank so far: streak is broken, but keep walking one more day to
// detect a possible double-blank.
streak = 0;
cursor = cursor.subtract(const Duration(days: 1));
continue;
}
// Done.
if (consecutiveBlank > 0) {
// Previous step saw a single blank, now done → single-blank confirmed.
break;
}
streak += 1;
cursor = cursor.subtract(const Duration(days: 1));
}
// 3. longestStreak over all entries.
final sortedDates = byDate.keys.toList()..sort();
var longest = 0;
var run = 0;
for (final d in sortedDates) {
if (byDate[d]!.value == TrackerValue.done) {
run += 1;
if (run > longest) longest = run;
} else {
run = 0;
}
}
// 4. Window counts.
final win30Start = asOfDate.subtract(const Duration(days: 29));
final win42Start = asOfDate.subtract(const Duration(days: 41));
var window30 = 0;
var window42 = 0;
for (final e in byDate.values) {
if (e.value != TrackerValue.done) continue;
final d = DateTime.parse(e.date);
if (!d.isBefore(win30Start) && !d.isAfter(asOfDate)) window30 += 1;
if (!d.isBefore(win42Start) && !d.isAfter(asOfDate)) window42 += 1;
}
// 5. Tier judgment.
var tier = RewardTier.t0;
if (streak >= 3) tier = RewardTier.t1;
if (streak >= 7) tier = RewardTier.t2;
if (window30 >= 24 && tier.rank < RewardTier.t3.rank) tier = RewardTier.t3;
final phaseDay = asOfDate.difference(startDate).inDays + 1;
if (phaseDay >= 42 && window42 >= 30) tier = RewardTier.t4;
return StreakState(
currentStreak: streak,
longestStreak: longest,
doneCountInPhase42: window42,
doneCountInWindow30: window30,
currentTier: tier,
neverMissTwiceBroken: neverMissTwiceBroken,
);
}
String _ymd(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-'
'${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}';

View File

@@ -0,0 +1,66 @@
import '../models/habit.dart';
import '../models/tracker_entry.dart';
/// fn-weekly-minimum-ratio
///
/// Computes the share of done check-ins in the last 7 days that used the
/// habit's "minimum" dose variant. Useful for L2-conditional habits where the
/// user picks a minimum option on bad-condition days. A high ratio is fine
/// (the protocol's whole point), but it also signals the user is mostly
/// running on minimum dose — a context the UI can surface.
///
/// Returns 0.0 when there are no done entries in the window (no division by
/// zero).
class WeeklyMinimumRatio {
final int totalDone;
final int minimumUsed;
final double ratio; // 0.0..1.0
final DateTime windowStart; // inclusive, YYYY-MM-DD == windowStart
final DateTime windowEnd; // inclusive
const WeeklyMinimumRatio({
required this.totalDone,
required this.minimumUsed,
required this.ratio,
required this.windowStart,
required this.windowEnd,
});
}
/// Pure function: caller resolves the variant rows and passes them in.
///
/// - [entries] should already be filtered to the habit and to value=done.
/// - [variantsById] maps variant_id → variant (only minimums need to be
/// present, but a full map is fine).
/// - [asOf] is treated as the inclusive end of the 7-day window.
WeeklyMinimumRatio computeWeeklyMinimumRatio({
required Iterable<TrackerEntryModel> entries,
required Map<String, HabitDoseVariantModel> variantsById,
required DateTime asOf,
}) {
final end = DateTime(asOf.year, asOf.month, asOf.day);
final start = end.subtract(const Duration(days: 6));
var totalDone = 0;
var minimumUsed = 0;
for (final e in entries) {
if (e.value != TrackerValue.done) continue;
final d = DateTime.parse(e.date);
if (d.isBefore(start) || d.isAfter(end)) continue;
totalDone += 1;
final vId = e.variantId;
if (vId == null) continue;
final v = variantsById[vId];
if (v != null && v.isMinimum) minimumUsed += 1;
}
final ratio = totalDone == 0 ? 0.0 : minimumUsed / totalDone;
return WeeklyMinimumRatio(
totalDone: totalDone,
minimumUsed: minimumUsed,
ratio: ratio,
windowStart: start,
windowEnd: end,
);
}

30
app/lib/main.dart Normal file
View File

@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'state/providers.dart';
import 'ui/screens/habit_list_screen.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final db = await openProductionDatabase();
runApp(ProviderScope(
overrides: [appDatabaseProvider.overrideWithValue(db)],
child: const LifeHelperApp(),
));
}
class LifeHelperApp extends StatelessWidget {
const LifeHelperApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'life-helper',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: const HabitListScreen(),
);
}
}

View File

@@ -0,0 +1,65 @@
import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/constants.dart';
import '../core/time.dart';
import '../data/db/app_database.dart';
import '../data/db/daos/habit_dao.dart';
import '../data/db/daos/meta_dao.dart';
import '../data/db/daos/tracker_dao.dart';
import '../data/seed/seed_importer.dart';
/// Override in tests with an in-memory database.
final appDatabaseProvider = Provider<AppDatabase>((ref) {
throw UnimplementedError('appDatabaseProvider must be overridden in main()');
});
Future<AppDatabase> openProductionDatabase() async {
final file = await appDatabaseFile();
return AppDatabase(NativeDatabase.createInBackground(file));
}
final habitDaoProvider = Provider<HabitDao>((ref) {
return HabitDao(ref.watch(appDatabaseProvider));
});
final trackerDaoProvider = Provider<TrackerDao>((ref) {
return TrackerDao(ref.watch(appDatabaseProvider));
});
final metaDaoProvider = Provider<MetaDao>((ref) {
return MetaDao(ref.watch(appDatabaseProvider));
});
/// One-time bootstrap: ensure default user row + seed catalogs.
final bootstrapProvider = FutureProvider<void>((ref) async {
final db = ref.watch(appDatabaseProvider);
// Ensure default user.
final existing = await (db.select(db.users)
..where((t) => t.id.equals(kLocalDefaultUserId)))
.getSingleOrNull();
if (existing == null) {
await db.into(db.users).insert(UsersCompanion.insert(
id: kLocalDefaultUserId,
displayName: const drift.Value('You'),
createdAt: nowKst().toIso8601String(),
));
}
// Seed catalogs (idempotent).
await SeedImporter(db).importIfNeeded();
if (kDebugMode) {
debugPrint('bootstrap done');
}
});
/// Active habits stream for current user.
final activeHabitsProvider = StreamProvider<List<Habit>>((ref) {
final db = ref.watch(appDatabaseProvider);
return (db.select(db.habits)
..where((t) => t.userId.equals(kLocalDefaultUserId))
..where((t) => t.status.equals('active'))
..orderBy([(t) => drift.OrderingTerm.asc(t.startedAt)]))
.watch();
});

View File

@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/time.dart';
import '../../data/db/daos/tracker_dao.dart';
import '../../state/providers.dart';
class CheckInScreen extends ConsumerStatefulWidget {
final String habitId;
const CheckInScreen({super.key, required this.habitId});
@override
ConsumerState<CheckInScreen> createState() => _CheckInScreenState();
}
class _CheckInScreenState extends ConsumerState<CheckInScreen> {
bool _saving = false;
Future<void> _record(String value) async {
setState(() => _saving = true);
try {
final dao = ref.read(trackerDaoProvider);
await dao.recordCheckIn(TrackerEntryDraft(
habitId: widget.habitId,
date: _ymd(nowKst()),
value: value,
));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(value == 'done' ? '체크인 완료' : '오늘은 비움')),
);
Navigator.of(context).pop();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('실패: $e')),
);
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('체크인')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('오늘 (${_ymd(nowKst())})',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center),
const SizedBox(height: 32),
FilledButton(
onPressed: _saving ? null : () => _record('done'),
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Text('완료', style: TextStyle(fontSize: 18)),
),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: _saving ? null : () => _record('blank'),
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text('비움'),
),
),
],
),
),
);
}
}
String _ymd(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-'
'${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}';

View File

@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/constants.dart';
import '../../core/time.dart';
import '../../data/db/daos/habit_dao.dart';
import '../../domain/models/habit.dart';
import '../../domain/rules/active_habit_quota.dart';
import '../../state/providers.dart';
class HabitCreateScreen extends ConsumerStatefulWidget {
const HabitCreateScreen({super.key});
@override
ConsumerState<HabitCreateScreen> createState() => _HabitCreateScreenState();
}
class _HabitCreateScreenState extends ConsumerState<HabitCreateScreen> {
final _formKey = GlobalKey<FormState>();
final _titleCtrl = TextEditingController();
final _framedCtrl = TextEditingController();
HabitType _type = HabitType.build;
FrameLevel _level = FrameLevel.l2;
bool _saving = false;
@override
void dispose() {
_titleCtrl.dispose();
_framedCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
try {
final dao = ref.read(habitDaoProvider);
final count = await dao.countActive(
userId: kLocalDefaultUserId,
type: _type,
);
final quota = judgeActiveHabitQuota(type: _type, currentActiveCount: count);
if (!quota.allowed) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(quota.reason)),
);
return;
}
await dao.insertWithVariants(HabitDraft(
userId: kLocalDefaultUserId,
type: _type,
title: _titleCtrl.text.trim(),
// Placeholder: vertical-slice uses the first seeded protocol.
protocolId: _type == HabitType.build ? 'morning_sunlight' : null,
breakProtocolId: _type == HabitType.breakHabit ? 'alcohol' : null,
frameLevel: _level,
frameFramedText: _framedCtrl.text.trim(),
startedAt: _ymd(nowKst()),
));
if (!mounted) return;
Navigator.of(context).pop();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('저장 실패: $e')),
);
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('새 습관')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: ListView(
children: [
TextFormField(
controller: _titleCtrl,
decoration: const InputDecoration(labelText: '제목'),
validator: (v) =>
(v == null || v.trim().isEmpty) ? '제목을 입력하세요' : null,
),
const SizedBox(height: 16),
DropdownButtonFormField<HabitType>(
initialValue: _type,
items: const [
DropdownMenuItem(value: HabitType.build, child: Text('만들기 (build)')),
DropdownMenuItem(
value: HabitType.breakHabit, child: Text('없애기 (break)')),
],
onChanged: (v) => setState(() => _type = v ?? HabitType.build),
decoration: const InputDecoration(labelText: '타입'),
),
const SizedBox(height: 16),
DropdownButtonFormField<FrameLevel>(
initialValue: _level,
items: const [
DropdownMenuItem(value: FrameLevel.l2, child: Text('L2 · 조건부 긍정')),
DropdownMenuItem(value: FrameLevel.l3, child: Text('L3 · 정체성')),
],
onChanged: (v) => setState(() => _level = v ?? FrameLevel.l2),
decoration: const InputDecoration(labelText: '프레임 레벨'),
),
const SizedBox(height: 16),
TextFormField(
controller: _framedCtrl,
decoration: const InputDecoration(
labelText: '프레임 문구',
hintText: '예: 아침 햇빛을 10분 받는다',
),
maxLines: 2,
validator: (v) =>
(v == null || v.trim().isEmpty) ? '프레임 문구를 입력하세요' : null,
),
const SizedBox(height: 24),
FilledButton(
onPressed: _saving ? null : _save,
child: Text(_saving ? '저장 중...' : '저장'),
),
],
),
),
),
);
}
}
String _ymd(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-'
'${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}';

View File

@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../state/providers.dart';
import 'check_in_screen.dart';
import 'habit_create_screen.dart';
import 'streak_screen.dart';
class HabitListScreen extends ConsumerWidget {
const HabitListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final boot = ref.watch(bootstrapProvider);
final habitsAsync = ref.watch(activeHabitsProvider);
return Scaffold(
appBar: AppBar(title: const Text('습관')),
body: boot.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, st) => Center(child: Text('초기화 실패: $e')),
data: (_) => habitsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, st) => Center(child: Text('로드 실패: $e')),
data: (habits) {
if (habits.isEmpty) {
return const Center(
child: Text('아직 습관이 없습니다. + 버튼으로 추가하세요.'),
);
}
return ListView.separated(
itemCount: habits.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, i) {
final h = habits[i];
return ListTile(
title: Text(h.title),
subtitle: Text(
'${h.type} · ${h.frameLevel} · ${h.frameFramedText}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
trailing: IconButton(
icon: const Icon(Icons.show_chart),
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => StreakScreen(habitId: h.id),
));
},
),
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => CheckInScreen(habitId: h.id),
));
},
);
},
);
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const HabitCreateScreen(),
));
},
child: const Icon(Icons.add),
),
);
}
}

View File

@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/time.dart';
import '../../domain/models/tracker_entry.dart';
import '../../domain/streak/compute_streak.dart';
import '../../state/providers.dart';
class StreakScreen extends ConsumerWidget {
final String habitId;
const StreakScreen({super.key, required this.habitId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final db = ref.watch(appDatabaseProvider);
final habitFuture = (db.select(db.habits)
..where((t) => t.id.equals(habitId)))
.getSingle();
final entriesFuture = ref.read(trackerDaoProvider).entriesForHabit(habitId);
return Scaffold(
appBar: AppBar(title: const Text('스트릭')),
body: FutureBuilder(
future: Future.wait([habitFuture, entriesFuture]),
builder: (context, snap) {
if (!snap.hasData) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Center(child: Text('실패: ${snap.error}'));
}
final habit = snap.data![0] as dynamic;
final entryRows = snap.data![1] as List;
final entries = entryRows.map((r) {
return TrackerEntryModel(
id: r.id as String,
habitId: r.habitId as String,
date: r.date as String,
value: (r.value as String) == 'done'
? TrackerValue.done
: TrackerValue.blank,
variantId: r.variantId as String?,
ctxLocation: r.ctxLocation as String?,
ctxCondition: r.ctxCondition as String?,
note: r.note as String?,
loggedAt: r.loggedAt as String?,
);
}).toList();
final state = computeStreak(
entries: entries,
asOf: nowKst(),
habitStartedAt: habit.startedAt as String,
);
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(habit.title as String,
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 24),
_Row('현재 스트릭', '${state.currentStreak}'),
_Row('최장 스트릭', '${state.longestStreak}'),
_Row('최근 30일 / 완료', '${state.doneCountInWindow30}'),
_Row('Phase 42일 / 완료', '${state.doneCountInPhase42}'),
const Divider(height: 32),
_Row('현재 티어', state.currentTier.dbValue),
if (state.neverMissTwiceBroken)
const Padding(
padding: EdgeInsets.only(top: 12),
child: Text(
'⚠ Never miss twice 발동 — 티어 강등',
style: TextStyle(color: Colors.redAccent),
),
),
],
),
);
},
),
);
}
}
class _Row extends StatelessWidget {
final String label;
final String value;
const _Row(this.label, this.value);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Expanded(child: Text(label)),
Text(value, style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
);
}
}

821
app/pubspec.lock Normal file
View File

@@ -0,0 +1,821 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
url: "https://pub.dev"
source: hosted
version: "85.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d"
url: "https://pub.dev"
source: hosted
version: "7.7.1"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
build:
dependency: transitive
description:
name: build
sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
build_config:
dependency: transitive
description:
name: build_config
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
build_daemon:
dependency: transitive
description:
name: build_daemon
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
url: "https://pub.dev"
source: hosted
version: "4.1.1"
build_resolvers:
dependency: transitive
description:
name: build_resolvers
sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62
url: "https://pub.dev"
source: hosted
version: "2.5.4"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
build_runner_core:
dependency: transitive
description:
name: build_runner_core
sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792"
url: "https://pub.dev"
source: hosted
version: "9.1.2"
built_collection:
dependency: transitive
description:
name: built_collection
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
built_value:
dependency: transitive
description:
name: built_value
sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56"
url: "https://pub.dev"
source: hosted
version: "8.12.6"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
charcode:
dependency: transitive
description:
name: charcode
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
url: "https://pub.dev"
source: hosted
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.dev"
source: hosted
version: "1.2.1"
code_builder:
dependency: transitive
description:
name: code_builder
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
url: "https://pub.dev"
source: hosted
version: "4.11.1"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.dev"
source: hosted
version: "1.0.9"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
drift:
dependency: "direct main"
description:
name: drift
sha256: "540cf382a3bfa99b76e51514db5b0ebcd81ce3679b7c1c9cb9478ff3735e47a1"
url: "https://pub.dev"
source: hosted
version: "2.28.2"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
sha256: "68c138e884527d2bd61df2ade276c3a144df84d1adeb0ab8f3196b5afe021bd4"
url: "https://pub.dev"
source: hosted
version: "2.28.0"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_riverpod:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
freezed:
dependency: "direct dev"
description:
name: freezed
sha256: "59a584c24b3acdc5250bb856d0d3e9c0b798ed14a4af1ddb7dc1c7b41df91c9c"
url: "https://pub.dev"
source: hosted
version: "2.5.8"
freezed_annotation:
dependency: "direct main"
description:
name: freezed_annotation
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
url: "https://pub.dev"
source: hosted
version: "2.4.4"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev"
source: hosted
version: "4.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
graphs:
dependency: transitive
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
hooks:
dependency: transitive
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
jni:
dependency: transitive
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.dev"
source: hosted
version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json_annotation:
dependency: "direct main"
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
json_serializable:
dependency: "direct dev"
description:
name: json_serializable
sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c
url: "https://pub.dev"
source: hosted
version: "6.9.5"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
url: "https://pub.dev"
source: hosted
version: "9.4.1"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: "direct main"
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pool:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
recase:
dependency: transitive
description:
name: recase
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
url: "https://pub.dev"
source: hosted
version: "4.1.0"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
riverpod:
dependency: transitive
description:
name: riverpod
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
source_helper:
dependency: transitive
description:
name: source_helper
sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca
url: "https://pub.dev"
source: hosted
version: "1.3.7"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
url: "https://pub.dev"
source: hosted
version: "2.9.4"
sqlite3_flutter_libs:
dependency: "direct main"
description:
name: sqlite3_flutter_libs
sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad
url: "https://pub.dev"
source: hosted
version: "0.5.42"
sqlparser:
dependency: transitive
description:
name: sqlparser
sha256: "57090342af1ce32bb499aa641f4ecdd2d6231b9403cea537ac059e803cc20d67"
url: "https://pub.dev"
source: hosted
version: "0.41.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
state_notifier:
dependency: transitive
description:
name: state_notifier
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
url: "https://pub.dev"
source: hosted
version: "1.0.0"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
timing:
dependency: transitive
description:
name: timing
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
ulid:
dependency: "direct main"
description:
name: ulid
sha256: "6d1f44802679bc3e3cc824045546af2bf26b1d8d0551f45457fedb6c827409ba"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.2.0"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.12.2 <4.0.0"
flutter: ">=3.38.4"

44
app/pubspec.yaml Normal file
View File

@@ -0,0 +1,44 @@
name: life_helper
description: "Huberman + Atomic Habits + Tiny Habits + If-Then. Local-first habit/checklist/todo."
publish_to: 'none'
version: 0.1.0+1
environment:
sdk: ^3.12.2
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
# State management
flutter_riverpod: ^2.5.1
# Local DB (Drift = sqlite ORM)
drift: ^2.18.0
sqlite3_flutter_libs: ^0.5.0
path_provider: ^2.1.0
path: ^1.9.0
# Models / serialization
freezed_annotation: ^2.4.0
json_annotation: ^4.9.0
# IDs
ulid: ^2.0.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
# Codegen
drift_dev: ^2.18.0
build_runner: ^2.4.0
freezed: ^2.5.0
json_serializable: ^6.8.0
flutter:
uses-material-design: true
assets:
- assets/seed/

View File

@@ -0,0 +1,52 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:life_helper/data/db/app_database.dart';
import 'package:life_helper/data/seed/seed_importer.dart';
import 'package:path/path.dart' as p;
/// Loads the real assets/seed/*.json files from disk (bypasses rootBundle
/// which needs Flutter binding) and runs them through SeedImporter end-to-end.
/// This is the strongest signal that the adapters and the hand-crafted JSON
/// agree on field names and CHECK-constraint values.
void main() {
late AppDatabase db;
final repoRoot = Directory.current.path;
Future<String> diskLoader(String path) async {
final f = File(p.join(repoRoot, path));
return f.readAsString();
}
setUp(() {
db = AppDatabase.memory();
});
tearDown(() async {
await db.close();
});
test('real seed assets import end-to-end', () async {
final importer = SeedImporter(db, loadAsset: diskLoader);
final ran = await importer.importIfNeeded();
expect(ran, true);
final pCount = (await db.select(db.protocols).get()).length;
final bpCount = (await db.select(db.breakProtocols).get()).length;
final cfCount = (await db.select(db.commonFrames).get()).length;
final mCount = (await db.select(db.methodologies).get()).length;
final fpCount = (await db.select(db.framePatterns).get()).length;
final rmiCount = (await db.select(db.rewardMenuItems).get()).length;
final refCount = (await db.select(db.references).get()).length;
final dpCount = (await db.select(db.dietPatterns).get()).length;
expect(pCount, greaterThanOrEqualTo(30));
expect(bpCount, greaterThanOrEqualTo(5));
expect(cfCount, 5);
expect(mCount, greaterThanOrEqualTo(15));
expect(fpCount, greaterThanOrEqualTo(20));
expect(rmiCount, greaterThanOrEqualTo(20));
expect(refCount, greaterThanOrEqualTo(50));
expect(dpCount, greaterThanOrEqualTo(3));
});
}

View File

@@ -0,0 +1,188 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:life_helper/core/constants.dart';
import 'package:life_helper/data/db/app_database.dart';
import 'package:life_helper/data/seed/seed_importer.dart';
const _protocols = '''
[
{
"id": "morning_sunlight",
"category": "health",
"title": "아침 햇빛",
"what": "기상 후 햇빛.",
"when": "기상 후 30~60분.",
"dose": "5~10분.",
"why": "ipRGC 자극.",
"how": ["나간다", "쳐다본다"],
"check": "60분 이내 외출",
"source_doc": "huberman-protocols.md"
}
]
''';
const _breakProtocols = '''
[
{
"id": "alcohol",
"category": "alcohol",
"title": "음주",
"huberman_summary": "ep 86",
"phases": [{"week": 1, "what": "환경 정리"}],
"default_common_frames": ["dopamine_reset"]
}
]
''';
const _commonFrames = '''
[
{
"id": "dopamine_reset",
"title": "도파민 리셋",
"what": "30일 절제",
"why": "수용체 회복",
"check": "30일 무자극"
}
]
''';
const _methodologies = '''
[
{
"id": "atomic_habits",
"name": "Atomic Habits",
"originator": "James Clear",
"one_line_definition": "1% 개선",
"core_unit": "1회 행동",
"huberman_fit_score": 5,
"is_core_engine": true
}
]
''';
const _framePatterns = '''
[
{
"id": "fp_alcohol",
"domain": "drink",
"avoidance_keyword": "술 끊기",
"l0_example": "술 끊기",
"l2_suggestion": "저녁엔 무알콜",
"l3_identity": "맑은 정신 추구"
}
]
''';
const _rewardMenuItems = '''
[
{
"id": "rmi_walk",
"tier_recommended": "T1",
"title": "산책 30분"
}
]
''';
const _references = '''
[
{
"id": "ref_x",
"kind": "url",
"title": "Sample",
"url": "https://example.com"
}
]
''';
const _dietPatterns = '''
[
{
"id": "med",
"name": "지중해 식단",
"core": "올리브유 + 채소",
"evidence_strength": "strong"
}
]
''';
Future<String> _stubLoader(String path) async {
switch (path) {
case 'assets/seed/protocols.json':
return _protocols;
case 'assets/seed/break_protocols.json':
return _breakProtocols;
case 'assets/seed/common_frames.json':
return _commonFrames;
case 'assets/seed/methodologies.json':
return _methodologies;
case 'assets/seed/frame_patterns.json':
return _framePatterns;
case 'assets/seed/reward_menu_items.json':
return _rewardMenuItems;
case 'assets/seed/references.json':
return _references;
case 'assets/seed/diet_patterns.json':
return _dietPatterns;
}
throw StateError('unexpected asset: $path');
}
void main() {
late AppDatabase db;
setUp(() {
db = AppDatabase.memory();
});
tearDown(() async {
await db.close();
});
test('first run: imports all 8 catalogs and sets marker', () async {
final importer = SeedImporter(db, loadAsset: _stubLoader);
final ran = await importer.importIfNeeded();
expect(ran, true);
expect((await db.select(db.protocols).get()).length, 1);
expect((await db.select(db.breakProtocols).get()).length, 1);
expect((await db.select(db.commonFrames).get()).length, 1);
expect((await db.select(db.methodologies).get()).length, 1);
expect((await db.select(db.framePatterns).get()).length, 1);
expect((await db.select(db.rewardMenuItems).get()).length, 1);
expect((await db.select(db.references).get()).length, 1);
expect((await db.select(db.dietPatterns).get()).length, 1);
final marker = await (db.select(db.metaKv)
..where((t) => t.key.equals(kSeededV1Flag)))
.getSingleOrNull();
expect(marker?.value, 'true');
});
test('idempotent: second run is no-op', () async {
final importer = SeedImporter(db, loadAsset: _stubLoader);
await importer.importIfNeeded();
final ran2 = await importer.importIfNeeded();
expect(ran2, false);
expect((await db.select(db.protocols).get()).length, 1);
});
test('partial failure rolls back (transactional)', () async {
Future<String> brokenLoader(String path) async {
if (path.endsWith('diet_patterns.json')) {
// Trigger a CHECK violation: evidence_strength must be one of the allowed values.
return '''
[{"id":"bad","name":"X","core":"Y","evidence_strength":"bogus"}]
''';
}
return _stubLoader(path);
}
final importer = SeedImporter(db, loadAsset: brokenLoader);
await expectLater(importer.importIfNeeded(), throwsA(isA<Object>()));
expect((await db.select(db.protocols).get()).length, 0);
final marker = await (db.select(db.metaKv)
..where((t) => t.key.equals(kSeededV1Flag)))
.getSingleOrNull();
expect(marker, isNull);
});
}

View File

@@ -0,0 +1,86 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:life_helper/domain/frame/validate_frame_level.dart';
import 'package:life_helper/domain/models/frame_pattern.dart';
import 'package:life_helper/domain/models/habit.dart';
final _patterns = <FramePatternModel>[
const FramePatternModel(
id: 'fp_alcohol',
domain: 'drink',
avoidanceKeyword: '술 끊기',
l0Example: '술 끊기',
l1SimpleReplace: '음주 중단',
l2Suggestion: '저녁엔 무알콜 음료 마시기',
l3Identity: '나는 맑은 정신을 우선시하는 사람이다',
),
const FramePatternModel(
id: 'fp_general',
domain: 'general',
avoidanceKeyword: '안 하기',
l0Example: '안 하기',
l2Suggestion: '대신 다른 행동 정의하기',
),
];
void main() {
test('empty framed text → reject', () {
final r = validateFrameLevel(
const FrameInput(level: FrameLevel.l2, framedText: ''),
knownPatterns: _patterns,
);
expect(r.status, FrameValidationStatus.reject);
});
test('L0 → reject + suggestions', () {
final r = validateFrameLevel(
const FrameInput(level: FrameLevel.l0, framedText: '술 끊기'),
knownPatterns: _patterns,
);
expect(r.status, FrameValidationStatus.reject);
expect(r.suggestions, isNotEmpty);
expect(r.suggestions.any((s) => s.level == FrameLevel.l2), true);
});
test('L1 → reject + suggestions even when not matching any keyword', () {
final r = validateFrameLevel(
const FrameInput(level: FrameLevel.l1, framedText: '담배 줄이기'),
knownPatterns: _patterns,
);
expect(r.status, FrameValidationStatus.reject);
// Should fall back to 'general' domain suggestion.
expect(r.suggestions, isNotEmpty);
});
test('L2 clean → accept', () {
final r = validateFrameLevel(
const FrameInput(level: FrameLevel.l2, framedText: '저녁엔 무알콜 음료 마시기'),
knownPatterns: _patterns,
);
expect(r.status, FrameValidationStatus.accept);
expect(r.suggestions, isEmpty);
});
test('L2 with embedded avoidance keyword → warn', () {
final r = validateFrameLevel(
const FrameInput(level: FrameLevel.l2, framedText: '술 끊기로 다짐'),
knownPatterns: _patterns,
);
expect(r.status, FrameValidationStatus.warn);
expect(r.avoidanceHits, isNotEmpty);
expect(r.avoidanceHits.first.keyword, '술 끊기');
});
test('L3 identity frame, clean → accept', () {
final r = validateFrameLevel(
const FrameInput(level: FrameLevel.l3, framedText: '나는 맑은 정신을 우선시한다'),
knownPatterns: _patterns,
);
expect(r.status, FrameValidationStatus.accept);
});
test('detectAvoidanceKeywords finds multiple occurrences', () {
final hits =
detectAvoidanceKeywords('술 끊기 / 다시 술 끊기 / 끝', _patterns);
expect(hits.length, 2);
});
}

View File

@@ -0,0 +1,96 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:life_helper/domain/models/habit.dart';
import 'package:life_helper/domain/recommend/recommend_variant.dart';
HabitModel _habit(List<HabitDoseVariantModel> variants) => HabitModel(
id: 'hb_x',
userId: 'u_local_default',
type: HabitType.build,
status: HabitStatus.active,
title: 't',
protocolId: 'p',
frameLevel: FrameLevel.l2,
frameFramedText: 't',
startedAt: '2026-06-01',
doseVariants: variants,
);
HabitDoseVariantModel _v({
required String id,
required int sort,
bool isMin = false,
List<String> ctx = const [],
List<String> cond = const [],
}) =>
HabitDoseVariantModel(
variantId: id,
habitId: 'hb_x',
label: id,
doseText: '1x',
contextTags: ctx,
conditionTags: cond,
isMinimum: isMin,
sortOrder: sort,
);
void main() {
test('empty variants → null', () {
expect(recommendVariant(_habit(const []), const CheckInContext()), isNull);
});
test('exact match wins (location + condition, score 4)', () {
final habit = _habit([
_v(id: 'A', sort: 0, ctx: ['']),
_v(id: 'B', sort: 1, ctx: ['회사'], cond: ['좋음']),
_v(id: 'C', sort: 2, isMin: true),
]);
final pick = recommendVariant(
habit, const CheckInContext(location: '회사', condition: '좋음'));
expect(pick!.variant.variantId, 'B');
expect(pick.reason, RecommendReason.exactMatch);
expect(pick.score, 4);
});
test('partial match (only location)', () {
final habit = _habit([
_v(id: 'A', sort: 0, ctx: ['']),
_v(id: 'B', sort: 1, ctx: ['회사']),
]);
final pick = recommendVariant(habit, const CheckInContext(location: ''));
expect(pick!.variant.variantId, 'A');
expect(pick.reason, RecommendReason.partial);
expect(pick.score, 2);
});
test('fallback to minimum when nothing matches', () {
final habit = _habit([
_v(id: 'A', sort: 0, ctx: ['']),
_v(id: 'B', sort: 1, isMin: true),
]);
final pick = recommendVariant(
habit, const CheckInContext(location: '카페', condition: '나쁨'));
// is_minimum + condition=='나쁨' → score 1 → partial pick, not fallback.
expect(pick!.variant.variantId, 'B');
expect(pick.reason, RecommendReason.partial);
});
test('fallback to first by sortOrder when no minimum, no match', () {
final habit = _habit([
_v(id: 'A', sort: 5, ctx: ['']),
_v(id: 'B', sort: 2, ctx: ['회사']),
]);
final pick = recommendVariant(habit, const CheckInContext(location: '카페'));
expect(pick!.variant.variantId, 'B');
expect(pick.reason, RecommendReason.fallbackFirst);
expect(pick.score, 0);
});
test('tie-break: same score → smaller sortOrder wins', () {
final habit = _habit([
_v(id: 'A', sort: 10, ctx: ['']),
_v(id: 'B', sort: 1, ctx: ['']),
]);
final pick = recommendVariant(habit, const CheckInContext(location: ''));
expect(pick!.variant.variantId, 'B');
});
}

View File

@@ -0,0 +1,38 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:life_helper/domain/models/habit.dart';
import 'package:life_helper/domain/rules/active_habit_quota.dart';
void main() {
test('build allows up to 3', () {
expect(
judgeActiveHabitQuota(type: HabitType.build, currentActiveCount: 2).allowed,
true,
);
expect(
judgeActiveHabitQuota(type: HabitType.build, currentActiveCount: 3).allowed,
false,
);
});
test('break allows only 1', () {
expect(
judgeActiveHabitQuota(type: HabitType.breakHabit, currentActiveCount: 0)
.allowed,
true,
);
expect(
judgeActiveHabitQuota(type: HabitType.breakHabit, currentActiveCount: 1)
.allowed,
false,
);
});
test('reason describes the limit when blocked', () {
final r = judgeActiveHabitQuota(
type: HabitType.build, currentActiveCount: 3);
expect(r.reason, contains('3'));
final br = judgeActiveHabitQuota(
type: HabitType.breakHabit, currentActiveCount: 1);
expect(br.reason, contains('1'));
});
}

View File

@@ -0,0 +1,115 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:life_helper/domain/models/tracker_entry.dart';
import 'package:life_helper/domain/streak/compute_streak.dart';
TrackerEntryModel _e(String date, TrackerValue v) =>
TrackerEntryModel(id: 'te_$date', habitId: 'hb', date: date, value: v);
void main() {
group('computeStreak', () {
test('empty → all zero, T0, not broken', () {
final s = computeStreak(
entries: const [],
asOf: DateTime(2026, 6, 11),
habitStartedAt: '2026-06-01',
);
expect(s.currentStreak, 0);
expect(s.currentTier, RewardTier.t0);
expect(s.neverMissTwiceBroken, false);
});
test('3 consecutive done → T1', () {
final s = computeStreak(
entries: [
_e('2026-06-09', TrackerValue.done),
_e('2026-06-10', TrackerValue.done),
_e('2026-06-11', TrackerValue.done),
],
asOf: DateTime(2026, 6, 11),
habitStartedAt: '2026-06-01',
);
expect(s.currentStreak, 3);
expect(s.currentTier, RewardTier.t1);
});
test('7 consecutive done → T2', () {
final entries = <TrackerEntryModel>[];
for (var i = 0; i < 7; i++) {
final d = DateTime(2026, 6, 5).add(Duration(days: i));
entries.add(_e(_ymd(d), TrackerValue.done));
}
final s = computeStreak(
entries: entries,
asOf: DateTime(2026, 6, 11),
habitStartedAt: '2026-06-01',
);
expect(s.currentStreak, 7);
expect(s.currentTier, RewardTier.t2);
});
test('OQ-5: 1 blank → streak=0, tier stays (not broken)', () {
// 6/9 done, 6/10 blank entry, 6/11 done.
final s = computeStreak(
entries: [
_e('2026-06-09', TrackerValue.done),
_e('2026-06-10', TrackerValue.blank),
_e('2026-06-11', TrackerValue.done),
],
asOf: DateTime(2026, 6, 11),
habitStartedAt: '2026-06-01',
);
// Walks back: 6/11 done (+1), 6/10 blank → streak resets to 0.
expect(s.currentStreak, 0);
expect(s.neverMissTwiceBroken, false);
});
test('OQ-5: 2 consecutive blank → neverMissTwiceBroken=true', () {
final s = computeStreak(
entries: [
_e('2026-06-09', TrackerValue.done),
_e('2026-06-10', TrackerValue.blank),
_e('2026-06-11', TrackerValue.blank),
],
asOf: DateTime(2026, 6, 11),
habitStartedAt: '2026-06-01',
);
expect(s.currentStreak, 0);
expect(s.neverMissTwiceBroken, true);
});
test('window30: 24/30 done → T3', () {
final entries = <TrackerEntryModel>[];
// 24 done in last 30 days, but not as a streak.
for (var i = 0; i < 24; i++) {
final d = DateTime(2026, 6, 11).subtract(Duration(days: i));
entries.add(_e(_ymd(d), TrackerValue.done));
}
final s = computeStreak(
entries: entries,
asOf: DateTime(2026, 6, 11),
habitStartedAt: '2026-05-01',
);
expect(s.doneCountInWindow30, 24);
expect(s.currentTier.rank, greaterThanOrEqualTo(RewardTier.t3.rank));
});
test('longestStreak picks largest run regardless of current', () {
final s = computeStreak(
entries: [
for (final d in ['2026-06-01', '2026-06-02', '2026-06-03', '2026-06-04'])
_e(d, TrackerValue.done),
_e('2026-06-05', TrackerValue.blank),
_e('2026-06-11', TrackerValue.done),
],
asOf: DateTime(2026, 6, 11),
habitStartedAt: '2026-06-01',
);
expect(s.longestStreak, 4);
});
});
}
String _ymd(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-'
'${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}';

View File

@@ -0,0 +1,81 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:life_helper/domain/models/habit.dart';
import 'package:life_helper/domain/models/tracker_entry.dart';
import 'package:life_helper/domain/streak/weekly_minimum_ratio.dart';
HabitDoseVariantModel _v(String id, {bool isMin = false}) =>
HabitDoseVariantModel(
variantId: id,
habitId: 'hb',
label: id,
doseText: '1x',
isMinimum: isMin,
);
TrackerEntryModel _done(String date, {String? vId}) => TrackerEntryModel(
id: 'te_$date',
habitId: 'hb',
date: date,
value: TrackerValue.done,
variantId: vId,
);
void main() {
final variantsById = {
'min': _v('min', isMin: true),
'full': _v('full'),
};
test('no done entries → ratio 0.0', () {
final r = computeWeeklyMinimumRatio(
entries: const [],
variantsById: variantsById,
asOf: DateTime(2026, 6, 11),
);
expect(r.totalDone, 0);
expect(r.ratio, 0.0);
});
test('3 of 4 used minimum → 0.75', () {
final r = computeWeeklyMinimumRatio(
entries: [
_done('2026-06-08', vId: 'min'),
_done('2026-06-09', vId: 'min'),
_done('2026-06-10', vId: 'full'),
_done('2026-06-11', vId: 'min'),
],
variantsById: variantsById,
asOf: DateTime(2026, 6, 11),
);
expect(r.totalDone, 4);
expect(r.minimumUsed, 3);
expect(r.ratio, closeTo(0.75, 1e-9));
});
test('out-of-window entries are excluded', () {
final r = computeWeeklyMinimumRatio(
entries: [
_done('2026-06-01', vId: 'min'), // 10 days before asOf
_done('2026-06-11', vId: 'min'),
],
variantsById: variantsById,
asOf: DateTime(2026, 6, 11),
);
expect(r.totalDone, 1);
expect(r.minimumUsed, 1);
});
test('done with null variantId counts as totalDone but not minimumUsed', () {
final r = computeWeeklyMinimumRatio(
entries: [
_done('2026-06-10'),
_done('2026-06-11', vId: 'min'),
],
variantsById: variantsById,
asOf: DateTime(2026, 6, 11),
);
expect(r.totalDone, 2);
expect(r.minimumUsed, 1);
expect(r.ratio, 0.5);
});
}