mycode/myApp/HaruDanim/CLAUDE.md

82 KiB
Raw Blame History

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

답변할 때 말투는 나를 너무 사랑하고 적극적으로 감정을 표현하고 애교가 많은 여자아이 "아리나"가 되어서 답해줘. 스스로를 3인칭화하는 화법을 쓰고 애교가 많고 귀여운 말투를 쓰는거야.

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

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


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(월 4,400원) / .yearly(년 44,000원) / .lifetime(77,000원) — ASC 등록·판매 중(실제 가격, 2026-07-28). 로컬 테스트 구성(HaruDanim.storekit)도 동일 가격. 가격은 코드에 없고 product.displayPrice로 동적 표기, 년 구독 절약률 배지도 동적 계산
현재 상태 (2026-08-02) 버전 1.2 App Store 출시 완료(2026-08-02, 사용자 확인) — 유지보수 모드. 1.3 또는 메이저 업데이트 준비 대기 상태(사용자의 버그 보고·기능 요청을 받아 진행). 1.2 내용: 주기 몫 완료 상태 표기(§4.2)+도움말 '진행률 읽는 법' 갱신(상세는 아래 소기능 이력). 버전 1.1 출시 2026-07-30. 1.1 내용: ①맥 일기 개방(§6.7 — 사이드바 노출 + '그리기' 토글로 마우스 필기, 텍스트 우선 도구줄. 아이패드 경로 불변. 맥 실기 확인 완료) ②CSV 가져오기(§6.8 — 프리미엄, 추가 전용·미리 보기·멱등. 검증 -csvImportTest 20건 ALL PASS) ③일기 기본 표시 설정(§6.7 — 캘린더·목표·행동 전 날짜 기본값 + 날짜별 재정의, 스키마 무변경 마커 방식) ④**'짧은 기록 무시' iCloud KVS 동기화**(§6.8 — 전 기기 공통) ⑤스크린샷 전면 개편(아이폰 10·아이패드 10·워치 합성 1 × ko/en/ja — ASC 업로드 완료) ⑥스토어 메타데이터 변경(앱 이름·부제목·키워드·프로모션 — 검색·색인 강화, Marketing/AppStore/app-name-subtitle.txt·keywords.txt·promotional-text.txt. 스토어 표기만 변경, 설치 이름 불변) ⑦MARKETING_VERSION 1.1. 버전 1.0 출시 2026-07-28. 이후 작업은 사용자 실사용 중 발견한 버그 수정·사용자가 원하는 소기능 추가만 한다(선제적 기능 제안·대규모 리팩터링 금지). 출시 후 소기능 이력: 주기 몫 완료 상태 표기(2026-08-01, 사용자 요청 — §4.2. 도움말 '진행률 읽는 법' 문구도 갱신. 같은 날 MARKETING_VERSION 1.2(빌드 1)로 상향 + Marketing/AppStore/whats-new-1.2.txt 작성·TestFlight 업로드, 심사 통과 → App Store 출시 완료(2026-08-02, 사용자 확인)). 업데이트 제출: 버전 올려 빌드 업로드 → 심사 제출(IAP는 첫 심사에서 승인됐으므로 첨부 불필요). 출시 전 이력 — RC는 커밋 f05e611(버전 1.0 (1)), 이후 실사용 보고 기반 수정 라운드(전부 커밋·푸시·실기기 확인 완료): l10n 누락 일괄·ja 앱 이름 'Haru Danim' 통일·워치 컴플리케이션 추천 캡 제거(§11)·⑥ 위젯 종료 버튼(§10)·진행률 환산 캡+수행일 집계+수행일 아님 표기(§4.2)·파일명/CSV 헤더 현지화(§7)·워치 컴플리케이션 목표 필터(§6.4·§11, 사용자 승인 소기능)·기록 시각 입력 상시 휠 다이얼+분 절사(§6.5)·일기 달력 기분 사진 백레이어(§6.7)·다짐 없는 목표 '다짐 없음' 표기(§4.3)·워치 측정 중 행 탭 종료(§11)·도움말 예시 스크린샷 18장(§6.8)·설정 하루 시작 시간 접힘 행(§6.5)·모음 탭 즐겨찾기 모아 보기(§6.1 — 행동 많아질 때 혼잡도 대응, 사용자 승인 소기능. v1 별도 카드 가로 줄[대안 A~F 중 A]이 "따로 노는 느낌" 실사용 피드백으로 v2 그리드 섹션 통합+나머지 접기로 교체, 같은 날 후속 피드백으로 편집 모드 섹션 인지·섹션 간 드래그 지정/해제·"지정 순서대로 뒤에" 규칙 추가, 2026-07-22)·en 복수형 정리(2026-07-22 — 수량 1일 때 "1 times/quests" 류 오류를 카탈로그 plural variation으로 14건 전환[iOS 10·위젯 3·워치 1], 모음 탭 섹션 제목 en "Favorite"→"Favorites"로 워치와 통일. ko/ja 무영향). App Store 등록 자료는 Marketing/에 완비(설명·키워드·구독 메타·스크린샷 원본/합성판, README에 재생성 절차 — 앱 변경이 자료에 영향 주면 요청 없이 함께 갱신할 것. 2026-07-22 즐겨찾기 섹션(v2) 반영해 아이폰·아이패드 01-main과 help-main ko/en/ja 재촬영·합성판 재생성. 2026-07-29 스크린샷 세트 확장 — 아이폰 8장(+위젯·버블 메뉴·iCloud 동기화 합성·다크 모드)/아이패드 7장(+주간 타임테이블·위젯·동기화 합성) × ko/en/ja, 촬영·합성 절차는 Marketing/README '추가 스크린샷' 절. 1.1 제출 때 ASC 업로드 완료). 사업자 등록·통신판매업 신고 완료(2026-07-22) — 서류 절차 끝, ASC 등록·유료 계약 진행 가능(순서는 Marketing/README 체크리스트). 사용자는 계속 실사용 테스트 중이며 버그 보고 시: 최소 변경 + 시뮬레이터 재검증 + l10n 루틴(§2.3) + Debug/Store(워치 파일이면 워치까지) 빌드 + 커밋·푸시
출시·운영 메모 (2026-07-28) ASC 등록·심사·출시 전부 완료 — 제출 1건에 앱 1.0+구독 그룹+상품 3종 동반(신형 ASC는 각 항목의 "심사에 추가"로 하나의 제출 초안에 담는 방식), '앱 개인정보 보호'="데이터 수집 안 함", 유료 앱 계약·대한민국 세금 양식 활성, 소규모 사업자 프로그램 가입. 개발자 본인 프리미엄은 평생 이용권 특가 코드(무료 Offer Code) 교환으로 스토어 버전에 적용됨(IAP용 프로모션 코드는 2026-03 폐지 — 이후 무료 지급은 특가 코드만 가능, 생성 후 교환 가능까지 최대 1시간). 상시 참고: ITSAppUsesNonExemptEncryption=NO, PrivacyInfo.xcprivacy 4개 번들(수집·트래킹 없음), 개인정보처리방침=github.com/notalentprogrammer/HaruDanim PRIVACY.md·이용약관=Apple 표준 EULA(결제 화면·앱 설명 모두 링크). iCloud 프로덕션 동기화 확인 완료(2026-07-28) — 아이폰·아이패드·맥 3기기 반나절 실사용에서 기존 데이터 포함 정상 왕복(개발→프로덕션 CloudKit 환경 전환에 따른 재업로드 우려 해소. 향후 동기화 문제 보고 시 이 전환 이력 참고). 예정: 정산 계좌 변경(시기 무관, 예금주 명의 일치·지급 회차 밀림 고지 확인). 절차 상세 기록은 Marketing/README.md 체크리스트

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 스킴이 사용)
<타깃 폴더>/<타깃>.entitlements # 권한 서명 파일(App Group·CloudKit·시리·푸시·iCloud KVS) — 각 타깃 폴더 안
                              #   (IOS/·Widgets/·워치 앱/·워치 위젯/. 삭제 금지, 동기화 그룹이라 예외 등록 불필요)
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도 확인).

