mycode/myApp/HaruDanim/CLAUDE.md
songyc macbook a400a71896 feat(ui): unify time wheels + diary calendar mood-photo backlayer
- 다짐 '마감 시각'·설정 '하루 시작 시간'의 컴팩트 시각 픽커도 RecordTimeWheel(시·분 전용)로 교체 — 기록 편집기와 같은 팝오버 커밋 유실 기전 제거, 앱의 시각 다이얼 전부 상시 노출 휠로 통일
- 일기 달력: 기분 사진이 있는 날짜는 셀 배경(백레이어)에 사진 썸네일 표시 — 이모지/연필 마커·오늘 표시는 그대로 위에 유지, 사진 없으면 기존과 동일. ImageIO ≤220px 축소 + NSCache(DiaryMoodThumbs)로 한 달치 렌더 부담 최소화, 검은 스크림 0.16+흰 날짜·마커 그림자로 가독성 확보
- 사진만 있는 엔트리는 hasContent가 moodImageData를 포함해 빈 엔트리 정리에서 보존됨(기존 로직 확인)
- 검증 인자 -diarySeedPhoto 신설(오늘=이모지+사진·어제=사진만) — iPad 시뮬 달력 스크린샷으로 백레이어·마커 확인, 마감 시각·하루 시작 시간 휠 스크린샷 확인
- l10n 무변화(기존 키 재사용, 카탈로그 클린). CLAUDE.md §6.5·§6.7·§14 갱신. Debug/Store 두 스킴 빌드 성공

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CkLDi8Lr21vGqo4SjZaTp
2026-07-18 00:03:04 +09:00

55 KiB
Raw Blame History

하루 다님 (HaruDanim) — 프로젝트 가이드

한 줄 소개: 하루의 습관과 시간을 추적하는 iOS 앱. "행동"의 시간을 측정하거나 횟수를 기록하고, 이를 바탕으로 목표(Goal)와 다짐(Quest) 의 달성률을 관리한다. 프리미엄(StoreKit 2)으로 위젯·워치·iCloud 동기화·iPad 일기를 제공한다.

문서 버전: v2.2 (2026-07-17) — 실제 구현 코드를 기준으로 작성된 현행 명세. 새 세션에서 이 문서만 읽어도 앱 전체를 파악할 수 있도록 유지한다. 기능이 바뀌면 이 문서도 함께 갱신할 것.


1. 개요

