소킹 중 '1.4 대비 버벅임 증가' 보고의 원인 수정 (동작·수치 완전 불변): - HealthCache/HealthIntervals 인메모리 캐시(세대 카운터 무효화) + workoutKeys 이중 load 제거 - DayMath.calendar 저장 프로퍼티화 (접근마다 Calendar 사본 생성 제거) - 수면 백필 O(400일×샘플수) → 정렬+포인터 스위프 (퍼즈 3,000회 등가 증명) - refreshRecent 캐시 기록 배칭 (일수×2회 → 총 2회) - QuestProgress.spanValue/spanRawValue 하루 버킷화 (게이지의 미래 기록 즉시 반영 스펙 보존) - 위젯 라이브 타임라인 makeEntry refresh 파라미터 (엔트리 7개×컨테이너 오픈 → 1회) - 일기 건강 카드 칩 value() 이중 호출 제거 검증: 옛/새 빌드 A/B — 덤프 3종 값 동일·4화면 픽셀 동일, 자가 검증 62/20/27/10 ALL PASS(26.5+18.5), 벌크 1만 건 렌더 정상, 3종 빌드 그린, 카탈로그 무변경. whats-new-1.5 3언어에 성능 개선 줄 추가. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
653 lines
33 KiB
Swift
653 lines
33 KiB
Swift
//
|
||
// HealthStore.swift
|
||
// Haru_Danim
|
||
//
|
||
// 애플 건강(HealthKit) 연동 (1.5 — Docs/plan-1.5.md §3.2)
|
||
// 구조 원칙:
|
||
// - 메인 앱만 HealthKit을 조회한다. 조회 결과는 HealthCache(App Group)에 적어 두고,
|
||
// 위젯·시리 확장·동기 계산 경로는 캐시만 읽는다 (위젯 확장은 HK 직접 조회 불가)
|
||
// - ⚠️ 건강 '값'은 SwiftData·CloudKit에 절대 저장하지 않는다 (심사 지침 5.1.3 —
|
||
// HealthKit 데이터의 iCloud 저장 금지). 이 캐시는 기기 로컬 defaults다
|
||
// - 읽기 권한은 거부돼도 앱이 알 수 없고 빈 값이 온다(애플 프라이버시 설계) —
|
||
// "요청한 적 있는지" 플래그로만 CTA/데이터 상태를 가른다
|
||
// - 하루 창은 논리적 하루(DayMath) 기준. 수면만 예외적으로 '수면 구간'(기본 전날 21시~
|
||
// 당일 09시)을 통째로 깨어난 날에 귀속한다 (겹침 분할과 다른 의도된 규칙 — plan §3.4)
|
||
//
|
||
|
||
import Foundation
|
||
import HealthKit
|
||
import Observation
|
||
import SwiftData
|
||
import UIKit
|
||
|
||
// (HealthCache·HealthSupport·HealthQuestValues 정의는 Shared/HealthMetric.swift —
|
||
// 위젯·확장이 같은 캐시를 읽어야 해서 공유 계층으로 옮겼다. 여기는 쓰는 쪽[HK 조회]만.)
|
||
|
||
// MARK: - 건강 데이터 스토어 (메인 앱 전용)
|
||
|
||
@MainActor
|
||
@Observable
|
||
final class HealthDataStore {
|
||
static let shared = HealthDataStore()
|
||
|
||
private let store = HKHealthStore()
|
||
/// 오늘(논리적 하루)의 지표 값 — 모음 탭 타일이 읽는다
|
||
private(set) var todayValues: [HealthMetric: Double] = [:]
|
||
|
||
/// 기기에서 건강 데이터 사용 가능 여부 — 맥(Designed for iPad)은 HealthKit이 없어 false
|
||
static var isAvailable: Bool {
|
||
#if DEBUG
|
||
if UserDefaults.standard.bool(forKey: "seedHealthCache") { return true }
|
||
#endif
|
||
return HKHealthStore.isHealthDataAvailable() && !DeviceLayout.isMac
|
||
}
|
||
|
||
/// 권한을 한 번이라도 요청했는지 (거부 여부는 알 수 없음 — CTA 표시 판단 전용)
|
||
var hasRequestedAuth: Bool {
|
||
#if DEBUG
|
||
if UserDefaults.standard.bool(forKey: "seedHealthCache") { return true }
|
||
#endif
|
||
return AppGroup.defaults.bool(forKey: "health.authRequested")
|
||
}
|
||
|
||
private init() {
|
||
// 포그라운드 복귀 때 최근 값 재조회 (걸음 수 등은 앱 밖에서 계속 쌓인다)
|
||
NotificationCenter.default.addObserver(
|
||
forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main
|
||
) { _ in
|
||
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()
|
||
}
|
||
}
|
||
|
||
/// 읽기 권한 대상 전체 — 타일 8종 + 운동(종목별 다짐용, 한 번에 요청해 이중 프롬프트 방지)
|
||
static var readTypes: Set<HKObjectType> {
|
||
[
|
||
HKQuantityType(.stepCount),
|
||
HKQuantityType(.appleExerciseTime),
|
||
HKQuantityType(.activeEnergyBurned),
|
||
HKQuantityType(.distanceWalkingRunning),
|
||
HKQuantityType(.dietaryWater),
|
||
HKCategoryType(.sleepAnalysis),
|
||
HKCategoryType(.appleStandHour),
|
||
HKCategoryType(.mindfulSession),
|
||
HKObjectType.workoutType(),
|
||
]
|
||
}
|
||
|
||
func requestAuthorization() async {
|
||
guard HKHealthStore.isHealthDataAvailable() else { return }
|
||
try? await store.requestAuthorization(toShare: [], read: Self.readTypes)
|
||
AppGroup.defaults.set(true, forKey: "health.authRequested")
|
||
await refreshRecent()
|
||
await backfillIfNeeded()
|
||
}
|
||
|
||
/// 오늘(논리적 하루)의 전 지표 값을 조회해 화면 상태와 캐시를 갱신
|
||
func refreshToday() async {
|
||
#if DEBUG
|
||
if UserDefaults.standard.bool(forKey: "seedHealthCache") {
|
||
let seeded: [HealthMetric: Double] = [
|
||
.steps: 8432, .exerciseMinutes: 32 * 60, .sleep: 7 * 3600 + 28 * 60,
|
||
.activeEnergy: 512, .distance: 5230, .standHours: 9,
|
||
.mindfulMinutes: 10 * 60, .water: 1200,
|
||
]
|
||
todayValues = seeded
|
||
let math = DayMath()
|
||
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)
|
||
// 실구간도 시드 — 일기 타임테이블 수면·운동 블록 렌더 검증·촬영용.
|
||
// 수면은 자정을 걸친 모양(새벽 몫 + 밤 몫), 달리기는 아침 구간
|
||
let dayStart = math.dayRange(forKey: key).lowerBound
|
||
func at(_ minutes: Double) -> Double {
|
||
dayStart.addingTimeInterval(minutes * 60).timeIntervalSince1970
|
||
}
|
||
HealthIntervals.merge([
|
||
HealthIntervals.sleepKey: [key: [[at(0), at(7 * 60 + 28)], [at(23 * 60 + 20), at(24 * 60)]]],
|
||
HealthIntervals.workoutKey(.running): [key: [[at(7 * 60 + 42), at(8 * 60 + 13)]]],
|
||
])
|
||
}
|
||
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
|
||
let cal = math.calendar
|
||
let todayKey = math.dayKey(for: now)
|
||
let keys = questHealthKeys()
|
||
// 날짜별로 즉시 쓰지 않고 모아서 저장 1회씩 — 캐시 전체 기록(defaults 직렬화)이
|
||
// 하루당 2회 × 일수에서 총 2회로 줄어든다. 최종 캐시 상태는 완전히 동일 (1.5(9) 최적화)
|
||
var cacheBatch: [String: [Date: Double]] = [:]
|
||
var intervalBatch: [String: [Date: [[Double]]]] = [:]
|
||
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
|
||
)
|
||
for (metricRaw, value) in values {
|
||
cacheBatch[metricRaw, default: [:]][key] = value
|
||
}
|
||
for (intervalKey, dayLists) in await dayIntervals(forDayKey: key, math: math) {
|
||
// 같은 종류 키가 여러 날짜에 걸쳐 나와도 안쪽 딕셔너리 키(dayKey)는 날짜별로
|
||
// 유일하므로 단순 병합으로 충돌이 없다
|
||
intervalBatch[intervalKey, default: [:]].merge(dayLists) { _, new in new }
|
||
}
|
||
if offset == 0 {
|
||
var tiles: [HealthMetric: Double] = [:]
|
||
for metric in HealthMetric.allCases { tiles[metric] = values[metric.rawValue] ?? 0 }
|
||
todayValues = tiles
|
||
}
|
||
}
|
||
HealthCache.merge(cacheBatch)
|
||
HealthIntervals.merge(intervalBatch)
|
||
}
|
||
|
||
/// 건강 다짐이 실제로 쓰는 캐시 키 집합 — 수면 구간 변형(기본 구간 포함)·운동 종목.
|
||
/// 타일·일기 카드용 수면 표시 구간 설정(SleepWindowPrefs — 전체+요일별)도 함께 계산 대상이다
|
||
private func questHealthKeys() -> (sleepWindows: Set<String>, workoutKinds: Set<HealthWorkoutKind>) {
|
||
var windows: Set<String> = [Self.defaultSleepSpec]
|
||
windows.formUnion(SleepWindowPrefs.allSpecs)
|
||
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)
|
||
result["sleep@\(spec)"] = await sleepDuration(window: window)
|
||
}
|
||
// 평문 "sleep" 키(타일·일기 카드) = 그날의 유효 표시 구간 값 (1.5 추가분 —
|
||
// 설정에서 구간을 바꾸면 교대·야간 근무의 낮잠도 그날 값으로 잡힌다)
|
||
let effectiveSpec = SleepWindowPrefs.spec(forDayKey: key)
|
||
result[HealthMetric.sleep.rawValue] =
|
||
result["sleep@\(effectiveSpec)"] ?? result["sleep@\(Self.defaultSleepSpec)"] ?? 0
|
||
if !workoutKinds.isEmpty {
|
||
let byKind = await workoutDurations(in: range)
|
||
for kind in workoutKinds {
|
||
result["workout.\(kind.rawValue)"] = byKind[kind] ?? 0
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// MARK: 지표별 조회 (표준 단위 — HealthMetric 헤더 주석 참고)
|
||
|
||
/// date가 속한 논리적 하루의 지표 값
|
||
func value(of metric: HealthMetric, forDayContaining date: Date, math: DayMath) async -> Double {
|
||
let range = math.dayRange(containing: date)
|
||
switch metric {
|
||
case .steps:
|
||
return await sumQuantity(.stepCount, unit: .count(), in: range)
|
||
case .exerciseMinutes:
|
||
return await sumQuantity(.appleExerciseTime, unit: .minute(), in: range) * 60
|
||
case .activeEnergy:
|
||
return await sumQuantity(.activeEnergyBurned, unit: .kilocalorie(), in: range)
|
||
case .distance:
|
||
return await sumQuantity(.distanceWalkingRunning, unit: .meter(), in: range)
|
||
case .water:
|
||
// 표준 단위 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 })
|
||
case .mindfulMinutes:
|
||
let samples = await categorySamples(.mindfulSession, in: range)
|
||
return samples.reduce(0) { total, sample in
|
||
total + overlapDuration(sample, range: range)
|
||
}
|
||
case .sleep:
|
||
return await sleepDuration(window: Self.sleepWindow(forDayRange: range))
|
||
}
|
||
}
|
||
|
||
/// 논리적 하루 range에 귀속되는 기본 수면 구간 — "구간 끝(기본 오전 9시)이 그 하루 안"
|
||
/// 이 되는 12시간 창. 하루 시작 시간이 어떤 값이어도 정의된다 (plan §10 R8)
|
||
static func sleepWindow(forDayRange range: Range<Date>,
|
||
endMinutes: Int = 9 * 60, length: TimeInterval = 12 * 3600) -> Range<Date> {
|
||
let calendar = Calendar.current
|
||
// range 하한이 속한 달력일부터 이틀 안에서 "끝 시각"이 range 안에 드는 날을 찾는다
|
||
for dayOffset in 0...1 {
|
||
guard let base = calendar.date(byAdding: .day, value: dayOffset,
|
||
to: calendar.startOfDay(for: range.lowerBound)),
|
||
let end = calendar.date(byAdding: .minute, value: endMinutes, to: base) else { continue }
|
||
if range.contains(end) || end == range.lowerBound {
|
||
return end.addingTimeInterval(-length)..<end
|
||
}
|
||
}
|
||
// 이론상 도달 불가 — 방어적으로 range 하한 기준 창
|
||
return range.lowerBound.addingTimeInterval(-length)..<range.lowerBound
|
||
}
|
||
|
||
/// 수면 구간 안 '잠듦' 샘플의 합집합 길이 (아이폰+워치 중복 소스는 구간 병합으로 흡수)
|
||
private func sleepDuration(window: Range<Date>) async -> Double {
|
||
let samples = await categorySamples(.sleepAnalysis, in: window)
|
||
let asleepValues: Set<Int> = Set(HKCategoryValueSleepAnalysis.allAsleepValues.map(\.rawValue))
|
||
let intervals: [(Date, Date)] = samples.compactMap { sample in
|
||
guard asleepValues.contains(sample.value) else { return nil }
|
||
let start = max(sample.startDate, window.lowerBound)
|
||
let end = min(sample.endDate, window.upperBound)
|
||
guard end > start else { return nil }
|
||
return (start, end)
|
||
}
|
||
return Self.mergedDuration(intervals)
|
||
}
|
||
|
||
/// 구간 합집합 (겹침 병합, 시작 오름차순) — mergedDuration의 구간 보존판.
|
||
/// 아이폰+워치가 같은 잠을 겹쳐 기록해도 타임테이블에 블록이 이중으로 그려지지 않게 한다.
|
||
/// tolerance: 이 간격 이하로 떨어진 이웃 구간도 이어 붙인다 (표시 전용 — 값 합산에는 쓰지 않음)
|
||
static func mergedIntervals(_ intervals: [(Date, Date)],
|
||
tolerance: TimeInterval = 0) -> [(Date, Date)] {
|
||
let sorted = intervals.sorted { $0.0 < $1.0 }
|
||
var result: [(Date, Date)] = []
|
||
for (start, end) in sorted {
|
||
if let last = result.last, start.timeIntervalSince(last.1) <= tolerance {
|
||
result[result.count - 1].1 = max(last.1, end)
|
||
} else {
|
||
result.append((start, end))
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
/// 그 논리적 하루와 겹치는 수면·운동 실구간 (HealthIntervals 갱신값 — refreshRecent 전용).
|
||
/// 이전에 있던 종류가 이번 조회에 없으면 빈 배열로 명시해 지운다 (건강 앱에서의 삭제 반영)
|
||
private func dayIntervals(forDayKey dayKey: Date, math: DayMath) async -> [String: [Date: [[Double]]]] {
|
||
let range = math.dayRange(forKey: dayKey)
|
||
var perKey: [String: [[Double]]] = [:]
|
||
for existing in HealthIntervals.workoutKeys(dayKey: dayKey) { perKey[existing] = [] }
|
||
|
||
let samples = await categorySamples(.sleepAnalysis, in: range)
|
||
let asleepValues = Set(HKCategoryValueSleepAnalysis.allAsleepValues.map(\.rawValue))
|
||
let sleepIntervals = Self.mergedIntervals(samples.compactMap { sample in
|
||
guard asleepValues.contains(sample.value) else { return nil }
|
||
let start = max(sample.startDate, range.lowerBound)
|
||
let end = min(sample.endDate, range.upperBound)
|
||
return end > start ? (start, end) : nil
|
||
})
|
||
perKey[HealthIntervals.sleepKey] = sleepIntervals.map {
|
||
[$0.0.timeIntervalSince1970, $0.1.timeIntervalSince1970]
|
||
}
|
||
|
||
for workout in await workoutSamples(in: range) {
|
||
let start = max(workout.startDate, range.lowerBound)
|
||
let end = min(workout.endDate, range.upperBound)
|
||
guard start < end else { continue }
|
||
perKey[HealthIntervals.workoutKey(Self.workoutKind(for: workout.workoutActivityType)), default: []]
|
||
.append([start.timeIntervalSince1970, end.timeIntervalSince1970])
|
||
}
|
||
return perKey.mapValues { [dayKey: $0] }
|
||
}
|
||
|
||
/// 구간 합집합 길이 (겹침 병합)
|
||
static func mergedDuration(_ intervals: [(Date, Date)]) -> TimeInterval {
|
||
let sorted = intervals.sorted { $0.0 < $1.0 }
|
||
var total: TimeInterval = 0
|
||
var currentStart: Date?
|
||
var currentEnd: Date?
|
||
for (start, end) in sorted {
|
||
if let cs = currentStart, let ce = currentEnd {
|
||
if start <= ce {
|
||
currentEnd = max(ce, end)
|
||
} else {
|
||
total += ce.timeIntervalSince(cs)
|
||
currentStart = start
|
||
currentEnd = end
|
||
}
|
||
} else {
|
||
currentStart = start
|
||
currentEnd = end
|
||
}
|
||
}
|
||
if let cs = currentStart, let ce = currentEnd {
|
||
total += ce.timeIntervalSince(cs)
|
||
}
|
||
return total
|
||
}
|
||
|
||
private func overlapDuration(_ sample: HKSample, range: Range<Date>) -> TimeInterval {
|
||
let start = max(sample.startDate, range.lowerBound)
|
||
let end = min(sample.endDate, range.upperBound)
|
||
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: ","))|\(SleepWindowPrefs.mappingSignature)"
|
||
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
|
||
|
||
// ④ 수면 — 구간 변형별로 각 날짜 창의 합집합 (창이 전날 밤으로 넘어가므로 하루 여유).
|
||
// 1.5(9) 최적화: 예전에는 날짜(400)마다 전체 수면 샘플(수면 단계 로우데이터라
|
||
// 수만 개)을 다시 순회해 O(일수×샘플수) — 하루 첫 실행의 메인 스레드 정지 원인.
|
||
// 시작 시각으로 1회 정렬 후 창을 시간순으로 훑는 포인터 스위프로 바꿨다.
|
||
// 날짜 창은 하루씩 뒤로 진행하므로 lower·upper가 모두 단조 증가 — ①start ≥ 창끝인
|
||
// 구간은 이후로도 아직 안 왔고(hi), ②end ≤ 창시작인 구간은 이후 창에도 절대 못
|
||
// 겹치므로(lo) 후보 창구간만 검사한다. 후보 중 실제 안 겹치는 것은 기존과 같은
|
||
// ce > cs 판정이 거르므로 결과 수치는 예전 전수 검사와 완전히 동일하다.
|
||
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
|
||
}
|
||
.sorted { $0.0 < $1.0 }
|
||
for spec in keys.sleepWindows {
|
||
guard let parsed = Self.windowSpec(spec) else { continue }
|
||
var days: [Date: Double] = [:]
|
||
var key = startKey
|
||
var lo = 0
|
||
var hi = 0
|
||
while key <= todayKey {
|
||
let window = Self.sleepWindow(
|
||
forDayRange: math.dayRange(forKey: key),
|
||
endMinutes: parsed.endMinutes, length: parsed.length
|
||
)
|
||
while hi < asleepIntervals.count, asleepIntervals[hi].0 < window.upperBound { hi += 1 }
|
||
while lo < hi, asleepIntervals[lo].1 <= window.lowerBound { lo += 1 }
|
||
var clipped: [(Date, Date)] = []
|
||
for index in lo..<hi {
|
||
let (start, end) = asleepIntervals[index]
|
||
let cs = max(start, window.lowerBound)
|
||
let ce = min(end, window.upperBound)
|
||
if ce > cs { clipped.append((cs, ce)) }
|
||
}
|
||
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
|
||
}
|
||
// 평문 "sleep" 키 = 각 날짜의 유효 표시 구간 값 (SleepWindowPrefs — allValues와 동일 규칙)
|
||
var plainSleep: [Date: Double] = [:]
|
||
var plainKey = startKey
|
||
while plainKey <= todayKey {
|
||
let spec = SleepWindowPrefs.spec(forDayKey: plainKey)
|
||
if let value = merged["sleep@\(spec)"]?[plainKey] { plainSleep[plainKey] = value }
|
||
guard let next = cal.date(byAdding: .day, value: 1, to: plainKey) else { break }
|
||
plainKey = next
|
||
}
|
||
merged[HealthMetric.sleep.rawValue] = plainSleep
|
||
|
||
// 수면 실구간 — 일기 타임테이블 표시용 (논리적 하루로 분할 후 병합, HealthIntervals.
|
||
// 다짐의 '깨어난 날 통귀속'과 달리 사실 그대로 이틀에 나뉘는 의도된 규칙)
|
||
var sleepIntervalDays: [Date: [(Date, Date)]] = [:]
|
||
for (start, end) in asleepIntervals {
|
||
let clippedStart = max(start, lower)
|
||
let clippedEnd = min(end, upper)
|
||
guard clippedStart < clippedEnd else { continue }
|
||
for segment in math.splitByDay(start: clippedStart, end: clippedEnd)
|
||
where segment.range.lowerBound < segment.range.upperBound {
|
||
sleepIntervalDays[segment.dayKey, default: []]
|
||
.append((segment.range.lowerBound, segment.range.upperBound))
|
||
}
|
||
}
|
||
var intervalMerged: [String: [Date: [[Double]]]] = [:]
|
||
intervalMerged[HealthIntervals.sleepKey] = sleepIntervalDays.mapValues { list in
|
||
Self.mergedIntervals(list).map { [$0.0.timeIntervalSince1970, $0.1.timeIntervalSince1970] }
|
||
}
|
||
|
||
// ⑤ 운동 — 범위 1회 조회 후 종목×하루 버킷팅 (하루 합계는 다짐 종목만 캐시,
|
||
// 실구간은 전 종목 — 일기 타임테이블은 다짐 여부와 무관하게 그날 운동을 그린다)
|
||
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)
|
||
intervalMerged[HealthIntervals.workoutKey(kind), default: [:]][segment.dayKey, default: []]
|
||
.append([segment.range.lowerBound.timeIntervalSince1970,
|
||
segment.range.upperBound.timeIntervalSince1970])
|
||
}
|
||
}
|
||
for kind in keys.workoutKinds {
|
||
merged["workout.\(kind.rawValue)"] = byKindDays[kind] ?? [:]
|
||
}
|
||
|
||
HealthCache.merge(merged)
|
||
HealthIntervals.merge(intervalMerged)
|
||
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 {
|
||
await withCheckedContinuation { continuation in
|
||
let predicate = HKQuery.predicateForSamples(
|
||
withStart: range.lowerBound, end: range.upperBound, options: .strictStartDate
|
||
)
|
||
let query = HKStatisticsQuery(
|
||
quantityType: HKQuantityType(id),
|
||
quantitySamplePredicate: predicate,
|
||
options: .cumulativeSum
|
||
) { _, statistics, _ in
|
||
continuation.resume(returning: statistics?.sumQuantity()?.doubleValue(for: unit) ?? 0)
|
||
}
|
||
store.execute(query)
|
||
}
|
||
}
|
||
|
||
/// range와 겹치는 카테고리 샘플 (기본 술어 = 겹침 매칭 — 수면·마음챙김의 경계 걸침 대응)
|
||
private func categorySamples(_ id: HKCategoryTypeIdentifier, in range: Range<Date>) async -> [HKCategorySample] {
|
||
await withCheckedContinuation { continuation in
|
||
let predicate = HKQuery.predicateForSamples(
|
||
withStart: range.lowerBound, end: range.upperBound, options: []
|
||
)
|
||
let query = HKSampleQuery(
|
||
sampleType: HKCategoryType(id), predicate: predicate,
|
||
limit: HKObjectQueryNoLimit, sortDescriptors: nil
|
||
) { _, samples, _ in
|
||
continuation.resume(returning: (samples as? [HKCategorySample]) ?? [])
|
||
}
|
||
store.execute(query)
|
||
}
|
||
}
|
||
}
|