Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions PulseLoop/Events/PulseEventBus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion PulseLoop/Models/PulseModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions PulseLoop/PulseLoopApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
327 changes: 327 additions & 0 deletions PulseLoop/Services/DailyCalorieEstimator.swift

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions PulseLoop/Services/DerivedSummaries.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 44 additions & 11 deletions PulseLoop/Services/PulseServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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()
}
Expand Down
5 changes: 4 additions & 1 deletion PulseLoop/Services/WidgetSnapshotPublisher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -195,14 +195,17 @@ 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),
stepsGoal: Double(summary.goals.stepsDaily),
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,
Expand Down
11 changes: 8 additions & 3 deletions PulseLoop/Services/WorkoutMetricsEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion PulseLoop/Views/ActivityView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }
Expand Down Expand Up @@ -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)
}
Expand Down
2 changes: 2 additions & 0 deletions PulseLoop/Views/OnboardingFlowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down
13 changes: 13 additions & 0 deletions PulseLoop/Views/RootViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
2 changes: 2 additions & 0 deletions PulseLoop/Views/Settings/ProfileSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
5 changes: 4 additions & 1 deletion PulseLoop/Views/TodayTiles.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)
Expand Down
Loading
Loading