항목 내용
앱 이름 하루 다님 (스플래시 멘트: "당신의 하루에 다녀가다")
플랫폼 iOS (iPhone) + iPadOS(사이드바·일기) + watchOS 앱/컴플리케이션 + 홈·잠금화면 위젯. 최소 iOS/iPadOS 26.0(리퀴드 글라스 API 하한) / watchOS 10.0(watchOS 11.5·26.2 시뮬레이터 실기동 검증)
기본 언어 한국어. 설정에서 영어/일본어 전환 (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 등록은 아직 안 됨
현재 상태 (2026-07-17) 기능 동결(release freeze) — 첫 배포 후보(RC)는 커밋 f05e611(버전 1.0 (1)). 이후 실사용 보고 기반 수정 라운드가 진행됨(전부 커밋·푸시·실기기 확인 완료): l10n 누락 일괄·ja 앱 이름 'Haru Danim' 통일·워치 컴플리케이션 추천 캡 제거(§11)·⑥ 위젯 종료 버튼(§10)·진행률 환산 캡+수행일 집계+수행일 아님 표기(§4.2)·파일명/CSV 헤더 현지화(§7)·워치 컴플리케이션 목표 필터(§6.4·§11, 사용자 승인 소기능). App Store 등록 자료는 Marketing/에 완비(설명·키워드·구독 메타·스크린샷 원본/합성판, README에 재생성 절차 — 앱 변경이 자료에 영향 주면 요청 없이 함께 갱신할 것). 사업자 등록 완료, 통신판매업 신고 처리 대기 — 처리되면 ASC 등록·유료 계약 진행(순서는 Marketing/README 체크리스트). 사용자는 계속 실사용 테스트 중이며 버그 보고 시: 최소 변경 + 시뮬레이터 재검증 + l10n 루틴(§2.3) + Debug/Store(워치 파일이면 워치까지) 빌드 + 커밋·푸시
남은 배포 작업 App Store Connect 상품 3종 등록(구독 그룹 + 비소모성, 유료 앱 계약), CloudKit 프로덕션 스키마 배포(대시보드 Deploy Schema to Production — DiaryTemplate 등 새 레코드 타입/필드는 개발 환경에만 자동 생성되므로 출시 전 필수), 실기기 검증(펜슬 필기감·캘린더 권한), App Store 배포. 코드 측 잔여 없음 — ITSAppUsesNonExemptEncryption=NO 등록 완료, 개인정보 매니페스트(PrivacyInfo.xcprivacy) 4개 번들 완비(UserDefaults CA92.1·1C8F.1, 수집·트래킹 없음 — 위젯·워치위젯 폴더 것은 앱·워치 앱 타깃에서 membershipException으로 중복 복사 제외), 결제 화면 약관 링크 완료(개인정보처리방침 Docs/PRIVACY.md ko/en/ja는 github.com/notalentprogrammer/HaruDanim main의 PRIVACY.md로 게시됨, 이용약관은 Apple 표준 EULA URL). ASC 등록 시 앱 설명에도 두 링크 기재 + '앱 개인정보 보호'는 "데이터 수집 안 함" 선택

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/                 # 아이콘 원본 등
Marketing/                    # App Store 등록 자료 — 설명·키워드·구독 메타·스크린샷(6.9"/13" × ko·en·ja)
                              #   ⚠️ 앱 기능·문구가 자료에 영향을 주게 바뀌면 별도 요청 없이 함께 갱신할 것
                              #   (사용자 지시. 재생성 방법·등록 체크리스트는 Marketing/README.md)

2.2 빌드 커맨드

# 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, 워치 watchOS 11.5(최소 OS 근접 검증용) 686B7E3C-C685-409E-B624-B409E5F7D06D / watchOS 26.2 3B7C0B47-EBD0-41C9-8BC3-166AA35F80D4. 마케팅 스크린샷용(Marketing/README): iPhone 17 Pro Max(6.9") DB9D0281-123D-425D-9547-10CFF7946733, iPad Pro 13"(M5) 39E5A4BE-9082-47AF-B6E4-1B9E8D271681, 비페어링 워치 'MarketingWatch' 0A2CB37C-A755-4784-9D14-C7AB8FD72C3E, 페어링 워치(iPhone B134와 페어) 8B57A70E-C0E1-43F2-BB4C-FEB05E82D741
  • ⚠️ 같은 프로젝트에 xcodebuild 2개를 동시에 돌리면 빌드 DB 경합으로 위장 실패(BUILD FAILED인데 에러 없음) — 항상 직렬로. simctl install 직후의 launch는 런치 인자가 무시될 수 있음 — terminate 후 sleep 2 두고 재실행
  • 워치 UI 검증: 스킴 Haru_DanimWatch Watch App + platform=watchOS Simulator 대상으로 빌드·설치 후 -complicationPreview 인자로 실행(§14 워치) — 페어링 폰 없이 컴플리케이션 3종 미리보기가 렌더된다
  • SourceKit 인라인 진단은 항상 오탐 노이즈("No such module 'UIKit'" 등) — 판정은 xcodebuild 결과만 신뢰
  • DEBUG 런치 인자 검증은 콜드 스타트 필수 (terminate 후 재실행). 목록은 §14
  • 캘린더 권한 부여: xcrun simctl privacy <sim> grant calendar com.yechan.HaruDanim

2.3 다국어(l10n) 루틴

String(localized:) 문자열 추가 후:

# 1) Debug 빌드 → 2) stringsdata 동기화 — ⚠️ 카탈로그마다 "자기 타깃"의 stringsdata만 넣을 것
#    (경로의 <타깃명>.build로 구분. 전체를 한꺼번에 넣으면 위젯 전용 문자열이
#     IOS 카탈로그에 미번역으로 추가됐다가 Xcode 재저장 때 stale로 남는 오염이 생긴다)
#    ⚠️ 반드시 Objects-normal/arm64로 한정 — DerivedData에는 x86_64·Release·iphoneos 등
#    다른 구성의 낡은 stringsdata가 남아 있어, 넓은 패턴으로 잡으면 방금 수정한 문구의
#    옛 버전이 계속 "추출 중"으로 부활한다(진행률 도움말 수정 때 실제 발생)
app_files=(${(f)"$(find ~/Library/Developer/Xcode/DerivedData/Haru_Danim-*/ -name '*.stringsdata' -path '*Debug-iphonesimulator/Haru_Danim.build/Objects-normal/arm64/*')"})
xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${app_files[@]}"
widget_files=(${(f)"$(find ~/Library/Developer/Xcode/DerivedData/Haru_Danim-*/ -name '*.stringsdata' -path '*Debug-iphonesimulator/Haru_DanimWidgets.build/Objects-normal/arm64/*')"})
xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_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), deadlineMinutes(-1=없음 — 하루 단위 전용 마감 시각, §4.2), 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:): 시각 → 그 시각이 속한 논리적 하루의 달력일 자정. 경계 이전 시각은 전날 귀속
  • ⚠️ 자정 Date(=키)를 dayKey(for:)나 ~Range(containing:)에 다시 넣지 말 것 — 하루 시작 시간이 자정보다 늦으면 전날로 밀린다(일기 달력이 매달 1일에 이전 달을 그리고, 내보내기 '날짜 지정'이 전날 일기를 담던 버그). 달력 UI에서 고른 달력일은 calendar.startOfDay를 키로 직접 쓰고, 키로 시각 연산이 필요하면 dayRange(forKey:).lowerBound로 변환해 넘긴다(기록·통계 탭 패턴)
  • 세션은 실제 시각 그대로 하나로 저장하고, 집계는 구간 겹침(overlap)으로 자동 분할 — 하루 경계를 걸친 세션이 날짜별로 나뉘어 계산됨. Aggregator.seconds/count가 이 규칙의 단일 구현이며 화면·위젯·내보내기 모두 이를 공유