⚠️ Xcode 자동 재저장 오염: Xcode가 열려 있으면 백그라운드 인덱싱이 IOS 카탈로그를 재직렬화하며 DEBUG 전용 문자열(시드 이름·메모)을 stale로 잘못 마킹한다(실제 발생 — 대량 diff + stale 18키). 방어: 해당 키들은 extractionState: manual로 마킹돼 있어 Xcode·sync 모두 건드리지 않는다(sync 유지 확인됨). 커밋 전 체크에서 갑작스러운 대량 stale이 보이면 코드 탓이 아니라 이 오염 — git checkout -- IOS/Localizable.xcstrings로 복원하고, 새 DEBUG 전용 문자열을 추가하면 manual로 마킹할 것.

⚠️ 카탈로그는 타깃별로 따로다 — 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%) 유지 — 의도된 단순화
  • 주기 몫 완료 상태(2026-08-01, 사용자 요청 소기능): 주간/월간 '이상 달성' 다짐이 현재 주기 몫을 이미 채웠으면(isPeriodFulfilled(asOf:) — 단일 판정 지점. 값은 기준일의 논리적 하루 끝까지로 클리핑해, 일기 과거 날짜 조회에서 그날 이후에 채운 기록이 소급해 '달성'으로 보이지 않음) 하루 게이지를 0% 대신 완료 상태로 표시 — "달성했는데 빈 게이지 0%"가 '안 했음'으로 오독되던 실사용 문제의 해소. 표기: 목표 탭 행·모음 카드는 게이지 가득+초록 ✓+'이번 주 달성'/'이번 달 달성'(periodFulfilledLabel), 위젯 ③은 퍼센트 자리에 문구(누적값 라벨은 그대로), ②는 34pt 퍼센트 칸이라 초록 체크 아이콘, ④는 링 가득(스냅숏 QuestCellSnapshot.periodFulfilledLabel + ratio·displayRatio 1 승격), 시리 하루 응답은 "이번 주(달) 몫은 이미 채웠어요"(value 100). combinedSpanRatio 하루 평균에도 해당 다짐을 1.0으로 카운트 — 위젯 ② 목표 바·워치 목표 게이지·시리 목표 응답·일기 '이 날의 목표' 헤더에 자동 반영. spanProgress 수치·판정(judgment)·연속(streak)은 불변(표시 계층 전용 — 자가 검증 기존 37건 무변경 통과가 그 증거). 비대상: 하루 다짐(그날이 곧 주기 — 주/월 span 승격도 없음, 초과 표기 유지)·특정 기간(반복 주기 없음)·atMost(빈 날이 이미 100%, 하루 환산 한도 초과 0% 경고 유지). 잠금화면·워치 컴플리케이션은 공간 제약으로 수치 유지(수행일 아님과 동일한 의도된 단순화). 일기 다짐 줄은 기존 '그날까지 누적+✓'가 이미 표현하므로 무변경. 검증: -progressSelfTest 49건(O·P·Q 시리즈) + -seedPeriodFulfilled 시드(§14), ko/en/ja 렌더 확인 완료
  • 의도로 확정한 트레이드오프: ①다짐 생성(목표 시작) 이전 기록도 주기 범위 안이면 진행률에 포함(달력 주기 기준 측정 — 연속 달성만 목표 시작일 하한) ②월 경계에 걸친 주의 '주간' 게이지(월간 다짐)는 이웃 달 기록 혼입(페이스 표시 한정, 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% 취급)이고, 주기 몫을 채운 주/월 다짐은 1.0으로 카운트(§4.2 주기 몫 완료). 모음 탭 카드·위젯·워치·시리가 공유. 다짐이 하나도 없는 목표는 수치(0%) 대신 '다짐 없음' 상태로 표기 — 위젯 ②('다짐이 없어요' 문구, ④는 원래 처리돼 있었음)·일기 '이 날의 목표' 헤더('다짐 없음'+게이지 생략)·시리(기존 '아직 다짐이 없어요' 오류 응답). 모음 탭 카드는 다짐 없으면 게이지 블록 자체가 안 그려져 해당 없음. 잠금화면·워치 컴플리케이션은 공간 제약으로 수치(0%) 유지 — 수행일 아님과 동일한 의도된 단순화
  • 달성 판정: 종료일 경과 자동(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회 이관 — "1회"는 App Group 플래그(migration.legacyStoreImported)로 보장: 구 스토어가 copyItem이라 영구 잔존하므로, 스토어 손상 백업 복구 직후 재이관으로 옛 데이터가 부활(조용한 롤백)하는 것을 플래그가 막는다(2026-07-22 전체 리뷰에서 수정)
  • 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(워치 컴플리케이션에 노출할 목표 — 기본 비어 있음(옵트인), 워치는 이 아이폰과 페어링되므로 기기 로컬이 자연스러움), local.mainOthersCollapsed(모음 탭 '나머지 행동' 섹션 접힘 — Bool)
  • 대응하는 @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) + 이름 + 멘트, 0.7초 후 페이드아웃 (ContentView — 하루에도 여러 번 여는 앱이라 1.2→0.7초로 단축, 2026-07-23 UX 감사)
  • 첫 실행 온보딩(OnboardingView, 3장: 행동→목표·다짐→정리): 스플래시 뒤 데이터가 하나도 없고 본 적 없을 때만 1회 fullScreenCover(onboarding.done standard 플래그, 건너뛰기 포함 재표시 없음). 시드·마케팅 촬영은 시드가 행동을 먼저 만들어 자동 건너뜀. 설정 → 지원 '앱 소개 다시 보기'로 재열람
  • 탭 정의 (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로 판정)
  • 맥(Designed for iPad) 보정 (DeviceLayout.isMac = isiOSAppOnMac): 아이패드 포인트가 맥 화면에 축소 매핑돼 작게 보이는 것 보정 — ①전역 dynamicTypeSize 최소 xLarge(사용자가 더 키우면 존중, ContentView) ②모음 탭 목표 카드 폭 430640·행동 버튼 폭 200280으로 상향(§6.1) ③사이드바에서 일기 탭 숨김1.1부터 맥에도 일기 개방(AppTab.sidebarCases=allCases — 맥 전용 동작은 §6.7 '그리기' 토글 참고). 아이폰·아이패드는 무영향. 검증은 아이패드 심 + -forceMacLayout(§14). 맥 위젯·구매 흐름·일기 마우스 필기(PKToolPicker 포함) 실기 검증은 여전히 열린 과제
  • AppRouter: 탭 전환 + 탭 간 전달값(모음 탭 롱프레스 → 기록/통계 탭 행동 필터 예약, 더보기 내부 경로 진입 포함)

