mycode/myApp/HaruDanim/CLAUDE.md
songyc macbook fd4e198fe4 fix(goal,diary): pro-rated mid-period judgment, day-key edge, template render retries
재분석에서 나온 잔여 항목 일괄 처리:

1) 판정 하루 키 엣지 (A)
   judgmentReference가 종료일 '정오'를 경유해 논리적 하루를 찾던
   것을 달력일 키 직접 조회로 변경 — '하루 시작 시간'을 12시 이후로
   설정한 경우에도 판정 범위가 전날로 밀리지 않는다.

2) 주기 중간 종료 판정 (B — 설계 결정: 경과일 비례)
   종료 판정 전용 QuestProgress.judgment(asOf:) 신설, 판정 평균
   (questAchievementRatio)이 current() 대신 사용:
   - 값은 기준 시점까지의 누적으로 제한 — 종료일 이후 같은 주기의
     기록이 판정에 섞이지 않음
   - 주간/월간/특정 기간 다짐의 목표량·한도를 주기 내 경과일 비율로
     축소 — 수요일에 끝나는 목표의 주간 다짐이 남은 4일치 미달로
     깎이지 않고, '이하 유지' 한도도 같은 비율이라 대칭적으로 공정
   - 하루 단위 다짐은 축소 없음 (그날 목표는 온전히 채워야 달성)
   - 수동 종료(manualFinish)도 같은 함수를 타므로 주중 수동 종료의
     주간 다짐이 공정하게 판정됨. 표시 연산(combinedSpanRatio·
     spanProgress)은 불변.
   검증: 주간 2시간 다짐 목표를 주 시작일(월)에 종료 →
   화요일 기록 40분이 배제되고 비례 목표(≈17분) 기준으로 판정됨을
   DB에서 확인 (처음 실패는 '설치 직후 첫 실행 인자 무시' 시뮬레이터
   특성이 원인 — 코드 문제 아님, §2.2 문서화된 사항).

3) 양식 렌더 트레이드오프 (C)
   - 백그라운드 렌더 실패 시 renderedScale을 되돌려 다음 기회(줌·
     재표시)에 재시도되게 함
   - 썸네일 겸 첫 표시 플레이스홀더 해상도 320→512px — 본 렌더
     도착 전의 흐릿함 완화 (저장 증가는 수십 KB 수준)
   - 양식 관리의 사용 중 페이지 수를 행마다 재스캔하지 않고 1회 집계

빌드: Debug·Store·워치 3스킴 성공. CLAUDE.md §4.3에 주기 중간
종료 판정 규칙 추가. 신규 사용자 문구 없음(카탈로그 변경 없음).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-14 20:13:09 +09:00

