diff --git a/PulseLoop/Events/PulseEventBus.swift b/PulseLoop/Events/PulseEventBus.swift index 5bf4598..a503813 100644 --- a/PulseLoop/Events/PulseEventBus.swift +++ b/PulseLoop/Events/PulseEventBus.swift @@ -161,6 +161,9 @@ final class EventPersistenceSubscriber { /// Persist any pending batched writes immediately. Call on app background/suspend so a sync that /// is mid-batch isn't lost. func flush() { + // A suspended mid-sync burst may never see `.syncProgress("done")` — settle any pending + // calorie-estimate recomputes before the save (no-op when nothing is dirty). + DailyCalorieEstimator.flushDirty(context: context) flushNow() } @@ -281,6 +284,11 @@ final class EventPersistenceSubscriber { entityId: row.id.uuidString, payloadJSON: #"{"steps":\#(row.steps),"calories":\#(Int(row.calories)),"distance_m":\#(Int(row.distanceMeters))}"# )) + // Devices that report no calorie counter (send 0): keep today's on-device estimate + // tracking the live step ratchet. Throttled — cumulative packets can stream every second. + if calories <= 0 { + DailyCalorieEstimator.recomputeThrottled(day: timestamp, context: context) + } case let .activityBucket(timestamp, steps, distanceMeters): // Per-quarter-hour ring history: upserted by timestamp + the day total recomputed as the // sum of distinct buckets, so re-syncs are idempotent (no drift). Calories omitted. @@ -343,6 +351,9 @@ final class EventPersistenceSubscriber { } // The rows are committed by now; the next sync re-checks against the database. seenHistoryKeys.removeAll(keepingCapacity: true) + // Recompute calorie estimates for every day this sync touched, in one pass — the + // batched flush below saves the writes and fires the coalesced change signal. + DailyCalorieEstimator.flushDirty(context: context) } case .heartRateComplete, .spo2Progress, .spo2Complete, .workoutStarted, .workoutPaused, .workoutResumed, .workoutFinished, .coachTrace: break diff --git a/PulseLoop/Models/PulseModels.swift b/PulseLoop/Models/PulseModels.swift index 76aa345..f33b14f 100644 --- a/PulseLoop/Models/PulseModels.swift +++ b/PulseLoop/Models/PulseModels.swift @@ -180,7 +180,12 @@ final class ActivityDaily { var syncedAt: Date? var createdAt: Date var updatedAt: Date - + /// On-device NET active-energy estimate (workouts + HR segments + step cadence), written by + /// `DailyCalorieEstimator`. Device-reported `calories` always wins at read time; the displayed + /// total adds the BMR baseline at read. Optional/defaulted for additive lightweight migration — + /// same pattern as `UserProfile`'s physiology fields. + var estimatedActiveCalories: Double? = nil + init( id: UUID = UUID(), date: Date, diff --git a/PulseLoop/PulseLoopApp.swift b/PulseLoop/PulseLoopApp.swift index 34153ed..2c97457 100644 --- a/PulseLoop/PulseLoopApp.swift +++ b/PulseLoop/PulseLoopApp.swift @@ -114,6 +114,10 @@ struct PulseLoopApp: App { // internally, so this is a cheap no-op most launches). RestingHRBaselineService.refreshIfStale(context: container.mainContext) + // One-shot upgrade backfill: give the trailing week's rows a calorie estimate if they + // predate the estimator (no-op once every recent day carries one). + DailyCalorieEstimator.recomputeRecentDays(onlyMissing: true, context: container.mainContext) + // Start persistence + coordinator draining the bus; auto-reconnect happens when // CoreBluetooth reports poweredOn (see RingBLEClient.centralManagerDidUpdateState). subscriber.start() diff --git a/PulseLoop/Services/DailyCalorieEstimator.swift b/PulseLoop/Services/DailyCalorieEstimator.swift new file mode 100644 index 0000000..8f88168 --- /dev/null +++ b/PulseLoop/Services/DailyCalorieEstimator.swift @@ -0,0 +1,327 @@ +import Foundation +import SwiftData + +// MARK: - Inputs (value types, SwiftData-free) + +/// One intraday step bucket (ring history quarter-hour sample). +struct DayStepBucket: Equatable { + var start: Date + var durationSeconds: Double + var steps: Int + + init(start: Date, durationSeconds: Double = 900, steps: Int) { + self.start = start + self.durationSeconds = durationSeconds + self.steps = steps + } +} + +/// A finished workout's time window and its (engine-estimated or coach-provided) gross calories. +struct DayWorkoutWindow: Equatable { + var start: Date + var end: Date + var calories: Double? +} + +/// Everything the pure daily model needs for one calendar day. +struct DayEstimateInputs { + var dayStart: Date + var dayTotalSteps: Int + var buckets: [DayStepBucket] + var workouts: [DayWorkoutWindow] + /// All HR samples inside the day (any source — workout-window samples are excluded internally). + var hrSamples: [(timestamp: Date, bpm: Double)] + var profile: MetricsProfileValues +} + +// MARK: - Pure math + +/// Daily active-energy model for wearables that don't report calories. Each interval of the day is +/// attributed to exactly one estimator — workout window > HR-above-FLEX segment > step bucket — and +/// every term is net of resting energy, so adding the Mifflin-St Jeor BMR baseline at read time +/// never double-counts. Mirrors the market approach (Whoop: BMR + Keytel; Fitbit/Oura: BMR + motion). +enum DailyCalorieMath { + static let defaultHeightCm = 170.0 + static let defaultAge = 35 + /// HR-based estimation only above this fraction of HRmax (220 − age): the Keytel model is only + /// valid in moderate-to-vigorous exercise, and below the FLEX threshold stress/caffeine HR would + /// inflate the estimate (Spurr's Flex-HR method). + static let flexHRFraction = 0.6 + /// An all-day HR sample covers at most this long (ring log interval is 5–60 min; beyond 10 min + /// we'd be extrapolating one elevated reading over a stretch it may not represent). + static let hrSampleMaxCoverageSeconds: TimeInterval = 600 + /// Assumed actual stepping cadence inside sparse buckets (intermittent walking) — ~100 steps/min + /// is the validated moderate-walking cadence, so `steps / 100` recovers true walking minutes. + static let intermittentCadenceSpm = 100.0 + /// Bucket-mean cadence at or above this is a continuous brisk walk for the whole bucket. + static let briskCadenceSpm = 100.0 + /// Bucket-mean cadence at or above this is sustained running for the whole bucket. + static let runCadenceSpm = 130.0 + + /// Mifflin-St Jeor basal metabolic rate, kcal/day. Missing fields fall back to population + /// defaults; an unspecified sex uses the mean of the male/female constants. + static func mifflinBMR(profile: MetricsProfileValues) -> Double { + let weight = profile.weightKg ?? WorkoutMetricsEngine.defaultWeightKg + let height = profile.heightCm ?? defaultHeightCm + let age = Double(profile.age ?? defaultAge) + let sexConstant: Double + switch profile.sex?.lowercased() { + case "male": sexConstant = 5 + case "female": sexConstant = -161 + default: sexConstant = -78 + } + return max(0, 10 * weight + 6.25 * height - 5 * age + sexConstant) + } + + /// Net active calories for the day (excludes the BMR baseline — add that at read time). + static func estimateNetActive(_ inputs: DayEstimateInputs) -> Double { + let dayStart = inputs.dayStart + let dayEnd = dayStart.addingTimeInterval(86_400) + let weight = inputs.profile.weightKg ?? WorkoutMetricsEngine.defaultWeightKg + let bmrPerMin = mifflinBMR(profile: inputs.profile) / 1440 + + // 1. Workouts — reuse the session's stored calories (Keytel-or-MET from + // WorkoutMetricsEngine, or coach-provided), prorated across midnight and netted of the + // resting energy the BMR baseline already covers for those minutes. + var covered: [(start: Date, end: Date)] = [] + var workoutKcal = 0.0 + for workout in inputs.workouts { + let clippedStart = max(workout.start, dayStart) + let clippedEnd = min(workout.end, dayEnd) + guard clippedEnd > clippedStart else { continue } + let totalSeconds = workout.end.timeIntervalSince(workout.start) + let clippedSeconds = clippedEnd.timeIntervalSince(clippedStart) + let fraction = totalSeconds > 0 ? clippedSeconds / totalSeconds : 0 + workoutKcal += max(0, (workout.calories ?? 0) * fraction - bmrPerMin * clippedSeconds / 60) + covered.append((clippedStart, clippedEnd)) + } + + // 2. All-day HR above the FLEX threshold, outside workout windows — Keytel per-minute rate + // (net of resting) over each sample's coverage interval. Catches unlogged exertion. Requires + // the same profile completeness the workout Keytel path does; otherwise HR contributes + // nothing and steps/workouts carry the estimate. + if let sex = inputs.profile.sex?.lowercased(), sex == "male" || sex == "female", + let age = inputs.profile.age, let profileWeight = inputs.profile.weightKg { + let flexHR = flexHRFraction * (220 - Double(age)) + let samples = inputs.hrSamples + .filter { $0.bpm > 0 && $0.timestamp >= dayStart && $0.timestamp < dayEnd } + .sorted { $0.timestamp < $1.timestamp } + for (index, sample) in samples.enumerated() where sample.bpm >= flexHR { + let nextTimestamp = index + 1 < samples.count ? samples[index + 1].timestamp : dayEnd + let coverage = min(max(0, nextTimestamp.timeIntervalSince(sample.timestamp)), hrSampleMaxCoverageSeconds) + let segment = (start: sample.timestamp, end: sample.timestamp.addingTimeInterval(coverage)) + let minutes = max(0, coverage - overlapSeconds(segment, covered)) / 60 + guard minutes > 0 else { continue } + let rate = WorkoutMetricsEngine.keytelRate(hr: sample.bpm, male: sex == "male", age: Double(age), weightKg: profileWeight) + workoutKcal += max(0, rate - bmrPerMin) * minutes + covered.append(segment) + } + } + + // 3. Step buckets — cadence-tiered walking/running METs, net of 1 MET, scaled by the + // fraction of the bucket not already attributed to a workout or HR segment. + var stepKcal = 0.0 + var bucketSteps = 0 + for bucket in inputs.buckets { + bucketSteps += bucket.steps + guard bucket.steps > 0, bucket.durationSeconds > 0 else { continue } + let bucketEnd = bucket.start.addingTimeInterval(bucket.durationSeconds) + let keepFraction = 1 - overlapSeconds((bucket.start, bucketEnd), covered) / bucket.durationSeconds + guard keepFraction > 0 else { continue } + let durationMinutes = bucket.durationSeconds / 60 + let cadence = Double(bucket.steps) / durationMinutes + let met: Double + let activeMinutes: Double + if cadence >= runCadenceSpm { + met = 8.3 + activeMinutes = durationMinutes + } else if cadence >= briskCadenceSpm { + met = 3.5 + activeMinutes = durationMinutes + } else { + met = intermittentWalkMET(heightCm: inputs.profile.heightCm) + activeMinutes = Double(bucket.steps) / intermittentCadenceSpm + } + stepKcal += max(0, met - 1) * weight * (activeMinutes * keepFraction) / 60 + } + + // 4. Residual steps the buckets don't represent — live-only days, or today's live cumulative + // counter running ahead of the bucket log — credited at the intermittent-walking rate. When + // there are no buckets at all, deduct an allowance for steps taken inside already-covered + // windows (a run workout's steps are in the day total but its energy is already counted). + var residual = Double(max(0, inputs.dayTotalSteps - bucketSteps)) + if inputs.buckets.isEmpty { + let coveredMinutes = mergedDurationSeconds(covered) / 60 + residual = max(0, residual - coveredMinutes * intermittentCadenceSpm) + } + let residualMET = intermittentWalkMET(heightCm: inputs.profile.heightCm) + stepKcal += max(0, residualMET - 1) * weight * (residual / intermittentCadenceSpm) / 60 + + return max(0, workoutKcal + stepKcal) + } + + /// Walking MET for intermittent stepping at ~100 steps/min, refined by stride length from + /// height when available (stride ≈ 0.414 × height → speed → Compendium walking MET tier). + static func intermittentWalkMET(heightCm: Double?) -> Double { + guard let heightCm, heightCm > 0 else { return 3.0 } + let strideMeters = 0.414 * heightCm / 100 + let speedMps = strideMeters * intermittentCadenceSpm / 60 + if speedMps < 1.0 { return 2.8 } // < 3.6 km/h easy + if speedMps < 1.35 { return 3.5 } // ~4.8 km/h moderate + if speedMps < 1.65 { return 4.3 } // ~5.6 km/h brisk + return 5.0 // ≥ 6 km/h very brisk + } + + // MARK: interval helpers + + private static func overlapSeconds(_ interval: (start: Date, end: Date), _ windows: [(start: Date, end: Date)]) -> TimeInterval { + mergedDurationSeconds(windows.compactMap { window in + let start = max(interval.start, window.start) + let end = min(interval.end, window.end) + return end > start ? (start, end) : nil + }) + } + + private static func mergedDurationSeconds(_ windows: [(start: Date, end: Date)]) -> TimeInterval { + let sorted = windows.sorted { $0.start < $1.start } + var total: TimeInterval = 0 + var currentEnd = Date.distantPast + for window in sorted { + let start = max(window.start, currentEnd) + if window.end > start { + total += window.end.timeIntervalSince(start) + currentEnd = window.end + } + } + return total + } +} + +// MARK: - Orchestrator + +/// Recomputes a day's `ActivityDaily.estimatedActiveCalories` from persisted buckets, workouts, and +/// HR samples. Always from-scratch, so re-syncs, edits, and repeated calls converge (idempotent). +/// The estimate is stored for every recomputed day regardless of source; device-vs-estimate +/// selection happens at read time (`ActivityDaily.effectiveCalories`), so device data always wins. +@MainActor +enum DailyCalorieEstimator { + /// Days touched during a sync burst, recomputed once when the sync completes. + private(set) static var dirtyDays: Set = [] + /// Per-day throttle for the live-update hook (cumulative packets can stream every second). + private static var lastLiveRecomputeAt: [Date: Date] = [:] + static let liveThrottleSeconds: TimeInterval = 60 + + static func markDirty(_ day: Date) { + dirtyDays.insert(Calendar.current.startOfDay(for: day)) + } + + /// Recompute every dirty day. Called from the event bus on sync completion — the subsequent + /// batched save persists the writes and fires the coalesced change signal. + static func flushDirty(context: ModelContext) { + guard !dirtyDays.isEmpty else { return } + let days = dirtyDays + dirtyDays.removeAll(keepingCapacity: true) + for day in days { recompute(day: day, context: context) } + } + + /// Live-packet hook: keep today's estimate tracking the step ratchet without recomputing on + /// every packet. + static func recomputeThrottled(day: Date, context: ModelContext) { + let dayStart = Calendar.current.startOfDay(for: day) + if let last = lastLiveRecomputeAt[dayStart], Date().timeIntervalSince(last) < liveThrottleSeconds { return } + lastLiveRecomputeAt[dayStart] = Date() + recompute(day: dayStart, context: context) + } + + /// Recompute the day(s) a workout touches — both sides of midnight for a crossing session. + static func recompute(around session: ActivitySession, context: ModelContext) { + recompute(day: session.startedAt, context: context) + if let ended = session.endedAt, !Calendar.current.isDate(ended, inSameDayAs: session.startedAt) { + recompute(day: ended, context: context) + } + } + + /// Recompute one day's estimate. No `ActivityDaily` row → no-op (a day with no synced or + /// logged activity has nothing to estimate against). Does not save — callers batch the save. + static func recompute(day: Date, context: ModelContext) { + let calendar = Calendar.current + let dayStart = calendar.startOfDay(for: day) + guard let row = MetricsRepository.activity(on: dayStart, context: context), + let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) + else { return } + + let buckets = ((try? context.fetch(FetchDescriptor( + predicate: #Predicate { $0.date == dayStart } + ))) ?? []).map { DayStepBucket(start: $0.timestamp, steps: $0.steps) } + + let workouts = ActivityRepository.sessions(context: context).compactMap { session -> DayWorkoutWindow? in + guard session.status == .finished, let ended = session.endedAt, + session.startedAt < dayEnd, ended > dayStart + else { return nil } + return DayWorkoutWindow(start: session.startedAt, end: ended, calories: session.calories) + } + + let hrSamples = MetricsRepository.measurements(kind: .heartRate, start: dayStart, end: dayEnd, limit: 10_000, context: context) + .map { (timestamp: $0.timestamp, bpm: $0.value) } + + let profile = MetricsProfileValues(profile: ProfileRepository.profile(context: context)) + row.estimatedActiveCalories = DailyCalorieMath.estimateNetActive(DayEstimateInputs( + dayStart: dayStart, + dayTotalSteps: row.steps, + buckets: buckets, + workouts: workouts, + hrSamples: hrSamples, + profile: profile + )) + row.updatedAt = Date() + } + + /// Recompute the trailing week — after a profile change (weight/height/age shift every term) or + /// as a one-shot launch backfill (`onlyMissing`) so pre-upgrade days gain an estimate. + static func recomputeRecentDays(_ days: Int = 7, onlyMissing: Bool = false, context: ModelContext) { + let calendar = Calendar.current + for offset in 0.. Double? { + if let device = deviceReportedCalories { return device } + guard let active = estimatedActiveCalories else { return nil } + let calendar = Calendar.current + let elapsedMinutes: Double + if calendar.isDate(date, inSameDayAs: now) { + elapsedMinutes = min(1440, max(0, now.timeIntervalSince(calendar.startOfDay(for: now)) / 60)) + } else { + elapsedMinutes = date < now ? 1440 : 0 + } + return DailyCalorieMath.mifflinBMR(profile: profile) / 1440 * elapsedMinutes + active + } + + /// The active-energy portion (device value or net estimate) — what the calorie goal ring + /// measures, keeping `UserGoal.calories` an active-energy goal even when the displayed number + /// is an estimated total. + @MainActor + var effectiveActiveCalories: Double? { + deviceReportedCalories ?? estimatedActiveCalories + } +} diff --git a/PulseLoop/Services/DerivedSummaries.swift b/PulseLoop/Services/DerivedSummaries.swift index 754926c..16829c8 100644 --- a/PulseLoop/Services/DerivedSummaries.swift +++ b/PulseLoop/Services/DerivedSummaries.swift @@ -227,7 +227,10 @@ struct LatestReading: Equatable { struct TodaySummary { var date: Date var steps: Int? + /// Display value: device-reported, or the estimated TOTAL burn (BMR + net active). var calories: Double? + /// Active-energy portion (device value or net estimate) — what the calorie goal ring measures. + var activeCalories: Double? var distanceMeters: Double? var activeMinutes: Int? var activeMinutesSource: String diff --git a/PulseLoop/Services/PulseServices.swift b/PulseLoop/Services/PulseServices.swift index 56d685b..b3d7618 100644 --- a/PulseLoop/Services/PulseServices.swift +++ b/PulseLoop/Services/PulseServices.swift @@ -39,9 +39,10 @@ enum MetricsService { let spo2Freshness = freshness(lastUpdatedAt: latestSpO2?.timestamp, isDemo: isDemo) let sleep = SleepService.latestSleep(context: context) let goals = goalsSummary(context: context) + let profileValues = MetricsProfileValues(profile: ProfileRepository.profile(context: context)) let trends = TrendsSummary( steps7d: alignedRows.map { DailyMetricPoint(date: $0.date, value: Double($0.steps)) }, - calories7d: alignedRows.map { DailyMetricPoint(date: $0.date, value: $0.calories) }, + calories7d: alignedRows.map { DailyMetricPoint(date: $0.date, value: $0.effectiveCalories(profile: profileValues) ?? 0) }, distance7d: alignedRows.map { DailyMetricPoint(date: $0.date, value: $0.distanceMeters) }, hrSamples24h: hrSamplesDisplay, spo2Samples24h: spo2SamplesDisplay @@ -58,13 +59,14 @@ enum MetricsService { isDemo: isDemo )) - // The ring's calorie field is unverified, so ring-history days don't carry calories — show - // "—" rather than a misleading 0. Steps/distance from the ring are trustworthy. - let todayCalories: Double? = today?.source == ActivityService.ringHistorySource ? nil : today?.calories + // Device-reported calories when the ring gave them; otherwise the on-device estimated + // TOTAL burn (BMR accrued over elapsed minutes + net active estimate). Days with neither + // stay nil and render "—". The goal ring keeps measuring the active-energy portion. return TodaySummary( date: today?.date ?? calendar.startOfDay(for: Date()), steps: today?.steps, - calories: todayCalories, + calories: today?.effectiveCalories(profile: profileValues), + activeCalories: today?.effectiveActiveCalories, distanceMeters: today?.distanceMeters, activeMinutes: today?.activeMinutes, activeMinutesSource: today?.source ?? "none", @@ -264,6 +266,8 @@ enum MetricsService { let rows = MetricsRepository.activityRows(context: context) let isDemo = rows.contains { $0.source == "mock" } let filtered = rowsSinceCutoff(rows: rows, range: range, includeAll: isDemo) + // Fetched once per call — the calorie read path needs the profile for the BMR baseline. + let profileValues = MetricsProfileValues(profile: ProfileRepository.profile(context: context)) if range == .twelveMonths { let calendar = Calendar.current let grouped = Dictionary(grouping: filtered) { row in @@ -274,17 +278,18 @@ enum MetricsService { guard let first = rows.map(\.date).min() else { return nil } let components = calendar.dateComponents([.year, .month], from: first) let monthStart = calendar.date(from: DateComponents(year: components.year, month: components.month, day: 1, hour: 12)) ?? first - return MetricSample(timestamp: monthStart, value: rows.reduce(0) { $0 + value(row: $1, metric: metric) }) + let monthTotal = rows.reduce(0) { $0 + value(row: $1, metric: metric, profile: profileValues) } + return MetricSample(timestamp: monthStart, value: monthTotal) } .sorted { $0.timestamp < $1.timestamp } } - return filtered.map { MetricSample(timestamp: $0.date, value: value(row: $0, metric: metric)) } + return filtered.map { MetricSample(timestamp: $0.date, value: value(row: $0, metric: metric, profile: profileValues)) } } - - private static func value(row: ActivityDaily, metric: MetricKey) -> Double { + + private static func value(row: ActivityDaily, metric: MetricKey, profile: MetricsProfileValues) -> Double { switch metric { case .steps: return Double(row.steps) - case .calories: return row.calories + case .calories: return row.effectiveCalories(profile: profile) ?? 0 case .distance: return row.distanceMeters case .activeMinutes: return Double(row.activeMinutes) default: return 0 @@ -797,6 +802,9 @@ enum ActivityService { } row.syncedAt = syncedAt row.updatedAt = Date() + // Cheap during a sync burst — the day recomputes its calorie estimate once, when the + // event bus sees the sync complete (`DailyCalorieEstimator.flushDirty`). + DailyCalorieEstimator.markDirty(dayStart) return row } @@ -824,6 +832,8 @@ enum ActivityService { // Preserve explicitly provided calories (coach-created sessions) at finish. let summary = recomputeSummary(for: session, preserveProvidedCalories: true, context: context) creditDailyRollup(for: session, durationSeconds: summary.durationSeconds ?? 0, context: context) + // After the rollup credit so a workout on an otherwise-empty day has its ActivityDaily row. + DailyCalorieEstimator.recompute(around: session, context: context) return summary } @@ -833,7 +843,10 @@ enum ActivityService { /// here (the stored value came from this engine at finish; late HR improves the estimate). @discardableResult static func refreshSummary(for session: ActivitySession, context: ModelContext) -> ActivitySessionSummary { - recomputeSummary(for: session, preserveProvidedCalories: false, context: context) + let summary = recomputeSummary(for: session, preserveProvidedCalories: false, context: context) + // Late-arriving ring HR changes the session's calories, which feed the day's estimate. + DailyCalorieEstimator.recompute(around: session, context: context) + return summary } private static func recomputeSummary(for session: ActivitySession, preserveProvidedCalories: Bool, context: ModelContext) -> ActivitySessionSummary { @@ -900,6 +913,10 @@ enum ActivityService { else { return false } let payload = editPayload(from: session, newType: newType, newStart: newStartedAt, newEnd: newEndedAt) + // Capture the pre-edit window: the old day(s) need their calorie estimate recomputed after + // the workout moves away from them. + let oldStartedAt = session.startedAt + let oldEndedAt = session.endedAt reverseDailyRollup(for: session, context: context) session.type = newType @@ -919,6 +936,13 @@ enum ActivityService { let summary = refreshSummary(for: session, context: context) creditDailyRollup(for: session, durationSeconds: summary.durationSeconds ?? 0, context: context) + // Old day(s) lose the workout's energy; the new day(s) recompute after the rollup credit + // (which creates the ActivityDaily row when the workout moved to an empty day). + DailyCalorieEstimator.recompute(day: oldStartedAt, context: context) + if let oldEndedAt, !Calendar.current.isDate(oldEndedAt, inSameDayAs: oldStartedAt) { + DailyCalorieEstimator.recompute(day: oldEndedAt, context: context) + } + DailyCalorieEstimator.recompute(around: session, context: context) try? context.save() PulseDataChange.shared.notify() return true @@ -1105,6 +1129,10 @@ enum ActivityRecorderService { static func delete(_ session: ActivitySession, context: ModelContext) { ActivityService.reverseDailyRollup(for: session, context: context) + // Capture before the row is gone; the day's calorie estimate recomputes without it below. + let affectedStart = session.startedAt + let affectedEnd = session.endedAt + let id = session.id ActivityRepository.samples(sessionId: id, context: context).forEach(context.delete) ActivityRepository.gpsPoints(sessionId: id, context: context).forEach(context.delete) @@ -1113,6 +1141,11 @@ enum ActivityRecorderService { polls.forEach(context.delete) context.delete(session) try? context.save() + DailyCalorieEstimator.recompute(day: affectedStart, context: context) + if let affectedEnd, !Calendar.current.isDate(affectedEnd, inSameDayAs: affectedStart) { + DailyCalorieEstimator.recompute(day: affectedEnd, context: context) + } + try? context.save() HealthSyncService.shared.deleteExportedWorkout(sessionId: id) PulseDataChange.shared.notify() } diff --git a/PulseLoop/Services/WidgetSnapshotPublisher.swift b/PulseLoop/Services/WidgetSnapshotPublisher.swift index 4a1cee8..117c2b8 100644 --- a/PulseLoop/Services/WidgetSnapshotPublisher.swift +++ b/PulseLoop/Services/WidgetSnapshotPublisher.swift @@ -195,6 +195,9 @@ final class WidgetSnapshotPublisher { private func activityPayload(_ summary: TodaySummary, units: UnitsPreference) -> WidgetActivityPayload { let caloriesAvailable = MetricsService.isVisible(.calories, context: modelContext, scope: .today) let calories = caloriesAvailable ? summary.calories : nil + // Ring progress measures the active-energy portion vs the active-energy goal (matches + // ActivityTileView); the text shows the display value (device or estimated total). + let activeCalories = caloriesAvailable ? summary.activeCalories : nil let distanceText = summary.distanceMeters.map { UnitsFormatter.distance(meters: $0, units: units).value } return WidgetActivityPayload( steps: summary.steps.map(Double.init), @@ -202,7 +205,7 @@ final class WidgetSnapshotPublisher { distanceDisplay: distanceText.flatMap(Double.init), distanceGoalDisplay: Double(UnitsFormatter.distance(meters: summary.goals.distanceMetersDaily, units: units).value) ?? 0, distanceUnitLabel: units == .imperial ? "MI" : "KM", - calories: calories, + calories: activeCalories, caloriesGoal: Double(summary.goals.caloriesDaily), stepsText: summary.steps.map { $0.formatted() }, distanceText: distanceText, diff --git a/PulseLoop/Services/WorkoutMetricsEngine.swift b/PulseLoop/Services/WorkoutMetricsEngine.swift index d330aaa..c6b095f 100644 --- a/PulseLoop/Services/WorkoutMetricsEngine.swift +++ b/PulseLoop/Services/WorkoutMetricsEngine.swift @@ -5,15 +5,18 @@ struct MetricsProfileValues: Sendable, Equatable { var sex: String? var age: Int? var weightKg: Double? + /// Only the daily model consumes height (BMR + stride); the workout Keytel/MET paths ignore it. + var heightCm: Double? - init(sex: String? = nil, age: Int? = nil, weightKg: Double? = nil) { + init(sex: String? = nil, age: Int? = nil, weightKg: Double? = nil, heightCm: Double? = nil) { self.sex = sex self.age = age self.weightKg = weightKg + self.heightCm = heightCm } init(profile: UserProfile?) { - self.init(sex: profile?.sex, age: profile?.age, weightKg: profile?.weightKg) + self.init(sex: profile?.sex, age: profile?.age, weightKg: profile?.weightKg, heightCm: profile?.heightCm) } } @@ -102,7 +105,9 @@ enum WorkoutMetricsEngine { } /// kcal/min for a given heart rate (Keytel et al. 2005, without VO2max), clamped ≥ 0. - private static func keytelRate(hr: Double, male: Bool, age: Double, weightKg: Double) -> Double { + /// Internal (not private) so `DailyCalorieMath` reuses the exact same coefficients for all-day + /// HR segments. + static func keytelRate(hr: Double, male: Bool, age: Double, weightKg: Double) -> Double { let kj = male ? -55.0969 + 0.6309 * hr + 0.1988 * weightKg + 0.2017 * age : -20.4022 + 0.4472 * hr - 0.1263 * weightKg + 0.0740 * age diff --git a/PulseLoop/Views/ActivityView.swift b/PulseLoop/Views/ActivityView.swift index c4e3b7d..327cf51 100644 --- a/PulseLoop/Views/ActivityView.swift +++ b/PulseLoop/Views/ActivityView.swift @@ -217,6 +217,9 @@ struct DailyActivitySummaryCard: View { /// Calories only when the device actually tracks them; otherwise nil so text shows "—" and the /// ring stays a muted track (matches the Today page's `isVisible(.calories)` gating). private var effectiveCalories: Double? { caloriesAvailable ? summary.calories : nil } + /// The goal ring measures active energy (`UserGoal.calories` is an active-energy goal), even + /// when the displayed number is an estimated total that includes the BMR baseline. + private var effectiveActiveCalories: Double? { caloriesAvailable ? summary.activeCalories : nil } private var distanceUnit: String { UnitsFormatter.distance(meters: 0, units: units).unit } private var distanceValue: String? { summary.distanceMeters.map { UnitsFormatter.distance(meters: $0, units: units).value } } @@ -244,7 +247,7 @@ struct DailyActivitySummaryCard: View { ActivityRingsView(rings: [ ActivityRing(value: summary.steps.map(Double.init), goal: Double(summary.goals.stepsDaily), color: PulseColors.steps), ActivityRing(value: distanceDisplay, goal: distanceGoalDisplay, color: PulseColors.distance), - ActivityRing(value: effectiveCalories, goal: Double(summary.goals.caloriesDaily), color: PulseColors.calories) + ActivityRing(value: effectiveActiveCalories, goal: Double(summary.goals.caloriesDaily), color: PulseColors.calories) ], size: 112, stroke: 11, spacing: 5) .frame(width: 112, height: 112) } diff --git a/PulseLoop/Views/OnboardingFlowView.swift b/PulseLoop/Views/OnboardingFlowView.swift index 0346642..bcdceda 100644 --- a/PulseLoop/Views/OnboardingFlowView.swift +++ b/PulseLoop/Views/OnboardingFlowView.swift @@ -346,6 +346,8 @@ struct OnboardingProfileView: View { draft.apply(to: profile) try? modelContext.save() coordinator.applyUserProfile() + // Weight/height/age/sex shift every term of the daily calorie estimate. + DailyCalorieEstimator.recomputeRecentDays(context: modelContext) next() } } diff --git a/PulseLoop/Views/RootViews.swift b/PulseLoop/Views/RootViews.swift index 3a16886..37686fd 100644 --- a/PulseLoop/Views/RootViews.swift +++ b/PulseLoop/Views/RootViews.swift @@ -42,6 +42,19 @@ struct RootAppView: View { SeedData.clearAll(modelContext) SeedData.seedDemo(modelContext, completeOnboarding: true) } + // Test tooling: `-demoEstimatedCalories YES` reshapes the seeded recent days into + // what a phone-away ring-history sync produces (source `ring_history`, no device + // calories) so the on-device estimated-total path is visible in the UI. + if UserDefaults.standard.bool(forKey: "demoEstimatedCalories") { + for offset in 0...2 { + guard let day = Calendar.current.date(byAdding: .day, value: -offset, to: Date()), + let row = MetricsRepository.activity(on: day, context: modelContext) else { continue } + row.source = ActivityService.ringHistorySource + DailyCalorieEstimator.recompute(day: day, context: modelContext) + } + try? modelContext.save() + PulseDataChange.shared.notify() + } // Test tooling: fake a connected Strava account. Must run before anything touches // StravaAuthService.shared (it reads the token store at init). if UserDefaults.standard.bool(forKey: "demoStravaConnected") { diff --git a/PulseLoop/Views/Settings/ProfileSettingsView.swift b/PulseLoop/Views/Settings/ProfileSettingsView.swift index d35fb24..760ff62 100644 --- a/PulseLoop/Views/Settings/ProfileSettingsView.swift +++ b/PulseLoop/Views/Settings/ProfileSettingsView.swift @@ -79,6 +79,8 @@ struct ProfileSettingsView: View { draft.apply(to: profile) try? modelContext.save() coordinator.applyUserProfile() + // Weight/height/age/sex shift every term of the daily calorie estimate. + DailyCalorieEstimator.recomputeRecentDays(context: modelContext) } private func importFromHealth() { diff --git a/PulseLoop/Views/TodayTiles.swift b/PulseLoop/Views/TodayTiles.swift index 9523d29..e668688 100644 --- a/PulseLoop/Views/TodayTiles.swift +++ b/PulseLoop/Views/TodayTiles.swift @@ -60,6 +60,9 @@ struct ActivityTileView: View { var onTap: () -> Void private var effectiveCalories: Double? { caloriesAvailable ? summary.calories : nil } + /// The goal ring measures active energy (`UserGoal.calories` is an active-energy goal), even + /// when the displayed number is an estimated total that includes the BMR baseline. + private var effectiveActiveCalories: Double? { caloriesAvailable ? summary.activeCalories : nil } private var distanceGoalDisplay: Double { Double(UnitsFormatter.distance(meters: summary.goals.distanceMetersDaily, units: units).value) ?? 0 } @@ -86,7 +89,7 @@ struct ActivityTileView: View { ActivityRingsView(rings: [ ActivityRing(value: summary.steps.map(Double.init), goal: Double(summary.goals.stepsDaily), color: PulseColors.steps), ActivityRing(value: distanceDisplay, goal: distanceGoalDisplay, color: PulseColors.distance), - ActivityRing(value: effectiveCalories, goal: Double(summary.goals.caloriesDaily), color: PulseColors.calories) + ActivityRing(value: effectiveActiveCalories, goal: Double(summary.goals.caloriesDaily), color: PulseColors.calories) ], size: 88, stroke: 9, spacing: 4) // Nudge into the card padding so the bigger loop doesn't crowd the numbers. .padding(.leading, -6) diff --git a/PulseLoopTests/DailyCalorieEstimatorTests.swift b/PulseLoopTests/DailyCalorieEstimatorTests.swift new file mode 100644 index 0000000..7686bce --- /dev/null +++ b/PulseLoopTests/DailyCalorieEstimatorTests.swift @@ -0,0 +1,267 @@ +import XCTest +import SwiftData +@testable import PulseLoop + +/// The daily calorie model: Mifflin BMR, cadence-tiered step energy, FLEX-gated all-day Keytel, +/// workout proration/netting, single-attribution overlap rules, and the read-time device-vs-estimate +/// selection on `ActivityDaily`. +final class DailyCalorieEstimatorTests: XCTestCase { + /// A fixed midnight-aligned day start so bucket/HR timestamps are deterministic. + private let dayStart = Calendar.current.startOfDay(for: Date(timeIntervalSince1970: 1_750_000_000)) + + private let maleProfile = MetricsProfileValues(sex: "male", age: 30, weightKg: 70, heightCm: 175) + + private func inputs( + steps: Int = 0, + buckets: [DayStepBucket] = [], + workouts: [DayWorkoutWindow] = [], + hrSamples: [(timestamp: Date, bpm: Double)] = [], + profile: MetricsProfileValues = MetricsProfileValues() + ) -> DayEstimateInputs { + DayEstimateInputs(dayStart: dayStart, dayTotalSteps: steps, buckets: buckets, + workouts: workouts, hrSamples: hrSamples, profile: profile) + } + + // MARK: Mifflin-St Jeor + + func testMifflinKnownValues() { + // Male 70 kg / 175 cm / 30 y: 700 + 1093.75 − 150 + 5 = 1648.75. + XCTAssertEqual(DailyCalorieMath.mifflinBMR(profile: maleProfile), 1648.75, accuracy: 0.01) + // Female 60 kg / 165 cm / 30 y: 600 + 1031.25 − 150 − 161 = 1320.25. + let female = MetricsProfileValues(sex: "female", age: 30, weightKg: 60, heightCm: 165) + XCTAssertEqual(DailyCalorieMath.mifflinBMR(profile: female), 1320.25, accuracy: 0.01) + // Empty profile falls back to 70 kg / 170 cm / 35 y with the sex-neutral constant (−78): + // 700 + 1062.5 − 175 − 78 = 1509.5. Never suppressed. + XCTAssertEqual(DailyCalorieMath.mifflinBMR(profile: MetricsProfileValues()), 1509.5, accuracy: 0.01) + } + + // MARK: Step energy + + func testResidualStepsOnlyMatchesIntermittentWalkMath() { + // 10k steps, no buckets/workouts/HR, default profile: net (3.0 − 1) MET × 70 kg over + // 10000/100 = 100 walking minutes → 2 × 70 × 100/60 ≈ 233 kcal. + let kcal = DailyCalorieMath.estimateNetActive(inputs(steps: 10_000)) + XCTAssertEqual(kcal, 233.3, accuracy: 1) + } + + func testCadenceTiers() { + // Brisk bucket: 1500 steps / 15 min = 100 spm → MET 3.5 over the whole bucket: + // 2.5 × 70 × 15/60 = 43.75. + let brisk = DailyCalorieMath.estimateNetActive(inputs( + steps: 1500, buckets: [DayStepBucket(start: dayStart, steps: 1500)])) + XCTAssertEqual(brisk, 43.75, accuracy: 0.5) + + // Run bucket: 2000 steps / 15 min ≈ 133 spm → MET 8.3: 7.3 × 70 × 0.25 = 127.75. + let run = DailyCalorieMath.estimateNetActive(inputs( + steps: 2000, buckets: [DayStepBucket(start: dayStart, steps: 2000)])) + XCTAssertEqual(run, 127.75, accuracy: 0.5) + + // Sparse bucket: 300 steps → intermittent walking, 3 active minutes at MET 3.0 (no height): + // 2 × 70 × 3/60 = 7.0. + let sparse = DailyCalorieMath.estimateNetActive(inputs( + steps: 300, buckets: [DayStepBucket(start: dayStart, steps: 300)])) + XCTAssertEqual(sparse, 7.0, accuracy: 0.2) + } + + func testHeightRefinesIntermittentWalkMET() { + // 175 cm → stride 0.72 m → ~1.21 m/s at 100 spm → MET 3.5 tier (vs 3.0 default). + XCTAssertEqual(DailyCalorieMath.intermittentWalkMET(heightCm: 175), 3.5, accuracy: 0.01) + XCTAssertEqual(DailyCalorieMath.intermittentWalkMET(heightCm: nil), 3.0, accuracy: 0.01) + } + + // MARK: Workouts + + func testWorkoutIsNettedOfResting() { + // 60-min workout, 500 kcal gross, default profile (BMR 1509.5 → 1.048 kcal/min resting): + // net ≈ 500 − 62.9 = 437.1. + let workout = DayWorkoutWindow(start: dayStart.addingTimeInterval(3600), + end: dayStart.addingTimeInterval(7200), calories: 500) + let kcal = DailyCalorieMath.estimateNetActive(inputs(workouts: [workout])) + XCTAssertEqual(kcal, 437.1, accuracy: 1) + } + + func testMidnightCrossingWorkoutIsProrated() { + // 23:00–01:00, 600 kcal: this day only gets the first hour — 300 kcal minus resting. + let workout = DayWorkoutWindow(start: dayStart.addingTimeInterval(23 * 3600), + end: dayStart.addingTimeInterval(25 * 3600), calories: 600) + let bmrPerMin = DailyCalorieMath.mifflinBMR(profile: MetricsProfileValues()) / 1440 + let kcal = DailyCalorieMath.estimateNetActive(inputs(workouts: [workout])) + XCTAssertEqual(kcal, 300 - bmrPerMin * 60, accuracy: 1) + } + + func testBucketInsideWorkoutWindowIsNotDoubleCounted() { + // A brisk bucket entirely inside the workout window contributes nothing on top of the + // workout's own calories (single attribution per interval). + let workout = DayWorkoutWindow(start: dayStart, end: dayStart.addingTimeInterval(3600), calories: 400) + let withBucket = DailyCalorieMath.estimateNetActive(inputs( + steps: 1500, buckets: [DayStepBucket(start: dayStart, steps: 1500)], workouts: [workout])) + let withoutBucket = DailyCalorieMath.estimateNetActive(inputs(workouts: [workout])) + XCTAssertEqual(withBucket, withoutBucket, accuracy: 0.01) + } + + func testLiveOnlyDayDeductsWorkoutStepsFromResidual() { + // No buckets (live-only day): a 60-min workout window earns a 60 × 100-step allowance, so + // only 4000 of the 10000 day-total steps are credited as residual walking. + let workout = DayWorkoutWindow(start: dayStart, end: dayStart.addingTimeInterval(3600), calories: 400) + let bmrPerMin = DailyCalorieMath.mifflinBMR(profile: MetricsProfileValues()) / 1440 + let kcal = DailyCalorieMath.estimateNetActive(inputs(steps: 10_000, workouts: [workout])) + let expectedWorkout = 400 - bmrPerMin * 60 + let expectedResidual = 2.0 * 70 * (4000.0 / 100.0) / 60 + XCTAssertEqual(kcal, expectedWorkout + expectedResidual, accuracy: 1) + } + + // MARK: All-day HR (FLEX-gated Keytel) + + func testHRAboveFlexEarnsKeytelNetOfResting() { + // Male 30 y → FLEX = 0.6 × 190 = 114 bpm. Six samples at 120 bpm, 5 min apart, bounded by + // a below-FLEX sample → 30 covered minutes at the Keytel(120) rate minus resting. + var samples: [(timestamp: Date, bpm: Double)] = (0..<6).map { + (timestamp: dayStart.addingTimeInterval(Double($0) * 300), bpm: 120) + } + samples.append((timestamp: dayStart.addingTimeInterval(1800), bpm: 70)) + let rate = WorkoutMetricsEngine.keytelRate(hr: 120, male: true, age: 30, weightKg: 70) + let bmrPerMin = DailyCalorieMath.mifflinBMR(profile: maleProfile) / 1440 + let kcal = DailyCalorieMath.estimateNetActive(inputs(hrSamples: samples, profile: maleProfile)) + XCTAssertEqual(kcal, (rate - bmrPerMin) * 30, accuracy: 1) + } + + func testHRBelowFlexContributesNothing() { + // 90 bpm all day is below FLEX (114) — stress/caffeine elevation never adds calories. + let samples = (0..<12).map { (timestamp: dayStart.addingTimeInterval(Double($0) * 300), bpm: 90.0) } + XCTAssertEqual(DailyCalorieMath.estimateNetActive(inputs(hrSamples: samples, profile: maleProfile)), 0, accuracy: 0.01) + } + + func testHRWithoutCompleteProfileContributesNothing() { + // The Keytel path needs sex/age/weight (same guard as the workout engine); steps still count. + let samples = (0..<6).map { (timestamp: dayStart.addingTimeInterval(Double($0) * 300), bpm: 150.0) } + let kcal = DailyCalorieMath.estimateNetActive(inputs(steps: 1000, hrSamples: samples)) + XCTAssertEqual(kcal, 2.0 * 70 * (1000.0 / 100.0) / 60, accuracy: 0.5) + } + + func testHRInsideWorkoutWindowIsNotDoubleCounted() { + // Elevated samples inside the workout window are already covered by the workout's calories. + // A below-FLEX sample at the workout end bounds the last elevated sample's coverage, so the + // segments lie entirely inside the window and contribute exactly nothing extra. + let workout = DayWorkoutWindow(start: dayStart, end: dayStart.addingTimeInterval(1800), calories: 300) + var samples = (0..<6).map { (timestamp: dayStart.addingTimeInterval(Double($0) * 300), bpm: 150.0) } + samples.append((timestamp: dayStart.addingTimeInterval(1800), bpm: 70)) + let withHR = DailyCalorieMath.estimateNetActive(inputs(workouts: [workout], hrSamples: samples, profile: maleProfile)) + let withoutHR = DailyCalorieMath.estimateNetActive(inputs(workouts: [workout], profile: maleProfile)) + XCTAssertEqual(withHR, withoutHR, accuracy: 0.01) + } + + func testEstimateIsDeterministic() { + let mixed = inputs( + steps: 8000, + buckets: [DayStepBucket(start: dayStart.addingTimeInterval(3600), steps: 1500)], + workouts: [DayWorkoutWindow(start: dayStart.addingTimeInterval(7200), + end: dayStart.addingTimeInterval(9000), calories: 250)], + hrSamples: [(timestamp: dayStart.addingTimeInterval(10_800), bpm: 130)], + profile: maleProfile + ) + XCTAssertEqual(DailyCalorieMath.estimateNetActive(mixed), DailyCalorieMath.estimateNetActive(mixed)) + } + + // MARK: Read-time selection (device wins; estimate = BMR-elapsed + active) + + @MainActor + func testEffectiveCaloriesPrefersDeviceValue() throws { + let context = try TestSupport.makeContext() + let row = TestSupport.insertActivity(date: TestSupport.day(-1), steps: 5000, calories: 320, + source: "live", into: context) + row.estimatedActiveCalories = 150 + XCTAssertEqual(row.effectiveCalories(profile: MetricsProfileValues()), 320) + XCTAssertEqual(row.effectiveActiveCalories, 320) + } + + @MainActor + func testEffectiveCaloriesFallsBackToEstimatedTotalOnHistoryDays() throws { + let context = try TestSupport.makeContext() + // Ring-history day: any stored calorie value is untrusted → estimate path. + let row = TestSupport.insertActivity(date: TestSupport.day(-1), steps: 5000, calories: 320, + source: ActivityService.ringHistorySource, into: context) + row.estimatedActiveCalories = 150 + // A completed past day accrues the full BMR. + let expected = DailyCalorieMath.mifflinBMR(profile: MetricsProfileValues()) + 150 + XCTAssertEqual(row.effectiveCalories(profile: MetricsProfileValues()) ?? 0, expected, accuracy: 0.5) + XCTAssertEqual(row.effectiveActiveCalories, 150) + // Without an estimate the day renders "—". + row.estimatedActiveCalories = nil + XCTAssertNil(row.effectiveCalories(profile: MetricsProfileValues())) + } + + @MainActor + func testEffectiveCaloriesTodayAccruesBMRForElapsedMinutesOnly() throws { + let context = try TestSupport.makeContext() + let row = TestSupport.insertActivity(date: Date(), steps: 2000, calories: 0, + source: ActivityService.ringHistorySource, into: context) + row.estimatedActiveCalories = 100 + let bmr = DailyCalorieMath.mifflinBMR(profile: MetricsProfileValues()) + // Noon: half the daily BMR has accrued. + let noon = Calendar.current.startOfDay(for: Date()).addingTimeInterval(12 * 3600) + XCTAssertEqual(row.effectiveCalories(profile: MetricsProfileValues(), now: noon) ?? 0, + bmr / 2 + 100, accuracy: 0.5) + } + + // MARK: Orchestrator (in-memory store) + + @MainActor + func testRecomputeWritesEstimateFromBucketsAndIsIdempotent() throws { + let context = try TestSupport.makeContext() + let day = TestSupport.day(-1) + // Two history buckets → daily row via the real ingest path (also marks the day dirty). + ActivityService.applyActivityBucket(date: day.addingTimeInterval(9 * 3600), steps: 1500, distanceMeters: 1000, context: context) + ActivityService.applyActivityBucket(date: day.addingTimeInterval(10 * 3600), steps: 500, distanceMeters: 300, context: context) + try context.save() + + XCTAssertTrue(DailyCalorieEstimator.dirtyDays.contains(day)) + DailyCalorieEstimator.flushDirty(context: context) + XCTAssertTrue(DailyCalorieEstimator.dirtyDays.isEmpty) + + let row = try XCTUnwrap(MetricsRepository.activity(on: day, context: context)) + let first = try XCTUnwrap(row.estimatedActiveCalories) + XCTAssertGreaterThan(first, 0) + + // Re-syncing the same buckets converges to the same estimate. + ActivityService.applyActivityBucket(date: day.addingTimeInterval(9 * 3600), steps: 1500, distanceMeters: 1000, context: context) + DailyCalorieEstimator.flushDirty(context: context) + XCTAssertEqual(row.estimatedActiveCalories ?? 0, first, accuracy: 0.01) + } + + @MainActor + func testWorkoutFinishAndDeleteUpdateDayEstimate() throws { + let context = try TestSupport.makeContext() + let session = ActivityRecorderService.start(type: "run", useGps: false, notes: nil, context: context) + session.startedAt = Date().addingTimeInterval(-1800) + ActivityRecorderService.finish(session, context: context) + + let day = Calendar.current.startOfDay(for: session.startedAt) + let row = try XCTUnwrap(MetricsRepository.activity(on: day, context: context)) + let withWorkout = try XCTUnwrap(row.estimatedActiveCalories) + XCTAssertGreaterThan(withWorkout, 0) + + ActivityRecorderService.delete(session, context: context) + XCTAssertEqual(row.estimatedActiveCalories ?? -1, 0, accuracy: 0.01) + } + + @MainActor + func testBuildTodaySummaryUsesEstimateOnHistoryDaysAndDeviceValueOnLiveDays() throws { + let historyContext = try TestSupport.makeContext() + let historyRow = TestSupport.insertActivity(date: Date(), steps: 6000, calories: 0, + source: ActivityService.ringHistorySource, into: historyContext) + DailyCalorieEstimator.recompute(day: Date(), context: historyContext) + let historySummary = MetricsService.buildTodaySummary(context: historyContext) + let active = try XCTUnwrap(historyRow.estimatedActiveCalories) + XCTAssertEqual(historySummary.activeCalories ?? 0, active, accuracy: 0.01) + // Displayed total = active + BMR accrued so far today (bounded by the full-day BMR). + let total = try XCTUnwrap(historySummary.calories) + XCTAssertGreaterThanOrEqual(total + 0.01, active) + XCTAssertLessThanOrEqual(total, active + DailyCalorieMath.mifflinBMR(profile: MetricsProfileValues())) + + let liveContext = try TestSupport.makeContext() + TestSupport.insertActivity(date: Date(), steps: 6000, calories: 410, source: "live", into: liveContext) + let liveSummary = MetricsService.buildTodaySummary(context: liveContext) + XCTAssertEqual(liveSummary.calories, 410) + XCTAssertEqual(liveSummary.activeCalories, 410) + } +}