diff --git a/myApp/HaruDanim/CLAUDE.md b/myApp/HaruDanim/CLAUDE.md index a277cf3..d82b488 100644 --- a/myApp/HaruDanim/CLAUDE.md +++ b/myApp/HaruDanim/CLAUDE.md @@ -352,9 +352,9 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f |---|---| | 모음 | `-startEditing` `-expandGoalCard` `-pinGoals ` `-openStatsFor "이름"` `-openHistoryFor "이름"` `-autoStart "이름"` `-recordAddFor "이름"`(기록 직접 입력 시트) `-mainOthersCollapsed`('나머지 행동' 섹션 접힘 강제 — 표시 전용 오버라이드, 실 설정 무변경) | | 측정 알림 | `-settings.longSessionAlertHours `(설정 주입) `-longSessionAlertTestSeconds `(시간 대신 N초 발화, provisional 권한 자동) `-alertDump`(예약 상태 → Documents/session-alerts.txt) | -| 목표 | `-goalShowEditor` `-goalShowFinished` `-goalReorder` `-goalScrollBottom` `-questShowEditor`(다짐 편집기 표시 — 마감 시각 다짐 우선, 없으면 첫 다짐) `-endGoalYesterday "제목"`(종료일을 어제로 — 실행 시점 자동 판정 검증) `-watchGoals `(생성순 목표 N개를 '애플워치에서 보기'로 설정, 0=전체 해제 — 설정 후 스냅숏 즉시 푸시, 컴플리케이션 필터 검증용) `-watchActions `(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개를 '애플워치에서 보기'로 설정, 0=전체 해제 — 설정 후 스냅숏 즉시 푸시, 컴플리케이션 필터 검증용) `-watchActions `(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일 전 날짜로 시작 — 지난주 등 과거 주 표시, 마케팅 촬영용) `-excludeActions "이름,이름"` `-historyShowFilter` `-statShowFilter` `-statSpan ` `-statRolling`('오늘 기준' 롤링 토글 켠 상태로 시작 — 1.4) `-statScrollBottom` `-showExport` `-exportDump`(Documents/export-dump.png) `-recordShowEditor `(최신 기록 수정 시트) | -| 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-settingsScrollMeasure`(측정 섹션 — 짧은 기록 무시 푸터 검증) `-settingsShowDayStartWheel`(하루 시작 시간 휠 펼침 시작) `-premiumPreview` `-helpPreview`(+`-helpExpandAll`: 전 주제 펼침, `-helpScrollTo `: 이미지 주제로 스크롤) `-dataExportPreview`(+`-dataExportRun`이면 CSV 자동 생성 후 Documents 복사, `-dataExportRangeDays `이면 최근 N일 구간 지정 상태로 시작 — 1.4) `-dataImportPreview`(가져오기 화면 직접 표시 — -premium과 함께) `-csvImportTest`(CSV 가져오기 자가 검증 20건[파서·판별·연결·중복·멱등] → Documents/csv-import-test.txt — 인메모리 컨테이너라 실데이터 무영향) `-dataResetPreview`(데이터 정리·초기화 화면 직접 표시 — 1.4. +`-dataResetBackupRun pdf|image`면 백업 3종 자동 생성 후 Documents 복사, `-dataResetRangeDays `이면 날짜 지정[오늘-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 `: 이미지 주제로 스크롤) `-dataExportPreview`(+`-dataExportRun`이면 CSV 자동 생성 후 Documents 복사, `-dataExportRangeDays `이면 최근 N일 구간 지정 상태로 시작 — 1.4) `-dataImportPreview`(가져오기 화면 직접 표시 — -premium과 함께) `-csvImportTest`(CSV 가져오기 자가 검증 20건[파서·판별·연결·중복·멱등] → Documents/csv-import-test.txt — 인메모리 컨테이너라 실데이터 무영향) `-dataResetPreview`(데이터 정리·초기화 화면 직접 표시 — 1.4. +`-dataResetBackupRun pdf|image`면 백업 3종 자동 생성 후 Documents 복사, `-dataResetRangeDays `이면 날짜 지정[오늘-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 ` `-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 ` `-autoRunFirstAction` `-autoStopRunning`(측정 중 행 탭과 동일 경로로 대표 측정 종료 — 페어링 심 E2E용) `-watchOpenActionDetail`(1.5 — 스냅숏 수신 후 첫 옵트인 행동의 미니 행동 화면을 딥링크와 같은 경로로 push, 착지·뒤로가기 검증) | diff --git a/myApp/HaruDanim/Docs/plan-1.5.md b/myApp/HaruDanim/Docs/plan-1.5.md index c8bcfaf..75032ec 100644 --- a/myApp/HaruDanim/Docs/plan-1.5.md +++ b/myApp/HaruDanim/Docs/plan-1.5.md @@ -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개로 저비용. ⚠️'운동 시간'(엑서사이즈 분, 활동 링 기준)과 종목별(기록된 운동 시간)은 값 기준이 다름 — 가이드 문구·도움말에 차이 설명 필수. diff --git a/myApp/HaruDanim/IOS/ContentView.swift b/myApp/HaruDanim/IOS/ContentView.swift index 81bd70a..0854e5b 100644 --- a/myApp/HaruDanim/IOS/ContentView.swift +++ b/myApp/HaruDanim/IOS/ContentView.swift @@ -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() diff --git a/myApp/HaruDanim/IOS/Core/DebugSeed.swift b/myApp/HaruDanim/IOS/Core/DebugSeed.swift index 8fbdda5..f560142 100644 --- a/myApp/HaruDanim/IOS/Core/DebugSeed.swift +++ b/myApp/HaruDanim/IOS/Core/DebugSeed.swift @@ -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())) ?? [] + 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())) ?? [] + 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) { diff --git a/myApp/HaruDanim/IOS/Core/HealthStore.swift b/myApp/HaruDanim/IOS/Core/HealthStore.swift index 439fc77..85b8b63 100644 --- a/myApp/HaruDanim/IOS/Core/HealthStore.swift +++ b/myApp/HaruDanim/IOS/Core/HealthStore.swift @@ -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.. (sleepWindows: Set, workoutKinds: Set) { + var windows: Set = [Self.defaultSleepSpec] + var kinds: Set = [] + let quests = (try? DataStore.shared.mainContext.fetch(FetchDescriptor())) ?? [] + 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, workoutKinds: Set) 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) 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) 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.. 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.. [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) async -> Double { diff --git a/myApp/HaruDanim/IOS/Views/GoalViews.swift b/myApp/HaruDanim/IOS/Views/GoalViews.swift index 473086a..2bb1304 100644 --- a/myApp/HaruDanim/IOS/Views/GoalViews.swift +++ b/myApp/HaruDanim/IOS/Views/GoalViews.swift @@ -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 { diff --git a/myApp/HaruDanim/IOS/Views/MainView.swift b/myApp/HaruDanim/IOS/Views/MainView.swift index 7729bf2..b07532a 100644 --- a/myApp/HaruDanim/IOS/Views/MainView.swift +++ b/myApp/HaruDanim/IOS/Views/MainView.swift @@ -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, diff --git a/myApp/HaruDanim/IOS/Views/QuestEditorView.swift b/myApp/HaruDanim/IOS/Views/QuestEditorView.swift index 39c2e7a..b0b195a 100644 --- a/myApp/HaruDanim/IOS/Views/QuestEditorView.swift +++ b/myApp/HaruDanim/IOS/Views/QuestEditorView.swift @@ -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 diff --git a/myApp/HaruDanim/Shared/HealthMetric.swift b/myApp/HaruDanim/Shared/HealthMetric.swift index fcf3bb7..3076e2d 100644 --- a/myApp/HaruDanim/Shared/HealthMetric.swift +++ b/myApp/HaruDanim/Shared/HealthMetric.swift @@ -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." (한 필드로 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, math: DayMath) -> Double { + math.dayKeys(in: range).reduce(0) { partial, key in + partial + (dayValue(target: target, quest: quest, dayKey: key) ?? 0) + } + } +} diff --git a/myApp/HaruDanim/Shared/Models.swift b/myApp/HaruDanim/Shared/Models.swift index 71449ab..c43f33c 100644 --- a/myApp/HaruDanim/Shared/Models.swift +++ b/myApp/HaruDanim/Shared/Models.swift @@ -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)회") } /// 하루 단위 다짐에 마감 시각이 지정돼 있는지. diff --git a/myApp/HaruDanim/Shared/QuestProgress.swift b/myApp/HaruDanim/Shared/QuestProgress.swift index 4760622..01c8cfb 100644 --- a/myApp/HaruDanim/Shared/QuestProgress.swift +++ b/myApp/HaruDanim/Shared/QuestProgress.swift @@ -143,7 +143,12 @@ struct QuestProgress { } } + /// 값 공급 단일 지점 (1.5) — 건강 다짐은 HealthCache의 하루 값 합산, + /// 기록 다짐은 기존 Aggregator 그대로. 수학(비율·환산·판정)은 이 아래에서 공통이다. func value(in range: Range, 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 } diff --git a/myApp/HaruDanim/Widgets/QuestRingWidget.swift b/myApp/HaruDanim/Widgets/QuestRingWidget.swift index 78e74db..0b115eb 100644 --- a/myApp/HaruDanim/Widgets/QuestRingWidget.swift +++ b/myApp/HaruDanim/Widgets/QuestRingWidget.swift @@ -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 } diff --git a/myApp/HaruDanim/Widgets/WidgetSupport.swift b/myApp/HaruDanim/Widgets/WidgetSupport.swift index 480a415..4fda3a2 100644 --- a/myApp/HaruDanim/Widgets/WidgetSupport.swift +++ b/myApp/HaruDanim/Widgets/WidgetSupport.swift @@ -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,