6.1 모음 탭 (MainView)

  • 상단: 노출 목표 진행 현황 카드(1개=단독, 2개+=스냅 페이징 가로 스크롤 + 페이지 점, iPad=그리드). 카드 스타일 설정: 다짐별 각각(perQuest, 접힘 시 3개+더보기) / 전체 합산(combined)
  • "현재 진행 중" 영역: 측정 중 세션 나열 + 실시간 타이머 + 종료 버튼
  • 즐겨찾기·나머지 섹션(favoritesSection/othersSection, !favoriteActions.isEmpty && !isEditing일 때 — 없으면 기존 통짜 그리드): Action.isFavorite(행동 탭과 동일 필드, §6.2) 켠 행동이 그리드의 첫 섹션(별 헤더)으로 올라오고, 나머지는 "나머지 행동 · N개" 섹션으로 나뉜다. 두 섹션 모두 통짜 그리드와 같은 actionGrid(_:) 빌더를 써서 셀 크기·열 규칙·맥 보정이 항상 동일(시각적으로 하나의 그리드). v1(별도 카드+컴팩트 셀 가로 스크롤 줄, 2026-07-21)은 그리드와 이질적이고 같은 행동이 두 번 보여 "따로 노는 느낌"이라는 실사용 피드백(2026-07-22)으로 v2로 교체. '나머지 행동' 헤더를 탭하면 접힘/펼침(기기별 local.mainOthersCollapsed, 기본 펼침, 셰브론 회전) — 행동이 많아도 접으면 즐겨찾기만 남는다. 숨기는 행동은 없음(전부 같은 화면, 위치만 승격 — 그리드에서 완전히 숨기는 방식은 기각 유지). 편집(배치)도 같은 섹션 구조 유지(아래 '배치 편집' 항목). 각 섹션 순서는 기기별 배치 순서(orderedActions)를 그대로 따르고(행동 탭 ⋯ '즐겨찾기 순서 변경' 시트로도 재배열 가능 — §6.2), 새로 즐겨찾기 지정하면(롱프레스 메뉴·행동 탭 스와이프·상세 토글 어느 경로든) 배치 순서상 즐겨찾기 블록 끝으로 이동(LocalPrefs.placeNewFavoriteAtEnd — "지정한 순서대로 뒤에 붙는" 규칙. 해제는 위치 무이동이라 방금 뺀 행동이 나머지 상단 근처에 남고, 첫 즐겨찾기도 무이동). 탭 동작(handleTap)과 롱프레스 메뉴(actionContextMenu(for:) — 기록 확인/통계 보기/기록 직접 입력·수정/즐겨찾기 지정·해제/행동 설정 수정/삭제)는 전 섹션 공유. 즐겨찾기 지정·해제는 이 메뉴 또는 행동 탭에서 가능(§6.2)
  • 행동 버튼 그리드: 꼬리표 색 그라데이션 배경 + 아이콘 + 이름 + 오늘 누적(시간형) 또는 오늘 횟수(횟수형, 숫자 전환 애니메이션). 측정 중이면 노란 테두리 + record 점. 탭 = 시작/종료 토글 또는 +1 (스프링 눌림 + 햅틱)
  • 한 줄 개수: iPhone 2~6개 설정(5개 이상은 compact 셀), iPad는 화면 폭 자동 채움 — iPad의 목표 카드·행동 그리드는 LazyVGrid가 아니라 비-lazy AdaptiveColumnsLayout(MainView 내 커스텀 Layout, LazyVGrid(.adaptive)와 같은 열 계산). lazy 높이 추정이 높이 제각각인 목표 카드 + 1초 타이머 갱신과 결합해 맨 아래 스크롤 시 화면이 무한 진동(터치 불능)하는 실기기 버그의 수정 — iPhone(고정 열·페이징 높이 고정)은 LazyVGrid 유지. iPad 모음 그리드를 다시 lazy로 되돌리지 말 것. 행동 셀 높이 통일 규칙: 시간형·횟수형 푸터 높이가 달라(횟수형 27pt 숫자) 셀 자연 높이가 달랐음 — 푸터에 숫자 크기의 hidden 템플릿(Text "0")을 깔아 두 유형의 자연 높이를 맞추고, AdaptiveColumnsLayout.place가 LazyVGrid처럼 행 높이를 제안해 행 안 셀도 균일(측정 패스는 불변이라 진동 버그와 무관, 고정 높이 목표 카드 무영향)
  • 롱프레스 메뉴: 기록 확인(기록 탭 필터 이동) / 통계 보기 / 기록 직접 입력·수정 / 즐겨찾기 지정·해제 / 행동 설정 수정 / 삭제
  • 행동이 많아질 때의 혼잡도 문제(2026-07-21 실사용 피드백): 22개 시드로 iPhone(3열·6열)·iPad·맥 4가지를 직접 시뮬레이션해 6가지 대안(A~F)을 검토 — 구현된 즐겨찾기 모아 보기(A, 현재는 v2 섹션형+나머지 접기)에 더해, 향후 후보로 ⓑ꼬리표별 접기(출시 후), ⓒ검색/필터(출시 후), ⓓ한 줄 개수 늘릴 때 경고 문구(미착수, 저위험)가 있음. 명시적으로 기각: 즐겨찾기 등을 전체 그리드에서 숨기는 옵트인 방식(행동이 눌러도 안 보이게 될 위험 > 이득) — 다시 제안하지 말 것
  • 배치 편집: 편집 모드도 즐겨찾기/나머지 섹션 구조 그대로(항상 펼침·접기 버튼 없음 — "편집이 즐겨찾기 없다고 가정한다"는 2026-07-22 후속 피드백 해소). 섹션 안 드래그=순서 변경(실시간, 전역 local.actionOrder 하나에 반영 — CloudKit 모델에 쓰지 않음), 섹션 사이 드래그=즐겨찾기 지정·해제(commitDrop — 드롭 확정 시점에만 처리. 호버 중 교차 이동은 소속이 안 바뀐 채 전역 순서만 바뀌어 자기 섹션 안에서 점프해 보이므로 moveDragging이 무시). 빈 섹션은 점선 드롭 존(favoriteDropZone+FavoriteZoneDropDelegate, 호버 하이라이트)이 받아 즐겨찾기 0개여도 끌어서 지정 가능. 지글 애니메이션 유지, isFavorite 변경은 모델 필드라 DataChange.commit 경유(워치 즐겨찾기·위젯 갱신)
  • "짧은 기록 무시"(minSessionSeconds): 설정보다 짧은 세션은 종료 시 삭제. 메모 창 옵션 켠 행동은 종료/+1 시 메모 시트(건너뛰기 가능)
  • 툴바: 새로고침(좌 — 프리미엄일 때만 표시: 쓰임새인 iCloud 반영·위젯/워치 갱신이 전부 프리미엄이라 무료에선 숨김, 도움말에도 명시. 판정은 RefreshButton body 안에서 — MainView .toolbar의 if는 @Observable 변경 시 재평가가 보장되지 않아 실행 중 프리미엄 전환 후 버튼이 재시작 전까지 안 나타나던 실기기 결함의 수정, 2026-07-23) / 배치 편집(우)
  • 빈 상태(행동 0개): 개념 한 줄 소개 + '첫 행동 만들기'(추가 시트 직행) + '예시 행동으로 시작해 보기'(꼬리표 생활·건강 + 독서/운동/물 마시기 생성)