4.2 다짐 진행률 (QuestProgress)

  • ratio(게이지용, 0...1): atLeast = min(value/target, 1) / atMost = 한도 안이면 1, 넘으면 0
  • displayRatio(퍼센트 문구): atLeast는 100% 초과 가능 — 단 주기와 다른 span의 환산 목표(페이스 가이드)에서는 값을 목표에서 캡해 초과 표기 없음(주 2회 다짐을 오늘 1회 한 것이 "하루 350%"로 표기되던 왜곡 수정 — spanProgressusesScaledTarget 분기). 자기 주기 span(하루 다짐의 '하루' 등)과 특정 기간 다짐(환산 없음)은 초과 표기 유지, atMost는 값 무캡(하루 게이지의 환산 한도 초과 0%는 페이스 경고로 유효). 수치 검증은 -progressSelfTest(§14)
  • 하루 단위 다짐의 주간·월간 집계는 방향 무관 수행일 기록만 합산 — 목표량이 수행일 수 환산이라 비수행일 기록을 섞으면 분모·분자가 어긋남(월수금 '이하 유지'가 목요일 기록으로 초과 표시되던 비대칭 수정). '이상 달성'은 추가로 하루별 기여를 그날 목표량으로 캡 — 초과 달성이 다른 날 미달을 가리지 않음 (하루 span·주/월/기간 다짐은 캡 없음, 원본 기록 무영향)
  • 수행일 아닌 날의 '하루' 게이지는 '수행일 아님' 상태로 표기(isScheduled 기준 — 요일·날짜 다짐의 쉬는 날, 기간 밖 custom 포함): 목표 탭 행·모음 카드·위젯 ③(문구)·②(— 대시)·④(흐림)·시리 하루 응답이 공유. 쉬는 날의 기록은 주/월 집계에 안 잡히므로 퍼센트로 보여 주면 "오늘 채웠다"는 오해 유발. 워치 컴플리케이션·잠금화면 점은 공간 제약으로 수치(0%) 유지 — 의도된 단순화
  • 의도로 확정한 트레이드오프: ①다짐 생성(목표 시작) 이전 기록도 주기 범위 안이면 진행률에 포함(달력 주기 기준 측정 — 연속 달성만 목표 시작일 하한) ②월 경계에 걸친 주의 '주간' 게이지(월간 다짐)는 이웃 달 기록 혼입(페이스 표시 한정, 100% 캡으로 완화) ③적용일이 없는 달(예: 31일 지정 다짐의 6월)은 0% 표시 ④특정 기간 종료 후에도 주/월 게이지는 계속 계산(목표 종료일이 자연 해소, 하루는 '수행일 아님')
  • scaledTarget: 주기 목표량을 span 길이에 환산 (daily→적용일 수×목표, weekly→/7 등)
  • isScheduled(on:): 하루 진행률 모수 판정 — 수행일 아닌 daily 다짐은 그날 모수에서 제외
  • 마감 시각(deadlineMinutes, 하루 단위 전용): 그날의 집계 창을 [하루 시작, 마감)으로 줄인다(dayMeasurementRange — 단일 구현, 주기 범위·span 하루 루프·연속 버킷팅·위젯 라벨·일기 카드가 공유). 마감 뒤 기록은 원본은 남되 그 다짐엔 미집계(시간형은 겹침 클리핑으로 부분 인정). 하루 시작보다 이른 시각=다음 달력일 새벽, 시작과 같은 시각=온전한 하루(0길이 창 없음, 검증 불필요). 의도된 트레이드오프: '마감 지남' 전용 표시 상태 없음(게이지 값이 마감 시점에 연속이라 위젯 타임라인 마감 경계도 불필요), 연속 달성은 값 클리핑만 반영(오늘 마감을 놓쳐도 유예 규칙대로 끊김은 내일부터), ③ 위젯 누적 라벨은 창 안 값(spanRawValue). 주간/월간/기간 다짐 마감은 미지원(교차 span 의미가 정의 안 됨 — 확장 시 §4.2 전체 재설계 필요)

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.plistUIBackgroundModes: 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(기록 시 메모 창), local.watchGoals(워치 컴플리케이션에 노출할 목표 — 기본 비어 있음(옵트인), 워치는 이 아이폰과 페어링되므로 기기 로컬이 자연스러움)
  • 대응하는 @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%)·'애플워치에서 보기' 토글(iPhone 전용 — 기본 꺼짐, 켠 목표만 워치 컴플리케이션 목록에 노출. 기기 로컬 local.watchGoals §5.2)
  • 목표 상세: 다짐 목록(탭=수정, 스와이프 삭제, 순서 모드), 다짐 추가(무료 한도 3개), 종료일 없으면 수동 종료(기준<100%면 안내 문구에 기준 표기), 삭제
  • 다짐 편집 3단계: ①대상(행동 또는 꼬리표+측정 기준) ②주기(하루[매일/요일/날짜/n번째 요일]·주·월·특정 기간 — ordinalWeekday 구현은 weekdayOrdinal 즉 "그 달의 n번째 ◯요일"이며 UI 문구도 그렇게 표기. 하루 단위는 '마감 시각까지만 집계' 토글+시간 선택 — §4.2 마감 규칙) ③목표량(시간 휠 또는 횟수 스테퍼)+방향(이상 달성/이하 유지)
  • 연속 달성(QuestProgress.streakQuestStreakInfo): 하루 단위=적용일만 연속 계산("연속 N일" — 매주 수요일 다짐을 4주 연속이면 연속 4일), 주간/월간="연속 N주/N달", 특정 기간=nil. 진행 중인 오늘/이번 주기는 atLeast면 미달이어도 끊기지 않고(건너뜀) 채웠으면 포함, atMost는 넘는 순간 끊김. 하한 = 목표 시작일 — 행동이 아니라 "목표 안 다짐"의 연속이므로 목표 시작 전 날짜/주기는 세지 않음(주·월은 시작일이 걸친 부분 주기까지 포함). 특히 atMost는 기록 없는 날이 전부 달성이라 이 하한이 없으면 과거 전체가 연속으로 잡힘. 소급 상한 400일(성능). 기록을 하루 키로 1회 버킷팅해 O(기록+일수). 미래 시각 기록(수동 입력)은 진행률·게이지에는 즉시 반영되지만 연속 계산은 now까지만 집계 — 그 시각이 지나야 연속에 반영된다(선기록으로 연속이 미리 쌓이지 않게 하는 의도된 동작, 고치지 말 것). 표기: 목표 탭 다짐 행 + 위젯 ②다짐 줄·③링 셀·④현황 그리드 (불꽃+노랑, 0이면 숨김). '스트릭'이라는 단어는 한국어 UI에 쓰지 않는다
  • 목록 진입 시 evaluateIfEnded 일괄 실행