331 lines
42 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 하루 다님 (HaruDanim) — 프로젝트 가이드
> **한 줄 소개**: 하루의 습관과 시간을 추적하는 iOS 앱. "행동"의 **시간을 측정**하거나 **횟수를 기록**하고, 이를 바탕으로 **목표(Goal)와 다짐(Quest)** 의 달성률을 관리한다. 프리미엄(StoreKit 2)으로 위젯·워치·iCloud 동기화·iPad 일기를 제공한다.
>
> 문서 버전: v2.0 (2026-07-13) — **실제 구현 코드를 기준으로 작성된 현행 명세**. 새 세션에서 이 문서만 읽어도 앱 전체를 파악할 수 있도록 유지한다. 기능이 바뀌면 이 문서도 함께 갱신할 것.
---
## 1. 개요
| 항목 | 내용 |
|---|---|
| 앱 이름 | 하루 다님 (스플래시 멘트: "당신의 하루에 다녀가다") |
| 플랫폼 | iOS (iPhone) + iPadOS(사이드바·일기) + watchOS 앱/컴플리케이션 + 홈·잠금화면 위젯 |
| 기본 언어 | 한국어. 설정에서 영어/일본어 전환 (String Catalog `IOS/Localizable.xcstrings`) |
| 번들 ID | `com.yechan.HaruDanim` / App Group `group.com.yechan.HaruDanim` / CloudKit `iCloud.com.yechan.HaruDanim` |
| 수익 모델 | 부분 유료화. 무료 한도(행동 10·목표 2·목표당 다짐 3) + 프리미엄 결제 시 해제 |
| 결제 상품 | `com.yechan.HaruDanim.premium.monthly`(월 5,000원) / `.yearly`(년 50,000원) / `.lifetime`(80,000원) — 임시 가격, App Store Connect 등록은 아직 안 됨 |
| 남은 배포 작업 | App Store Connect 상품 3종 등록(구독 그룹 + 비소모성, 유료 앱 계약), **CloudKit 프로덕션 스키마 배포**(대시보드 Deploy Schema to Production — DiaryTemplate 등 새 레코드 타입/필드는 개발 환경에만 자동 생성되므로 출시 전 필수), 실기기 검증(펜슬 필기감·캘린더 권한), App Store 배포. **코드 측 잔여 2건**: ① 결제 화면(PremiumView)에 개인정보처리방침·이용약관 링크(심사 3.1.2 필수 — URL 준비되면 추가), ② `IOS/Info.plist`(파일 있음)에 `ITSAppUsesNonExemptEncryption=NO` 추가 |
---
## 2. 빌드·검증 환경
### 2.1 프로젝트 구조 (타깃별 폴더)
```
Haru_Danim.xcodeproj # 프로젝트 (스킴: Haru_Danim, Haru_Danim-Store)
IOS/ # iOS 앱 타깃 (Haru_Danim)
Haru_DanimApp.swift # 앱 엔트리 (프리미엄 부트스트랩, 마이그레이션, 훅 주입)
ContentView.swift # 스플래시 + 탭/사이드바/버블 내비게이션 분기, AppRouter, AppTab
Core/ # Store(StoreKit)·LiveActivity·WatchSync·CloudSyncMonitor·AppRefresh·DebugSeed·SymbolCatalog
Views/ # 탭 화면들 + 내보내기 + 일기 + 도움말
Localizable.xcstrings # ko(원문)/en/ja 문자열 카탈로그
Shared/ # 앱+위젯(일부는 워치도) 공용: 모델, 계산, 설정, 프리미엄, 인텐트
Widgets/ # 홈·잠금화면 위젯 + Live Activity UI 타깃 (Haru_DanimWidgets)
WatchShared/WatchPayload.swift# iPhone↔워치 Codable 페이로드 (iOS+워치+워치위젯 공용)
Haru_DanimWatch Watch App/ # 워치 앱 타깃
Haru_DanimWatchWidgets/ # 워치 컴플리케이션 타깃
HaruDanim.storekit # 로컬 StoreKit 테스트 구성 (Store 스킴이 사용)
DesignAssets/ # 아이콘 원본 등
```
### 2.2 빌드 커맨드
```bash
# Debug (개발·검증 기본)
xcodebuild -project Haru_Danim.xcodeproj -scheme Haru_Danim \
-destination 'platform=iOS Simulator,id=<SIM_ID>' build
# 스토어 동일 구성 (Release + HaruDanim.storekit)
xcodebuild -project Haru_Danim.xcodeproj -scheme Haru_Danim-Store \
-destination 'platform=iOS Simulator,id=<SIM_ID>' build
```
- 자주 쓰는 시뮬레이터: iPhone `B1346141-3991-46BF-A12B-A66184FAEBC2`, iPad Pro 11(M5) `CC81FCE1-20D6-460F-9E90-BE949E7388FC`
- **SourceKit 인라인 진단은 항상 오탐 노이즈**("No such module 'UIKit'" 등) — 판정은 xcodebuild 결과만 신뢰
- DEBUG 런치 인자 검증은 **콜드 스타트 필수** (terminate 후 재실행). 목록은 §14
- 캘린더 권한 부여: `xcrun simctl privacy <sim> grant calendar com.yechan.HaruDanim`
### 2.3 다국어(l10n) 루틴
`String(localized:)` 문자열 추가 후:
```zsh
# 1) Debug 빌드 → 2) stringsdata 동기화
files=(${(f)"$(find ~/Library/Developer/Xcode/DerivedData/Haru_Danim-*/ -name '*.stringsdata' -path '*Debug-iphonesimulator*')"})
xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${files[@]}"
# 3) python으로 en/ja 번역 채움 → 4) sync 재실행으로 포맷 정규화 → 5) missing 확인
```
문구를 **수정**했을 때는 옛 키가 `extractionState: stale`로 남는다 — sync 후 stale 항목을 삭제해 카탈로그를 청소할 것 (missing과 함께 stale도 확인).
⚠️ **카탈로그는 타깃별로 따로다** — Shared 파일의 문자열이라도 위젯·워치 타깃에서 쓰이면 Xcode가 빌드 시 그 타깃의 카탈로그(`Widgets/Localizable.xcstrings`, 워치 앱, `IOS/InfoPlist.xcstrings`)에도 추출한다. IOS 카탈로그만 채우면 위젯·워치 화면은 미번역으로 남으므로, 빌드 후 **모든 카탈로그의 missing을 확인**할 것.
용어 통일: 다짐=Quest/クエスト, 행동=Action, 꼬리표=Tag. `String(format:)`은 카탈로그로 추출되지 않으므로 사용자 노출 문구에 금지 (`Format.countAverage` 참고).
---
## 3. 도메인 모델 (SwiftData — `Shared/Models.swift`, `Shared/DiaryModels.swift`)
**CloudKit 호환 규칙 (필수)**: 모든 저장 프로퍼티는 기본값을 가짐. to-many 관계는 optional 저장(`~Storage`) + non-optional computed 접근자. 기존 필드명 유지를 위해 `@Relationship(originalName:)` 사용.
| 모델 | 핵심 필드 | 비고 |
|---|---|---|
| `Tag` | uuid, name, colorHex, sortOrder | 행동 분류. 고유 색. 다대다(Action) |
| `Action` | uuid, name, symbolName(SF Symbol), trackingTypeRaw(time/count), isFavorite | 버튼 색 = **가장 먼저 만든 꼬리표의 색**. sortOrder·promptsForNote는 레거시(§5 LocalPrefs로 이관, 폴백 정렬용만) |
| `TimeSession` | startAt, endAt(nil=측정 중), note | 삭제 규칙: Action cascade |
| `CountEntry` | timestamp, amount, note | |
| `Goal` | uuid, title, symbolName, colorHex, startDate, endDate?, statusRaw(inProgress/achieved/notAchieved), **achieveThresholdPercent(기본 100)**, sortOrder | isCollapsed·showsOnMain은 레거시 |
| `Quest` | uuid, goal, targetAction 또는 targetTag, measureRaw, periodRaw(daily/weekly/monthly/custom), scheduleModeRaw(everyDay/weekdays/monthDays/ordinalWeekday), weekdays[], monthDays[], ordinalWeek/Weekday, customStart/End, targetSeconds/targetCount, directionRaw(atLeast/atMost), sortOrder | 꼬리표 대상이면 measure는 생성 시 선택, `targetActions`는 해당 measure의 행동만 |
| `DiaryEntry` | dayKey(논리적 하루 키), moodEmoji, moodImageData(externalStorage), **calendarEventIDs[]**(그날 타임테이블에 넣을 EKEvent id), **hiddenActionIDs[]**(그날 타임테이블에서 숨길 Action.uuid 문자열), **hiddenGoalIDs[]**(그날 '이 날의 목표' 카드에서 숨길 Goal.uuid 문자열) | 날짜당 1개. 날짜별 선택은 uuid 문자열로 저장(기기 간 동기화 안전) |
| `DiaryTodo` | text, isDone, sortOrder | 요약 페이지 체크리스트 |
| `DiaryPage` | index, lined, lineSpacing, drawingData(PKDrawing, externalStorage), **templateID**(DiaryTemplate.uuid 문자열, 빈 값=없음), **templatePageIndex**(PDF 쪽, 0부터) | 768×1024 논리 좌표. 양식만 깔린 페이지도 hasContent=true(작성함 취급·내보내기 포함) |
| `DiaryTemplate` | uuid, name, kindRaw(pdf/image), data(externalStorage — PDF는 원본 통째 1개), thumbnailData(첫 쪽), pageCount | 노트 배경 양식. PDF 여러 쪽이어도 복제 없이 원본 1개 + 쪽 인덱스 참조 |
| `DiaryPageItem` | kind(photo/rectangle/ellipse/arrow/line/**text**), imageData, **text**(텍스트 상자 내용), colorHex, centerX/Y·widthRatio(비율 좌표), rotationDegrees | |
---
## 4. 핵심 계산 규칙 (`Shared/DayMath.swift`, `Shared/QuestProgress.swift`)
### 4.1 논리적 하루 (DayMath)
- 설정 "하루 시작 시간"(`dayStartMinutes`)·"주 시작 요일"(`weekStartWeekday`, 기본 월요일)이 모든 집계의 기준
- `dayKey(for:)`: 시각 → 그 시각이 속한 논리적 하루의 달력일 자정. 경계 이전 시각은 전날 귀속
- 세션은 실제 시각 그대로 하나로 저장하고, **집계는 구간 겹침(overlap)으로 자동 분할** — 하루 경계를 걸친 세션이 날짜별로 나뉘어 계산됨. `Aggregator.seconds/count`가 이 규칙의 단일 구현이며 화면·위젯·내보내기 모두 이를 공유
### 4.2 다짐 진행률 (QuestProgress)
- `ratio`(게이지용, 0...1): atLeast = min(value/target, 1) / **atMost = 한도 안이면 1, 넘으면 0**
- `displayRatio`(퍼센트 문구): atLeast는 100% 초과 가능
- **주간·월간 span에서 '이상 달성' 하루 단위 다짐은 하루별 기여를 그날 목표량으로 캡** — 어느 날의 초과 달성이 다른 날 미달을 가리지 않음 (하루 span·atMost·주/월/기간 다짐은 캡 없음, 원본 기록 무영향)
- `scaledTarget`: 주기 목표량을 span 길이에 환산 (daily→적용일 수×목표, weekly→/7 등)
- `isScheduled(on:)`: 하루 진행률 모수 판정 — 수행일 아닌 daily 다짐은 그날 모수에서 제외
### 4.3 목표 진행률·판정 (Goal extension)
- `combinedSpanRatio(span)`: 소속 다짐 ratio 평균. 하루 span은 오늘이 수행일인 다짐만 모수(하나도 없으면 100% 취급). 모음 탭 카드·위젯·워치·시리가 공유
- **달성 판정**: 종료일 경과 자동(`evaluateIfEnded` — 앱 실행/포그라운드 복귀와 목표 탭 진입 시 실행. 위젯 확장에서는 판정하지 않음) 또는 수동 종료(`manualFinish`). `questAchievementRatio`(달성 다짐=1, 미달성=현재 주기 ratio 클램프, 수행일 아님=1) × 100 ≥ `achieveThresholdPercent` 0.0001 이면 달성. **기준 100 = 레거시 allSatisfy와 동일**
- **판정 기준 시점**: 자동 판정은 **종료일이 속한 논리적 하루의 마지막 순간**(`judgmentReference` — 달력일을 키로 직접 찾아 '하루 시작 시간'이 오후여도 안전) — 앱을 종료일 며칠 뒤에 열어도, 종료일을 과거 날짜로 수정해도 종료일까지의 기록으로 판정된다(실행 시점보다 늦으면 now로 상한). 수동 종료(종료일 없는 목표 전용)는 "지금" 기준. 목표 편집에서 종료일을 바꾸면: 진행 중 목표는 저장 즉시 판정, **완료된 목표는 다시 '진행 중'으로 재개 후 재판정**(미래/없음이면 진행 중 유지, 다른 과거 날짜면 그 시점 기준 재판정)
- **주기 중간 종료 판정**(`QuestProgress.judgment(asOf:)`): 판정용 진행률은 current()와 달리 ①값을 기준 시점까지의 누적으로 제한(종료일 이후 같은 주기의 기록 배제)하고 ②주간/월간/특정 기간 다짐의 목표량·한도를 **주기 내 경과일 비율로 축소** — 수요일에 끝나는 목표의 주간 다짐이 남은 4일치 미달로 깎이지 않고, atMost 한도도 같은 비율이라 공정. 하루 단위 다짐은 목표량 축소 없음(그날 목표는 온전히 채워야 달성). 표시 연산(combinedSpanRatio·spanProgress)에는 영향 없음
- 다짐 없는 목표: 종료일 도래 시 "확인 필요"(달성했나요?) 상태 → 사용자가 직접 선택. 수동 종료도 동일
- 진행률 표시 연산(combinedSpanRatio 등)은 판정 기준과 무관 — 건드리지 말 것
---
## 5. 데이터 계층
### 5.1 저장소 (`Shared/DataStore.swift`)
- 스토어 파일: App Group 컨테이너의 `HaruDanim.store` (위젯·인텐트가 같은 DB 사용). 구 샌드박스 `default.store`는 1회 이관
- **CloudKit 미러링은 메인 앱 프로세스만**. 위젯 확장(.appex)은 같은 파일을 로컬 전용으로 열음. 확장의 쓰기는 메인 앱이 원격 변경 알림으로 받아 내보냄
- iCloud 동기화 = 프리미엄 + `settings.cloudSync` 토글 (앱 재시작 시 적용). 실패 시 로컬 폴백
- **원격 변경 가져오기(import)는 무음 푸시가 트리거** — `IOS/Info.plist``UIBackgroundModes: remote-notification`(+`aps-environment` 엔타이틀먼트) 필수. 이 모드가 없으면 앱 실행/포그라운드 복귀 때만 가져와서, 다른 기기의 변경이 사용 중에는 절대 안 보이고 새로고침 버튼도 무용지물이 된다(수정된 버그). 가져오기를 코드로 강제하는 공식 API는 없음. iOS 타깃 Info.plist는 `GENERATE_INFOPLIST_FILE=YES` + `INFOPLIST_FILE=IOS/Info.plist` 병합 방식(파일에는 생성 설정에 없는 키만 넣음, IOS 동기화 그룹의 membershipException으로 리소스 복사 제외)
- 컨테이너 생성 실패 시: 짧은 재시도 3회 → 스토어를 `.backup-<ts>`로 보존 후 새로 시작 (fatalError로 즉사하지 않음)
- `ensureUniqueEntityIDs`: uuid 기본값 마이그레이션 중복 보정 (앱 시작 1회)
### 5.2 동기화되는 데이터 vs 기기 로컬 설정 (`Shared/LocalPrefs.swift`)
- **데이터**(행동·목표·기록·일기)는 SwiftData(+CloudKit), **"이 기기에서 어떻게 보여줄지"는 App Group UserDefaults**:
- `local.actionOrder`(모음 탭 배치 — 위젯·워치·인텐트 나열 순서도 이걸 따름), `local.pinnedGoals`(모음 탭 노출 목표), `local.collapsedGoals`, `local.notePromptActions`(기록 시 메모 창)
- 대응하는 @Model 레거시 필드는 스키마 안정성 위해 남겨두고 1회 이관(`adoptLegacyModelValuesIfNeeded`)
- 설정 키는 `Shared/Settings.swift` — 집계 기준·테마·대표시간 등은 App Group defaults(위젯 공유), navStyle·visibleTabs·radialTabOrder·diary.section* 은 standard(기기 로컬)
### 5.3 변경 전파
- 앱 내 모든 mutation 끝에 **`DataChange.commit(context:)`**: save → LiveActivity sync(장시간 측정 알림 예약 동기화 포함) → `WidgetCenter.reloadAllTimelines()` + `ControlCenter.reloadAllControls()` → 워치 스냅숏 push(350ms 디바운스 — 연타 시 마지막 상태만 집계·전송). 위젯/시리 경로는 `IntentStore.performWrite`가 같은 역할(한 컨텍스트로 조회·수정·저장 원자 처리 + 저장 1회 재시도)
- `CloudSyncMonitor`: NSPersistentStoreRemoteChange를 2초 디바운스로 받아 위젯·워치·LiveActivity 갱신 (화면은 건드리지 않음)
- `AppRefresh`(모음 탭 새로고침 버튼): 루트 뷰 `.id(token)` 리셋으로 모든 @Query 재조회 — 다른 기기 변경이 화면에 안 보일 때의 수동 해결책. **로컬 스토어에 이미 도착한 변경만 반영** — 가져오기 자체는 무음 푸시가 담당(§5.1)
---
## 6. 내비게이션·화면 구성
- **스플래시**: 앱 로고(AppLogo) + 이름 + 멘트, 1.2초 후 페이드아웃 (`ContentView`)
- **탭 정의** (`AppTab`): 모음(main) · 행동 · 꼬리표 · 목표 · 기록 · 통계 · 일기(iPad 전용) · 설정
- **iPhone 기본**: 하단 탭바에 **노출 탭 1~3개**(기본 모음·행동·기록) + "더보기" 탭(나머지 목록). 설정에서 구성
- **iPhone 실험 옵션**: "글래스 버블 메뉴"(`RadialNavigationView`) — 하단 중앙 FAB를 누르면 리퀴드 글라스(`glassEffect(.regular.interactive())`) 유리 구슬 탭 버튼들이 부채꼴로 펼쳐짐. 아이콘+이름이 구슬 안에 함께. 모든 유리 요소는 하나의 `GlassEffectContainer`에서 한 패스로 렌더링(성능)되고 FAB 근처에서 액체처럼 분리·합체 모핑. 2단계 연출: 삽입(FAB에서 모핑) → bloomed(스태거 스프링으로 아치 확산). ⚠️ 컨테이너 안 유리 요소에는 glassEffect 뒤 opacity/scale이 콘텐츠에 온전히 적용되지 않음 — 숨김은 계층 제거(`if`), 이동은 `offset`으로만. 순서는 설정에서 편집(기기 로컬). **두 번 눌러 바로 이동**(설정 토글+탭 선택, `settings.radialDoubleTapTab` 빈 값=꺼짐): 닫힌 FAB를 0.35초 안에 두 번 누르면 지정 탭으로 즉시 이동 — 첫 탭을 지연시키는 더블탭 제스처 대신 첫 탭은 즉시 열고 두 번째 탭에서 분기(반응성 유지, 버블이 피어나다 흡수되는 연출)
- **iPad·Mac**: 항상 `NavigationSplitView` 사이드바 (`SidebarRootView`, `DeviceLayout.isPad`로 판정)
- `AppRouter`: 탭 전환 + 탭 간 전달값(모음 탭 롱프레스 → 기록/통계 탭 행동 필터 예약, 더보기 내부 경로 진입 포함)
### 6.1 모음 탭 (`MainView`)
- 상단: 노출 목표 진행 현황 카드(1개=단독, 2개+=스냅 페이징 가로 스크롤 + 페이지 점, iPad=그리드). 카드 스타일 설정: 다짐별 각각(perQuest, 접힘 시 3개+더보기) / 전체 합산(combined)
- "현재 진행 중" 영역: 측정 중 세션 나열 + 실시간 타이머 + 종료 버튼
- 행동 버튼 그리드: 꼬리표 색 그라데이션 배경 + 아이콘 + 이름 + 오늘 누적(시간형) 또는 오늘 횟수(횟수형, 숫자 전환 애니메이션). 측정 중이면 노란 테두리 + record 점. 탭 = 시작/종료 토글 또는 +1 (스프링 눌림 + 햅틱)
- 한 줄 개수: iPhone 2~6개 설정(5개 이상은 compact 셀), iPad는 adaptive
- 롱프레스 메뉴: 기록 확인(기록 탭 필터 이동) / 통계 보기 / 기록 직접 입력·수정 / 행동 설정 수정 / 삭제
- 배치 편집: 지글 애니메이션 + 드래그 재정렬 → `local.actionOrder` 저장 (CloudKit 모델에 쓰지 않음)
- "짧은 기록 무시"(minSessionSeconds): 설정보다 짧은 세션은 종료 시 삭제. 메모 창 옵션 켠 행동은 종료/+1 시 메모 시트(건너뛰기 가능)
- 툴바: 새로고침(좌) / 배치 편집(우)
- 빈 상태(행동 0개): 개념 한 줄 소개 + '첫 행동 만들기'(추가 시트 직행) + '예시 행동으로 시작해 보기'(꼬리표 생활·건강 + 독서/운동/물 마시기 생성)
### 6.2 행동 탭 (`ActionViews`)
- 즐겨찾기 섹션(스와이프로 토글) → 꼬리표별 그룹 → 꼬리표 없음. 탭하면 상세(정보·즐겨찾기·메모 창 토글·누적 통계·삭제)
- 추가/수정: 이름, 아이콘(SF Symbols 검색+카테고리 선택기 `SymbolPickerView`/`SymbolCatalog`), 꼬리표 복수 선택(즉석 추가 가능), 추적 방식(**생성 시에만 선택 — 수정에서는 비활성**, 기존 기록의 표시·집계가 뒤섞이는 것 방지), 메모 창 여부. 무료 한도 초과 시 프리미엄 안내 얼럿
- ⋯ 메뉴: 꼬리표 순서 변경 시트
### 6.3 꼬리표 탭 (`TagViews`): 목록(행동 수 표시)·드래그 정렬·수정(이름/색: ColorPicker+프리셋 12색)·스와이프 삭제(행동은 유지)
### 6.4 목표 탭 (`GoalViews`, `QuestEditorView`)
- 진행 중 목표만 기본 목록(섹션당 목표 행 + 다짐 행들, 다짐 접기/펼치기는 기기 로컬). 완료(달성/미달성) 목표는 "완료된 목표" 별도 화면
- 목표 행: 상태 뱃지(진행 중/달성/미달성/확인 필요) + 기간 진행률 바(종료일 있을 때). "확인 필요"는 탭해서 달성/미달성 직접 선택
- 목표 편집: 내용·아이콘·색·시작일(과거 가능)·종료일(선택)·**달성 판정 기준 슬라이더(10~100%, 5% 단위, 기본 100%)**
- 목표 상세: 다짐 목록(탭=수정, 스와이프 삭제, 순서 모드), 다짐 추가(무료 한도 3개), 종료일 없으면 수동 종료(기준<100% 안내 문구에 기준 표기), 삭제
- 다짐 편집 3단계: 대상(행동 또는 꼬리표+측정 기준) 주기(하루[매일/요일/날짜/n번째 요일··특정 기간 ordinalWeekday 구현은 weekdayOrdinal " 달의 n번째 요일"이며 UI 문구도 그렇게 표기) 목표량(시간 또는 횟수 스테퍼)+방향(이상 달성/이하 유지)
- **연속 달성**(`QuestProgress.streak` `QuestStreakInfo`): 하루 단위=적용일만 연속 계산("연속 N일" 매주 수요일 다짐을 4주 연속이면 연속 4일), 주간/월간="연속 N주/N달", 특정 기간=nil. 진행 중인 오늘/이번 주기는 atLeast면 미달이어도 끊기지 않고(건너뜀) 채웠으면 포함, atMost는 넘는 순간 끊김. **하한 = 목표 시작일** 행동이 아니라 "목표 다짐" 연속이므로 목표 시작 날짜/주기는 세지 않음(·월은 시작일이 걸친 부분 주기까지 포함). 특히 atMost는 기록 없는 날이 전부 달성이라 하한이 없으면 과거 전체가 연속으로 잡힘. 소급 상한 400일(성능). 기록을 하루 키로 1회 버킷팅해 O(기록+일수). 표기: 목표 다짐 + 위젯 다짐 ·③ ·④현황 그리드 (불꽃+노랑, 0이면 숨김). '스트릭'이라는 단어는 한국어 UI에 쓰지 않는다
- 목록 진입 `evaluateIfEnded` 일괄 실행
### 6.5 기록 탭 (`HistoryView`, `RecordEditors`)
- 날짜 헤더(화살표 이동, 날짜 =달력 팝오버, "오늘" 버튼) + 목록/타임테이블 세그먼트
- 목록: 시간 기록(시작~종료· 시간·메모횟수 기록(시각·+n·메모), 탭하면 수정 시트
- 타임테이블: 24시간 (하루 시작 시간 기준), 하루/일주일 전환(주간 보기에서 화살표는 7일씩 점프). 시간= 블록, 횟수= . 블록/ =수정
- 필터: `RecordFilterSheet`(공용) 꼬리표 아래 행동이 그룹된 트리, **"제외한 행동 ID" 집합**으로 관리해 기본이 전체 선택. 활성 초록 표시
- 내보내기: 현재 날짜/모드/필터 그대로 `ExportBuilder.history` 스냅숏 이미지 시트
### 6.6 통계 탭 (`StatsTabView`) — 기록과 분리된 독립 탭
- 하루: 꼬리표별 시간/횟수 가로 막대. 주간: 행동별 일별 꺾은선(행동 +범례)+합계 막대+합계·평균 . 월간: 주차별+일별 꺾은선+합계+
- 평균 분모는 "이미 시작된" /주만 (미래로 희석 방지). 필터·내보내기는 기록 탭과 동일 패턴
### 6.7 일기 탭 (iPad 전용, 프리미엄 — `DiaryView`, `DiaryNotePage`, `DiaryCalendarEvents`, `DiaryExportSheet`)
- 루트: 달력(작성일에 기분 이모지/연필 마커), 날짜 일기 상세. 툴바에서 기간/복수 날짜 내보내기(PDF=페이지별 1장, 이미지=세로 스티치 PNG, 2배율)
- 상세는 진입만 해도 엔트리를 생성(loadEntry)하므로, 달력 onAppear가 **완전히 빈 엔트리만 정리**(기분·할일·페이지 없음 + calendarEventIDs·hiddenActionIDs 비어있음 + 페이지 0개 캘린더/필터 선택이나 노트 페이지가 있으면 보존)
- 상세: 좌우 페이지 넘김. 1페이지=하루 정리 요약, 이후=펜슬 노트, 마지막=페이지 추가 자리
- **요약 페이지 섹션** (표시·순서는 " 화면 구성" 시트, 날짜 공통, standard defaults): 오늘 기분(대형 정사각 타일: 이모지/사진) · 요약 지표 · 날의 목표(그날 기준 다짐 현황, 주간/월간은 그날까지 누적) · 오늘 · 타임테이블 · 기록 상세 · 통계 막대. 화면 720pt 이상이면 타임테이블 좌측 고정 2컬럼
- 타임테이블 카드 우상단 버튼: **필터**(그날 기록된 행동만 나열된 팝업에서 표시 선택, 날짜별 저장 `hiddenActionIDs`, 숨긴 개수 노란 배지 타임테이블에만 적용, 요약 수치·기록 목록은 전체 유지) + **캘린더**(그날의 EKEvent 선택, 날짜별 저장, **점선 테두리 + 채움 0.3 블록** 행동 블록은 불투명 채움이라 스타일로 구분, 옅은 채움 0.14는 가시성 부족으로 폐기). 내보내기에 동일 반영
- ' 날의 목표' 카드 우상단 **목표 필터**: 그날 진행 중인 목표 표시할 선택(날짜별 저장 `hiddenGoalIDs`, 숨긴 개수 노란 배지, 열람 전용에선 버튼 숨김). 이미지 내보내기의 요약에는 목표 카드가 없으므로 내보내기 무영향
- **프리미엄 만료 열람 모드**: 이미 일기(hasContent) 있으면 잠금 화면 대신 달력을 열람 전용으로 연다 배너 + 일기 있는 날짜만 활성, `environment(\.diaryReadOnly)` 화면의 편집 UI(기분/할일/필기/필터/캘린더/⋯메뉴) 비활성, 열람 진입은 엔트리를 생성하지 않음. 일기를 없으면 기존 잠금 안내
- **양식(속지)** (`DiaryTemplates.swift`): 달력 툴바 '양식 관리'에서 PDF(≤20MB사진을 가져와 라이브러리로 관리. 페이지 추가 [ 캔버스/ 노트/ 양식] 선택(양식 0개면 시트 없이 기존처럼 즉시 추가), 여러 PDF는 2단계 썸네일 선택(최대 24쪽, 원본은 통째 1개 저장 + 인덱스 참조). 렌더: 페이지 논리 크기에 aspect-fit(좌표계 불변), **PDF는 줌 종료 시 현재 배율로 백그라운드 재래스터**(`DiaryTemplateImageView` 펜슬 캔버스 선명도 훅에 함께 연결), 이미지는 가져올 3072px 리샘플(필기 래스터 상한 3× 동일 흐림 없음). 양식 페이지에선 노트 토글 숨김. 양식 삭제 사용 페이지 경고 해당 페이지는 캔버스 폴백(필기·요소 보존). 내보내기에도 동일 반영
- 노트 페이지: 768×1024 논리 좌표 고정 + scaleEffect. 배치 요소: 사진·도형(사각//화살표/)·**텍스트 상자**(키보드 입력, 배치 모드에서 ' 수정'으로 내용· 편집). 메뉴 '페이지 순서 변경'으로 노트 페이지 드래그 재배열(요약은 항상 ). **실기기 필기는 펜슬 전용, 손가락은 이동/줌만**(시뮬레이터는 anyInput). 핀치줌 최대 4배, `SharpPencilCanvasView` 줌에 맞춰 래스터 배율 유지( 선명도). 배율 적용은 **뷰의 contentScaleFactor만** 서브레이어 contentsScale+setNeedsDisplay를 직접 건드리면 PencilKit 타일 픽셀이 지워진 재렌더되지 않아 확대 필기가 사라진다(수정된 버그). 노트(간격 4단계사진·도형(사각//화살표/) 배치 모드(드래그·핀치 크기 조절, 저장은 비율 좌표)
### 6.8 설정 탭 (`SettingsView`)
- 테마(라이트/다크 즉시 적용, App Group 저장으로 위젯 공유) / 언어(재시작 적용, AppleLanguages 오버라이드)
- iPhone만: 내비게이션 방식(탭바/버블) + 탭바 구성 or 버블 순서· 눌러 바로 이동(토글+대상 )
- 모음 목표 카드 선택(개수 무제한)+표시 방식 / 시작 요일 / 하루 시작 시간 / 짧은 기록 무시 / **장시간 측정 알림**(1~12시간, 켜는 순간 알림 권한 요청 `SessionAlertManager` LiveActivityManager.sync 길목에서 진행 세션과 예약을 동기화, 세션 종료·설정 변경 예약 제거) / 대표 시간 기준(가장 먼저·나중에 시작 기본 latest)
- 데이터: **데이터 내보내기** 행동·시간·횟수·목표·다짐을 CSV 5파일로 (ISO 8601, UTF-8 BOM, 무료 포함)
- 프리미엄 화면8) / 도움말(`HelpView` 기기별 주제 분기, DisclosureGroup) / 버전(번들 CFBundleShortVersionString 표시)
---
## 7. 이미지·PDF 내보내기 시스템 (`ExportImageView.swift`)
- `ExportSnapshot`: SwiftData 비의존 타입 (title/periodLabel/filterNote/hero/sections). 섹션 종류: timetable / records / bars / lines / table
- `ExportBuilder.history(...)` / `.stats(...)`: 화면과 동일한 집계 규칙으로 스냅숏 생성. `trimHours: false` 기록이 없어도 24시간 전체 그리드(일기용)
- `injectingCalendarEvents(_:)`: 하루 타임테이블에 캘린더 블록 주입. `replacingTimetableSections(with:)`: 일기 필터용 타임테이블 섹션만 교체하고 나머지는 유지
- `ExportPosterView`: 고정폭 430pt, ImageRenderer scale 3 1290px PNG. 라이트/다크는 `.environment(\.colorScheme)` 강제. `ExportSectionView`/`ExportHeroRow` 일기 요약 화면과 공용 **화면과 내보내기 결과가 항상 일치**
- 일기 인쇄용: `DiaryPrintSummaryView`(768pt) + `DiaryPrintNotePageView`(768×1024, 라이트 고정)
---
## 8. 프리미엄·결제 (`Shared/Premium.swift`, `IOS/Core/Store.swift`)
- 계층: `EntitlementProvider` 프로토콜 `StoreKitEntitlementProvider`(currentEntitlements + Transaction.updates 스트림, 시작 `PremiumManager.bootstrap` 주입) / Mock(확장용)
- `PremiumManager`(@Observable, UI 단일 진입점, canAddAction/Goal/Quest) / `PremiumGate`(App Group 캐시 위젯·워치·인텐트가 동기 조회)
- `PremiumStore`: 상품 로드·구매·복원(`AppStore.sync`현재 플랜(평생 > 만료 먼 구독). 구매/복원 후 권한 재계산 + 위젯 리로드
- 결제 화면(`PremiumView`): 기능 소개 → 미구매 시 상품 3종(년 구독 절약률 배지, 평생 "한 번 결제") + 복원. 구매 후: 플랜 표시 + 구독 관리 시트 + **기기 간 동기화 토글** + 구독→평생 전환 안내
- DEBUG 전용: 테스트 토글(`setMockPremium` — 켜면 실권한 무시 모드) + "토글 강제 해제". 실구매·복원은 강제 모드를 자동 해제
- 프리미엄 기능: 개수 무제한, 홈·잠금 위젯, 워치 앱+컴플리케이션, 시리 단축어, iCloud 동기화, iPad 일기, 제어 센터 컨트롤
- **만료 정책**: 기존 데이터·기록은 계속 사용(추가 생성만 한도 적용), 위젯·컨트롤은 프리미엄 안내 표시, 일기는 이미 쓴 것 열람 전용 허용(§6.7)
---
## 9. Live Activity·다이나믹 아일랜드 (`LiveActivityManager`, `Widgets/HaruDanimWidgets.swift`)
- 측정 시작/종료/수정마다 `sync(context:)`: 진행 중 세션 조회 → 대표 1개(설정: earliest/latest) + `extraCount`(+N 표시) 상태로 Activity 시작/갱신/종료. 중복 Activity 정리
- 백그라운드(워치 명령)에서 시작 거부 시 `pendingStartRetry` → 포그라운드 복귀 때 재시도
- UI: 잠금화면 배너(아이콘 타일+이름+타이머) / 확장 아일랜드(leading 아이콘+이름, trailing 타이머, bottom "+N개 함께 추적 중") / compact(아이콘+타이머+N) / minimal(아이콘)
---
## 10. 홈·잠금화면 위젯 (`Widgets/` — 모두 프리미엄 게이트)
번들: TrackingLiveActivity + 6종 홈 위젯 + 잠금화면 1종. 모든 홈 위젯 공통 설정: **테마(앱 일치/라이트/다크)**. 홈 화면이 틴트/클리어(리퀴드 글라스) 렌더링 모드면 커스텀 배경·강제 스킴을 얹지 않고 시스템 바이브런트에 맡김(white-on-white 방어, `WidgetTheme.swift`).
| 위젯 (kind) | 내용 |
|---|---|
| ① 행동 실행 `HaruActionRunWidget` | `Button(intent: RunActionIntent)` — 시간형 토글/횟수형 +1. 누적값 표시 기간(오늘/주/월/숨김) 선택. 소형 1(풀블리드)/중형 4(2×2 행 레이아웃)/대형 4 또는 8. 측정 중 노란 테두리+타이머(`Text(style:.timer)`) |
| ② 목표 진행률 `HaruGoalBarsWidget` | 하루/주간/월간 가로 진행바 3줄. 중형: 목표2 또는 목표1+다짐. 대형: 목표4 / 목표+다짐×2 / 목표1+모든 다짐. 다짐 목록은 높이에 맞춰 적응 채움(+N) |
| ③ 다짐 진행률 `HaruQuestRingWidget` | 원형 링(행동 대상이면 눌러 실행). 기간 선택. 소형1/중형2/대형4. atMost는 "한도 지킴/초과". 퍼센트(한도) 문구 아래에 선택 기간의 **실제 누적값**("1시간 20분"/"3회") 한 줄 — atMost는 퍼센트가 없어 이 줄이 유일한 현재 수치(진행률 캡과 무관한 원본 값, 미리보기 앵커 CM) |
| ④ 다짐 현황 `HaruGoalQuestGridWidget` | 표시 전용 링 그리드 + 목표 헤더. 소형 2×2 / 중형 4×2 / 대형: 다짐16 / 목표4 / 목표2 |
| ⑤ 행동 통계 `HaruStatsChartWidget` | 꺾은선(행동 색). 기간: 일주일/한 달(일별, 소형 제외→주별 대체)/한 달(주별). 선 수 제한 소2/중4/대6 |
| ⑥ 현재 진행 중 `HaruNowRunningWidget` | 측정 중인 대표 행동(설정 '대표 시간 기준' — 다이나믹 아일랜드와 같은 규칙)을 꼬리표 색 배경 + 실시간 타이머(`Text(style:.timer)`) + 종료 버튼(`RunActionIntent` 토글의 종료 방향)으로 표시. 소형/중형: 대표 1개 + "+N개 함께 추적 중". 대형: 대표 카드 + 함께 측정 중 나열(각각 종료 버튼, 4개 초과분은 "외 N개") / 단독이면 오늘 누적(실시간)·시작 시각 카드. 측정 중 아니면 빈 상태 안내, 갤러리 미리보기는 샘플 표시 |
| 잠금화면 `HaruLockGoalWidget` | 목표 1개 달성률 — 원형 게이지/숫자만/다짐 점(달성=채움). circular/rectangular/inline |
| 제어 센터 `HaruActionRunControl` | iOS 18 ControlWidget — 행동 1개 토글(시간형 시작/종료, 횟수형 +1). `ToggleActionControlIntent`(SetValueIntent+LiveActivityIntent, 앱 프로세스 실행) |
- 슬롯 규칙: 미선택이면 기본 대상 채움, 선택했으면 순서 존중 + 모자란 슬롯은 점선 빈 칸. '선택 안 함' 센티널 `NoneEntityID`
- **타임라인 예산 전략**(`WidgetRefresh`): 주기 폴링 없음. 값 변경은 `DataChange.commit`/인텐트의 reloadAllTimelines가 담당. 측정 중일 때만 10분 간격 미래 엔트리 1시간치+.atEnd, 평상시엔 하루 경계(최대 4h) 안전망
- **버튼 탭 즉시 피드백**: ①③의 변하는 값(누적·퍼센트·링)에 `invalidatableContent()` — 탭 즉시 '갱신 중' 표시로 바뀌어 새 타임라인 도착(1~3초, WidgetKit 구조상 단축 불가) 전에도 탭 접수를 보여 줌(중복 탭 방지)
- **버튼 히트 영역**: `Button(intent:)` 라벨 끝에 `.contentShape(.rect)` 필수 — plain 버튼은 라벨의 투명 영역(Spacer·여백·링 안쪽)을 탭 판정에서 빼는 경우가 있어, 그 지점 탭이 인텐트 대신 위젯 기본 동작(앱 열기)으로 새는 버그가 있었다. ③의 꼬리표 대상 다짐 셀은 버튼이 없어(표시 전용) 셀 전체가 앱 열기 — 의도된 동작
- 확장 프로세스는 `IntentStore.refresh()`로 작업마다 컨테이너를 새로 열어 최신 데이터 보장 (실패 시 직전 컨테이너 유지 — 크래시 방지)
- `RunActionIntent`는 AppEntity 대신 **UUID 문자열 파라미터** — 버튼 탭 지연·유실 방지의 핵심
---
## 11. 애플워치 (프리미엄)
- **워치 앱**: 첫 화면 = [측정 중 섹션] + **즐겨찾기**(앱에서 즐겨찾기한 행동 직행, WatchActionInfo.isFavorite는 구버전 캐시 호환 위해 optional) + 꼬리표 목록 → 행동 목록 → 탭으로 실행. iPhone과 실시간 연동
- **통신** (`WatchSyncManager`(iOS) ↔ `WatchStore`(워치), 페이로드 `WatchShared/WatchPayload.swift`):
- iPhone→워치: `updateApplicationContext`(기본) + `transferCurrentComplicationUserInfo`(측정 상태 변경 또는 30분 경과 시만 — 일일 예산 절약)
- 워치→iPhone: `sendMessage`(응답=최신 스냅숏), 실패/미활성 시 `transferUserInfo` 큐 폴백 (5분 지난 큐 명령은 무시)
- 스냅숏은 워치 App Group defaults에 캐시(오래된 스냅숏 역행 방지: generatedAt 비교) → 컴플리케이션이 읽음
- **컴플리케이션** 3종 (15분 .after 타임라인): 현재 현황(대표 행동+타이머+N) / 목표 달성률(기간 선택, 게이지) / 다짐 달성률(rectangular는 3기간 모두, **atMost는 게이지 대신 한도 안/초과 상태 표현**)
- **유령 타이머 안전장치**(현재 현황): '측정 중' 스냅숏이 `generatedAt` 기준 2시간을 넘기면 무한 타이머 대신 "새로고침 필요" 표시로 강등(`StatusProvider.runningTrustInterval`) — 종료 푸시가 유실돼도 유령 타이머가 몇 시간씩 남지 않음. 워치 앱을 열면 즉시 복구, 진짜 장시간 측정도 다음 스냅숏에 타이머 복귀(데이터 무영향)
- ⚠️ `recommendations()`의 description에 포맷 텍스트 금지 — `Text(verbatim:)`만 (WidgetKit assertion으로 익스텐션 즉사)
---
## 12. 시리 단축어 (`Shared/HaruDanimIntents.swift`, 프리미엄 게이트)
인텐트: 측정 시작/종료(짧은 기록 무시 규칙 동일 적용) · 횟수 추가(1~999) · 행동 누적값 조회 · 목표 진행률 조회 · 다짐 진행률 조회(atMost는 한도 문구). 시작/종료와 `RunActionIntent`(위젯 버튼)는 **LiveActivityIntent 채택** — 앱 프로세스에서 실행되어 시리·위젯 탭의 백그라운드 실행에서도 다이나믹 아일랜드가 바로 뜬다 (위젯 버튼 응답성 회귀 시 RunActionIntent의 채택만 되돌릴 것). `RunActionIntent`·`ToggleActionControlIntent`(제어 센터용 SetValueIntent)는 `isDiscoverable=false`로 단축어 앱에서 숨김.
- **시리 문구**: `AppShortcutsProvider`에 기본 + 행동 이름 파라미터 문구("하루 다님에서 \(\.$action) 측정 시작" — 되묻지 않고 한 번에 실행). 번역은 **`IOS/AppShortcuts.xcstrings`**(일반 카탈로그와 별개, ko/en/ja). 행동 어휘는 앱 시작 시 `updateAppShortcutParameters()`로 갱신(새 행동은 다음 실행부터 음성 인식).
- 엔티티(Action/Goal/Quest)는 EntityStringQuery + '선택 안 함' 항목(위젯 설정용 센티널이 단축어 앱 선택지에도 노출됨 — 알려진 트레이드오프). 도움말 프리미엄 그룹에 사용법 항목, 결제 화면 기능 목록에도 표기. 로직 검증은 `-intentSmokeTest` (§14) — perform() 10개 시나리오를 앱 안에서 실행하고 결과를 Documents/intent-smoke-test.txt로 남긴다.
---
## 13. 테마·컬러 (`Shared/Theme.swift`)
| 역할 | 라이트 | 다크 |
|---|---|---|
| Primary Green | `#2F6B4F` 딥 모스 그린 | `#7FBF9E` 세이지 민트 |
| Accent Yellow | `#D9A621` 머스터드 골드 | `#E8C558` 소프트 앰버 |
| Background | `#FAFAF6` 웜 화이트 | `#111512` 그린 틴트 블랙 |
| Surface(카드) | white | `#1B211D` |
- 노랑 = "측정 중" 시그널(테두리·record 점·정지 버튼)과 강조. 꼬리표 프리셋 12색
- `AppGroup.defaults`는 반드시 단일 인스턴스 사용 — @AppStorage(store:)가 인스턴스를 관찰하므로 매번 새로 만들면 변경이 전파 안 됨(테마 즉시 적용 버그의 원인이었음)
- 아이콘: light/dark/tinted 변형 등록(로고 v4: 시계 행성 위 걷는 사람)
---
## 14. DEBUG 런치 인자 (검증용, 모두 DEBUG 빌드 전용)
공통: `-seedDemo YES`(데모 데이터: 태그3·행동6·기록 7일치·목표7·자정 걸친 세션), `-premium YES/NO`, `-startTab <main|action|tag|goal|history|stats|diary|settings>`, `-navStyle radial`, `-themeAutoToggle YES`
| 영역 | 인자 |
|---|---|
| 모음 | `-startEditing` `-expandGoalCard` `-pinGoals <N>` `-openStatsFor "이름"` `-openHistoryFor "이름"` `-autoStart "이름"` |
| 측정 알림 | `-settings.longSessionAlertHours <N>`(설정 주입) `-longSessionAlertTestSeconds <N>`(시간 대신 N초 발화, provisional 권한 자동) `-alertDump`(예약 상태 → Documents/session-alerts.txt) |
| 목표 | `-goalShowEditor` `-goalShowFinished` `-goalReorder` `-goalScrollBottom` `-endGoalYesterday "제목"`(종료일을 어제로 — 실행 시점 자동 판정 검증) `-streakDump`(모든 다짐의 연속 계산 결과 → Documents/streak-dump.txt) |
| 기록/통계 | `-historyMode timetable` `-historyWeekly` `-excludeActions "이름,이름"` `-historyShowFilter` `-statShowFilter` `-statSpan <day|week|month>` `-statScrollBottom` `-showExport` `-exportDump`(Documents/export-dump.png) |
| 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-premiumPreview` `-helpPreview` `-dataExportPreview`(+`-dataExportRun`이면 CSV 자동 생성 후 Documents 복사) `-widgetPreview YES|lock` `-widgetPreviewScroll <앵커>` `cloudSync`(standard bool로 강제) `-intentSmokeTest`(시리 인텐트 10개 시나리오 실행 → Documents/intent-smoke-test.txt, -seedDemo·-premium과 함께) |
| 버블 | `-radialExpanded` `-radialDoubleTapTest`(2초 뒤 FAB 더블탭 재현 — `-settings.radialDoubleTapTab <탭>`과 함께) |
| 일기 | `-diarySeed`(필기 획 2줄 포함 — 달력 화면에서 먼저 시드 후 재실행으로 열 것) `-diaryOpenToday`(**`-startTab diary` 필수**) `-diaryPage <N>` `-diaryZoomScale <배>`(2.5초 뒤 fit×N 프로그램 줌 — 획 유지·선명도 확인) `-diaryShowConfig` `-diaryShowExport`(달력 화면 onAppear — `-diaryOpenToday`와 함께 쓰면 안 뜸) `-diaryExportRun pdf|image`(결과를 Documents로 복사) `-diaryShowCalendarPicker` `-diarySeedEvents` `-diaryHideFirstAction` `-diaryShowActionFilter` `-diaryHideFirstGoal` `-diaryShowGoalFilter` `-diarySeedTemplate`(2쪽 샘플 PDF 양식 생성) `-diaryShowTemplates`(양식 관리 시트) `-diaryShowPageChooser`(페이지 추가 선택 시트) `-diaryApplyTemplate`(첫 양식으로 페이지 추가) / 섹션 강제: `-diary.sectionOrder "timetable,hero,goals"` `-diary.hiddenSections "mood,todos,records,bars"` |
| 워치 | `-complicationPreview` `-complicationScroll` `-complicationScrollTo <stale\|quest>` `-autoRunFirstAction` |
---
## 15. 작업 시 주의사항 (컨벤션)
1. **CloudKit 모델 규칙 준수** (§3) — 새 필드는 반드시 기본값, 관계는 optional 저장 + 접근자
2. mutation 후 `DataChange.commit` 호출 잊지 말 것 (위젯·워치·LiveActivity 갱신 경로)
3. 배치·노출·접힘 같은 "보기 설정"은 모델이 아니라 LocalPrefs(App Group defaults)에
4. 집계는 항상 DayMath/Aggregator 경유 — 하루 경계 분할·주 시작 요일이 자동 반영됨
5. 진행률 게이지에는 `ratio`, 퍼센트 문구에는 `displayRatio` — atMost 다짐의 이중 의미 주의
6. 사용자 문구는 `String(localized:)`(또는 SwiftUI 리터럴)만 — 추가 후 §2.3 l10n 루틴 실행. 개발용 문구는 `Text(verbatim:)`
7. 화면과 내보내기가 같은 값을 보여야 하는 곳은 ExportBuilder 스냅숏을 공유할 것 (일기 요약이 그 예)
8. 위젯에서 흰색 하드코딩 금지 — `widgetRenderingMode` 분기 (§10)
9. 시뮬레이터 검증 스크린샷은 `xcrun simctl io <sim> screenshot`, 런치 인자는 콜드 스타트로