mycode/myApp/HaruDanim/IOS/Core/HealthStore.swift
songyc macbook 2ae469d187 perf(1.5-p11): 등가 성능 최적화 — 건강 캐시 인메모리·백필 스위프·게이지 버킷화·위젯 타임라인, 빌드 9
소킹 중 '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
2026-08-26 14:23:56 +09:00

653 lines
33 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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)
}
}
}