6.5 기록 탭 (HistoryView, RecordEditors)

  • 범위 조회: 기록은 전량 로드하지 않고 선택 날짜가 속한 주 범위만 @Query로 가져온다(HistoryRecordsView — SwiftData 동적 쿼리 패턴: 날짜가 바뀌면 새 조건으로 재생성). 하루 경계 걸침·진행 중 세션은 "시작 < 범위끝 AND (종료 없음 OR 종료 > 범위시작)" 겹침 조건으로 포함
  • 날짜 헤더(화살표 이동, 날짜 탭=달력 팝오버, "오늘" 버튼) + 목록/타임테이블 세그먼트
  • 목록: 시간 기록(시작~종료·총 시간·메모)·횟수 기록(시각·+n·메모), 탭하면 수정 시트. 수정·추가 시트의 시각 입력은 상시 노출 휠 다이얼(RecordEditors.swift RecordTimeWheel) — 컴팩트 팝오버는 다이얼 감속 중 닫히면 정착값이 유실되는 실기기 보고가 있어 폐기. in: 범위 제약도 하한 근처 선택을 소리 없이 되감아 금지(검증은 종료<시작 경고 문구+저장 비활성). 편집 상태는 분 단위 절사(flooredToMinute) — 실측정 기록의 초 찌꺼기(14:32:47)가 남으면 다이얼로 맞춘 1시간이 59분으로 표시되는 어긋남 방지. 다짐 '마감 시각'·설정 '하루 시작 시간'도 같은 RecordTimeWheel(components: .hourAndMinute)로 통일 — 앱의 시각 입력 다이얼은 전부 상시 노출 휠
  • 타임테이블: 24시간 축(하루 시작 시간 기준), 하루/일주일 전환(주간 보기에서 화살표는 7일씩 점프). 시간=색 블록, 횟수=색 점. 블록/점 탭=수정
  • 필터: RecordFilterSheet(공용) — 꼬리표 아래 행동이 그룹된 트리, "제외한 행동 ID" 집합으로 관리해 기본이 전체 선택. 활성 시 초록 칩 표시
  • 내보내기: 현재 날짜/모드/필터 그대로 ExportBuilder.history 스냅숏 → 이미지 시트

