feat(1.5-p3): 건강 다짐 — 8지표+운동 종목 20종, 값 공급 이음새로 §4.2 수학 무변경

- Quest 필드 3종(healthMetricRaw·수면 구간, CloudKit 기본값 규칙)+행동/꼬리표 우선 정규화
- HealthQuestTarget/HealthWorkoutKind(종목 큐레이션)·HealthCache 공유 계층 이전(위젯 읽기)
- QuestProgress 값 공급 단일 지점 3곳(value·spanValue·perDayValues)만 분기 — 기존 49건 무변경 그린
- 편집기 대상 3유형째(지표·종목·수면 구간 휠·단위별 목표 입력·프리미엄 게이트·하루 전용)
- §8ⓒⓕ: 데이터 없는 기기 모수 제외+'데이터 없음' 표기(목표 행·모음 카드·위젯 ②③④)
  +자동/수동 판정 보류, 위젯 값 라벨 지표 단위화, HealthStore 백필(400일)·운동 조회·부트스트랩
- 검증: progressSelfTest 61건(H 시리즈 12 신규)·20·27·10 ALL PASS(26.5+18.5),
  Debug/Store/워치 빌드, 시각 QA(목표 행·편집기·타일). 신규 인자 3종 §14 기록

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
This commit is contained in:
songyc macbook 2026-08-22 06:07:25 +09:00
parent e04cdfdcb9
commit 78bad15852
13 changed files with 1034 additions and 113 deletions

View File