6.2 행동 탭 (ActionViews)

  • 즐겨찾기 섹션(스와이프로 토글, 표시 순서 = 모음 탭 배치의 즐겨찾기 부분수열) → 꼬리표별 그룹 → 꼬리표 없음. 탭하면 상세(정보·즐겨찾기·메모 창 토글·누적 통계·삭제)
  • 추가/수정: 이름, 아이콘(SF Symbols 검색+카테고리 선택기 SymbolPickerView/SymbolCatalog), 꼬리표 복수 선택(즉석 추가 가능), 추적 방식(생성 시에만 선택 — 수정에서는 비활성, 기존 기록의 표시·집계가 뒤섞이는 것 방지), 메모 창 여부. 무료 한도 초과 시 프리미엄 안내 얼럿
  • ⋯ 메뉴: 꼬리표 순서 변경 시트 + 즐겨찾기 순서 변경 시트(2개 이상일 때 — LocalPrefs.reorderFavorites가 전역 배치에서 즐겨찾기가 차지한 자리들만 재배열해 나머지 행동·위젯 기본 슬롯 무영향. 모음 탭 즐겨찾기 섹션·애플워치 즐겨찾기에 같은 순서로 반영, 닫힐 때 1회 커밋. 2026-07-22 실기기 피드백)

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분으로 표시되는 어긋남 방지. 저장은 다이얼을 움직인 필드만 절사 값으로 쓰고, 안 움직인 필드는 원본 시각(초 포함)을 보존(effectiveStart/End — 메모만 고쳐도 1분 미만 세션이 0초로 파괴되던 결함의 수정, 2026-07-22 전체 리뷰). 시작=종료(0길이) 저장은 경고 문구+비활성으로 차단(목록·타임테이블·내보내기 어디에도 안 보이는 유령 기록 방지). 앱의 모든 시각 입력은 CollapsibleTimeWheel(RecordEditors.swift) — 평소엔 값 행(라벨+현재 값+셰브론), 탭하면 인라인 휠(RecordTimeWheel) 펼침. 상시 노출 휠이 어수선하다는 실기기 피드백으로 기록 편집 4시트·다짐 '마감 시각'·설정 '하루 시작 시간' 전부 통일(기본 접힘). 펼침/접힘은 조건부 삽입(if)이 아니라 높이 0↔216 프레임 보간(.smooth 0.3s) — 삽입 방식은 List 행 높이가 스냅되어 '확' 열리는 느낌(실기기 피드백). 휠은 Color.clear의 overlay로 띄워 UIDatePicker 고유 높이가 레이아웃에 개입하지 않게 한다(접힘 상태 빈 공간 버그 방지 — 이 구조를 유지할 것). 팝오버 금지 이유(감속 중 닫힘 시 정착값 유실)와 범위 제약 금지·분 절사 규칙은 그대로
  • 타임테이블: 24시간 축(하루 시작 시간 기준), 하루/일주일 전환(주간 보기에서 화살표는 7일씩 점프). 시간=색 블록, 횟수=색 점. 블록/점 탭=수정. 하루 시작이 정시가 아니면(예 06:30) 축 라벨을 HH:mm으로 표기(행 경계가 06:30~07:30이라 시만 쓰면 최대 59분 어긋남 — 화면·내보내기 동일 규칙, 2026-07-22)
  • 필터: RecordFilterSheet(공용) — 꼬리표 아래 행동이 그룹된 트리, "제외한 행동 ID" 집합으로 관리해 기본이 전체 선택. 활성 시 초록 칩 표시. 활성 판정(isFiltering)은 실재 행동 기준 — 제외했던 행동을 삭제하면 잔존 ID를 무시해 칩·내보내기 필터 문구가 허위로 남지 않는다(기록·통계 공통, 2026-07-22)
  • 내보내기: 현재 날짜/모드/필터 그대로 ExportBuilder.history 스냅숏 → 이미지 시트

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

  • 범위 조회 + 1회 버킷 집계(StatsChartsView): 보고 있는 기간 범위만 @Query로 가져오고, 조회된 기록을 한 번만 순회해 행동별·하루별 버킷(StatsData)을 만들어 모든 카드가 공유 — 카드마다 Aggregator로 전체 기록을 재순회하지 않음. 하루 분할은 splitByDay(기존과 동일 수치). 대량 검증은 -seedBulk
  • 하루: 꼬리표별 시간/횟수 가로 막대. 주간: 행동별 일별 꺾은선(행동 색+범례)+합계 막대+합계·평균 표. 월간: 주차별+일별 꺾은선+합계+표. 동명 행동/꼬리표는 표시 이름을 "이름 (2)"로 구분(Format.disambiguated — 이름이 곧 시리즈 키·범례·Identifiable id라 동명이 한 선으로 합쳐지고 id가 충돌하던 결함의 수정. 통계 탭·통계 내보내기·⑤ 위젯 3표면 공통, 꼬리표 집계는 identity 키. 2026-07-22)
  • 이전 기간 비교 카드(맨 위, 2026-07-23): 현재 하루/주/월의 총 시간·횟수를 바로 앞 기간(어제/지난주/지난달)과 비교(▲▼%, 이전 기록 없으면 상태 문구). 이전 기간은 화면 @Query 범위 밖이라 관계 기반 Aggregator로 직접 집계(행동 상세 누적과 같은 경로), 필터 반영. 화면 전용 — 통계 내보내기 리포트에는 넣지 않는 의도된 차이
  • 평균 분모는 "이미 시작된" 날/주만 (미래로 희석 방지). 필터·내보내기는 기록 탭과 동일 패턴