6.6 통계 탭 (StatsTabView) — 기록과 분리된 독립 탭

  • 범위 조회 + 1회 버킷 집계(StatsChartsView): 보고 있는 기간 범위만 @Query로 가져오고, 조회된 기록을 한 번만 순회해 행동별·하루별 버킷(StatsData)을 만들어 모든 카드가 공유 — 카드마다 Aggregator로 전체 기록을 재순회하지 않음. 하루 분할은 splitByDay(기존과 동일 수치). 대량 검증은 -seedBulk
  • 하루: 꼬리표별 시간/횟수 가로 막대. 주간: 행동별 일별 꺾은선(행동 색+범례)+합계 막대+합계·평균 표. 월간: 주차별+일별 꺾은선+합계+표
  • 평균 분모는 "이미 시작된" 날/주만 (미래로 희석 방지). 필터·내보내기는 기록 탭과 동일 패턴

6.7 일기 탭 (iPad 전용, 프리미엄 — DiaryView, DiaryNotePage, DiaryCalendarEvents, DiaryExportSheet)

  • 루트: 월 달력(작성일에 기분 이모지/연필 마커, 기분 사진이 있으면 셀 배경에 썸네일 백레이어 — ImageIO ≤220px 축소를 NSCache에 캐시(DiaryMoodThumbs), 검은 스크림 0.16+흰 날짜·마커 그림자로 가독성 유지, 사진 없으면 기존 그대로), 날짜 탭 → 일기 상세. 툴바에서 기간/복수 날짜 내보내기(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:): 일기 필터용 — 타임테이블 섹션만 교체하고 나머지는 유지
  • 내보내기 파일명은 언어별 현지화 — ko "하루다님-기록-…" / en "HaruDanim-History-…" / ja "HaruDanim-記録-…" (수신 환경이 한글을 지원하지 않을 수 있어 접두는 en/ja에서 라틴 통일). 리포트 PNG·일기 PDF/PNG·CSV 5종 모두 적용, 패턴은 카탈로그 포맷 키("하루다님-%@.png" 등). CSV 헤더(열 이름)도 현지화 — 값의 rawValue(time/count, inProgress 등)는 데이터라 언어 무관 유지
  • 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는 퍼센트가 없어 이 줄이 유일한 현재 수치(진행률 캡과 무관한 원본 값이되, 마감 시각 다짐은 창 안 값 spanRawValue — §4.2, 미리보기 앵커 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 구조상 단축 불가) 전에도 탭 접수를 보여 줌(중복 탭 방지). ⚠️ 도형·아이콘 ZStack에는 금지 — invalidatable로 표시된 래스터 서브트리는 별도 레이어로 분리돼 Button(intent:) 히트 영역에서 빠지고, 그 위 탭이 앱 열기로 샌다(③ 링, ⑥ 종료 버튼 아이콘에서 실기기 재현·수정된 버그. contentShape(.rect)로도 못 살림 — 텍스트 값에만 붙일 것)
  • 버튼 히트 영역: 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는 게이지 대신 한도 안/초과 상태 표현)
  • 추천 목록(recommendations) = 선택지 전부: 워치는 컴플리케이션 파라미터 편집 UI가 없어 페이스 편집기의 목록이 recommendations()가 돌려준 조합으로 한정된다 — 스냅숏에 담긴 목표 전부 × 3기간, 다짐 전부 × 3기간을 상한 없이 나열할 것(예전 prefix(2) 캡이 "3번째 목표부터 페이스에 추가 불가" 버그였음). 워치 앱이 스냅숏을 적용할 때 invalidateConfigurationRecommendations()를 호출해 목표·다짐 변경이 목록에 자동 반영된다
  • 스냅숏의 목표 = 진행 중 ∩ '애플워치에서 보기' 켠 것(makeSnapshot의 단일 필터 지점, LocalPrefs.showsOnWatch) — 목표·다짐이 많아지면 목록이 폭증하므로 옵트인(기본 전부 꺼짐 → 컴플리케이션은 '목표 없음' 빈 상태로 시작). 완료 목표는 진행 중 조건으로 자동 제외. 행동·측정 중 상태(워치 앱 화면, 현재 현황)는 필터와 무관. 페이스에 이미 올린 미체크·완료 목표 컴플리케이션은 스냅숏 첫 목표로 폴백(없으면 빈 상태)
  • 유령 타이머 안전장치(현재 현황): '측정 중' 스냅숏이 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·행동7·기록 7일치·목표8·자정 걸친 세션·마감 시각 다짐 1개[아침 루틴 08:00, 기대: 하루 100%·연속 1일 — 시드 기록이 고정 시각이라 오전 7:30 이전에 실행하면 오늘 기록이 미래 시각으로 연속 계산에서 빠져 연속 0일이 정상]. 시드 이름·메모는 String(localized:) — 실행 로케일을 따르므로(마케팅 스크린샷용) 이름으로 찾는 인자(-autoStart·-openStatsFor·-excludeActions·-endGoalYesterday)는 그 로케일의 이름을 넘길 것, ko 기본 사용은 기존과 동일), -seedBulk YES(성능 검증용 과거 500일 약 1만 건 — 벌크 행동 4개), -premium YES/NO, -startTab <main|action|tag|goal|history|stats|diary|settings>, -navStyle radial, -themeAutoToggle YES

