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:
parent
e04cdfdcb9
commit
78bad15852
@ -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, 착지·뒤로가기 검증) |
|
||||
|
||||
@ -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개로 저비용. ⚠️'운동 시간'(엑서사이즈 분, 활동 링 기준)과 종목별(기록된 운동 시간)은 값 기준이 다름 — 가이드 문구·도움말에 차이 설명 필수.
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)회")
|
||||
}
|
||||
|
||||
/// 하루 단위 다짐에 마감 시각이 지정돼 있는지.
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user