@ -352,9 +352,9 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
|---|---|
| 모음 | `-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=전체 해제 — 설정 후 스냅숏 즉시 푸시, 컴플리케이션 필터 검증용) `-watchActions <N>`(1.5 — 배치순 행동 N개를 '행동 기록' 컴플리케이션에 올림. 표시 기간은 하루→이번 주→이번 달→지난 7일→지난 30일 순환, 스냅숏 즉시 푸시) `-streakDump`(모든 다짐의 연속 계산 결과 → Documents/streak-dump.txt) `-progressSelfTest`(spanProgress 수치 49건 자가 검증[환산 캡·수행일 집계·마감·atMost·custom·주기 몫 완료+목표 하루 평균+과거 기준 소급 차단] → Documents/progress-self-test.txt — 인메모리 컨테이너·설정 고정이라 실데이터/기기 설정 무영향) `-seedPeriodFulfilled YES`(주기 몫을 이미 채운 주간·월간 다짐 목표 1개 시드 — '이번 주/달 달성' 표기 검증용, 기본 시드와 분리·이름 비현지화. 기록은 주/달 첫날 09:00이라 실행일이 첫날이면 오늘 기록이 되지만 완료 표기는 동일) |
| 목표 | `-goalShowEditor` `-goalShowFinished` `-goalReorder` `-goalScrollBottom` `-questShowEditor`(다짐 편집기 표시 — 마감 시각 다짐 우선, 없으면 첫 다짐. `-questShowEditorHealth`를 함께 주면 건강 다짐 우선 — 1.5) `-endGoalYesterday "제목"`(종료일을 어제로 — 실행 시점 자동 판정 검증) `-watchGoals <N>`(생성순 목표 N개를 '애플워치에서 보기'로 설정, 0=전체 해제 — 설정 후 스냅숏 즉시 푸시, 컴플리케이션 필터 검증용) `-watchActions <N>`(1.5 — 배치순 행동 N개를 '행동 기록' 컴플리케이션에 올림. 표시 기간은 하루→이번 주→이번 달→지난 7일→지난 30일 순환, 스냅숏 즉시 푸시) `-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|week|month>` `-statRolling`('오늘 기준' 롤링 토글 켠 상태로 시작 — 1.4) `-statScrollBottom` `-showExport` `-exportDump`(Documents/export-dump.png) `-recordShowEditor <session|count>`(최신 기록 수정 시트) |
| 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-settingsScrollMeasure`(측정 섹션 — 짧은 기록 무시 푸터 검증) `-settingsShowDayStartWheel`(하루 시작 시간 휠 펼침 시작) `-premiumPreview` `-helpPreview`(+`-helpExpandAll`: 전 주제 펼침, `-helpScrollTo <help-main 등>`: 이미지 주제로 스크롤) `-dataExportPreview`(+`-dataExportRun`이면 CSV 자동 생성 후 Documents 복사, `-dataExportRangeDays <N>`이면 최근 N일 구간 지정 상태로 시작 — 1.4) `-dataImportPreview`(가져오기 화면 직접 표시 — -premium과 함께) `-csvImportTest`(CSV 가져오기 자가 검증 20건[파서·판별·연결·중복·멱등] → Documents/csv-import-test.txt — 인메모리 컨테이너라 실데이터 무영향) `-dataResetPreview`(데이터 정리·초기화 화면 직접 표시 — 1.4. +`-dataResetBackupRun pdf|image`면 백업 3종 자동 생성 후 Documents 복사, `-dataResetRangeDays <N>`이면 날짜 지정[오늘-N일까지], `-dataResetScope diary`면 '일기만' 선택으로 시작) `-dataResetTest`(초기화 자가 검증 27건[범위 삭제 경계·보존·cascade·전체 초기화·일기만] → Documents/data-reset-test.txt — 인메모리·설정 고정, 실데이터 무영향) `-seedHealthCache`(1.5 — 건강 스토어를 합성 값으로 대체[HK 비의존]·캐시 기록, 건강 타일·다짐 렌더 검증용) `-healthShowGuide`(1.5 — 건강 안내 시트 표시) `-healthShowMetricPicker`(1.5 — 건강 지표 선택 시트 표시) `-symbolAuditDump`(아이콘 카탈로그 중 현재 OS에 없는 심볼 → Documents/symbol-audit.txt — 하위 OS QA, 1.4) `-actionShowFavoriteOrder`(행동 탭 즐겨찾기 순서 시트 — `-startTab action`과 함께) `-actionShowEditor`(1.5 — 첫 행동의 편집 시트를 열고 애플워치 섹션까지 자동 스크롤, `-startTab action`과 함께) `-showOnboarding`(온보딩 강제 표시) `-widgetPreview YES|lock|marketing`(marketing = 디버그 라벨 없는 위젯 실렌더 모아 보기 — 마케팅 스크린샷 05-widgets/06-widgets 촬영용, Marketing/README 참고) `-widgetPreviewScroll <앵커>` `-widgetRenderMode accented|vibrant`(틴트/투명 홈 렌더링 분기 검증) `cloudSync`(standard bool로 강제) `-intentSmokeTest`(시리 인텐트 10개 시나리오 실행 → Documents/intent-smoke-test.txt, -seedDemo·-premium과 함께) |
| 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-settingsScrollMeasure`(측정 섹션 — 짧은 기록 무시 푸터 검증) `-settingsShowDayStartWheel`(하루 시작 시간 휠 펼침 시작) `-premiumPreview` `-helpPreview`(+`-helpExpandAll`: 전 주제 펼침, `-helpScrollTo <help-main 등>`: 이미지 주제로 스크롤) `-dataExportPreview`(+`-dataExportRun`이면 CSV 자동 생성 후 Documents 복사, `-dataExportRangeDays <N>`이면 최근 N일 구간 지정 상태로 시작 — 1.4) `-dataImportPreview`(가져오기 화면 직접 표시 — -premium과 함께) `-csvImportTest`(CSV 가져오기 자가 검증 20건[파서·판별·연결·중복·멱등] → Documents/csv-import-test.txt — 인메모리 컨테이너라 실데이터 무영향) `-dataResetPreview`(데이터 정리·초기화 화면 직접 표시 — 1.4. +`-dataResetBackupRun pdf|image`면 백업 3종 자동 생성 후 Documents 복사, `-dataResetRangeDays <N>`이면 날짜 지정[오늘-N일까지], `-dataResetScope diary`면 '일기만' 선택으로 시작) `-dataResetTest`(초기화 자가 검증 27건[범위 삭제 경계·보존·cascade·전체 초기화·일기만] → Documents/data-reset-test.txt — 인메모리·설정 고정, 실데이터 무영향) `-seedHealthCache`(1.5 — 건강 스토어를 합성 값으로 대체[HK 비의존]·과거 7일+운동 종목·수면 구간 키까지 캐시 기록, 건강 타일·다짐 렌더 검증용) `-seedHealthQuest`(1.5 — 건강 다짐 목표 1개 시드: 걸음 8000·수면 8h·달리기 30분·물 2L, 멱등) `-healthQuestDump`(1.5 — 건강 다짐들의 캐시 키·오늘 값·하루 진행률 → Documents/health-quest-dump.txt) `-healthShowGuide`(1.5 — 건강 안내 시트 표시) `-healthShowMetricPicker`(1.5 — 건강 지표 선택 시트 표시) `-symbolAuditDump`(아이콘 카탈로그 중 현재 OS에 없는 심볼 → Documents/symbol-audit.txt — 하위 OS QA, 1.4) `-actionShowFavoriteOrder`(행동 탭 즐겨찾기 순서 시트 — `-startTab action`과 함께) `-actionShowEditor`(1.5 — 첫 행동의 편집 시트를 열고 애플워치 섹션까지 자동 스크롤, `-startTab action`과 함께) `-showOnboarding`(온보딩 강제 표시) `-widgetPreview YES|lock|marketing`(marketing = 디버그 라벨 없는 위젯 실렌더 모아 보기 — 마케팅 스크린샷 05-widgets/06-widgets 촬영용, Marketing/README 참고) `-widgetPreviewScroll <앵커>` `-widgetRenderMode accented|vibrant`(틴트/투명 홈 렌더링 분기 검증) `cloudSync`(standard bool로 강제) `-intentSmokeTest`(시리 인텐트 10개 시나리오 실행 → Documents/intent-smoke-test.txt, -seedDemo·-premium과 함께) |
| 버블 | `-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|image`(결과를 Documents로 복사) `-diaryShowCalendarPicker` `-diarySeedEvents` `-diaryHideFirstAction` `-diaryShowActionFilter` `-diaryHideFirstGoal` `-diaryShowGoalFilter` `-diaryShowDefaults`(기본 표시 설정 시트) `-diaryDefaultsSeed`(첫 목표·첫 행동 기본 숨김+캘린더 기본 '모든 캘린더' 주입 — 기본값 적용 검증용) `-diarySeedPhoto`(오늘=이모지+사진·어제=사진만 기분 사진 시드 — 달력 백레이어 검증) `-diarySeedTemplate`(2쪽 샘플 PDF 양식 생성) `-diaryLockScreen`(잠금 게이트 화면 강제 — 인증 없이 표시) `-diaryShowTemplates`(양식 관리 시트) `-diaryShowPageChooser`(페이지 추가 선택 시트) `-diaryApplyTemplate`(첫 양식으로 페이지 추가) `-diaryShapeEdit`(활성 노트 페이지 첫 도형 선택+'도형 수정' 시트 — `-diarySeed`·`-diaryOpenToday`·`-diaryPage 1`과 함께. 시드 요소들이 스타일 필드[굵기·둥글기·채움·자유 비율·회전·글자 크기·정렬·테두리 색]를 전부 렌더) `-diaryTextEdit`(첫 텍스트 상자를 제자리 입력 상태로 — 같은 조합) `-diaryStrokeTest`(3초 뒤 프로그램 획 추가로 디바운스 저장 파이프라인 검증 → Documents/diary-stroke-test.txt[strokes·delivered] — `-diaryOpenToday`와 함께, 1.4) `-diaryExportDeleteTest`(내보내기 완료 후 '삭제' 버튼과 같은 가드·경로로 삭제 실행 — `-diaryShowExport -diaryExportRun`과 함께, 재실행 후 달력 마커 소멸로 판정, 1.4) / 섹션 강제: `-diary.sectionOrder "timetable,hero,goals"` `-diary.hiddenSections "mood,todos,records,bars"` |
| 워치 | `-complicationPreview` `-complicationScroll` `-complicationScrollTo <stale\|quest\|actionValue>` `-autoRunFirstAction` `-autoStopRunning`(측정 중 행 탭과 동일 경로로 대표 측정 종료 — 페어링 심 E2E용) `-watchOpenActionDetail`(1.5 — 스냅숏 수신 후 첫 옵트인 행동의 미니 행동 화면을 딥링크와 같은 경로로 push, 착지·뒤로가기 검증) |

View File

@ -66,7 +66,7 @@
- 상태 처리: 맥=섹션 숨김 / 권한 없음·데이터 없음=상태 문구 타일 / 아이패드=자기 HK 기준.
- 운동은 종목별 집계 가능(HKWorkout.workoutActivityType) — 타일 v1은 '운동 시간(엑서사이즈 분)' 기준, 종목별은 후속 후보로만.
### 3.4 건강 다짐 (프리미엄)
### 3.4 건강 다짐 (프리미엄) ✅구현 완료(2026-08-22 — 모델 3필드·값 공급 이음새 3곳·운동 종목 20종·수면 구간·판정 보류·데이터 없음 상태·편집기·자가 검증 H 시리즈 12건. 회귀 61·20·27·10 ALL PASS×2심)
- **모델**: `Quest`에 건강 대상 필드 신설(예: `healthMetricRaw` 기본 "" — §3 CloudKit 규칙 준수: 기본값+스키마 안전). 대상 3유형째(행동/꼬리표/건강 지표). 수면 구간 필드(시작·끝 분, 기본값) 추가.
- **v1 지표 세트(확정 2026-08-22)**: 걸음 수 · 운동 시간 · 수면(특수) · 활동 에너지(kcal) · 이동 거리(km) · 스탠드 시간 · 마음챙김 분 · 물 섭취(L) — 8종. 수면 외 7종은 전부 "논리적 하루 창의 누적 수량"으로 구조 동일 — 한 어댑터로 처리.
- **운동 세부 종목 다짐(확정 2026-08-22 — 사용자 핵심 니즈)**: '운동'은 전체 합산 외에 **종목별 다짐** 지원(예: 달리기 30분 이상) — HKWorkout의 종목별 duration 합산(기록된 운동 기준). `healthMetricRaw`에 종목 파라미터 인코딩(예: workout.running), HealthCache 키도 지표+종목으로 확장(위험 구조는 8종과 동일 — 누적 수량 패턴). **종목 이름은 API가 현지화를 제공하지 않음 → 자체 큐레이션 목록(20종 내외: 달리기·걷기·사이클·수영·근력 운동·요가·필라테스·등산·축구·야구·농구·테니스·배드민턴·골프·줄넘기·복싱·댄스·스트레칭·HIIT·기타)을 ko/en/ja 직접 명명** — 구현 중 목록을 사용자에게 제시해 실사용 종목 포함 확인(비차단), 이후 추가는 케이스+문자열 3개로 저비용. ⚠️'운동 시간'(엑서사이즈 분, 활동 링 기준)과 종목별(기록된 운동 시간)은 값 기준이 다름 — 가이드 문구·도움말에 차이 설명 필수.

View File

@ -60,8 +60,11 @@ struct ContentView: View {
if DeviceLayout.isMac, size < .xLarge { size = .xLarge }
}
.task {
// (1.5): + + 1
HealthDataStore.shared.bootstrap()
#if DEBUG
DebugSeed.seedIfRequested(context: context)
DebugSeed.seedHealthQuestIfRequested(context: context)
DebugSeed.seedEmptyGoalIfRequested(context: context)
DebugSeed.seedPeriodFulfilledIfRequested(context: context)
DebugSeed.seedBulkIfRequested(context: context)
@ -71,6 +74,7 @@ struct ContentView: View {
DebugSeed.watchActionsIfRequested(context: context)
DebugSeed.endGoalYesterdayIfRequested(context: context)
DebugSeed.dumpStreaksIfRequested(context: context)
DebugSeed.dumpHealthQuestsIfRequested(context: context)
DebugSeed.dumpMissingSymbolsIfRequested()
DebugSeed.runProgressSelfTestIfRequested()
CSVImport.selfTestIfRequested()

View File

@ -388,6 +388,91 @@ enum DebugSeed {
QuestProgress(quest: qQuest, math: math).isPeriodFulfilled(asOf: now) ? 1 : 0, 1)
expect("Q 화요일 기준 미완료(소급 차단)",
QuestProgress(quest: qQuest, math: math).isPeriodFulfilled(asOf: at(1, 12)) ? 1 : 0, 0)
// H. (1.5) HealthCache(testOverride )
// (· ··) (plan §10 R2)
func makeHealthQuest(_ name: String, raw: String, isDuration: Bool,
targetSeconds: Double = 0, targetCount: Int = 0,
direction: QuestDirection = .atLeast,
configure: (Quest) -> Void = { _ in }) -> Quest {
let goal = Goal(title: name, symbolName: "heart.fill", colorHex: "#E0558C",
startDate: cal.date(byAdding: .day, value: -30, to: now)!, endDate: nil)
context.insert(goal)
let quest = Quest(goal: goal)
quest.healthMetricRaw = raw
quest.measure = isDuration ? .time : .count
quest.period = .daily
quest.direction = direction
quest.targetSeconds = targetSeconds
quest.targetCount = targetCount
configure(quest)
context.insert(quest)
return quest
}
func dayToken(_ offset: Int) -> String {
HealthCache.dayToken(math.dayKey(for: at(offset, 12)))
}
// : 8000· 9000( )· 0·() 4000
HealthQuestValues.testOverride = [
"steps": [dayToken(0): 8000, dayToken(1): 9000, dayToken(3): 4000],
"workout.running": [dayToken(2): 45 * 60, dayToken(3): 30 * 60],
"sleep@1260-540": [dayToken(1): 6 * 3600, dayToken(2): 8 * 3600, dayToken(3): 8 * 3600],
]
defer { HealthQuestValues.testOverride = nil }
// H1. 8000 4000 = 50%
let hSteps = makeHealthQuest("H 걸음", raw: "steps", isDuration: false, targetCount: 8000)
expect("H1 걸음 하루 표기(50%)", progress(hSteps, .day).displayRatio, 0.5)
// H2. : 7 × 8000 = 56000 ,
// ( 8000 + min(9000,8000)=8000 + 0 + 4000) = 20000
expect("H2 걸음 주간(하루 기여 캡)", progress(hSteps, .week).displayRatio, 20000.0 / 56000.0)
// H3. ( 30 ) = 100%, 45
let hRun = makeHealthQuest("H 달리기", raw: "workout.running", isDuration: true,
targetSeconds: 30 * 60)
expect("H3 달리기 하루 100%", progress(hRun, .day).displayRatio, 1.0)
// H4. 8 + (··) 6
let hSleep = makeHealthQuest("H 수면", raw: "sleep", isDuration: true,
targetSeconds: 8 * 3600) { quest in
quest.scheduleMode = .weekdays
quest.weekdays = [2, 4, 5] // ··
}
expect("H4 수면 오늘 100%", progress(hSleep, .day).displayRatio, 1.0)
// : (0)+(8h)+(8h) 3×8h 16/24
expect("H4 수면 주간(수행일만 합산)", progress(hSleep, .week).displayRatio, 16.0 / 24.0)
// H5. : · , 0( ) 2
let hStreak = QuestProgress(quest: hSleep, math: math).streak(now: now)
expect("H5 수면 연속 2일", Double(hStreak?.count ?? -1), 2)
// H6. atMost: 5000 4000 = (100%), 9000 ( )
let hMost = makeHealthQuest("H 걸음한도", raw: "steps", isDuration: false,
targetCount: 5000, direction: .atMost)
expect("H6 걸음 한도 안(오늘)", progress(hMost, .day).displayRatio, 1.0)
// H7. : () 45 30 1.0
let hJudge = makeHealthQuest("H 판정", raw: "workout.running", isDuration: true,
targetSeconds: 30 * 60)
if let goal = hJudge.goal {
goal.endDate = at(2, 12) //
expect("H7 종료일 기준 판정(달성)",
goal.questAchievementRatio(math: math, now: math.dayRange(forKey: math.dayKey(for: at(2, 12))).upperBound.addingTimeInterval(-1)),
1.0)
}
// H8. : ( )
let (dualAct, dualQuest) = makeQuest("H 이중상태", type: .count, period: .daily, targetCount: 2)
dualQuest.healthMetricRaw = "steps"
add(dualAct, at(3, 9))
expect("H8 이중 상태는 행동 우선(기록 1/2)", progress(dualQuest, .day).displayRatio, 0.5)
// H9. ( 30 )·
let mergeBase = at(3, 0)
let merged = HealthDataStore.mergedDuration([
(mergeBase, mergeBase.addingTimeInterval(3600)),
(mergeBase.addingTimeInterval(1800), mergeBase.addingTimeInterval(5400)),
(mergeBase.addingTimeInterval(7200), mergeBase.addingTimeInterval(9000)),
])
expect("H9 수면 합집합 병합", merged, 5400 + 1800)
let window = HealthDataStore.sleepWindow(
forDayRange: math.dayRange(forKey: math.dayKey(for: now)), endMinutes: 540, length: 12 * 3600
)
expect("H9 수면 창 끝(오늘 09:00)", window.upperBound.timeIntervalSince(at(3, 9)), 0)
expect("H9 수면 창 길이(12시간)", window.upperBound.timeIntervalSince(window.lowerBound), 12 * 3600)
} catch {
failures += 1
lines.append("FAIL 컨테이너 생성: \(error)")
@ -581,6 +666,56 @@ enum DebugSeed {
try? context.save()
}
/// `-seedHealthQuest YES`: 1 (1.5 ··· 4).
/// `-seedHealthCache YES` · .
/// ( ), ()
static func seedHealthQuestIfRequested(context: ModelContext) {
guard UserDefaults.standard.bool(forKey: "seedHealthQuest") else { return }
let title = "건강 습관 만들기"
let existing = (try? context.fetch(FetchDescriptor<Goal>())) ?? []
guard !existing.contains(where: { $0.title == title }) else { return }
let goal = Goal(title: title, symbolName: "heart.fill", colorHex: "#E0558C",
startDate: Calendar.current.date(byAdding: .day, value: -7, to: .now) ?? .now,
endDate: nil)
context.insert(goal)
func addQuest(_ raw: String, seconds: Double = 0, count: Int = 0, isDuration: Bool) {
let quest = Quest(goal: goal)
quest.healthMetricRaw = raw
quest.measure = isDuration ? .time : .count
if isDuration { quest.targetSeconds = seconds } else { quest.targetCount = count }
quest.period = .daily
quest.sortOrder = (goal.quests.map(\.sortOrder).max() ?? -1) + 1
context.insert(quest)
}
addQuest(HealthMetric.steps.rawValue, count: 8000, isDuration: false)
addQuest(HealthMetric.sleep.rawValue, seconds: 8 * 3600, isDuration: true)
addQuest("workout.running", seconds: 30 * 60, isDuration: true)
addQuest(HealthMetric.water.rawValue, count: 2000, isDuration: false)
DataChange.commit(context: context)
}
/// `-healthQuestDump YES`: · · (1.5 )
static func dumpHealthQuestsIfRequested(context: ModelContext) {
guard UserDefaults.standard.bool(forKey: "healthQuestDump") else { return }
var lines: [String] = []
let math = DayMath()
let today = math.dayKey(for: .now)
lines.append("todayToken=\(HealthCache.dayToken(today)) deviceAvailable=\(HealthSupport.deviceAvailable)")
let quests = (try? context.fetch(FetchDescriptor<Quest>())) ?? []
for quest in quests where !quest.healthMetricRaw.isEmpty {
let target = quest.healthTarget
let key = target.map { HealthQuestValues.cacheKey(target: $0, quest: quest) } ?? "nil-target"
let dayValue = target.flatMap { HealthQuestValues.dayValue(target: $0, quest: quest, dayKey: today) }
let result = QuestProgress(quest: quest, math: math).spanProgress(.day)
lines.append("raw=\(quest.healthMetricRaw) key=\(key) window=\(quest.sleepWindowStartMinutes)-\(quest.sleepWindowEndMinutes) measure=\(quest.measureRaw) dayValue=\(dayValue.map { String($0) } ?? "nil") value=\(result.value) target=\(result.target) display=\(result.displayRatio)")
}
if let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
try? lines.joined(separator: "\n")
.write(to: docs.appendingPathComponent("health-quest-dump.txt"),
atomically: true, encoding: .utf8)
}
}
/// `-seedEmptyGoal YES`: 1
/// · ' ' ( · )
static func seedEmptyGoalIfRequested(context: ModelContext) {

View File

@ -17,54 +17,11 @@
import Foundation
import HealthKit
import Observation
import SwiftData
import UIKit
// MARK: - × (App Group)
nonisolated enum HealthCache {
static let storageKey = "health.cache.v1"
/// (§4.2 400)
static let retentionDays = 400
/// dayKey( Date) "yyyyMMdd"
static func dayToken(_ dayKey: Date) -> String {
let parts = Calendar.current.dateComponents([.year, .month, .day], from: dayKey)
let y = parts.year ?? 0, m = parts.month ?? 0, d = parts.day ?? 0
let mm = m < 10 ? "0\(m)" : "\(m)"
let dd = d < 10 ? "0\(d)" : "\(d)"
return "\(y)\(mm)\(dd)"
}
private static func load() -> [String: [String: Double]] {
(AppGroup.defaults.dictionary(forKey: storageKey) as? [String: [String: Double]]) ?? [:]
}
static func value(_ metricRaw: String, dayKey: Date) -> Double? {
load()[metricRaw]?[dayToken(dayKey)]
}
/// (+ )
static func setValues(_ values: [String: Double], dayKey: Date) {
guard !values.isEmpty else { return }
var all = load()
let token = dayToken(dayKey)
for (metricRaw, value) in values {
var days = all[metricRaw] ?? [:]
days[token] = value
if days.count > retentionDays {
let sorted = days.keys.sorted(by: >)
for old in sorted.dropFirst(retentionDays) { days.removeValue(forKey: old) }
}
all[metricRaw] = days
}
AppGroup.defaults.set(all, forKey: storageKey)
}
/// ( )
static func clearAll() {
AppGroup.defaults.removeObject(forKey: storageKey)
}
}
// (HealthCache·HealthSupport·HealthQuestValues Shared/HealthMetric.swift
// · . [HK ].)
// MARK: - ( )
@ -94,11 +51,21 @@ final class HealthDataStore {
}
private init() {
// ( )
// ( )
NotificationCenter.default.addObserver(
forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main
) { _ in
Task { @MainActor in await HealthDataStore.shared.refreshToday() }
Task { @MainActor in HealthDataStore.shared.bootstrap() }
}
}
/// · (§8 ) + +
func bootstrap() {
AppGroup.defaults.set(Self.isAvailable, forKey: HealthSupport.deviceAvailableKey)
guard Self.isAvailable, hasRequestedAuth else { return }
Task { @MainActor in
await self.refreshRecent()
await self.backfillIfNeeded()
}
}
@ -121,7 +88,8 @@ final class HealthDataStore {
guard HKHealthStore.isHealthDataAvailable() else { return }
try? await store.requestAuthorization(toShare: [], read: Self.readTypes)
AppGroup.defaults.set(true, forKey: "health.authRequested")
await refreshToday()
await refreshRecent()
await backfillIfNeeded()
}
/// ( )
@ -131,29 +99,112 @@ final class HealthDataStore {
let seeded: [HealthMetric: Double] = [
.steps: 8432, .exerciseMinutes: 32 * 60, .sleep: 7 * 3600 + 28 * 60,
.activeEnergy: 512, .distance: 5230, .standHours: 9,
.mindfulMinutes: 10 * 60, .water: 1.2,
.mindfulMinutes: 10 * 60, .water: 1200,
]
todayValues = seeded
let math = DayMath()
HealthCache.setValues(
Dictionary(uniqueKeysWithValues: seeded.map { ($0.key.rawValue, $0.value) }),
dayKey: math.dayKey(for: .now)
)
let todayKey = math.dayKey(for: .now)
// 7 · .
// ()· .
let cal = math.calendar
for offset in 0...7 {
guard let key = cal.date(byAdding: .day, value: -offset, to: todayKey) else { continue }
var values = Dictionary(uniqueKeysWithValues: seeded.map { ($0.key.rawValue, $0.value) })
values["workout.running"] = 31 * 60
values["sleep@1260-540"] = seeded[.sleep] ?? 0
if offset > 0 {
// ( )
values = values.mapValues { ($0 * 0.9).rounded() }
}
HealthCache.setValues(values, dayKey: key)
}
return
}
#endif
await refreshRecent(days: 1)
}
/// ( 3)
/// · , .
/// ( · ) .
func refreshRecent(days: Int = 3) async {
#if DEBUG
if UserDefaults.standard.bool(forKey: "seedHealthCache") { await refreshToday(); return }
#endif
guard Self.isAvailable, hasRequestedAuth else { return }
let math = DayMath()
let now = Date.now
var result: [HealthMetric: Double] = [:]
for metric in HealthMetric.allCases {
result[metric] = await value(of: metric, forDayContaining: now, math: math)
let cal = math.calendar
let todayKey = math.dayKey(for: now)
let keys = questHealthKeys()
for offset in 0..<max(1, days) {
guard let key = cal.date(byAdding: .day, value: -offset, to: todayKey) else { continue }
let values = await allValues(
forDayKey: key, math: math,
sleepWindows: keys.sleepWindows, workoutKinds: keys.workoutKinds
)
HealthCache.setValues(values, dayKey: key)
if offset == 0 {
var tiles: [HealthMetric: Double] = [:]
for metric in HealthMetric.allCases { tiles[metric] = values[metric.rawValue] ?? 0 }
todayValues = tiles
}
}
todayValues = result
HealthCache.setValues(
Dictionary(uniqueKeysWithValues: result.map { ($0.key.rawValue, $0.value) }),
dayKey: math.dayKey(for: now)
)
}
/// ( )·
private func questHealthKeys() -> (sleepWindows: Set<String>, workoutKinds: Set<HealthWorkoutKind>) {
var windows: Set<String> = [Self.defaultSleepSpec]
var kinds: Set<HealthWorkoutKind> = []
let quests = (try? DataStore.shared.mainContext.fetch(FetchDescriptor<Quest>())) ?? []
for quest in quests {
switch quest.healthTarget {
case .metric(.sleep):
windows.insert("\(quest.sleepWindowStartMinutes)-\(quest.sleepWindowEndMinutes)")
case .workout(let kind):
kinds.insert(kind)
default:
break
}
}
return (windows, kinds)
}
/// ( 21:00~ 09:00) "sleep"
static let defaultSleepSpec = "1260-540"
/// "-" ( , ). = 24
static func windowSpec(_ spec: String) -> (endMinutes: Int, length: TimeInterval)? {
let parts = spec.split(separator: "-").compactMap { Int(String($0)) }
guard parts.count == 2, (0...1439).contains(parts[0]), (0...1439).contains(parts[1]) else { return nil }
var minutes = ((parts[1] - parts[0]) % 1440 + 1440) % 1440
if minutes == 0 { minutes = 1440 }
return (parts[1], TimeInterval(minutes * 60))
}
/// +
private func allValues(forDayKey key: Date, math: DayMath,
sleepWindows: Set<String>, workoutKinds: Set<HealthWorkoutKind>) async -> [String: Double] {
let range = math.dayRange(forKey: key)
let anchor = range.lowerBound
var result: [String: Double] = [:]
for metric in HealthMetric.allCases where metric != .sleep {
result[metric.rawValue] = await value(of: metric, forDayContaining: anchor, math: math)
}
for spec in sleepWindows {
guard let parsed = Self.windowSpec(spec) else { continue }
let window = Self.sleepWindow(forDayRange: range, endMinutes: parsed.endMinutes, length: parsed.length)
let value = await sleepDuration(window: window)
result["sleep@\(spec)"] = value
if spec == Self.defaultSleepSpec { result[HealthMetric.sleep.rawValue] = value }
}
if !workoutKinds.isEmpty {
let byKind = await workoutDurations(in: range)
for kind in workoutKinds {
result["workout.\(kind.rawValue)"] = byKind[kind] ?? 0
}
}
return result
}
// MARK: ( HealthMetric )
@ -171,7 +222,8 @@ final class HealthDataStore {
case .distance:
return await sumQuantity(.distanceWalkingRunning, unit: .meter(), in: range)
case .water:
return await sumQuantity(.dietaryWater, unit: .liter(), in: range)
// ml ( HealthMetric )
return await sumQuantity(.dietaryWater, unit: .literUnit(with: .milli), in: range)
case .standHours:
let samples = await categorySamples(.appleStandHour, in: range)
return Double(samples.count { $0.value == HKCategoryValueAppleStandHour.stood.rawValue })
@ -249,6 +301,202 @@ final class HealthDataStore {
return max(0, end.timeIntervalSince(start))
}
// MARK: () (plan §3.4)
/// range ( )
private func workoutDurations(in range: Range<Date>) async -> [HealthWorkoutKind: Double] {
let workouts = await workoutSamples(in: range)
var result: [HealthWorkoutKind: Double] = [:]
for workout in workouts {
let start = max(workout.startDate, range.lowerBound)
let end = min(workout.endDate, range.upperBound)
guard end > start else { continue }
result[Self.workoutKind(for: workout.workoutActivityType), default: 0]
+= end.timeIntervalSince(start)
}
return result
}
private func workoutSamples(in range: Range<Date>) async -> [HKWorkout] {
await withCheckedContinuation { continuation in
let predicate = HKQuery.predicateForSamples(
withStart: range.lowerBound, end: range.upperBound, options: []
)
let query = HKSampleQuery(
sampleType: HKObjectType.workoutType(), predicate: predicate,
limit: HKObjectQueryNoLimit, sortDescriptors: nil
) { _, samples, _ in
continuation.resume(returning: (samples as? [HKWorkout]) ?? [])
}
store.execute(query)
}
}
/// HKWorkoutActivityType ( ' ')
static func workoutKind(for type: HKWorkoutActivityType) -> HealthWorkoutKind {
switch type {
case .running: return .running
case .walking: return .walking
case .cycling: return .cycling
case .swimming: return .swimming
case .traditionalStrengthTraining, .functionalStrengthTraining: return .strength
case .yoga: return .yoga
case .pilates: return .pilates
case .hiking: return .hiking
case .soccer: return .soccer
case .baseball: return .baseball
case .basketball: return .basketball
case .tennis: return .tennis
case .badminton: return .badminton
case .golf: return .golf
case .jumpRope: return .jumpRope
case .boxing, .kickboxing, .martialArts: return .boxing
case .cardioDance, .socialDance, .barre: return .dance
case .flexibility, .cooldown, .mindAndBody: return .stretching
case .highIntensityIntervalTraining, .crossTraining: return .hiit
default: return .other
}
}
// MARK: ( 1 · · , plan §10 R9)
/// (400) . ( ) 1
/// , · 1 .
/// ( · ) .
func backfillIfNeeded() async {
#if DEBUG
if UserDefaults.standard.bool(forKey: "seedHealthCache") { return }
#endif
guard Self.isAvailable, hasRequestedAuth else { return }
let math = DayMath()
let now = Date.now
let cal = math.calendar
let todayKey = math.dayKey(for: now)
let keys = questHealthKeys()
let signature = "\(HealthCache.dayToken(todayKey))|\(keys.sleepWindows.sorted().joined(separator: ","))|\(keys.workoutKinds.map(\.rawValue).sorted().joined(separator: ","))"
guard AppGroup.defaults.string(forKey: "health.backfillSignature") != signature else { return }
guard let startKey = cal.date(byAdding: .day, value: -(HealthCache.retentionDays - 1), to: todayKey)
else { return }
let lower = math.dayRange(forKey: startKey).lowerBound
let upper = math.dayRange(forKey: todayKey).upperBound
var merged: [String: [Date: Double]] = [:]
// 1 ( )
let quantitySpecs: [(HealthMetric, HKQuantityTypeIdentifier, HKUnit, Double)] = [
(.steps, .stepCount, .count(), 1),
(.exerciseMinutes, .appleExerciseTime, .minute(), 60),
(.activeEnergy, .activeEnergyBurned, .kilocalorie(), 1),
(.distance, .distanceWalkingRunning, .meter(), 1),
(.water, .dietaryWater, .literUnit(with: .milli), 1),
]
for (metric, id, unit, factor) in quantitySpecs {
let buckets = await dailySums(id, unit: unit, lower: lower, upper: upper, math: math)
merged[metric.rawValue] = buckets.mapValues { $0 * factor }
}
//
let stand = await categorySamples(.appleStandHour, in: lower..<upper)
var standDays: [Date: Double] = [:]
for sample in stand where sample.value == HKCategoryValueAppleStandHour.stood.rawValue {
standDays[math.dayKey(for: sample.startDate), default: 0] += 1
}
merged[HealthMetric.standHours.rawValue] = standDays
//
let mindful = await categorySamples(.mindfulSession, in: lower..<upper)
var mindfulDays: [Date: Double] = [:]
for sample in mindful {
let start = max(sample.startDate, lower)
let end = min(sample.endDate, upper)
guard start < end else { continue }
for segment in math.splitByDay(start: start, end: end)
where segment.range.lowerBound < segment.range.upperBound {
mindfulDays[segment.dayKey, default: 0]
+= segment.range.upperBound.timeIntervalSince(segment.range.lowerBound)
}
}
merged[HealthMetric.mindfulMinutes.rawValue] = mindfulDays
// ( )
let sleepSamples = await categorySamples(
.sleepAnalysis, in: lower.addingTimeInterval(-24 * 3600)..<upper
)
let asleepValues = Set(HKCategoryValueSleepAnalysis.allAsleepValues.map(\.rawValue))
let asleepIntervals: [(Date, Date)] = sleepSamples.compactMap { sample in
asleepValues.contains(sample.value) ? (sample.startDate, sample.endDate) : nil
}
for spec in keys.sleepWindows {
guard let parsed = Self.windowSpec(spec) else { continue }
var days: [Date: Double] = [:]
var key = startKey
while key <= todayKey {
let window = Self.sleepWindow(
forDayRange: math.dayRange(forKey: key),
endMinutes: parsed.endMinutes, length: parsed.length
)
let clipped: [(Date, Date)] = asleepIntervals.compactMap { start, end in
let cs = max(start, window.lowerBound)
let ce = min(end, window.upperBound)
return ce > cs ? (cs, ce) : nil
}
let value = Self.mergedDuration(clipped)
if value > 0 { days[key] = value }
guard let next = cal.date(byAdding: .day, value: 1, to: key) else { break }
key = next
}
merged["sleep@\(spec)"] = days
if spec == Self.defaultSleepSpec { merged[HealthMetric.sleep.rawValue] = days }
}
// 1 ×
if !keys.workoutKinds.isEmpty {
let workouts = await workoutSamples(in: lower..<upper)
var byKindDays: [HealthWorkoutKind: [Date: Double]] = [:]
for workout in workouts {
let kind = Self.workoutKind(for: workout.workoutActivityType)
let start = max(workout.startDate, lower)
let end = min(workout.endDate, upper)
guard start < end else { continue }
for segment in math.splitByDay(start: start, end: end)
where segment.range.lowerBound < segment.range.upperBound {
byKindDays[kind, default: [:]][segment.dayKey, default: 0]
+= segment.range.upperBound.timeIntervalSince(segment.range.lowerBound)
}
}
for kind in keys.workoutKinds {
merged["workout.\(kind.rawValue)"] = byKindDays[kind] ?? [:]
}
}
HealthCache.merge(merged)
AppGroup.defaults.set(signature, forKey: "health.backfillSignature")
}
/// ( )
private func dailySums(_ id: HKQuantityTypeIdentifier, unit: HKUnit,
lower: Date, upper: Date, math: DayMath) async -> [Date: Double] {
await withCheckedContinuation { continuation in
let predicate = HKQuery.predicateForSamples(withStart: lower, end: upper, options: .strictStartDate)
let query = HKStatisticsCollectionQuery(
quantityType: HKQuantityType(id),
quantitySamplePredicate: predicate,
options: .cumulativeSum,
anchorDate: lower,
intervalComponents: DateComponents(day: 1)
)
query.initialResultsHandler = { _, collection, _ in
var result: [Date: Double] = [:]
collection?.enumerateStatistics(from: lower, to: upper) { statistics, _ in
let value = statistics.sumQuantity()?.doubleValue(for: unit) ?? 0
if value > 0 { result[math.dayKey(for: statistics.startDate)] = value }
}
continuation.resume(returning: result)
}
store.execute(query)
}
}
// MARK: HealthKit
private func sumQuantity(_ id: HKQuantityTypeIdentifier, unit: HKUnit, in range: Range<Date>) async -> Double {

View File

@ -185,10 +185,15 @@ struct GoalListView: View {
withAnimation { proxy.scrollTo("finishedLink", anchor: .bottom) }
}
}
// : -questShowEditor YES ( )
// : -questShowEditor YES ( ).
// -questShowEditorHealth YES (1.5 UI )
if UserDefaults.standard.bool(forKey: "questShowEditor") {
let quests = goals.flatMap(\.sortedQuests)
debugEditingQuest = quests.first { $0.deadlineMinutes >= 0 } ?? quests.first
if UserDefaults.standard.bool(forKey: "questShowEditorHealth") {
debugEditingQuest = quests.first { $0.isHealthQuest } ?? quests.first
} else {
debugEditingQuest = quests.first { $0.deadlineMinutes >= 0 } ?? quests.first
}
}
#endif
}
@ -374,9 +379,12 @@ struct QuestRow: View {
private func spanGauge(_ span: StatSpan) -> some View {
let progress = QuestProgress(quest: quest)
// ( ) (1.5 §8) span
// ' '
let healthUnavailable = quest.isHealthQuest && !HealthSupport.deviceAvailable
// (· , )
// / " "
let restDay = span == .day && !progress.isScheduled(on: .now)
let restDay = healthUnavailable || (span == .day && !progress.isScheduled(on: .now))
// / 0%
// " " (§4.2)
let fulfilled = span == .day && !restDay && progress.isPeriodFulfilled()
@ -390,7 +398,7 @@ struct QuestRow: View {
.tint(over ? .red : quest.targetColor)
.opacity(restDay ? 0.4 : 1)
if restDay {
Text("수행일 아님")
Text(healthUnavailable ? String(localized: "데이터 없음") : String(localized: "수행일 아님"))
.font(.caption2)
.foregroundStyle(.tertiary)
} else if fulfilled {

View File

@ -851,8 +851,10 @@ struct GoalSummaryCard: View {
}
HStack(spacing: 10) {
ForEach(StatSpan.allCases) { span in
// (1.5 §8) ' '
let healthUnavailable = quest.isHealthQuest && !HealthSupport.deviceAvailable
// ( )
let restDay = span == .day && !progress.isScheduled(on: .now)
let restDay = healthUnavailable || (span == .day && !progress.isScheduled(on: .now))
// / 0% (§4.2 )
let fulfilled = span == .day && !restDay && progress.isPeriodFulfilled()
let result = progress.spanProgress(span)
@ -861,7 +863,7 @@ struct GoalSummaryCard: View {
label: span.label,
ratio: restDay ? 0 : (fulfilled ? 1 : result.ratio),
percentText: restDay
? String(localized: "수행일 아님")
? (healthUnavailable ? String(localized: "데이터 없음") : String(localized: "수행일 아님"))
: (fulfilled ? progress.periodFulfilledLabel : Format.percent(result.displayRatio)),
color: over ? .red : quest.targetColor,
emphasized: over,

View File

@ -24,9 +24,15 @@ struct QuestEditorView: View {
let quest: Quest?
enum TargetKind: String, CaseIterable, Identifiable {
case action, tag
case action, tag, health
var id: String { rawValue }
var label: String { self == .action ? String(localized: "행동") : String(localized: "꼬리표") }
var label: String {
switch self {
case .action: return String(localized: "행동")
case .tag: return String(localized: "꼬리표")
case .health: return String(localized: "건강")
}
}
}
//
@ -35,6 +41,31 @@ struct QuestEditorView: View {
@State private var selectedTag: Tag?
@State private var measure: TrackingType = .time
// (1.5 , . plan §3.4)
private let premium = PremiumManager.shared
/// raw HealthMetric raw "workout"( )
@State private var healthChoiceRaw: String = HealthMetric.steps.rawValue
@State private var healthWorkoutKind: HealthWorkoutKind = .running
/// ( )
@State private var sleepStart: Date = Calendar.current.date(
bySettingHour: 21, minute: 0, second: 0, of: .now
) ?? .now
@State private var sleepEnd: Date = Calendar.current.date(
bySettingHour: 9, minute: 0, second: 0, of: .now
) ?? .now
/// ( / kcal / )
@State private var healthIntTarget = 8000
/// (km m)
@State private var healthKmTarget = 5.0
/// (L ml)
@State private var healthLitersTarget = 2.0
///
private var healthTargetSelection: HealthQuestTarget {
if healthChoiceRaw == "workout" { return .workout(healthWorkoutKind) }
return .metric(HealthMetric(rawValue: healthChoiceRaw) ?? .steps)
}
//
@State private var period: QuestPeriod = .daily
@State private var scheduleMode: QuestScheduleMode = .everyDay
@ -82,15 +113,66 @@ struct QuestEditorView: View {
// MARK:
private var targetSection: some View {
Section("① 대상 선택") {
Section {
Picker("대상 종류", selection: $targetKind.animation()) {
ForEach(TargetKind.allCases) { kind in
Text(kind.label).tag(kind)
}
}
.pickerStyle(.segmented)
.onChange(of: targetKind) {
// (plan §8)
if targetKind == .health {
period = .daily
hasDeadline = false
}
}
switch targetKind {
case .health:
if !premium.isPremium {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("건강 다짐은 프리미엄 기능이에요")
Text("설정 → 프리미엄에서 잠금을 해제하면 걸음·수면·운동 종목 같은 건강 데이터로 다짐을 만들 수 있어요.")
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "crown.fill")
.foregroundStyle(AppTheme.yellow)
}
} else {
Picker("지표", selection: $healthChoiceRaw.animation()) {
ForEach(HealthMetric.allCases) { metric in
Label(metric.name, systemImage: metric.symbolName).tag(metric.rawValue)
}
Label(String(localized: "운동 종목별"), systemImage: "figure.run.square.stack")
.tag("workout")
}
//
// onAppear onChange
.onChange(of: healthChoiceRaw) { if quest == nil { applyHealthDefaults() } }
if healthChoiceRaw == "workout" {
Picker("종목", selection: $healthWorkoutKind) {
ForEach(HealthWorkoutKind.allCases) { kind in
Label(kind.name, systemImage: kind.symbolName).tag(kind)
}
}
}
if healthChoiceRaw == HealthMetric.sleep.rawValue {
CollapsibleTimeWheel(
label: String(localized: "수면 구간 시작"),
selection: $sleepStart,
components: .hourAndMinute
)
CollapsibleTimeWheel(
label: String(localized: "수면 구간 끝"),
selection: $sleepEnd,
components: .hourAndMinute
)
}
}
case .action:
if allActions.isEmpty {
Text("등록된 행동이 없어요").foregroundStyle(.secondary)
@ -120,6 +202,32 @@ struct QuestEditorView: View {
}
}
}
} header: {
Text("① 대상 선택")
} footer: {
if targetKind == .health {
if healthChoiceRaw == HealthMetric.sleep.rawValue {
Text("잠은 보통 전날 밤에 시작되니까, 이 수면 구간 안에서 잔 시간을 통째로 '그날 잔 것'으로 세요. 값은 애플 건강 앱에서 읽어 오고 기기 밖으로 나가지 않아요.")
} else if healthChoiceRaw == "workout" {
Text("건강 앱에 기록된 운동 중 선택한 종목의 시간을 세요. '운동 시간' 지표(활동 링 기준)와는 값이 다를 수 있어요. 값은 기기 밖으로 나가지 않아요.")
} else {
Text("애플 건강 앱의 데이터를 읽어 하루 목표를 재요. 값은 기기 밖으로 나가지 않고, 건강 다짐은 하루 단위로만 반복돼요.")
}
}
}
}
/// ( )
private func applyHealthDefaults() {
switch healthTargetSelection {
case .metric(.steps): healthIntTarget = 8000
case .metric(.activeEnergy): healthIntTarget = 500
case .metric(.standHours): healthIntTarget = 12
case .metric(.distance): healthKmTarget = 5.0
case .metric(.water): healthLitersTarget = 2.0
case .metric(.sleep): targetHours = 8; targetMinutes = 0
case .metric(.exerciseMinutes), .workout: targetHours = 0; targetMinutes = 30
case .metric(.mindfulMinutes): targetHours = 0; targetMinutes = 10
}
}
@ -128,9 +236,19 @@ struct QuestEditorView: View {
@ViewBuilder
private var periodSection: some View {
Section {
Picker("반복 주기", selection: $period.animation()) {
ForEach(QuestPeriod.allCases) { p in
Text(p.label).tag(p)
// (plan §3.4 · v1 )
if targetKind == .health {
HStack {
Text("반복 주기")
Spacer()
Text("하루 (건강 다짐 전용)")
.foregroundStyle(.secondary)
}
} else {
Picker("반복 주기", selection: $period.animation()) {
ForEach(QuestPeriod.allCases) { p in
Text(p.label).tag(p)
}
}
}
@ -160,14 +278,17 @@ struct QuestEditorView: View {
}
}
}
// : [ , ] ( )
Toggle("마감 시각까지만 집계", isOn: $hasDeadline.animation())
if hasDeadline {
CollapsibleTimeWheel(
label: String(localized: "마감 시각"),
selection: $deadlineTime,
components: .hourAndMinute
)
// : [ , ] ( ).
// (§8 )
if targetKind != .health {
Toggle("마감 시각까지만 집계", isOn: $hasDeadline.animation())
if hasDeadline {
CollapsibleTimeWheel(
label: String(localized: "마감 시각"),
selection: $deadlineTime,
components: .hourAndMinute
)
}
}
}
@ -225,13 +346,55 @@ struct QuestEditorView: View {
// MARK: +
private var effectiveMeasure: TrackingType {
targetKind == .action ? (selectedAction?.trackingType ?? .time) : measure
switch targetKind {
case .action: return selectedAction?.trackingType ?? .time
case .tag: return measure
case .health: return healthTargetSelection.isDuration ? .time : .count
}
}
@ViewBuilder
private var amountSection: some View {
Section {
if effectiveMeasure == .time {
if targetKind == .health, !healthTargetSelection.isDuration {
// ( )
switch healthTargetSelection {
case .metric(.steps):
LabeledContent("목표 걸음 수") {
TextField("8000", value: $healthIntTarget, format: .number)
.keyboardType(.numberPad)
.multilineTextAlignment(.trailing)
}
case .metric(.activeEnergy):
LabeledContent("목표 kcal") {
TextField("500", value: $healthIntTarget, format: .number)
.keyboardType(.numberPad)
.multilineTextAlignment(.trailing)
}
case .metric(.distance):
LabeledContent("목표 거리 (km)") {
TextField("5", value: $healthKmTarget, format: .number.precision(.fractionLength(0...1)))
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
}
case .metric(.water):
LabeledContent("목표 물 (L)") {
TextField("2", value: $healthLitersTarget, format: .number.precision(.fractionLength(0...1)))
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
}
case .metric(.standHours):
Stepper(value: $healthIntTarget, in: 1...24) {
HStack {
Text("목표 일어서기")
Spacer()
Text("\(healthIntTarget)시간").foregroundStyle(.secondary)
}
}
default:
EmptyView()
}
} else if effectiveMeasure == .time {
HStack {
Picker("시간", selection: $targetHours) {
ForEach(0...23, id: \.self) { h in
@ -276,8 +439,21 @@ struct QuestEditorView: View {
// MARK:
private var isValid: Bool {
let hasTarget = targetKind == .action ? selectedAction != nil : selectedTag != nil
guard hasTarget else { return false }
switch targetKind {
case .action: guard selectedAction != nil else { return false }
case .tag: guard selectedTag != nil else { return false }
case .health:
guard premium.isPremium else { return false }
switch healthTargetSelection {
case .metric(.steps), .metric(.activeEnergy), .metric(.standHours):
guard healthIntTarget > 0 else { return false }
case .metric(.distance):
guard healthKmTarget > 0 else { return false }
case .metric(.water):
guard healthLitersTarget > 0 else { return false }
default: break //
}
}
if period == .daily {
switch scheduleMode {
case .weekdays: if weekdays.isEmpty { return false }
@ -298,6 +474,25 @@ struct QuestEditorView: View {
} else if let tag = quest.targetTag {
targetKind = .tag
selectedTag = tag
} else if let health = quest.healthTarget {
targetKind = .health
switch health {
case .metric(let metric):
healthChoiceRaw = metric.rawValue
switch metric {
case .steps, .activeEnergy, .standHours: healthIntTarget = quest.targetCount
case .distance: healthKmTarget = Double(quest.targetCount) / 1000
case .water: healthLitersTarget = Double(quest.targetCount) / 1000
default: break
}
case .workout(let kind):
healthChoiceRaw = "workout"
healthWorkoutKind = kind
}
let cal = Calendar.current
let dayStart = cal.startOfDay(for: .now)
sleepStart = cal.date(byAdding: .minute, value: quest.sleepWindowStartMinutes, to: dayStart) ?? sleepStart
sleepEnd = cal.date(byAdding: .minute, value: quest.sleepWindowEndMinutes, to: dayStart) ?? sleepEnd
}
measure = quest.measure
period = quest.period
@ -327,10 +522,34 @@ struct QuestEditorView: View {
target.targetAction = selectedAction
target.targetTag = nil
target.measure = selectedAction?.trackingType ?? .time
target.healthMetricRaw = "" // (plan §3.4)
case .tag:
target.targetTag = selectedTag
target.targetAction = nil
target.measure = measure
target.healthMetricRaw = ""
case .health:
target.targetAction = nil
target.targetTag = nil
let health = healthTargetSelection
target.healthMetricRaw = health.raw
target.measure = health.isDuration ? .time : .count
switch health {
case .metric(.steps), .metric(.activeEnergy), .metric(.standHours):
target.targetCount = max(1, healthIntTarget)
case .metric(.distance):
target.targetCount = max(1, Int((healthKmTarget * 1000).rounded()))
case .metric(.water):
target.targetCount = max(1, Int((healthLitersTarget * 1000).rounded()))
default:
break // targetSeconds
}
let cal = Calendar.current
let startComps = cal.dateComponents([.hour, .minute], from: sleepStart)
let endComps = cal.dateComponents([.hour, .minute], from: sleepEnd)
target.sleepWindowStartMinutes = (startComps.hour ?? 21) * 60 + (startComps.minute ?? 0)
target.sleepWindowEndMinutes = (endComps.hour ?? 9) * 60 + (endComps.minute ?? 0)
period = .daily // ( UI )
}
target.period = period
target.scheduleMode = period == .daily ? scheduleMode : .everyDay
@ -348,7 +567,10 @@ struct QuestEditorView: View {
target.customStart = period == .custom ? customStart : nil
target.customEnd = period == .custom ? customEnd : nil
target.targetSeconds = Double(targetHours * 3600 + targetMinutes * 60)
target.targetCount = targetCount
// targetCount switch
if !(targetKind == .health && !healthTargetSelection.isDuration) {
target.targetCount = targetCount
}
target.direction = direction
if quest == nil {
target.sortOrder = (goal.quests.map(\.sortOrder).max() ?? -1) + 1

View File

@ -4,8 +4,10 @@
//
// (1.5 Docs/plan-1.5.md §3)
// - · (HealthKit )
// - : (··)=, =, =kcal,
// =m, =, =L. HealthKit ··
// - : (··· )=, =, =kcal,
// =m, =, =ml. HealthKit ·· ·
// - HealthCache: × App Group (HealthStore),
// · ·QuestProgress ( 5.1.3 CloudKit )
//
import Foundation
@ -65,7 +67,9 @@ nonisolated enum HealthMetric: String, CaseIterable, Identifiable, Codable {
case .standHours:
return String(localized: "\(Int(value))시간")
case .water:
let text = value.formatted(.number.precision(.fractionLength(0...2)))
// ml L
let liters = value / 1000
let text = liters.formatted(.number.precision(.fractionLength(0...2)))
return String(localized: "\(text)L")
}
}
@ -79,3 +83,238 @@ nonisolated enum HealthMetric: String, CaseIterable, Identifiable, Codable {
return parsed.isEmpty ? defaultTileMetrics : parsed
}
}
// MARK: - ( , plan §3.4)
/// (20). HKWorkoutActivityType API
/// . = + HealthStore + 3.
nonisolated enum HealthWorkoutKind: String, CaseIterable, Identifiable {
case running, walking, cycling, swimming, strength, yoga, pilates, hiking
case soccer, baseball, basketball, tennis, badminton, golf
case jumpRope, boxing, dance, stretching, hiit, other
var id: String { rawValue }
var name: String {
switch self {
case .running: return String(localized: "달리기")
case .walking: return String(localized: "걷기 운동")
case .cycling: return String(localized: "사이클")
case .swimming: return String(localized: "수영")
case .strength: return String(localized: "근력 운동")
case .yoga: return String(localized: "요가")
case .pilates: return String(localized: "필라테스")
case .hiking: return String(localized: "등산")
case .soccer: return String(localized: "축구")
case .baseball: return String(localized: "야구")
case .basketball: return String(localized: "농구")
case .tennis: return String(localized: "테니스")
case .badminton: return String(localized: "배드민턴")
case .golf: return String(localized: "골프")
case .jumpRope: return String(localized: "줄넘기")
case .boxing: return String(localized: "복싱")
case .dance: return String(localized: "댄스")
case .stretching: return String(localized: "스트레칭")
case .hiit: return String(localized: "고강도 인터벌(HIIT)")
case .other: return String(localized: "기타 운동")
}
}
var symbolName: String {
switch self {
case .running: return "figure.run"
case .walking: return "figure.walk"
case .cycling: return "figure.outdoor.cycle"
case .swimming: return "figure.pool.swim"
case .strength: return "dumbbell.fill"
case .yoga: return "figure.yoga"
case .pilates: return "figure.pilates"
case .hiking: return "figure.hiking"
case .soccer: return "figure.indoor.soccer"
case .baseball: return "figure.baseball"
case .basketball: return "figure.basketball"
case .tennis: return "figure.tennis"
case .badminton: return "figure.badminton"
case .golf: return "figure.golf"
case .jumpRope: return "figure.jumprope"
case .boxing: return "figure.boxing"
case .dance: return "figure.dance"
case .stretching: return "figure.flexibility"
case .hiit: return "figure.highintensity.intervaltraining"
case .other: return "figure.mixed.cardio"
}
}
}
// MARK: - (Quest.healthMetricRaw )
/// .
/// raw : HealthMetric raw , "workout.<kind>" ( CloudKit )
nonisolated enum HealthQuestTarget: Equatable {
case metric(HealthMetric)
case workout(HealthWorkoutKind)
init?(raw: String) {
guard !raw.isEmpty else { return nil }
if let metric = HealthMetric(rawValue: raw) {
self = .metric(metric)
} else if raw.hasPrefix("workout."),
let kind = HealthWorkoutKind(rawValue: String(raw.dropFirst("workout.".count))) {
self = .workout(kind)
} else {
// ' ' ( )
return nil
}
}
var raw: String {
switch self {
case .metric(let metric): return metric.rawValue
case .workout(let kind): return "workout.\(kind.rawValue)"
}
}
var name: String {
switch self {
case .metric(let metric): return metric.name
case .workout(let kind): return kind.name
}
}
var symbolName: String {
switch self {
case .metric(let metric): return metric.symbolName
case .workout(let kind): return kind.symbolName
}
}
/// ( ) · ( )
var isDuration: Bool {
switch self {
case .metric(let metric): return metric.isDuration
case .workout: return true
}
}
///
func valueLabel(_ value: Double) -> String {
switch self {
case .metric(let metric): return metric.valueLabel(value)
case .workout: return Format.durationShort(value)
}
}
}
// MARK: - × (App Group · )
nonisolated enum HealthCache {
static let storageKey = "health.cache.v1"
/// (§4.2 400)
static let retentionDays = 400
/// dayKey( Date) "yyyyMMdd"
static func dayToken(_ dayKey: Date) -> String {
let parts = Calendar.current.dateComponents([.year, .month, .day], from: dayKey)
let y = parts.year ?? 0, m = parts.month ?? 0, d = parts.day ?? 0
let mm = m < 10 ? "0\(m)" : "\(m)"
let dd = d < 10 ? "0\(d)" : "\(d)"
return "\(y)\(mm)\(dd)"
}
private static func load() -> [String: [String: Double]] {
(AppGroup.defaults.dictionary(forKey: storageKey) as? [String: [String: Double]]) ?? [:]
}
static func value(_ metricRaw: String, dayKey: Date) -> Double? {
load()[metricRaw]?[dayToken(dayKey)]
}
/// (+ )
static func setValues(_ values: [String: Double], dayKey: Date) {
guard !values.isEmpty else { return }
var all = load()
let token = dayToken(dayKey)
for (metricRaw, value) in values {
var days = all[metricRaw] ?? [:]
days[token] = value
if days.count > retentionDays {
let sorted = days.keys.sorted(by: >)
for old in sorted.dropFirst(retentionDays) { days.removeValue(forKey: old) }
}
all[metricRaw] = days
}
AppGroup.defaults.set(all, forKey: storageKey)
}
/// × ( [: [: ]])
static func merge(_ values: [String: [Date: Double]]) {
guard !values.isEmpty else { return }
var all = load()
for (metricRaw, days) in values {
var stored = all[metricRaw] ?? [:]
for (dayKey, value) in days {
stored[dayToken(dayKey)] = value
}
if stored.count > retentionDays {
let sorted = stored.keys.sorted(by: >)
for old in sorted.dropFirst(retentionDays) { stored.removeValue(forKey: old) }
}
all[metricRaw] = stored
}
AppGroup.defaults.set(all, forKey: storageKey)
}
/// ( )
static func clearAll() {
AppGroup.defaults.removeObject(forKey: storageKey)
}
}
// MARK: - (§8 · )
nonisolated enum HealthSupport {
static let deviceAvailableKey = "health.deviceAvailable"
/// (=false).
/// ( ) true.
static var deviceAvailable: Bool {
guard AppGroup.defaults.object(forKey: deviceAvailableKey) != nil else { return true }
return AppGroup.defaults.bool(forKey: deviceAvailableKey)
}
}
// MARK: - (QuestProgress )
nonisolated enum HealthQuestValues {
#if DEBUG
/// (-progressSelfTest H ) ·
nonisolated(unsafe) static var testOverride: [String: [String: Double]]?
#endif
///
static func cacheKey(target: HealthQuestTarget, quest: Quest) -> String {
if case .metric(.sleep) = target {
return "sleep@\(quest.sleepWindowStartMinutes)-\(quest.sleepWindowEndMinutes)"
}
return target.raw
}
static func dayValue(target: HealthQuestTarget, quest: Quest, dayKey: Date) -> Double? {
let key = cacheKey(target: target, quest: quest)
#if DEBUG
if let override = testOverride {
return override[key]?[HealthCache.dayToken(dayKey)]
}
#endif
return HealthCache.value(key, dayKey: dayKey)
}
/// range .
/// range ( )
/// " = " now .
static func sum(target: HealthQuestTarget, quest: Quest, in range: Range<Date>, math: DayMath) -> Double {
math.dayKeys(in: range).reduce(0) { partial, key in
partial + (dayValue(target: target, quest: quest, dayKey: key) ?? 0)
}
}
}

View File

@ -388,6 +388,15 @@ final class Quest {
/// [ , ] (QuestProgress.dayMeasurementRange)
var deadlineMinutes: Int = -1
/// (1.5) HealthMetric raw "workout.<>". = .
/// / (
/// , plan §3.4). 1.4 ' '
var healthMetricRaw: String = ""
/// ( ) 21:00 ~ 09:00 (12).
/// '' ( plan §3.4)
var sleepWindowStartMinutes: Int = 1260
var sleepWindowEndMinutes: Int = 540
///
var sortOrder: Int = 0
var createdAt: Date = Date()
@ -419,20 +428,31 @@ extension Quest {
set { directionRaw = newValue.rawValue }
}
/// (1.5) / ( )
var healthTarget: HealthQuestTarget? {
guard targetAction == nil, targetTag == nil else { return nil }
return HealthQuestTarget(raw: healthMetricRaw)
}
var isHealthQuest: Bool { healthTarget != nil }
var targetName: String {
if let targetAction { return targetAction.name }
if let targetTag { return "#\(targetTag.name)" }
if let healthTarget { return healthTarget.name }
return String(localized: "대상 없음")
}
var targetSymbol: String {
if let targetAction { return targetAction.symbolName }
if targetTag == nil, let healthTarget { return healthTarget.symbolName }
return "tag.fill"
}
var targetColor: Color {
if let targetAction { return targetAction.color }
if let targetTag { return targetTag.color }
if healthTarget != nil { return .pink }
return AppTheme.green
}
@ -449,7 +469,9 @@ extension Quest {
}
var targetValueLabel: String {
measure == .time ? Format.durationShort(targetSeconds) : String(localized: "\(targetCount)")
// (·kcal·km·L ) "8000" (1.5)
if let healthTarget { return healthTarget.valueLabel(targetValue) }
return measure == .time ? Format.durationShort(targetSeconds) : String(localized: "\(targetCount)")
}
/// .

View File

@ -143,7 +143,12 @@ struct QuestProgress {
}
}
/// (1.5) HealthCache ,
/// Aggregator . (··) .
func value(in range: Range<Date>, now: Date = .now) -> Double {
if let health = quest.healthTarget {
return HealthQuestValues.sum(target: health, quest: quest, in: range, math: math)
}
let agg = Aggregator(math: math)
switch quest.measure {
case .time: return agg.seconds(for: quest.targetActions, in: range, now: now)
@ -258,15 +263,11 @@ struct QuestProgress {
}
let caps = quest.direction == .atLeast && quest.targetValue > 0
let dailyTarget = quest.targetValue
let agg = Aggregator(math: math)
var sum = 0.0
for key in math.dayKeys(in: range) where isActiveDay(key) {
let dayRange = dayMeasurementRange(forKey: key)
let dayValue: Double
switch quest.measure {
case .time: dayValue = agg.seconds(for: quest.targetActions, in: dayRange, now: now)
case .count: dayValue = Double(agg.count(for: quest.targetActions, in: dayRange))
}
// value(in:) Aggregator
// , (1.5)
let dayValue = value(in: dayMeasurementRange(forKey: key), now: now)
sum += caps ? min(dayValue, dailyTarget) : dayValue
}
return sum
@ -397,6 +398,22 @@ extension QuestProgress {
/// Aggregator (O(×))
/// O( + ) .
private func perDayValues(since lowerBound: Date, now: Date) -> [Date: Double] {
// 1.5: ( ).
// (values[key] ?? 0) .
if let health = quest.healthTarget {
var values: [Date: Double] = [:]
let cal = math.calendar
let lowerKey = math.dayKey(for: lowerBound)
var key = math.dayKey(for: now)
while key >= lowerKey {
if let value = HealthQuestValues.dayValue(target: health, quest: quest, dayKey: key) {
values[key] = value
}
guard let previous = cal.date(byAdding: .day, value: -1, to: key) else { break }
key = previous
}
return values
}
var values: [Date: Double] = [:]
// () .
// . ( ).
@ -517,10 +534,14 @@ extension Goal {
func combinedSpanRatio(_ span: StatSpan, math: DayMath = DayMath(), now: Date = .now) -> Double {
let quests = sortedQuests
guard !quests.isEmpty else { return 0 }
// ( )
let counted = span == .day
? quests.filter { QuestProgress(quest: $0, math: math).isScheduled(on: now) }
: quests
// ( ).
// 1.5 §8: ( )
// (0) ( )
let counted = quests.filter { quest in
if quest.isHealthQuest && !HealthSupport.deviceAvailable { return false }
guard span == .day else { return true }
return QuestProgress(quest: quest, math: math).isScheduled(on: now)
}
// 0% (100%)
guard !counted.isEmpty else { return 1 }
let sum = counted.reduce(0.0) { partial, quest in
@ -573,6 +594,10 @@ extension Goal {
/// , .
func evaluateIfEnded(math: DayMath = DayMath(), now: Date = .now) {
guard status == .inProgress, isPastEndDate(asOf: now), !quests.isEmpty else { return }
// 1.5 §8:
// CloudKit . ,
// ()
if !HealthSupport.deviceAvailable, quests.contains(where: \.isHealthQuest) { return }
status = meetsAchieveThreshold(math: math, now: judgmentReference(math: math, now: now))
? .achieved : .notAchieved
}
@ -581,6 +606,9 @@ extension Goal {
/// ""( ) .
func manualFinish(math: DayMath = DayMath(), now: Date = .now) -> GoalStatus? {
guard !quests.isEmpty else { return nil }
// 1.5 §8:
// (nil ' ' UI )
if !HealthSupport.deviceAvailable, quests.contains(where: \.isHealthQuest) { return nil }
status = meetsAchieveThreshold(math: math, now: now) ? .achieved : .notAchieved
return status
}

View File

@ -168,6 +168,7 @@ struct QuestRingActionCellView: View {
private var percentText: String {
// ( span )
if cell.isHealthUnavailable { return String(localized: "데이터 없음") }
if cell.isRestDay { return String(localized: "수행일 아님") }
// (§4.2) " " .
if let fulfilled = cell.periodFulfilledLabel { return fulfilled }

View File

@ -93,6 +93,9 @@ struct QuestCellSnapshot: Identifiable {
/// span (· , )
/// · 0 ' ' ( , · )
let isRestDay: Bool
/// ( ) (1.5 §8)
/// (isRestDay true) ' '
var isHealthUnavailable: Bool = false
/// (§4.2) /
/// (" " ). span , nil. ratio·displayRatio
/// 1 · ( , ).
@ -113,9 +116,12 @@ struct QuestCellSnapshot: Identifiable {
@MainActor
static func make(quest: Quest, span: StatSpan, now: Date = .now) -> QuestCellSnapshot {
let progress = QuestProgress(quest: quest)
// (1.5 §8) span
// (0··) ' '
let healthUnavailable = quest.isHealthQuest && !HealthSupport.deviceAvailable
// span
// " " (/ )
let isRestDay = span == .day && !progress.isScheduled(on: now)
let isRestDay = healthUnavailable || (span == .day && !progress.isScheduled(on: now))
// / 0% ·
// / (§4.2 , )
let fulfilled = span == .day && !isRestDay && progress.isPeriodFulfilled(asOf: now)
@ -127,23 +133,29 @@ struct QuestCellSnapshot: Identifiable {
// ()
let rawValue = progress.spanRawValue(span, now: now)
let valueLabel: String
switch quest.measure {
case .time:
valueLabel = rawValue < 60 ? String(localized: "0분") : Format.durationShort(rawValue)
case .count:
valueLabel = String(localized: "\(Int(rawValue))")
if let health = quest.healthTarget {
// (·kcal·km·L ) (1.5)
valueLabel = health.valueLabel(rawValue)
} else {
switch quest.measure {
case .time:
valueLabel = rawValue < 60 ? String(localized: "0분") : Format.durationShort(rawValue)
case .count:
valueLabel = String(localized: "\(Int(rawValue))")
}
}
return QuestCellSnapshot(
id: quest.uuid,
targetName: quest.targetName,
symbolName: quest.targetSymbol,
colorHex: action?.tags.sorted { $0.createdAt < $1.createdAt }.first?.colorHex
?? quest.targetTag?.colorHex ?? "#2F6B4F",
?? quest.targetTag?.colorHex ?? (quest.isHealthQuest ? "#E0558C" : "#2F6B4F"),
ratio: isRestDay ? 0 : (fulfilled ? 1 : result.ratio),
displayRatio: isRestDay ? 0 : (fulfilled ? 1 : result.displayRatio),
isAtMost: quest.direction == .atMost,
isAchieved: isRestDay ? false : (fulfilled || result.isAchieved),
isRestDay: isRestDay,
isHealthUnavailable: healthUnavailable,
periodFulfilledLabel: fulfilled ? progress.periodFulfilledLabel : nil,
runTarget: action.map(ActionEntity.init),
isRunning: action?.isRunning ?? false,