영역 인자
모음 -startEditing -expandGoalCard -pinGoals <N> -openStatsFor "이름" -openHistoryFor "이름" -autoStart "이름" -recordAddFor "이름"(기록 직접 입력 시트)
측정 알림 -settings.longSessionAlertHours <N>(설정 주입) -longSessionAlertTestSeconds <N>(시간 대신 N초 발화, provisional 권한 자동) -alertDump(예약 상태 → Documents/session-alerts.txt)
목표 -goalShowEditor -goalShowFinished -goalReorder -goalScrollBottom -questShowEditor(다짐 편집기 표시 — 마감 시각 다짐 우선, 없으면 첫 다짐) -endGoalYesterday "제목"(종료일을 어제로 — 실행 시점 자동 판정 검증) -watchGoals <N>(생성순 목표 N개를 '애플워치에서 보기'로 설정, 0=전체 해제 — 설정 후 스냅숏 즉시 푸시, 컴플리케이션 필터 검증용) -streakDump(모든 다짐의 연속 계산 결과 → Documents/streak-dump.txt) -progressSelfTest(spanProgress 수치 37건 자가 검증[환산 캡·수행일 집계·마감·atMost·custom] → Documents/progress-self-test.txt — 인메모리 컨테이너·설정 고정이라 실데이터/기기 설정 무영향)
기록/통계 -historyMode timetable -historyWeekly -excludeActions "이름,이름" -historyShowFilter -statShowFilter `-statSpan <day
설정/기타 -settingsScrollGoal -settingsScrollPremium -premiumPreview -helpPreview -dataExportPreview(+-dataExportRun이면 CSV 자동 생성 후 Documents 복사) `-widgetPreview YES
버블 -radialExpanded -radialDoubleTapTest(2초 뒤 FAB 더블탭 재현 — -settings.radialDoubleTapTab <탭>과 함께)
일기 -diarySeed(필기 획 2줄 포함 — 달력 화면에서 먼저 시드 후 재실행으로 열 것) -diaryOpenToday(-startTab diary 필수) -diaryPage <N> -diaryZoomScale <배>(2.5초 뒤 fit×N 프로그램 줌 — 획 유지·선명도 확인) -diaryShowConfig -diaryShowExport(달력 화면 onAppear — -diaryOpenToday와 함께 쓰면 안 뜸) `-diaryExportRun pdf
워치 -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, 런치 인자는 콜드 스타트로
  10. 아이콘 전용 버튼에는 accessibilityLabel 필수 (보이스오버가 "버튼"으로만 읽는 것 방지). 여러 요소로 된 셀은 label+value로 한 덩어리 낭독(모음 탭 행동 셀 참고), 타임테이블 블록·점처럼 텍스트 없는 시각 요소는 verbatim 라벨(이름+시각)로