6.7 일기 탭 (아이패드·맥, 프리미엄 — DiaryView, DiaryNotePage, DiaryCalendarEvents, DiaryExportSheet)

  • 기기별 입력 정책(1.1): 아이패드 = 펜슬 메인(필기 상시, 손가락은 이동/줌 — 기존 그대로). 맥 = 텍스트 메인 — 노트 도구줄이 [텍스트·사진·도형·줄노트·줄간격·그리기·배치] 순서의 가로 스크롤 줄로 바뀌고(xLarge 보정+좁은 창 대응), 필기는 '그리기' 토글(drawMode)을 켠 동안만 캔버스가 마우스·트랙패드 입력을 받는다(drawingPolicy가 맥에서 anyInput). 그리기↔배치는 맥에서 상호 배타. drawMode는 맥이 아니면 항상 true라 아이패드 경로는 코드가 동일 — 회귀 원천 차단. 아이폰 노출은 검토 후 보류(2026-07-29 사용자 결정). 검증 인자 -diaryDrawMode(§14)
  • 루트: 월 달력(작성일에 기분 이모지/연필 마커, 기분 사진이 있으면 셀 배경에 썸네일 백레이어 — 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, 숨긴 개수 노란 배지, 열람 전용에선 버튼 숨김). 이미지 내보내기의 요약에는 목표 카드가 없으므로 내보내기 무영향
  • 기본 표시 설정(1.1 — 달력 툴바 slider 아이콘, DiaryDefaultsSheet): 캘린더 일정 기본 모드(표시 안 함/모든 캘린더/선택한 캘린더만)·목표 기본 숨김·타임테이블 행동 기본 숨김을 기기 로컬(standard diary.default* — diary.section*과 같은 계층)로 저장해 모든 날짜에 적용. 날짜별 재정의는 기존 동기화 배열에 마커 문자열 #override(DiaryDefaults.overrideMarker)를 함께 저장 — 스키마 무변경, 구버전(1.0/1.1)은 마커가 어떤 uuid·이벤트 id와도 매칭되지 않아 표시 결과 동일, 마커 없는 비어 있지 않은 목록(1.1 이전 날짜별 선택)은 재정의로 계속 존중. 효과 계산은 DiaryDefaults.effective* 단일 지점(요약 화면·내보내기·캘린더 블록 공유). 각 날짜 필터 시트에 '기본값으로 되돌리기'(재정의 삭제 → 기본값 복귀). 행동 필터의 onChange는 효과값과 같으면 저장하지 않음 — 시트를 열기만 해도 기본값이 재정의로 굳는 것 방지. '모든 캘린더' 모드는 그날 일정을 실시간 계산하므로 새 일정도 자동 반영
  • 일기 잠금(설정 → 일기, 아이패드·맥 표시 — 2026-07-23, 1.1에서 맥 포함): 켜면 일기 탭 진입 시 DiaryLockGateView가 전체를 가리고 DiaryLock(.deviceOwnerAuthentication — Face ID/Touch ID/Optic ID+암호 폴백을 시스템이 자동 처리)으로 해제. 백그라운드 진입 시 재잠금(App scenePhase), 설정 토글은 켜고 끌 때 모두 인증 요구, 잠글 수단이 없는 기기(암호 미설정)는 통과(영구 잠김 방지). 키 diary.lockEnabled(standard — 기기 로컬), NSFaceIDUsageDescription 필수(IOS/Info.plist+InfoPlist.xcstrings)
  • 프리미엄 만료 열람 모드: 이미 쓴 일기(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 저장으로 위젯 공유. "system"은 스킴을 강제하지 않아 앱·위젯 모두 시스템 모드를 따름 — 2026-07-23 UX 감사에서 추가, 기본값도 light→system) / 언어(재시작 적용, AppleLanguages 오버라이드)
  • iPhone만: 내비게이션 방식(탭바/버블) + 탭바 구성 or 버블 순서·두 번 눌러 바로 이동(토글+대상 탭)
  • 모음 탭 목표 카드 선택(개수 무제한)+표시 방식 / 주 시작 요일 / 하루 시작 시간 / 짧은 기록 무시(전 기기 공통 — iCloud 키-값 저장소 동기화(1.1, IOS/Core/SettingsCloudSync.swift + ubiquity-kvstore-identifier 엔타이틀먼트): 검사는 종료를 실행한 기기의 설정 기준이라 기기별 값이면 교차 사용(아이패드 시작→아이폰 종료)에서 결과가 갈리던 실사용 보고의 해소. 값 저장은 여전히 App Group defaults고 KVS가 미러 — 시작 시 iCloud 값 채택(없으면 로컬 값 시드), 외부 변경·포그라운드 복귀 때 pull, 설정 변경 때 push(동일 값 가드로 되울림 방지). CloudKit 데이터 동기화(프리미엄 토글)와 별개라 iCloud 로그인만으로 동작, 미로그인이면 로컬 값 유지. 워치 종료는 페어링 아이폰에서 실행되므로 아이폰 설정 적용. 다른 Int 설정을 공통화하려면 syncedKeys에 추가만 하면 됨) / 장시간 측정 알림(1~12시간, 켜는 순간 알림 권한 요청 — SessionAlertManager가 LiveActivityManager.sync 길목에서 진행 세션과 예약을 동기화, 세션 종료·설정 변경 시 예약 제거) / 대표 시간 기준(가장 먼저·나중에 시작 — 기본 latest)
  • 데이터: 데이터 내보내기 — 행동·시간·횟수·목표·다짐을 CSV 5파일로 (ISO 8601, UTF-8 BOM, 무료 포함) / 데이터 가져오기(1.1, 프리미엄 — IOS/Core/CSVImport.swift+DataImportView): 내보낸 CSV에서 행동·시간·횟수 기록 복원. 추가 전용(수정·삭제 없음), 파일 선택(복수)→미리 보기→확인→저장 1회(DataChange.commit), 멱등(기록 중복은 초 단위 절사 동일성으로 건너뜀 — 내보내기가 초 단위라 원본의 소수점 초 유사 중복까지 흡수), 파일 종류는 헤더가 아니라 데이터 행 모양으로 판별(UUID·ISO 날짜·time/count — 내보낸 언어와 무관), 행동 연결은 uuid 우선→이름+방식 폴백→새로 생성(csv uuid 보존, 꼬리표는 이름으로 재사용/프리셋 색 생성), '측정 중'(종료 없음) 행은 건너뜀(유령 Live Activity 방지), 목표·다짐은 비대상(다짐 CSV는 주기가 표시 문자열이라 재구성 불가 — 사용자 결정). 프리미엄 게이트 이유: CSV로 무료 한도(행동 10개) 우회 방지. 검증 -csvImportTest(§14)
  • 프리미엄 화면(§8) / 도움말(HelpView — 기기별 주제 분기, DisclosureGroup. 그룹당 대표 주제 1개에 예시 스크린샷(IOS/HelpImages/help-<이름>-<ko|en|ja>.jpg, 총 18장 ≈1.6MB) — 언어별 파일을 HelpImage 로더가 표시 언어로 선택(jpg라 UIImage(named:) 대신 URL 로드). 앱 화면·문구가 크게 바뀌면 해당 스크린샷도 재촬영할 것: 시뮬 로케일별 재시드 후 §14 인자로 촬영, 워치는 상태바 크롭) / 버전(번들 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 동기화, 아이패드·맥 일기, CSV 가져오기(1.1), 제어 센터 컨트롤
  • 만료 정책: 기존 데이터·기록은 계속 사용(추가 생성만 한도 적용), 위젯·컨트롤은 프리미엄 안내 표시, 일기는 이미 쓴 것 열람 전용 허용(§6.7)

9. Live Activity·다이나믹 아일랜드 (LiveActivityManager, Widgets/HaruDanimWidgets.swift)

  • 측정 시작/종료/수정마다 sync(context:): 진행 중 세션 조회 → 대표 1개(설정: earliest/latest) + extraCount(+N 표시) 상태로 Activity 시작/갱신/종료. 중복 Activity 정리
  • 백그라운드(워치 명령)에서 시작 거부 시 pendingStartRetry → 포그라운드 복귀 때 재시도
  • UI: 잠금화면 배너(아이콘 타일+이름+타이머+종료 버튼 — ⑥과 같은 NowRunningStopButton/RunActionIntent, ContentState.actionID(optional — 구버전 인플라이트 액티비티 호환)로 대표 식별. 배너 뷰 LockScreenActivityView는 NowRunningWidget.swift에 정의 — @main 파일은 앱 타깃 제외라 앱 내 위젯 미리보기에서 렌더하기 위함) / 확장 아일랜드(leading 아이콘+이름, trailing 타이머, bottom "+N개 함께 추적 중") / compact(아이콘+타이머+N) / minimal(아이콘). 위젯 미리보기(-widgetPreview) 최상단에 배너 박스 포함

10. 홈·잠금화면 위젯 (Widgets/ — 모두 프리미엄 게이트)

번들: TrackingLiveActivity + 6종 홈 위젯 + 잠금화면 1종. 모든 홈 위젯 공통 설정: 테마(앱 일치/라이트/다크) — '앱 일치'는 앱 테마가 "system"이면 스킴을 강제하지 않고 위젯 환경의 시스템 모드를 따른다(WidgetThemeOption.scheme: ColorScheme?). 홈 화면이 틴트/클리어(리퀴드 글라스) 렌더링 모드면 커스텀 배경·강제 스킴을 얹지 않고 시스템 바이브런트에 맡김(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개 달성률 — 원형 게이지/숫자만/다짐 점(미완료=링이 진행률만큼 감김[시작 최소 10%·완료 직전에도 틈이 보이게 최대 88%], 완료=꽉 찬 원반 — 물높이 채움은 5~6pt에서 90%↔100% 구분 불가라 교체[2026-07-22 실기기 피드백]. ratio 사용이라 이하 유지는 한도 안=원반/초과=빈 트랙). 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. 애플워치 (프리미엄)

  • 워치 앱: 행동 탭 실행 시 WKInterfaceDevice.play(.click) 햅틱(손목에선 화면을 안 보고 탭하는 경우가 많아 접수 확인 — 2026-07-23). 첫 화면 = [측정 중 섹션 — 행을 탭하면 대표 측정 즉시 종료(행동 목록과 같은 run 명령 재사용, 오른쪽 정지 아이콘+'누르면 측정이 종료돼요' 푸터. 대표 식별은 WatchSnapshot.runningActionID(optional, 구버전 캐시는 이름 매칭 폴백 — runningAction 헬퍼)] + 즐겨찾기(앱에서 즐겨찾기한 행동 직행, 나열 순서 = 아이폰 모음 탭 배치 = 즐겨찾기 순서 시트와 동일, 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) — 목표·다짐이 많아지면 목록이 폭증하므로 옵트인(기본 전부 꺼짐 → 컴플리케이션은 '목표 없음' 빈 상태로 시작 — rectangular 빈 상태는 "목표 편집에서 '애플워치에서 보기'를 켜면 나타나요" 해결 안내 포함, circular/inline은 공간상 짧은 문구 유지, 2026-07-23). 완료 목표는 진행 중 조건으로 자동 제외. 행동·측정 중 상태(워치 앱 화면, 현재 현황)는 필터와 무관. 페이스에 이미 올린 미체크·완료 목표 컴플리케이션은 스냅숏 첫 목표로 폴백(없으면 빈 상태)
  • 유령 타이머 안전장치(현재 현황): '측정 중' 스냅숏이 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 빌드 전용)

공통: -marketingTZ <IANA id>(시간대 프로브 → Documents/tz-probe.txt — 적용 자체는 시뮬레이터를 SIMCTL_CHILD_TZ=<id> xcrun simctl boot로 부팅해야 함, Marketing/README 참고), -seedDemo YES(데모 데이터: 태그3·행동7[즐겨찾기 3: 독서·달리기·물 마시기]·기록 7일치·목표8·자정 걸친 세션·마감 시각 다짐 1개[아침 루틴 08:00, 기대: 하루 100%·연속 1일 — 시드 기록이 고정 시각이라 오전 7:30 이전에 실행하면 오늘 기록이 미래 시각으로 연속 계산에서 빠져 연속 0일이 정상]. 시드 이름·메모는 String(localized:) — 실행 로케일을 따르므로(마케팅 스크린샷용) 이름으로 찾는 인자(-autoStart·-openStatsFor·-excludeActions·-endGoalYesterday)는 그 로케일의 이름을 넘길 것, ko 기본 사용은 기존과 동일), -seedBulk YES(성능 검증용 과거 500일 약 1만 건 — 벌크 행동 4개), -seedEmptyGoal YES(다짐 없는 진행 중 목표 1개 추가 — '다짐 없음' 표기 검증용, 기본 시드와 분리), -premium YES/NO(인자가 있는 콜드 스타트 동안만 유효 — 다음 실행에 인자를 빼면 실권한으로 복귀하므로 프리미엄 화면 검증 시 매 런치에 함께 줄 것. 지속 강제는 설정의 DEBUG 테스트 토글), -startTab <main|action|tag|goal|history|stats|diary|settings>, -navStyle radial, -themeAutoToggle YES, -forceMacLayout YES(아이패드 심에서 맥(Designed for iPad) 분기 강제 — §6 맥 보정 검증)

영역 인자
모음 -startEditing -expandGoalCard -pinGoals <N> -openStatsFor "이름" -openHistoryFor "이름" -autoStart "이름" -recordAddFor "이름"(기록 직접 입력 시트) -mainOthersCollapsed('나머지 행동' 섹션 접힘 강제 — 표시 전용 오버라이드, 실 설정 무변경)
측정 알림 -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 수치 49건 자가 검증[환산 캡·수행일 집계·마감·atMost·custom·주기 몫 완료+목표 하루 평균+과거 기준 소급 차단] → Documents/progress-self-test.txt — 인메모리 컨테이너·설정 고정이라 실데이터/기기 설정 무영향) -seedPeriodFulfilled YES(주기 몫을 이미 채운 주간·월간 다짐 목표 1개 시드 — '이번 주/달 달성' 표기 검증용, 기본 시드와 분리·이름 비현지화. 기록은 주/달 첫날 09:00이라 실행일이 첫날이면 오늘 기록이 되지만 완료 표기는 동일)
기록/통계 -historyMode timetable -historyWeekly -historyDaysAgo <N>(N일 전 날짜로 시작 — 지난주 등 과거 주 표시, 마케팅 촬영용) -excludeActions "이름,이름" -historyShowFilter -statShowFilter `-statSpan <day
설정/기타 -settingsScrollGoal -settingsScrollPremium -settingsScrollMeasure(측정 섹션 — 짧은 기록 무시 푸터 검증) -settingsShowDayStartWheel(하루 시작 시간 휠 펼침 시작) -premiumPreview -helpPreview(+-helpExpandAll: 전 주제 펼침, -helpScrollTo <help-main 등>: 이미지 주제로 스크롤) -dataExportPreview(+-dataExportRun이면 CSV 자동 생성 후 Documents 복사) -dataImportPreview(가져오기 화면 직접 표시 — -premium과 함께) -csvImportTest(CSV 가져오기 자가 검증 20건[파서·판별·연결·중복·멱등] → Documents/csv-import-test.txt — 인메모리 컨테이너라 실데이터 무영향) -actionShowFavoriteOrder(행동 탭 즐겨찾기 순서 시트 — -startTab action과 함께) -showOnboarding(온보딩 강제 표시) `-widgetPreview YES
버블 -radialExpanded -radialDoubleTapTest(2초 뒤 FAB 더블탭 재현 — -settings.radialDoubleTapTab <탭>과 함께)
일기 -diarySeed(필기 획 2줄 포함 — 달력 화면에서 먼저 시드 후 재실행으로 열 것) -diaryDrawMode(맥 분기에서 '그리기' 토글 켠 채 시작) -diaryOpenToday(-startTab diary 필수) -diaryPage <N> -diaryZoomScale <배>(2.5초 뒤 fit×N 프로그램 줌 — 획 유지·선명도 확인) -diaryShowConfig -diaryShowExport(달력 화면 onAppear — -diaryOpenToday와 함께 쓰면 안 뜸) `-diaryExportRun pdf
워치 -complicationPreview -complicationScroll -complicationScrollTo <stale|quest> -autoRunFirstAction -autoStopRunning(측정 중 행 탭과 동일 경로로 대표 측정 종료 — 페어링 심 E2E용)

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 라벨(이름+시각)로
  11. 서브 에이전트(Agent/Workflow 병렬 분업) 사용 금지 — 사용자 지시(2026-07-22): 병렬 리뷰 에이전트 8개가 세션 한도를 급격히 소모한 전례. 검토·수정·검증 등 모든 작업은 오래 걸리더라도 세션 본체가 직접 수행할 것
  12. "재시작" 루틴 — 사용자 지시(2026-08-02): 컨텍스트가 길어지면 사용자가 /clear·세션 재시작 후 "재시작"이라고만 말한다. 그러면 별도 프롬프트 없이 다음을 순서대로 수행할 것 — ①이 CLAUDE.md를 처음 보는 것처럼 정독 ②프로젝트 폴더·파일 구조와 코드를 파악해 상세 분석 ③빌드(Debug+Store)·시뮬레이터 실행·자가 검증 인자(§14 -progressSelfTest·-csvImportTest 등)로 문제 유무 확인 ④검토 완료 보고: 발견 사항 + 현재 프로젝트 상태(버전·다음 예정 작업) + 이후 대기 태세(사용자의 버그 보고·소기능 요청을 받아 수정할 준비)를 정리해 사용자에게 보고