From f9eed52a4bc95ea1685909cf27fb8058f5ddfd8e Mon Sep 17 00:00:00 2001 From: ak710 Date: Fri, 31 Jul 2026 20:00:51 -0400 Subject: [PATCH 1/6] Add readiness score: algorithm, baselines, and storage First of several PRs implementing the readiness/recovery score from the roadmap's "Metrics you can trust" section (#103). This one lands the engine and its storage; the Today tile, detail screen, coach tool, and widget follow separately. A daily 0-100 score from five contributors, weighted 30/25/30/10/5: overnight HRV, resting heart rate, sleep, skin temperature, and yesterday's training load. Four are judged against the user's own baseline; sleep is absolute because SleepScore already encodes population-normal ranges. Three rules shape the design: - Missing signals leave the denominator rather than scoring zero. A night without a temperature reading is scored out of 90 points, not penalised 10, and the result reports its coverage. This mirrors the doctrine at the top of SleepInsights.swift. - An unestablished baseline counts as missing, not as "at baseline". Scoring a deviation against three days of data would look authoritative while being noise. - Every contributor carries its own explanation ("HRV 12% below your baseline"), so the score is never surfaced as a bare number. The full algorithm - every weight and threshold - is documented in docs/project/readiness.md. Reuses the existing baseline machinery rather than building a parallel one: BaselineStats for HRV and temperature, and UserProfile.hrRestingBaseline, which RestingHRBaselineService already learns and throttles. ReadinessService is shaped after that service. Overnight signals are read from the sleep session's own span, falling back to 22:00-08:00 when sleep wasn't decoded, so daytime readings can't masquerade as recovery data. Scores persist as ReadinessDaily with their breakdown, since recomputing an old morning against today's baseline would give a different and wrong answer. Rows carry an algorithmVersion that invalidates them on a weight change instead of silently reinterpreting them. Archive format version goes to 2. readinessDailies is Optional because PulseArchive uses the synthesized decoder, which has no notion of property defaults - a non-optional array would make every existing v1 backup unimportable. Covered by a test that strips the key from a real export. 39 new tests. Demo seed data produces 10 scored days across multiple bands, so the feature is reviewable without a ring. Co-Authored-By: Claude Opus 5 --- PulseLoop/Models/PulseModels.swift | 87 ++++ .../Persistence/DataArchive+Readiness.swift | 49 ++ PulseLoop/Persistence/DataArchive.swift | 10 +- .../Persistence/DataArchiveService.swift | 22 +- .../Persistence/ModelContainerFactory.swift | 1 + PulseLoop/Persistence/SeedData.swift | 6 + PulseLoop/PulseLoopApp.swift | 7 + PulseLoop/Services/ReadinessScore.swift | 449 ++++++++++++++++++ PulseLoop/Services/ReadinessService.swift | 277 +++++++++++ PulseLoop/Services/Repositories.swift | 37 ++ PulseLoop/Settings/ReadinessPrefsStore.swift | 70 +++ PulseLoopTests/DataArchiveTests.swift | 42 +- PulseLoopTests/ReadinessScoreTests.swift | 359 ++++++++++++++ PulseLoopTests/ReadinessServiceTests.swift | 375 +++++++++++++++ docs/project/readiness.md | 158 ++++++ docs/project/roadmap.md | 3 +- mkdocs.yml | 1 + 17 files changed, 1945 insertions(+), 8 deletions(-) create mode 100644 PulseLoop/Persistence/DataArchive+Readiness.swift create mode 100644 PulseLoop/Services/ReadinessScore.swift create mode 100644 PulseLoop/Services/ReadinessService.swift create mode 100644 PulseLoop/Settings/ReadinessPrefsStore.swift create mode 100644 PulseLoopTests/ReadinessScoreTests.swift create mode 100644 PulseLoopTests/ReadinessServiceTests.swift create mode 100644 docs/project/readiness.md diff --git a/PulseLoop/Models/PulseModels.swift b/PulseLoop/Models/PulseModels.swift index 76aa3450..467e2a3a 100644 --- a/PulseLoop/Models/PulseModels.swift +++ b/PulseLoop/Models/PulseModels.swift @@ -325,6 +325,93 @@ final class SleepStageBlock { var stage: SleepStage { SleepStage(rawValue: stageRaw) ?? .unknown } } +/// One morning's readiness score, persisted with the contributor breakdown that produced it. +/// +/// Stored rather than recomputed for three reasons: the trend chart wants 30–90 days and the +/// `TodayStore` signature architecture exists to keep that work off the render path; a score keeps +/// its *why* only if the breakdown is stored alongside it; and recomputing an old morning against +/// today's 30-day baseline would silently produce a different, wrong answer. +/// +/// `algorithmVersion` is what makes that safe — `ReadinessService` recomputes any row whose version +/// no longer matches `ReadinessScore.algorithmVersion` instead of reinterpreting old numbers under +/// new weights. +@Model +final class ReadinessDaily { + @Attribute(.unique) var id: UUID + /// Start-of-day of the morning this score describes. + var date: Date + var score: Int + var bandRaw: String + /// The denominator the score was taken over — how much of the 100-point picture was available. + var availablePoints: Double + /// JSON-encoded `[ReadinessContributorRecord]`: the breakdown behind `score`. + var contributorsJSON: String + var algorithmVersion: Int + var computedAt: Date + var createdAt: Date + var updatedAt: Date + + init( + id: UUID = UUID(), + date: Date, + score: Int, + band: ReadinessBand, + availablePoints: Double, + contributorsJSON: String, + algorithmVersion: Int = ReadinessScore.algorithmVersion, + computedAt: Date = Date() + ) { + self.id = id + self.date = Calendar.current.startOfDay(for: date) + self.score = score + self.bandRaw = band.rawValue + self.availablePoints = availablePoints + self.contributorsJSON = contributorsJSON + self.algorithmVersion = algorithmVersion + self.computedAt = computedAt + self.createdAt = Date() + self.updatedAt = Date() + } + + var band: ReadinessBand { ReadinessBand(rawValue: bandRaw) ?? .moderate } + + var coverage: Double { availablePoints > 0 ? availablePoints / 100 : 0 } + + /// Decoded breakdown. Returns `[]` rather than throwing — a readiness row with unreadable + /// contributors is still a usable score, and the detail screen degrades to "breakdown + /// unavailable" instead of the whole tile failing. + var contributors: [ReadinessContributorRecord] { + guard let data = contributorsJSON.data(using: .utf8) else { return [] } + return (try? JSONDecoder().decode([ReadinessContributorRecord].self, from: data)) ?? [] + } +} + +/// Codable mirror of `ReadinessContributor` for storage and export. Kept separate from the scoring +/// value type so `ReadinessScore` stays free of persistence concerns, and so the on-disk shape is +/// explicit and versioned by `ReadinessDaily.algorithmVersion`. +struct ReadinessContributorRecord: Codable, Equatable, Sendable { + var kindRaw: String + var earned: Double + var maxPoints: Double + var value: Double + var baseline: Double? + var deviation: Double? + var detail: String + + var kind: ReadinessContributor.Kind? { ReadinessContributor.Kind(rawValue: kindRaw) } + var drag: Double { maxPoints - earned } + + init(_ contributor: ReadinessContributor) { + kindRaw = contributor.kind.rawValue + earned = contributor.earned + maxPoints = contributor.maxPoints + value = contributor.value + baseline = contributor.baseline + deviation = contributor.deviation + detail = contributor.detail + } +} + @Model final class RawPacketRow { @Attribute(.unique) var id: UUID diff --git a/PulseLoop/Persistence/DataArchive+Readiness.swift b/PulseLoop/Persistence/DataArchive+Readiness.swift new file mode 100644 index 00000000..b127a745 --- /dev/null +++ b/PulseLoop/Persistence/DataArchive+Readiness.swift @@ -0,0 +1,49 @@ +import Foundation +import SwiftData + +// Readiness rows in the portable archive. Split out of `DataArchive.swift` purely to keep that file +// under SwiftLint's `file_length` error threshold — the DTO contract is identical to the others. + +nonisolated struct ArchiveReadinessDaily: Codable, Sendable { + var id: UUID + var date: Date + var score: Int + var bandRaw: String + var availablePoints: Double + var contributorsJSON: String + var algorithmVersion: Int + var computedAt: Date + var createdAt: Date + var updatedAt: Date + + @MainActor init(_ m: ReadinessDaily) { + id = m.id + date = m.date + score = m.score + bandRaw = m.bandRaw + availablePoints = m.availablePoints + contributorsJSON = m.contributorsJSON + algorithmVersion = m.algorithmVersion + computedAt = m.computedAt + createdAt = m.createdAt + updatedAt = m.updatedAt + } + + @MainActor func insert(into context: ModelContext) { + let m = ReadinessDaily( + date: date, + score: score, + band: ReadinessBand(rawValue: bandRaw) ?? .moderate, + availablePoints: availablePoints, + contributorsJSON: contributorsJSON, + algorithmVersion: algorithmVersion, + computedAt: computedAt + ) + m.id = id + m.date = date // init re-derives startOfDay in the local timezone; restore the exact value + m.bandRaw = bandRaw // preserve an unknown band verbatim rather than collapsing it + m.createdAt = createdAt + m.updatedAt = updatedAt + context.insert(m) + } +} diff --git a/PulseLoop/Persistence/DataArchive.swift b/PulseLoop/Persistence/DataArchive.swift index 8dddac34..12103ae1 100644 --- a/PulseLoop/Persistence/DataArchive.swift +++ b/PulseLoop/Persistence/DataArchive.swift @@ -21,7 +21,10 @@ import SwiftData // MARK: - Envelope nonisolated struct PulseArchive: Codable, Sendable { - static let currentFormatVersion = 1 + /// Bumped to 2 when readiness scores joined the archive. `importArchive` refuses anything + /// newer than this, which is the honest behaviour: an older build genuinely cannot restore a + /// table it has no model for. + static let currentFormatVersion = 2 var formatVersion: Int var exportedAt: Date @@ -35,6 +38,11 @@ nonisolated struct PulseArchive: Codable, Sendable { var batterySamples: [ArchiveBatterySample] var sleepSessions: [ArchiveSleepSession] var sleepStageBlocks: [ArchiveSleepStageBlock] + /// Added in format version 2. **Optional on purpose**: `PulseArchive` uses the synthesized + /// decoder, which has no notion of property defaults, so a non-optional array here would make + /// every existing v1 archive fail to decode. Read it as `?? []`; a new export always writes it. + /// Any future table added to this struct should follow the same pattern. + var readinessDailies: [ArchiveReadinessDaily]? var rawPackets: [ArchiveRawPacket] var derivedUpdates: [ArchiveDerivedUpdate] var userProfiles: [ArchiveUserProfile] diff --git a/PulseLoop/Persistence/DataArchiveService.swift b/PulseLoop/Persistence/DataArchiveService.swift index e53ce010..e8eddbe0 100644 --- a/PulseLoop/Persistence/DataArchiveService.swift +++ b/PulseLoop/Persistence/DataArchiveService.swift @@ -40,7 +40,8 @@ enum DataArchiveService { "pulseloop.workoutprefs.v1", "pulseloop.calibration.v1", "pulseloop.coach.settings.v1", - "pulseloop.applehealth.prefs.v1" + "pulseloop.applehealth.prefs.v1", + ReadinessPrefsStore.prefsKey ] /// Rows processed between `Task.yield()`s while mapping models to DTOs during export. @@ -100,6 +101,7 @@ enum DataArchiveService { let batterySamples = try await collect(BatterySample.self, context) { ArchiveBatterySample($0) } let sleepSessions = try await collect(SleepSession.self, context) { ArchiveSleepSession($0) } let sleepStageBlocks = try await collect(SleepStageBlock.self, context) { ArchiveSleepStageBlock($0) } + let readinessDailies = try await collect(ReadinessDaily.self, context) { ArchiveReadinessDaily($0) } let rawPackets = try await collect(RawPacketRow.self, context) { ArchiveRawPacket($0) } let derivedUpdates = try await collect(DerivedUpdateRow.self, context) { ArchiveDerivedUpdate($0) } let userProfiles = try await collect(UserProfile.self, context) { ArchiveUserProfile($0) } @@ -133,6 +135,7 @@ enum DataArchiveService { "batterySamples": batterySamples.count, "sleepSessions": sleepSessions.count, "sleepStageBlocks": sleepStageBlocks.count, + "readinessDailies": readinessDailies.count, "rawPackets": rawPackets.count, "derivedUpdates": derivedUpdates.count, "userProfiles": userProfiles.count, @@ -166,6 +169,7 @@ enum DataArchiveService { batterySamples: batterySamples, sleepSessions: sleepSessions, sleepStageBlocks: sleepStageBlocks, + readinessDailies: readinessDailies, rawPackets: rawPackets, derivedUpdates: derivedUpdates, userProfiles: userProfiles, @@ -286,9 +290,14 @@ enum DataArchiveService { if refreshStores { refreshSharedStores() } + + // 6. Self-heal readiness history. A v1 archive predates the table entirely, and a v2 one may + // carry rows scored by an older algorithm version. Both cases recompute from the + // measurements we just restored; rows already at the current version are skipped. + ReadinessService.backfill(days: 90, context: context) } - /// Whether any of the 24 model tables has at least one row — gates the destructive + /// Whether any of the 25 model tables has at least one row — gates the destructive /// "Replace all data?" confirmation. static func hasAnyData(context: ModelContext) -> Bool { func has(_ type: T.Type) -> Bool { @@ -296,6 +305,7 @@ enum DataArchiveService { } return has(Device.self) || has(ActivityDaily.self) || has(PulseLoop.Measurement.self) || has(BatterySample.self) || has(SleepSession.self) || has(SleepStageBlock.self) + || has(ReadinessDaily.self) || has(RawPacketRow.self) || has(DerivedUpdateRow.self) || has(UserProfile.self) || has(UserGoal.self) || has(DeviceMeasurementConfig.self) || has(ActivitySession.self) || has(ActivitySample.self) || has(ActivityBucketSample.self) || has(ActivityGpsPoint.self) @@ -304,7 +314,7 @@ enum DataArchiveService { || has(CoachNotificationRecord.self) || has(CoachSummary.self) || has(WearableLog.self) } - /// Deletes every row of every model in the schema — all 24 types, unlike `SeedData.clearAll` + /// Deletes every row of every model in the schema — all 25 types, unlike `SeedData.clearAll` /// (which predates six of them). Tracked deletes, no save, and deliberately synchronous — see /// the atomicity note in `importArchive`. static func wipeAllData(context: ModelContext) throws { @@ -314,6 +324,7 @@ enum DataArchiveService { try deleteAll(BatterySample.self, context) try deleteAll(SleepSession.self, context) try deleteAll(SleepStageBlock.self, context) + try deleteAll(ReadinessDaily.self, context) try deleteAll(RawPacketRow.self, context) try deleteAll(DerivedUpdateRow.self, context) try deleteAll(UserProfile.self, context) @@ -347,6 +358,7 @@ enum DataArchiveService { insert(archive.batterySamples, context) insert(archive.sleepSessions, context) insert(archive.sleepStageBlocks, context) + insert(archive.readinessDailies ?? [], context) insert(archive.rawPackets, context) insert(archive.derivedUpdates, context) insert(archive.userProfiles, context) @@ -382,6 +394,7 @@ enum DataArchiveService { try requireUnique(archive.batterySamples.map(\.id), entity: "battery sample") try requireUnique(archive.sleepSessions.map(\.id), entity: "sleep session") try requireUnique(archive.sleepStageBlocks.map(\.id), entity: "sleep stage") + try requireUnique((archive.readinessDailies ?? []).map(\.id), entity: "readiness score") try requireUnique(archive.rawPackets.map(\.id), entity: "raw packet") try requireUnique(archive.derivedUpdates.map(\.id), entity: "derived update") try requireUnique(archive.userProfiles.map(\.id), entity: "profile") @@ -473,7 +486,7 @@ enum DataArchiveService { } } -/// Shared shape of the 24 DTOs' model-restoring side, so `insertAll` can chunk generically. +/// Shared shape of the 25 DTOs' model-restoring side, so `insertAll` can chunk generically. @MainActor protocol ArchiveInsertable { func insert(into context: ModelContext) @@ -485,6 +498,7 @@ extension ArchiveMeasurement: ArchiveInsertable {} extension ArchiveBatterySample: ArchiveInsertable {} extension ArchiveSleepSession: ArchiveInsertable {} extension ArchiveSleepStageBlock: ArchiveInsertable {} +extension ArchiveReadinessDaily: ArchiveInsertable {} extension ArchiveRawPacket: ArchiveInsertable {} extension ArchiveDerivedUpdate: ArchiveInsertable {} extension ArchiveUserProfile: ArchiveInsertable {} diff --git a/PulseLoop/Persistence/ModelContainerFactory.swift b/PulseLoop/Persistence/ModelContainerFactory.swift index 6eef7a21..17aa9ffc 100644 --- a/PulseLoop/Persistence/ModelContainerFactory.swift +++ b/PulseLoop/Persistence/ModelContainerFactory.swift @@ -9,6 +9,7 @@ enum ModelContainerFactory { BatterySample.self, SleepSession.self, SleepStageBlock.self, + ReadinessDaily.self, RawPacketRow.self, DerivedUpdateRow.self, UserProfile.self, diff --git a/PulseLoop/Persistence/SeedData.swift b/PulseLoop/Persistence/SeedData.swift index 145cfd46..4efb0bbe 100644 --- a/PulseLoop/Persistence/SeedData.swift +++ b/PulseLoop/Persistence/SeedData.swift @@ -181,6 +181,11 @@ enum SeedData { context.insert(DerivedUpdateRow(kind: "seed", entityType: "database", entityId: "demo", payloadJSON: #"{"source":"SeedData"}"#)) try? context.save() + + // Score the demo history now that its measurements, sleep and workouts exist, so the + // readiness tile and its trend chart have something to show without waiting for a real + // week of wear. Runs last, and reads only what was just seeded. + ReadinessService.backfill(days: 30, context: context) } /// One demo meal to insert. @@ -333,6 +338,7 @@ enum SeedData { deleteAll(Measurement.self, context) deleteAll(SleepSession.self, context) deleteAll(SleepStageBlock.self, context) + deleteAll(ReadinessDaily.self, context) deleteAll(RawPacketRow.self, context) deleteAll(DerivedUpdateRow.self, context) deleteAll(UserProfile.self, context) diff --git a/PulseLoop/PulseLoopApp.swift b/PulseLoop/PulseLoopApp.swift index 34153eda..ec7fefe9 100644 --- a/PulseLoop/PulseLoopApp.swift +++ b/PulseLoop/PulseLoopApp.swift @@ -114,6 +114,11 @@ struct PulseLoopApp: App { // internally, so this is a cheap no-op most launches). RestingHRBaselineService.refreshIfStale(context: container.mainContext) + // Score this morning's readiness. Must run AFTER the resting-HR refresh above — readiness + // reads `UserProfile.hrRestingBaseline` as one of its five contributors. Throttled to 3h + // internally, so this is a cheap no-op most launches. + ReadinessService.refreshIfStale(context: container.mainContext) + // Start persistence + coordinator draining the bus; auto-reconnect happens when // CoreBluetooth reports poweredOn (see RingBLEClient.centralManagerDidUpdateState). subscriber.start() @@ -160,6 +165,8 @@ struct PulseLoopApp: App { // Refresh the learned resting-HR baseline on foreground (6h-throttled no-op usually). if !Self.isRunningUnitTests { RestingHRBaselineService.refreshIfStale(context: container.mainContext) + // Same ordering constraint as in `init`: readiness consumes the baseline above. + ReadinessService.refreshIfStale(context: container.mainContext) } // Foreground reconnect: the OS can silently tear down the BLE link while suspended without // delivering a disconnect, leaving us "connected" but dead. On every resume, re-link the diff --git a/PulseLoop/Services/ReadinessScore.swift b/PulseLoop/Services/ReadinessScore.swift new file mode 100644 index 00000000..c27eb226 --- /dev/null +++ b/PulseLoop/Services/ReadinessScore.swift @@ -0,0 +1,449 @@ +import Foundation + +/// Daily readiness scoring — how recovered the user is this morning, on 0–100. +/// +/// Pure and storage-free, in the same spirit as `SleepInsights.swift`: this file consumes a +/// plain-value `ReadinessInputs` and never touches SwiftData. `ReadinessService` owns the fetching. +/// +/// Three rules govern everything here, and every one of them exists because the alternative +/// silently invents data: +/// +/// 1. **A missing signal is excluded from the denominator, never scored as zero.** A night where +/// the ring dropped its temperature reading is a night scored out of 90 points, not a night +/// that lost 10. Mirrors the doctrine at the top of `SleepInsights.swift`. +/// 2. **A baseline that isn't established yet counts as missing, not as "at baseline".** Scoring a +/// deviation against three days of data would read as authoritative while being noise. +/// 3. **Every contributor carries its own explanation.** The project's stated principle is +/// documented metrics and no black boxes, so a score is never surfaced without the ability to +/// say which signal dragged it down and by how much. +/// +/// Contributor weights, band knots, and the reasoning behind them are documented in +/// `docs/project/readiness.md`. Changing any of them requires bumping `algorithmVersion`, which +/// invalidates stored rows rather than silently reinterpreting old scores under new weights. + +// MARK: - Inputs + +/// One morning's raw signals plus the personal baselines to judge them against. Every field is +/// optional: callers pass what the ring actually captured, and scoring adapts. +struct ReadinessInputs: Equatable { + /// Mean HRV across the overnight window, in ms. + var hrv: Double? + /// 30-day baseline of overnight HRV, excluding the night being scored. + var hrvBaseline: BaselineStats? + + /// 10th percentile of heart rate across the overnight window, in bpm. + var restingHeartRate: Double? + /// The learned resting-HR baseline (`UserProfile.hrRestingBaseline`), in bpm. + var restingHeartRateBaseline: Double? + + /// `SleepScore.calculate(_:).score` for last night, 0–100. Scored absolutely — `SleepScore` + /// already encodes population-normal ranges, so a second personal baseline would double-count. + var sleepScore: Int? + + /// Mean skin temperature across the overnight window, in °C. + var skinTemperature: Double? + /// 30-day baseline of overnight skin temperature, excluding the night being scored. + var skinTemperatureBaseline: BaselineStats? + + /// Yesterday's training load in minutes. + var priorDayLoadMinutes: Double? + /// Trailing 7-day mean load in minutes, excluding yesterday. + var loadBaselineMinutes: Double? + + init( + hrv: Double? = nil, + hrvBaseline: BaselineStats? = nil, + restingHeartRate: Double? = nil, + restingHeartRateBaseline: Double? = nil, + sleepScore: Int? = nil, + skinTemperature: Double? = nil, + skinTemperatureBaseline: BaselineStats? = nil, + priorDayLoadMinutes: Double? = nil, + loadBaselineMinutes: Double? = nil + ) { + self.hrv = hrv + self.hrvBaseline = hrvBaseline + self.restingHeartRate = restingHeartRate + self.restingHeartRateBaseline = restingHeartRateBaseline + self.sleepScore = sleepScore + self.skinTemperature = skinTemperature + self.skinTemperatureBaseline = skinTemperatureBaseline + self.priorDayLoadMinutes = priorDayLoadMinutes + self.loadBaselineMinutes = loadBaselineMinutes + } +} + +// MARK: - Output + +/// One scored signal, carrying both its arithmetic and its explanation. +struct ReadinessContributor: Equatable { + enum Kind: String, CaseIterable { + case hrv + case restingHeartRate + case sleep + case skinTemperature + case trainingLoad + + var title: String { + switch self { + case .hrv: return "HRV" + case .restingHeartRate: return "Resting HR" + case .sleep: return "Sleep" + case .skinTemperature: return "Skin temperature" + case .trainingLoad: return "Training load" + } + } + + /// Points this signal is worth when present. Documented in `docs/project/readiness.md`. + var maxPoints: Double { + switch self { + case .hrv: return 30 + case .restingHeartRate: return 25 + case .sleep: return 30 + case .skinTemperature: return 10 + case .trainingLoad: return 5 + } + } + } + + let kind: Kind + let earned: Double + let maxPoints: Double + /// The measured value, in the contributor's own unit (ms, bpm, 0–100, °C, ratio). + let value: Double + /// The personal baseline it was judged against. nil for `sleep`, which is absolute. + let baseline: Double? + /// Signed deviation from baseline, in the contributor's reporting unit (% for HRV, bpm for + /// resting HR, °C for temperature, a ratio for load). nil for `sleep`. + let deviation: Double? + /// Plain-language explanation, e.g. "HRV 12% below your baseline". Never mentions a value that + /// wasn't measured. + let detail: String + + /// Points this signal cost. Sorting by this surfaces what actually held the score back. + var drag: Double { maxPoints - earned } +} + +enum ReadinessBand: String, CaseIterable { + case primed = "Primed" + case ready = "Ready" + case moderate = "Moderate" + case restNeeded = "Rest needed" +} + +/// Why a morning couldn't be scored. The distinction matters to the UI: "we're still learning your +/// baseline, day 6 of 14" is a useful empty state, "wear your ring overnight" is a call to action, +/// and conflating them produces a tile that looks broken. +enum ReadinessUnavailableReason: String { + /// Nothing usable was captured overnight. + case noSignals + /// Signals arrived, but the personal baselines they'd be judged against aren't established yet. + case baselineLearning + /// Baselines are fine; too little of the night was captured to be worth a number. + case insufficientCoverage +} + +struct ReadinessResult: Equatable { + let score: Int + let band: ReadinessBand + /// Scored contributors, biggest drag first. + let contributors: [ReadinessContributor] + /// What couldn't be scored, in canonical order. + let missing: [ReadinessContributor.Kind] + /// Points actually available this morning — the denominator the score was taken over. + let availablePoints: Double + + /// How much of the full 100-point picture this score is based on. Surfaced so a 78 from a + /// partial night is never presented as equivalent to a 78 from a complete one. + var coverage: Double { availablePoints / 100 } +} + +enum ReadinessOutcome: Equatable { + case scored(ReadinessResult) + case unavailable(ReadinessUnavailableReason) +} + +// MARK: - Scoring + +enum ReadinessScore { + /// Bumping this invalidates stored `ReadinessDaily` rows so they recompute, rather than letting + /// old scores be reinterpreted under new weights. Changing weights or knots REQUIRES a bump, + /// and an update to `docs/project/readiness.md`. + static let algorithmVersion = 1 + + /// Below this many available points a score would be more suggestion than measurement. + static let minAvailablePoints: Double = 50 + + /// Where the "soft" knot sits as a fraction of a contributor's points. Deliberately harsher + /// than `SleepScore.bandScore`'s 0.65: a recovery score that never drops below 65 tells you + /// nothing on the days you most need it to. + static let softFraction: Double = 0.55 + + static func band(_ score: Int) -> ReadinessBand { + if score >= 85 { return .primed } + if score >= 70 { return .ready } + if score >= 55 { return .moderate } + return .restNeeded + } + + static func evaluate(_ inputs: ReadinessInputs) -> ReadinessOutcome { + var contributors: [ReadinessContributor] = [] + var missing: [ReadinessContributor.Kind] = [] + /// Did any signal arrive but get dropped purely because its baseline wasn't ready? That is + /// "still learning", which is a different — and recoverable — story from "no data". + var awaitingBaseline = false + /// Did anything usable arrive at all? Distinguishes "ring not worn" from "ring worn, thin night". + var sawAnySignal = false + + func admit(_ contributor: ReadinessContributor?, kind: ReadinessContributor.Kind) { + if let contributor { + contributors.append(contributor) + } else { + missing.append(kind) + } + } + + // HRV — relative to the user's own 30-day median, in percent. + if let value = inputs.hrv, value.isFinite, value > 0 { + sawAnySignal = true + if let baseline = usableBaseline(inputs.hrvBaseline) { + let deviation = ((value - baseline) / baseline) * 100 + admit( + ReadinessContributor( + kind: .hrv, + earned: lowerIsWorse(deviation, ideal: 0, soft: -15, hard: -40, + points: ReadinessContributor.Kind.hrv.maxPoints), + maxPoints: ReadinessContributor.Kind.hrv.maxPoints, + value: value, + baseline: baseline, + deviation: deviation, + detail: relativeDetail("HRV", deviation, unit: .percent) + ), + kind: .hrv + ) + } else { + awaitingBaseline = true + missing.append(.hrv) + } + } else { + missing.append(.hrv) + } + + // Resting HR — bpm above the learned baseline. Below baseline is never penalized. + if let value = inputs.restingHeartRate, value.isFinite, value > 0 { + sawAnySignal = true + if let baseline = inputs.restingHeartRateBaseline, baseline.isFinite, baseline > 0 { + let deviation = value - baseline + admit( + ReadinessContributor( + kind: .restingHeartRate, + earned: higherIsWorse(deviation, ideal: 0, soft: 5, hard: 12, + points: ReadinessContributor.Kind.restingHeartRate.maxPoints), + maxPoints: ReadinessContributor.Kind.restingHeartRate.maxPoints, + value: value, + baseline: baseline, + deviation: deviation, + detail: relativeDetail("Resting HR", deviation, unit: .bpm) + ), + kind: .restingHeartRate + ) + } else { + awaitingBaseline = true + missing.append(.restingHeartRate) + } + } else { + missing.append(.restingHeartRate) + } + + // Sleep — absolute, since `SleepScore` already encodes population-normal ranges. + if let sleepScore = inputs.sleepScore, sleepScore > 0 { + sawAnySignal = true + let value = Double(sleepScore) + contributors.append( + ReadinessContributor( + kind: .sleep, + earned: lowerIsWorse(value, ideal: 88, soft: 65, hard: 30, + points: ReadinessContributor.Kind.sleep.maxPoints), + maxPoints: ReadinessContributor.Kind.sleep.maxPoints, + value: value, + baseline: nil, + deviation: nil, + detail: "Sleep score \(sleepScore)" + ) + ) + } else { + missing.append(.sleep) + } + + // Skin temperature — symmetric: a deviation in either direction is a signal. + if let value = inputs.skinTemperature, value.isFinite, value > 0 { + sawAnySignal = true + if let baseline = usableBaseline(inputs.skinTemperatureBaseline) { + let deviation = value - baseline + admit( + ReadinessContributor( + kind: .skinTemperature, + earned: higherIsWorse(abs(deviation), ideal: 0.2, soft: 0.6, hard: 1.2, + points: ReadinessContributor.Kind.skinTemperature.maxPoints), + maxPoints: ReadinessContributor.Kind.skinTemperature.maxPoints, + value: value, + baseline: baseline, + deviation: deviation, + detail: relativeDetail("Skin temperature", deviation, unit: .celsius) + ), + kind: .skinTemperature + ) + } else { + awaitingBaseline = true + missing.append(.skinTemperature) + } + } else { + missing.append(.skinTemperature) + } + + // Training load — yesterday's minutes as a ratio of the trailing week. + if let value = inputs.priorDayLoadMinutes, value.isFinite, value >= 0 { + sawAnySignal = true + if let baseline = inputs.loadBaselineMinutes, baseline.isFinite, baseline > 0 { + let ratio = value / baseline + admit( + ReadinessContributor( + kind: .trainingLoad, + earned: higherIsWorse(ratio, ideal: 1.2, soft: 1.8, hard: 3.0, + points: ReadinessContributor.Kind.trainingLoad.maxPoints), + maxPoints: ReadinessContributor.Kind.trainingLoad.maxPoints, + value: value, + baseline: baseline, + deviation: ratio, + detail: loadDetail(ratio) + ), + kind: .trainingLoad + ) + } else { + awaitingBaseline = true + missing.append(.trainingLoad) + } + } else { + missing.append(.trainingLoad) + } + + guard sawAnySignal else { return .unavailable(.noSignals) } + + let available = contributors.reduce(0) { $0 + $1.maxPoints } + // HRV and sleep are the two signals that actually describe recovery. Resting HR and + // temperature qualify them; load and temperature alone would be a fitness score, not a + // readiness one. + let hasCoreSignal = contributors.contains { $0.kind == .hrv || $0.kind == .sleep } + + guard available >= minAvailablePoints, hasCoreSignal else { + return .unavailable(awaitingBaseline ? .baselineLearning : .insufficientCoverage) + } + + let earned = contributors.reduce(0) { $0 + $1.earned } + let score = Int(clamp(((earned / available) * 100).rounded(), 0, 100)) + + // Biggest drag first, falling back to declaration order so equal drags stay deterministic. + let ordering = Dictionary( + uniqueKeysWithValues: ReadinessContributor.Kind.allCases.enumerated().map { ($1, $0) } + ) + let ranked = contributors.sorted { + $0.drag == $1.drag + ? (ordering[$0.kind] ?? 0) < (ordering[$1.kind] ?? 0) + : $0.drag > $1.drag + } + + return .scored( + ReadinessResult( + score: score, + band: band(score), + contributors: ranked, + missing: ReadinessContributor.Kind.allCases.filter { missing.contains($0) }, + availablePoints: available + ) + ) + } + + // MARK: - Band shaping + + /// Full points at or above `ideal`, `softFraction` of them at `soft`, zero at or below `hard`, + /// linear between the knots. Requires `ideal > soft > hard`. + /// + /// Deliberately not a reuse of `SleepScore.bandScore`, which is two-sided and absolute — every + /// readiness contributor except sleep is a one-sided deviation from a personal baseline. + private static func lowerIsWorse( + _ value: Double, ideal: Double, soft: Double, hard: Double, + points: Double, softFraction: Double = ReadinessScore.softFraction + ) -> Double { + guard value.isFinite, ideal > soft, soft > hard else { return 0 } + if value >= ideal { return points } + if value <= hard { return 0 } + let softPoints = points * softFraction + if value >= soft { + // Between soft and ideal: softPoints → points. + return softPoints + (points - softPoints) * ((value - soft) / (ideal - soft)) + } + // Between hard and soft: 0 → softPoints. + return softPoints * ((value - hard) / (soft - hard)) + } + + /// Mirror of `lowerIsWorse` for signals where a rise is the bad direction. Requires + /// `ideal < soft < hard`. Implemented by negation so the two curves cannot drift apart. + private static func higherIsWorse( + _ value: Double, ideal: Double, soft: Double, hard: Double, + points: Double, softFraction: Double = ReadinessScore.softFraction + ) -> Double { + lowerIsWorse(-value, ideal: -ideal, soft: -soft, hard: -hard, + points: points, softFraction: softFraction) + } + + // MARK: - Helpers + + /// A baseline is usable only once `BaselineStats` considers it established (roughly a week of + /// wear, ≥20 samples) and its median is a positive number we can divide by. + private static func usableBaseline(_ stats: BaselineStats?) -> Double? { + guard let stats, stats.isEstablished else { return nil } + let median = stats.median + guard median.isFinite, median > 0 else { return nil } + return median + } + + private enum DeviationUnit { + case percent, bpm, celsius + + /// Deviations smaller than this read as "at baseline" rather than as a rounded-to-zero + /// delta — "HRV 0% below your baseline" is noise dressed up as a finding. + var epsilon: Double { + switch self { + case .percent: return 0.5 + case .bpm: return 0.5 + case .celsius: return 0.05 + } + } + + func format(_ magnitude: Double) -> String { + switch self { + case .percent: return "\(Int(magnitude.rounded()))%" + case .bpm: return "\(Int(magnitude.rounded())) bpm" + case .celsius: return String(format: "%.1f °C", magnitude) + } + } + } + + private static func relativeDetail(_ label: String, _ deviation: Double, unit: DeviationUnit) -> String { + guard deviation.isFinite, abs(deviation) >= unit.epsilon else { + return "\(label) at your baseline" + } + let direction = deviation < 0 ? "below" : "above" + return "\(label) \(unit.format(abs(deviation))) \(direction) your baseline" + } + + private static func loadDetail(_ ratio: Double) -> String { + guard ratio.isFinite else { return "Training load unknown" } + if ratio <= 1.2 { return "Yesterday's load in your usual range" } + return String(format: "Yesterday's load %.1f× your usual", ratio) + } + + private static func clamp(_ value: Double, _ lo: Double, _ hi: Double) -> Double { + min(hi, max(lo, value)) + } +} diff --git a/PulseLoop/Services/ReadinessService.swift b/PulseLoop/Services/ReadinessService.swift new file mode 100644 index 00000000..efebb79a --- /dev/null +++ b/PulseLoop/Services/ReadinessService.swift @@ -0,0 +1,277 @@ +import Foundation +import SwiftData + +/// Assembles readiness inputs from the store, scores them, and persists the result. +/// +/// The storage half of the readiness feature — `ReadinessScore` holds the (pure) maths. Shaped +/// after `RestingHRBaselineService`: a throttled `refreshIfStale` entry point, a bounded fetch, and +/// writes only when something actually changed. +/// +/// The window that matters here is **the night**, not the calendar day. Daytime HRV and heart rate +/// reflect what you were doing, not how you recovered, so every overnight signal is read from the +/// sleep session's own span and daytime samples are excluded outright. +enum ReadinessService { + /// Readiness changes when a night's data lands, not continuously. Three hours is frequent + /// enough to pick up a morning sync and cheap enough to call on every foreground. + static let refreshInterval: TimeInterval = 3 * 3600 + static let baselineWindowDays = 30 + static let loadBaselineDays = 7 + /// 30 days of continuous overnight sampling is a few thousand rows; cap defensively, matching + /// `RestingHRBaselineService.fetchLimit`. + static let fetchLimit = 5000 + /// Below this many usable days the trailing-load mean is noise, so load is left unscored. + static let minLoadBaselineDays = 4 + + /// Fallback overnight window when no sleep session was decoded: 22:00 the previous evening + /// through 08:00. Deliberately generous — a ring that captured HRV and HR overnight but failed + /// the sleep decode should still produce a score. + static let fallbackWindowStartHour = -2 + static let fallbackWindowEndHour = 8 + + // MARK: - Entry points + + /// Recompute today's readiness if the stored row is stale or was written by an older algorithm. + /// Cheap to call on every launch and foreground. + @MainActor + static func refreshIfStale(context: ModelContext, now: Date = Date()) { + guard ReadinessPrefsStore.shared.prefs.masterEnabled else { return } + let today = Calendar.current.startOfDay(for: now) + if let existing = ReadinessRepository.row(on: today, context: context), + existing.algorithmVersion == ReadinessScore.algorithmVersion, + now.timeIntervalSince(existing.computedAt) < refreshInterval { + return + } + refresh(day: today, context: context, now: now) + } + + /// Compute and upsert the row for `day`. + /// + /// Deletes any existing row when the outcome becomes unavailable, so a night whose sleep session + /// was corrected or deleted doesn't strand yesterday's score on screen. + @MainActor + @discardableResult + static func refresh(day: Date, context: ModelContext, now: Date = Date()) -> ReadinessOutcome { + let startOfDay = Calendar.current.startOfDay(for: day) + let outcome = ReadinessScore.evaluate(inputs(for: startOfDay, context: context)) + let existing = ReadinessRepository.row(on: startOfDay, context: context) + + switch outcome { + case .unavailable: + if let existing { + context.delete(existing) + try? context.save() + } + case .scored(let result): + let json = encodeContributors(result.contributors) + if let existing { + existing.score = result.score + existing.bandRaw = result.band.rawValue + existing.availablePoints = result.availablePoints + existing.contributorsJSON = json + existing.algorithmVersion = ReadinessScore.algorithmVersion + existing.computedAt = now + existing.updatedAt = now + } else { + context.insert( + ReadinessDaily( + date: startOfDay, + score: result.score, + band: result.band, + availablePoints: result.availablePoints, + contributorsJSON: json, + computedAt: now + ) + ) + } + try? context.save() + } + return outcome + } + + /// Fill in history. Idempotent: days already scored at the current algorithm version are + /// skipped, so this is safe to call after an import or a demo reseed. + @MainActor + static func backfill(days: Int = 30, context: ModelContext, now: Date = Date()) { + guard ReadinessPrefsStore.shared.prefs.masterEnabled else { return } + let calendar = Calendar.current + let today = calendar.startOfDay(for: now) + for offset in 0.. ReadinessInputs { + let calendar = Calendar.current + let startOfDay = calendar.startOfDay(for: day) + let window = overnightWindow(for: startOfDay, context: context) + + // Baselines end where the scored night begins, so a night can actually deviate from its own + // baseline rather than being averaged into it. + let baselineStart = window.start.addingTimeInterval(-Double(baselineWindowDays) * 86_400) + + func overnightValues(_ kind: MeasurementKind) -> [Double] { + MetricsRepository + .measurements(kind: kind, start: window.start, end: window.end, + limit: fetchLimit, context: context) + .map(\.value) + .filter { $0 > 0 } + } + + func baselineSamples(_ kind: MeasurementKind) -> BaselineStats? { + let rows = MetricsRepository.measurements( + kind: kind, start: baselineStart, end: window.start, + limit: fetchLimit, context: context + ) + // Only overnight readings belong in an overnight baseline; a 3pm HRV reading describes + // a different physiological state entirely. + let nightly = rows + .filter { isOvernight($0.timestamp, calendar: calendar) } + .map { MetricSample(timestamp: $0.timestamp, value: $0.value) } + return BaselineStats.compute(nightly) + } + + let hrvValues = overnightValues(.hrv) + let hrValues = overnightValues(.heartRate) + let tempValues = overnightValues(.temperature) + + var inputs = ReadinessInputs() + + if !hrvValues.isEmpty { + inputs.hrv = mean(hrvValues) + inputs.hrvBaseline = baselineSamples(.hrv) + } + + if !hrValues.isEmpty { + // The night's floor, not its average — the same statistic (p10) the learned baseline it + // is compared against uses, so the two are like for like. + inputs.restingHeartRate = percentile(hrValues.sorted(), 0.10) + inputs.restingHeartRateBaseline = ProfileRepository.profile(context: context)?.hrRestingBaseline + } + + if !tempValues.isEmpty { + inputs.skinTemperature = mean(tempValues) + inputs.skinTemperatureBaseline = baselineSamples(.temperature) + } + + if let sleep = SleepService.sleepForDate(startOfDay, context: context), sleep.session.totalMinutes > 0 { + inputs.sleepScore = SleepScore.calculate(sleep).score + } + + if let priorDay = calendar.date(byAdding: .day, value: -1, to: startOfDay) { + inputs.priorDayLoadMinutes = loadMinutes(on: priorDay, context: context) + inputs.loadBaselineMinutes = loadBaseline(before: priorDay, context: context) + } + + return inputs + } + + // MARK: - Overnight window + + /// The span to read overnight signals from. Prefers the night's own sleep session — which + /// `SleepService.sleepForDate` already resolves to the day's *longest* session, i.e. the night + /// rather than a nap — and falls back to a fixed 22:00–08:00 window when sleep wasn't decoded. + @MainActor + static func overnightWindow(for day: Date, context: ModelContext) -> (start: Date, end: Date) { + let calendar = Calendar.current + let startOfDay = calendar.startOfDay(for: day) + if let sleep = SleepService.sleepForDate(startOfDay, context: context), + sleep.session.endAt > sleep.session.startAt { + return (sleep.session.startAt, sleep.session.endAt) + } + let start = calendar.date(byAdding: .hour, value: fallbackWindowStartHour, to: startOfDay) ?? startOfDay + let end = calendar.date(byAdding: .hour, value: fallbackWindowEndHour, to: startOfDay) ?? startOfDay + return (start, end) + } + + /// Whether a timestamp falls in the overnight band used for baselines (22:00–08:00 local). + private static func isOvernight(_ date: Date, calendar: Calendar) -> Bool { + let hour = calendar.component(.hour, from: date) + return hour >= 22 || hour < 8 + } + + // MARK: - Training load + + /// Yesterday's load in minutes: `max` of the day's active minutes and its recorded workout + /// time, never the sum — a tracked run usually also generates active minutes, and adding them + /// would double-count the same hour of effort. + @MainActor + static func loadMinutes(on day: Date, context: ModelContext) -> Double? { + let calendar = Calendar.current + let startOfDay = calendar.startOfDay(for: day) + guard let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) else { return nil } + + let daily = MetricsRepository.activity(on: startOfDay, context: context) + let activeMinutes = daily.map { Double(max(0, $0.activeMinutes)) } + + let sessions = ActivityRepository.sessions(context: context).filter { session in + guard session.status == .finished, let ended = session.endedAt else { return false } + return ended >= startOfDay && ended < endOfDay + } + let workoutMinutes: Double? = sessions.isEmpty ? nil : sessions.reduce(0.0) { total, session in + guard let ended = session.endedAt else { return total } + let elapsed = ended.timeIntervalSince(session.startedAt) - session.totalPauseSeconds + return total + max(0, elapsed) / 60 + } + + switch (activeMinutes, workoutMinutes) { + case (nil, nil): return nil + case (let a?, nil): return a + case (nil, let w?): return w + case (let a?, let w?): return max(a, w) + } + } + + /// Trailing mean daily load over the `loadBaselineDays` before `day`, excluding `day` itself. + /// Returns nil below `minLoadBaselineDays` of usable history — a ratio against one or two days + /// would swing wildly for no real reason. + @MainActor + static func loadBaseline(before day: Date, context: ModelContext) -> Double? { + let calendar = Calendar.current + var values: [Double] = [] + for offset in 1...loadBaselineDays { + guard let past = calendar.date(byAdding: .day, value: -offset, to: day) else { continue } + if let minutes = loadMinutes(on: past, context: context) { + values.append(minutes) + } + } + guard values.count >= minLoadBaselineDays else { return nil } + let average = mean(values) + return average > 0 ? average : nil + } + + // MARK: - Helpers + + private static func encodeContributors(_ contributors: [ReadinessContributor]) -> String { + let records = contributors.map(ReadinessContributorRecord.init) + guard let data = try? JSONEncoder().encode(records), + let json = String(data: data, encoding: .utf8) else { return "[]" } + return json + } + + private static func mean(_ values: [Double]) -> Double { + guard !values.isEmpty else { return 0 } + return values.reduce(0, +) / Double(values.count) + } + + /// Interpolated percentile — same formula as `BaselineStats.compute` and + /// `RestingHRBaselineService`, so every resting-HR number in the app is derived identically. + private static func percentile(_ sorted: [Double], _ fraction: Double) -> Double { + guard !sorted.isEmpty else { return 0 } + guard sorted.count > 1 else { return sorted[0] } + let rank = fraction * Double(sorted.count - 1) + let lower = Int(rank.rounded(.down)) + let upper = Int(rank.rounded(.up)) + let weight = rank - Double(lower) + return sorted[lower] * (1 - weight) + sorted[upper] * weight + } +} diff --git a/PulseLoop/Services/Repositories.swift b/PulseLoop/Services/Repositories.swift index 07d7b1bd..cc8b604b 100644 --- a/PulseLoop/Services/Repositories.swift +++ b/PulseLoop/Services/Repositories.swift @@ -264,6 +264,43 @@ enum ProfileRepository { } } +/// Stored daily readiness scores. `ReadinessService` is the only writer. +enum ReadinessRepository { + /// The row for one morning, or nil if that day was never scored (or scored then invalidated). + @MainActor + static func row(on date: Date, context: ModelContext) -> ReadinessDaily? { + let start = Calendar.current.startOfDay(for: date) + guard let end = Calendar.current.date(byAdding: .day, value: 1, to: start) else { return nil } + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.date >= start && $0.date < end }, + sortBy: [SortDescriptor(\.date, order: .reverse)] + ) + descriptor.fetchLimit = 1 + return try? context.fetch(descriptor).first + } + + /// The most recently scored morning. `fetchLimit: 1` — one row, not the whole table. + @MainActor + static func latest(context: ModelContext) -> ReadinessDaily? { + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.date, order: .reverse)] + ) + descriptor.fetchLimit = 1 + return try? context.fetch(descriptor).first + } + + /// Scored mornings within `[from, to]`, oldest-first for a left-to-right chart axis. + @MainActor + static func rows(from: Date, to: Date, limit: Int = 400, context: ModelContext) -> [ReadinessDaily] { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.date >= from && $0.date <= to }, + sortBy: [SortDescriptor(\.date, order: .forward)] + ) + descriptor.fetchLimit = limit + return (try? context.fetch(descriptor)) ?? [] + } +} + /// Per-device measurement configuration (HR interval + all-day vital toggles), keyed by `Device.id`. enum MeasurementConfigRepository { @MainActor diff --git a/PulseLoop/Settings/ReadinessPrefsStore.swift b/PulseLoop/Settings/ReadinessPrefsStore.swift new file mode 100644 index 00000000..07ede503 --- /dev/null +++ b/PulseLoop/Settings/ReadinessPrefsStore.swift @@ -0,0 +1,70 @@ +import Foundation + +/// User-tunable readiness preferences, persisted as JSON in `UserDefaults`. +/// +/// Unlike `NutritionPrefs`, `masterEnabled` defaults to **true**. Nutrition defaults off because it +/// is manual data entry that can ship meal photos to a third-party LLM — a genuinely new privacy +/// surface. Readiness is derived entirely from data the ring already collects locally: it adds no +/// permission, no network egress, and stores nothing the user didn't already have. It is also +/// self-gating, since the tile can't appear without HRV-or-sleep capability and can't score until +/// personal baselines establish, so defaulting it on can't produce a misleading empty tile. +/// +/// Mirrors the `NutritionPrefsStore` pattern — no SwiftData, no migration — with tolerant decode so +/// adding a future key never wipes an existing user's blob. +struct ReadinessPrefs: Codable, Equatable { + /// Master opt-in. While off there is no tile, no coach context, and no computation. + var masterEnabled = true + /// Show the readiness tile on the Today dashboard and in the widget snapshot. + var showOnToday = true + /// Include the score and its contributor breakdown in the coach's context packet and tools. + var shareWithCoach = true + /// Mention readiness in daily check-in notifications (only when `shareWithCoach` is also on). + var includeInNotifications = true + + static let `default` = ReadinessPrefs() + + init() {} + + /// Tolerant decode: any missing key falls back to its default, so a stored blob written by an + /// older build (lacking a newer key) is never discarded. + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let d = ReadinessPrefs.default + masterEnabled = try c.decodeIfPresent(Bool.self, forKey: .masterEnabled) ?? d.masterEnabled + showOnToday = try c.decodeIfPresent(Bool.self, forKey: .showOnToday) ?? d.showOnToday + shareWithCoach = try c.decodeIfPresent(Bool.self, forKey: .shareWithCoach) ?? d.shareWithCoach + includeInNotifications = try c.decodeIfPresent(Bool.self, forKey: .includeInNotifications) ?? d.includeInNotifications + } +} + +/// Observable, `UserDefaults`-backed store for readiness preferences. +/// Follows the `NutritionPrefsStore` pattern; persists on `didSet`, reads at use-time. +@MainActor +@Observable +final class ReadinessPrefsStore { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + static let shared = ReadinessPrefsStore() + + static let prefsKey = "pulseloop.readiness.prefs.v1" + private let defaults: UserDefaults + + var prefs: ReadinessPrefs { + didSet { persist(prefs, forKey: Self.prefsKey) } + } + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + self.prefs = Self.load(ReadinessPrefs.self, forKey: Self.prefsKey, from: defaults) ?? .default + } + + private static func load(_ type: T.Type, forKey key: String, from defaults: UserDefaults) -> T? { + guard let data = defaults.data(forKey: key) else { return nil } + return try? JSONDecoder().decode(T.self, from: data) + } + + private func persist(_ value: T, forKey key: String) { + guard let data = try? JSONEncoder().encode(value) else { return } + defaults.set(data, forKey: key) + } +} diff --git a/PulseLoopTests/DataArchiveTests.swift b/PulseLoopTests/DataArchiveTests.swift index a9b7ad31..5285fa0a 100644 --- a/PulseLoopTests/DataArchiveTests.swift +++ b/PulseLoopTests/DataArchiveTests.swift @@ -2,7 +2,7 @@ import XCTest import SwiftData @testable import PulseLoop -/// Locks down the full-app export/import archive: a complete round trip over all 24 models, +/// Locks down the full-app export/import archive: a complete round trip over all 25 models, /// wipe completeness, version/corruption rejection (without data loss), and the settings + /// attachment side channels. Hermetic — in-memory SwiftData, suite-scoped UserDefaults, temp dirs. @MainActor @@ -14,7 +14,7 @@ final class DataArchiveTests: XCTestCase { (try? context.fetchCount(FetchDescriptor())) ?? -1 } - /// One row of every model type `SeedData.seedDemo` does NOT create, so seed + these = all 24. + /// One row of every model type `SeedData.seedDemo` does NOT create, so seed + these = all 25. private func insertModelsMissingFromSeed(_ context: ModelContext, deviceId: UUID) { context.insert(BatterySample(percent: 57, timestamp: Date(timeIntervalSince1970: 1_750_000_000))) context.insert(DeviceMeasurementConfig(deviceId: deviceId)) @@ -34,6 +34,15 @@ final class DataArchiveTests: XCTestCase { context.insert(CoachNotificationRecord(slotRaw: "morning", dateKey: "2026-07-25", title: "Hi", body: "Check in")) context.insert(CoachSummary(kind: "today", scopeKey: "2026-07-25", title: "Today", body: "Solid", dataSignature: "sig1")) context.insert(WearableLog(category: .sync, level: .info, message: "sync done", metadataJSON: #"{"n":1}"#)) + // Inserted explicitly rather than relying on the seed's readiness backfill, which only + // scores days that happen to have a full enough night. + context.insert(ReadinessDaily( + date: Date(timeIntervalSince1970: 1_750_000_000), + score: 78, + band: .ready, + availablePoints: 85, + contributorsJSON: #"[{"kindRaw":"hrv","earned":22.5,"maxPoints":30,"value":44,"baseline":50,"deviation":-12,"detail":"HRV 12% below your baseline"}]"# + )) try? context.save() } @@ -50,6 +59,7 @@ final class DataArchiveTests: XCTestCase { check(ActivityEvent.self); check(ActivitySensorPollEvent.self); check(CoachConversation.self) check(CoachMessage.self); check(CoachMemory.self); check(CoachToolCall.self) check(CoachNotificationRecord.self); check(CoachSummary.self); check(WearableLog.self) + check(ReadinessDaily.self) } private func makeSuiteDefaults(_ name: String) -> UserDefaults { @@ -169,6 +179,34 @@ final class DataArchiveTests: XCTestCase { XCTAssertEqual(count(PulseLoop.Measurement.self, context), before, "a rejected file must not touch existing data") } + /// Format version 2 added `readinessDailies`. Archives exported before it have no such key, and + /// `PulseArchive` decodes via the synthesized decoder — which has no notion of property + /// defaults — so the field must stay Optional or every older backup becomes unimportable. + /// This test builds a genuine v1 file by stripping the key from a real export. + func testV1ArchiveWithoutReadinessStillImports() async throws { + let source = try TestSupport.makeContext() + SeedData.seedDemo(source) + TestSupport.insertMeasurement(kind: .heartRate, value: 71, timestamp: Date(), into: source) + let measurementCount = count(PulseLoop.Measurement.self, source) + XCTAssertGreaterThan(measurementCount, 0) + + let data = try await DataArchiveService.exportArchive(context: source) + var json = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any], + "export should be a JSON object" + ) + XCTAssertNotNil(json["readinessDailies"], "a v2 export must write the key") + json.removeValue(forKey: "readinessDailies") + json["formatVersion"] = 1 + let v1Data = try JSONSerialization.data(withJSONObject: json) + + let destination = try TestSupport.makeContext() + try await DataArchiveService.importArchive(v1Data, context: destination, refreshStores: false) + + XCTAssertEqual(count(PulseLoop.Measurement.self, destination), measurementCount, + "a v1 archive must restore everything it does contain") + } + func testImportRejectsCorruptJSONWithoutDataLoss() async throws { let context = try TestSupport.makeContext() TestSupport.insertMeasurement(kind: .heartRate, value: 70, timestamp: Date(), into: context) diff --git a/PulseLoopTests/ReadinessScoreTests.swift b/PulseLoopTests/ReadinessScoreTests.swift new file mode 100644 index 00000000..e6ff645f --- /dev/null +++ b/PulseLoopTests/ReadinessScoreTests.swift @@ -0,0 +1,359 @@ +import XCTest +@testable import PulseLoop + +/// Locks the readiness algorithm: band knots, the missing-signal contract, and the exact wording of +/// the contributor explanations. Pure logic — no store, no hardware, no dates. +/// +/// The most important test here is `testMissingContributorIsNeverScoredAsZero`. Everything else is +/// arithmetic; that one encodes the design rule the whole feature rests on. +@MainActor +final class ReadinessScoreTests: XCTestCase { + + // MARK: - Fixtures + + /// An established baseline: `isEstablished` needs ≥7 span days and ≥20 samples. + private func baseline(_ median: Double, established: Bool = true) -> BaselineStats { + BaselineStats( + mean: median, + median: median, + standardDeviation: median * 0.1, + p25: median * 0.9, + p75: median * 1.1, + sampleCount: established ? 40 : 5, + spanDays: established ? 30 : 3 + ) + } + + /// Every contributor present and exactly at baseline. + private func perfectInputs() -> ReadinessInputs { + ReadinessInputs( + hrv: 50, hrvBaseline: baseline(50), + restingHeartRate: 55, restingHeartRateBaseline: 55, + sleepScore: 90, + skinTemperature: 36.1, skinTemperatureBaseline: baseline(36.0), + priorDayLoadMinutes: 45, loadBaselineMinutes: 45 + ) + } + + /// HRV at a given percentage deviation from a 50 ms baseline, paired with sleep so the + /// 50-point coverage gate is satisfied and the outcome is scoreable. + private func hrvInputs(deviationPercent: Double, sleepScore: Int = 88) -> ReadinessInputs { + ReadinessInputs( + hrv: 50 * (1 + deviationPercent / 100), hrvBaseline: baseline(50), + sleepScore: sleepScore + ) + } + + private func scored(_ inputs: ReadinessInputs, + file: StaticString = #filePath, line: UInt = #line) throws -> ReadinessResult { + guard case .scored(let result) = ReadinessScore.evaluate(inputs) else { + XCTFail("expected a scored outcome, got \(ReadinessScore.evaluate(inputs))", file: file, line: line) + throw XCTSkip("not scored") + } + return result + } + + private func contributor(_ kind: ReadinessContributor.Kind, + in result: ReadinessResult, + file: StaticString = #filePath, line: UInt = #line) throws -> ReadinessContributor { + let match = result.contributors.first { $0.kind == kind } + return try XCTUnwrap(match, "expected a \(kind.rawValue) contributor", file: file, line: line) + } + + private func earned(_ kind: ReadinessContributor.Kind, _ inputs: ReadinessInputs) throws -> Double { + try contributor(kind, in: try scored(inputs)).earned + } + + // MARK: - Composition + + func testAllContributorsAtBaselineScores100() throws { + let result = try scored(perfectInputs()) + XCTAssertEqual(result.score, 100) + XCTAssertEqual(result.band, .primed) + XCTAssertEqual(result.availablePoints, 100) + XCTAssertEqual(result.coverage, 1.0) + XCTAssertTrue(result.missing.isEmpty) + XCTAssertEqual(result.contributors.count, 5) + } + + /// The core invariant. A signal the ring didn't capture must leave the denominator, not drag + /// the score down. If this ever fails, readiness is punishing users for hardware gaps. + func testMissingContributorIsNeverScoredAsZero() throws { + // Sleep (30) + resting HR (25) = 55 available, both perfect. + let partial = ReadinessInputs( + restingHeartRate: 55, restingHeartRateBaseline: 55, + sleepScore: 90 + ) + let partialResult = try scored(partial) + XCTAssertEqual(partialResult.score, 100, "a night missing HRV is scored out of 55, not out of 100") + XCTAssertEqual(partialResult.availablePoints, 55) + XCTAssertEqual(partialResult.coverage, 0.55, accuracy: 0.0001) + XCTAssertTrue(partialResult.missing.contains(.hrv)) + + // The same night, but HRV was captured and is genuinely poor — now it must bite. + var withPoorHrv = partial + withPoorHrv.hrv = 30 + withPoorHrv.hrvBaseline = baseline(50) + let poorResult = try scored(withPoorHrv) + XCTAssertLessThan(poorResult.score, partialResult.score) + XCTAssertEqual(poorResult.availablePoints, 85) + } + + func testCoverageGateReturnsUnavailable() { + // Sleep alone is 30 points — below the 50-point floor. + let outcome = ReadinessScore.evaluate(ReadinessInputs(sleepScore: 90)) + XCTAssertEqual(outcome, .unavailable(.insufficientCoverage)) + } + + func testNoSignalsAtAllIsDistinctFromThinCoverage() { + XCTAssertEqual(ReadinessScore.evaluate(ReadinessInputs()), .unavailable(.noSignals)) + } + + /// Resting HR and temperature qualify recovery; they don't describe it. Without HRV or sleep + /// there is nothing to qualify. + func testWithoutCoreSignalIsUnavailableEvenWithEnoughPoints() { + let outcome = ReadinessScore.evaluate(ReadinessInputs( + restingHeartRate: 55, restingHeartRateBaseline: 55, + skinTemperature: 36.0, skinTemperatureBaseline: baseline(36.0), + priorDayLoadMinutes: 45, loadBaselineMinutes: 45 + )) + XCTAssertEqual(outcome, .unavailable(.insufficientCoverage)) + } + + /// A signal whose baseline isn't established is missing, not "at baseline" — and it reports the + /// recoverable reason so the tile can say "still learning" instead of "no data". + func testUnestablishedBaselineIsTreatedAsMissing() throws { + let inputs = ReadinessInputs( + hrv: 50, hrvBaseline: baseline(50, established: false), + sleepScore: 90 + ) + XCTAssertEqual(ReadinessScore.evaluate(inputs), .unavailable(.baselineLearning)) + + // With enough other coverage to score, HRV still stays out of the maths entirely. + var withRhr = inputs + withRhr.restingHeartRate = 55 + withRhr.restingHeartRateBaseline = 55 + let result = try scored(withRhr) + XCTAssertEqual(result.availablePoints, 55, "an unestablished baseline contributes no points") + XCTAssertTrue(result.missing.contains(.hrv)) + XCTAssertFalse(result.contributors.contains { $0.kind == .hrv }) + } + + // MARK: - Band knots + + func testHrvBandKnots() throws { + XCTAssertEqual(try earned(.hrv, hrvInputs(deviationPercent: 0)), 30.0, accuracy: 0.001) + XCTAssertEqual(try earned(.hrv, hrvInputs(deviationPercent: -15)), 16.5, accuracy: 0.001) + XCTAssertEqual(try earned(.hrv, hrvInputs(deviationPercent: -40)), 0.0, accuracy: 0.001) + XCTAssertEqual(try earned(.hrv, hrvInputs(deviationPercent: -60)), 0.0, accuracy: 0.001) + } + + func testHighHrvIsNotPenalized() throws { + XCTAssertEqual(try earned(.hrv, hrvInputs(deviationPercent: 40)), 30.0, accuracy: 0.001) + } + + func testRestingHeartRateBandKnots() throws { + func rhr(_ delta: Double) -> ReadinessInputs { + ReadinessInputs(restingHeartRate: 55 + delta, restingHeartRateBaseline: 55, sleepScore: 88) + } + XCTAssertEqual(try earned(.restingHeartRate, rhr(0)), 25.0, accuracy: 0.001) + XCTAssertEqual(try earned(.restingHeartRate, rhr(5)), 13.75, accuracy: 0.001) + XCTAssertEqual(try earned(.restingHeartRate, rhr(12)), 0.0, accuracy: 0.001) + XCTAssertEqual(try earned(.restingHeartRate, rhr(-6)), 25.0, accuracy: 0.001, + "a resting HR below baseline is a good sign, never a penalty") + } + + func testSleepBandKnots() throws { + func sleep(_ score: Int) -> ReadinessInputs { + ReadinessInputs(restingHeartRate: 55, restingHeartRateBaseline: 55, sleepScore: score) + } + XCTAssertEqual(try earned(.sleep, sleep(88)), 30.0, accuracy: 0.001) + XCTAssertEqual(try earned(.sleep, sleep(65)), 16.5, accuracy: 0.001) + XCTAssertEqual(try earned(.sleep, sleep(30)), 0.0, accuracy: 0.001) + } + + func testSkinTemperatureIsSymmetric() throws { + // Resting HR is carried at baseline purely to clear the 50-point coverage gate; it earns + // full marks, so it never moves the temperature contributor being measured. + func temp(_ delta: Double) -> ReadinessInputs { + ReadinessInputs( + restingHeartRate: 55, restingHeartRateBaseline: 55, + sleepScore: 88, + skinTemperature: 36.0 + delta, skinTemperatureBaseline: baseline(36.0) + ) + } + let above = try contributor(.skinTemperature, in: try scored(temp(0.9))) + let below = try contributor(.skinTemperature, in: try scored(temp(-0.9))) + XCTAssertEqual(above.earned, below.earned, "a deviation is a deviation in either direction") + XCTAssertEqual(above.detail, "Skin temperature 0.9 °C above your baseline") + XCTAssertEqual(below.detail, "Skin temperature 0.9 °C below your baseline") + + XCTAssertEqual(try earned(.skinTemperature, temp(0.2)), 10.0, accuracy: 0.001) + XCTAssertEqual(try earned(.skinTemperature, temp(0.6)), 5.5, accuracy: 0.001) + XCTAssertEqual(try earned(.skinTemperature, temp(1.2)), 0.0, accuracy: 0.001) + } + + func testTrainingLoadBandKnots() throws { + // Resting HR at baseline clears the coverage gate without affecting the load contributor. + func load(_ ratio: Double) -> ReadinessInputs { + ReadinessInputs( + restingHeartRate: 55, restingHeartRateBaseline: 55, + sleepScore: 88, + priorDayLoadMinutes: 40 * ratio, loadBaselineMinutes: 40 + ) + } + XCTAssertEqual(try earned(.trainingLoad, load(1.0)), 5.0, accuracy: 0.001) + XCTAssertEqual(try earned(.trainingLoad, load(1.2)), 5.0, accuracy: 0.001) + XCTAssertEqual(try earned(.trainingLoad, load(1.8)), 2.75, accuracy: 0.001) + XCTAssertEqual(try earned(.trainingLoad, load(3.0)), 0.0, accuracy: 0.001) + XCTAssertEqual(try earned(.trainingLoad, load(0.2)), 5.0, accuracy: 0.001, + "a rest day is never penalized") + } + + func testBandCutoffs() { + XCTAssertEqual(ReadinessScore.band(100), .primed) + XCTAssertEqual(ReadinessScore.band(85), .primed) + XCTAssertEqual(ReadinessScore.band(84), .ready) + XCTAssertEqual(ReadinessScore.band(70), .ready) + XCTAssertEqual(ReadinessScore.band(69), .moderate) + XCTAssertEqual(ReadinessScore.band(55), .moderate) + XCTAssertEqual(ReadinessScore.band(54), .restNeeded) + XCTAssertEqual(ReadinessScore.band(0), .restNeeded) + } + + // MARK: - Shape + + /// No cliffs, no inversions, no out-of-range scores anywhere across the HRV domain. + func testScoreIsMonotonicInHrv() throws { + var previous = Int.max + for step in stride(from: 20.0, through: -60.0, by: -1.0) { + let result = try scored(hrvInputs(deviationPercent: step)) + XCTAssertTrue((0...100).contains(result.score), "score \(result.score) out of range at \(step)%") + XCTAssertLessThanOrEqual(result.score, previous, "score rose as HRV fell, at \(step)%") + previous = result.score + } + } + + func testContributorsSortedByDragDescending() throws { + let inputs = ReadinessInputs( + hrv: 30, hrvBaseline: baseline(50), // −40%, full 30-point drag + restingHeartRate: 57, restingHeartRateBaseline: 55, // +2 bpm, small drag + sleepScore: 90, // no drag + skinTemperature: 36.0, skinTemperatureBaseline: baseline(36.0), + priorDayLoadMinutes: 45, loadBaselineMinutes: 45 + ) + let result = try scored(inputs) + let drags = result.contributors.map(\.drag) + XCTAssertEqual(drags, drags.sorted(by: >)) + XCTAssertEqual(result.contributors.first?.kind, .hrv) + } + + func testMissingIsReportedInCanonicalOrder() throws { + let result = try scored(ReadinessInputs( + hrv: 50, hrvBaseline: baseline(50), + sleepScore: 90 + )) + XCTAssertEqual(result.missing, [.restingHeartRate, .skinTemperature, .trainingLoad]) + } + + // MARK: - Explanations + + func testDetailStringsAreDataHonest() throws { + let result = try scored(ReadinessInputs( + hrv: 44, hrvBaseline: baseline(50), // −12% + restingHeartRate: 59, restingHeartRateBaseline: 55, // +4 bpm + sleepScore: 82 + )) + XCTAssertEqual(try contributor(.hrv, in: result).detail, "HRV 12% below your baseline") + XCTAssertEqual(try contributor(.restingHeartRate, in: result).detail, "Resting HR 4 bpm above your baseline") + XCTAssertEqual(try contributor(.sleep, in: result).detail, "Sleep score 82") + + // Nothing describes a signal that wasn't measured. + XCTAssertFalse(result.contributors.contains { $0.kind == .skinTemperature }) + XCTAssertFalse(result.contributors.contains { $0.detail.localizedCaseInsensitiveContains("temperature") }) + XCTAssertFalse(result.contributors.contains { $0.detail.localizedCaseInsensitiveContains("load") }) + } + + /// A deviation that rounds to zero is reported as "at baseline", not as a finding of zero. + func testNegligibleDeviationReadsAsAtBaseline() throws { + let result = try scored(ReadinessInputs( + hrv: 50.1, hrvBaseline: baseline(50), + restingHeartRate: 55.1, restingHeartRateBaseline: 55, + sleepScore: 88 + )) + XCTAssertEqual(try contributor(.hrv, in: result).detail, "HRV at your baseline") + XCTAssertEqual(try contributor(.restingHeartRate, in: result).detail, "Resting HR at your baseline") + } + + func testTrainingLoadDetailOnlyCallsOutRealSpikes() throws { + func detail(_ ratio: Double) throws -> String { + try contributor(.trainingLoad, in: try scored( + ReadinessInputs( + restingHeartRate: 55, restingHeartRateBaseline: 55, + sleepScore: 88, + priorDayLoadMinutes: 40 * ratio, loadBaselineMinutes: 40 + ) + )).detail + } + XCTAssertEqual(try detail(1.0), "Yesterday's load in your usual range") + XCTAssertEqual(try detail(2.1), "Yesterday's load 2.1× your usual") + } + + // MARK: - Robustness + + /// Garbage in must not produce a crash, a NaN, or a confidently wrong number. The service layer + /// filters its samples, but scoring must not depend on that. + func testDegenerateInputsAreSafe() { + let hostile: [ReadinessInputs] = [ + ReadinessInputs(hrv: .nan, hrvBaseline: baseline(50), sleepScore: 90), + ReadinessInputs(hrv: .infinity, hrvBaseline: baseline(50), sleepScore: 90), + ReadinessInputs(hrv: -10, hrvBaseline: baseline(50), sleepScore: 90), + ReadinessInputs(hrv: 50, hrvBaseline: baseline(0), sleepScore: 90), + ReadinessInputs(restingHeartRate: 55, restingHeartRateBaseline: 0, sleepScore: 90), + ReadinessInputs(sleepScore: 0, priorDayLoadMinutes: 45, loadBaselineMinutes: 0), + ReadinessInputs(sleepScore: -5), + ReadinessInputs( + hrv: 50, hrvBaseline: baseline(50), + sleepScore: 90, + skinTemperature: .nan, skinTemperatureBaseline: baseline(36.0), + priorDayLoadMinutes: .infinity, loadBaselineMinutes: 45 + ) + ] + + for inputs in hostile { + switch ReadinessScore.evaluate(inputs) { + case .unavailable: + continue + case .scored(let result): + XCTAssertTrue((0...100).contains(result.score), "score \(result.score) out of range") + XCTAssertGreaterThan(result.availablePoints, 0) + for c in result.contributors { + XCTAssertTrue(c.earned.isFinite, "\(c.kind.rawValue) earned a non-finite score") + XCTAssertTrue((0...c.maxPoints).contains(c.earned)) + XCTAssertFalse(c.detail.isEmpty) + } + } + } + } + + /// Bumping the version is what invalidates stored rows. Changing weights or knots without + /// bumping it would leave old scores silently reinterpreted — so this pin is deliberate. + /// If you changed the algorithm: bump `algorithmVersion`, update `docs/project/readiness.md`, + /// then update this test. + func testAlgorithmVersionIsPinned() { + XCTAssertEqual(ReadinessScore.algorithmVersion, 1) + XCTAssertEqual(ReadinessScore.minAvailablePoints, 50) + XCTAssertEqual(ReadinessScore.softFraction, 0.55) + } + + /// The weights are the contract agreed in the issue thread; they are not incidental. + func testContributorWeightsSumTo100() { + let total = ReadinessContributor.Kind.allCases.reduce(0) { $0 + $1.maxPoints } + XCTAssertEqual(total, 100) + XCTAssertEqual(ReadinessContributor.Kind.hrv.maxPoints, 30) + XCTAssertEqual(ReadinessContributor.Kind.restingHeartRate.maxPoints, 25) + XCTAssertEqual(ReadinessContributor.Kind.sleep.maxPoints, 30) + XCTAssertEqual(ReadinessContributor.Kind.skinTemperature.maxPoints, 10) + XCTAssertEqual(ReadinessContributor.Kind.trainingLoad.maxPoints, 5) + } +} diff --git a/PulseLoopTests/ReadinessServiceTests.swift b/PulseLoopTests/ReadinessServiceTests.swift new file mode 100644 index 00000000..d0376967 --- /dev/null +++ b/PulseLoopTests/ReadinessServiceTests.swift @@ -0,0 +1,375 @@ +import XCTest +import SwiftData +@testable import PulseLoop + +/// Storage-side readiness tests: overnight windowing, baseline windows, upsert/throttle behaviour, +/// and backfill. The scoring maths itself is covered by `ReadinessScoreTests`. +/// +/// Anchored to a fixed reference date rather than `Date()` so a run at 23:59 can't straddle +/// midnight and produce a different day's window than a run at noon. +@MainActor +final class ReadinessServiceTests: XCTestCase { + + /// 2026-03-15 12:00 local — midday, so every ±hours offset stays inside its intended day. + private let reference: Date = { + var components = DateComponents() + components.year = 2026 + components.month = 3 + components.day = 15 + components.hour = 12 + return Calendar.current.date(from: components) ?? Date() + }() + + private var calendar: Calendar { Calendar.current } + private var savedPrefs: ReadinessPrefs? + + override func setUp() async throws { + try await super.setUp() + // `refreshIfStale` and `backfill` are gated on the master toggle, which lives in a shared + // UserDefaults-backed singleton. Pin it on, and restore whatever was there afterwards. + savedPrefs = ReadinessPrefsStore.shared.prefs + var prefs = ReadinessPrefs.default + prefs.masterEnabled = true + ReadinessPrefsStore.shared.prefs = prefs + } + + override func tearDown() async throws { + if let savedPrefs { ReadinessPrefsStore.shared.prefs = savedPrefs } + try await super.tearDown() + } + + // MARK: - Fixtures + + private func day(_ offset: Int) -> Date { + calendar.date(byAdding: .day, value: offset, to: calendar.startOfDay(for: reference)) + ?? reference + } + + /// A time on the morning of `day`, or the evening before when `hour` is negative. + private func at(_ hour: Int, _ dayOffset: Int, minute: Int = 0) -> Date { + let base = day(dayOffset) + return calendar.date(byAdding: .minute, value: hour * 60 + minute, to: base) ?? base + } + + /// A night belonging to the morning of `dayOffset`: 23:00 the previous evening → +`minutes`. + /// Stage blocks are aggregated (one per stage) rather than per-minute, so a 30-day backfill + /// doesn't insert fifteen thousand rows. + @discardableResult + private func insertNight( + _ dayOffset: Int, + minutes: Int = 450, + deep: Int = 90, + light: Int = 320, + awake: Int = 40, + into context: ModelContext + ) -> SleepSession { + let start = at(-1, dayOffset) // 23:00 the evening before + let end = calendar.date(byAdding: .minute, value: minutes, to: start) ?? start + let session = SleepSession( + date: day(dayOffset), startAt: start, endAt: end, + totalMinutes: minutes, syncedAt: start + ) + context.insert(session) + var cursor = 0 + for (stage, duration) in [(SleepStage.deep, deep), (.light, light), (.awake, awake)] where duration > 0 { + let blockStart = calendar.date(byAdding: .minute, value: cursor, to: start) ?? start + context.insert(SleepStageBlock( + sessionId: session.id, startAt: blockStart, + startMinute: cursor, durationMinutes: duration, stage: stage + )) + cursor += duration + } + try? context.save() + return session + } + + /// Heart-rate readings spread across the night of `dayOffset`. + private func insertOvernightHR(_ dayOffset: Int, values: [Double], into context: ModelContext) { + for (index, value) in values.enumerated() { + let ts = calendar.date(byAdding: .minute, value: index * 30, to: at(-1, dayOffset)) ?? reference + TestSupport.insertMeasurement(kind: .heartRate, value: value, timestamp: ts, into: context) + } + } + + private func setRestingBaseline(_ bpm: Double, into context: ModelContext) { + let profile = UserProfile() + profile.hrRestingBaseline = bpm + context.insert(profile) + try? context.save() + } + + /// The cheapest fully scoreable morning: sleep (30 pts) + resting HR (25 pts) = 55 available, + /// which clears `minAvailablePoints` without needing a 30-day HRV baseline. + private func seedScoreableDay(_ dayOffset: Int, into context: ModelContext) { + insertNight(dayOffset, into: context) + insertOvernightHR(dayOffset, values: [58, 56, 55, 57, 59], into: context) + } + + // MARK: - Overnight window + + func testOvernightWindowFollowsTheSleepSession() throws { + let context = try TestSupport.makeContext() + let session = insertNight(0, minutes: 450, into: context) + + let window = ReadinessService.overnightWindow(for: day(0), context: context) + XCTAssertEqual(window.start, session.startAt) + XCTAssertEqual(window.end, session.endAt) + } + + func testFallbackWindowIsUsedWhenSleepWasNotDecoded() throws { + let context = try TestSupport.makeContext() + // No sleep session at all — a ring that captured vitals but failed the sleep decode. + let window = ReadinessService.overnightWindow(for: day(0), context: context) + XCTAssertEqual(window.start, at(-2, 0), "fallback should open at 22:00 the evening before") + XCTAssertEqual(window.end, at(8, 0), "fallback should close at 08:00") + + insertOvernightHR(0, values: [58, 56, 55], into: context) + setRestingBaseline(55, into: context) + let inputs = ReadinessService.inputs(for: day(0), context: context) + XCTAssertNotNil(inputs.restingHeartRate, "overnight HR must survive a missing sleep session") + } + + /// Daytime readings describe what you were doing, not how you recovered. A low afternoon heart + /// rate must not be allowed to masquerade as a good resting HR. + func testDaytimeSamplesAreExcludedFromTheOvernightWindow() throws { + let context = try TestSupport.makeContext() + insertNight(0, into: context) + insertOvernightHR(0, values: [58, 56, 55, 57, 59], into: context) + // A much lower reading at 14:00, well outside the night's window. + TestSupport.insertMeasurement(kind: .heartRate, value: 40, timestamp: at(14, 0), into: context) + setRestingBaseline(55, into: context) + + let inputs = ReadinessService.inputs(for: day(0), context: context) + let resting = try XCTUnwrap(inputs.restingHeartRate) + XCTAssertGreaterThan(resting, 50, "the 40 bpm afternoon reading leaked into the overnight p10") + XCTAssertLessThan(resting, 60) + } + + // MARK: - Baselines + + func testHrvBaselineExcludesTheNightBeingScored() throws { + let context = try TestSupport.makeContext() + // 30 prior nights at a steady 50 ms — enough span and samples for `isEstablished`. + for offset in 1...30 { + insertNight(-offset, into: context) + for index in 0..<3 { + let ts = calendar.date(byAdding: .hour, value: index, to: at(-1, -offset)) ?? reference + TestSupport.insertMeasurement(kind: .hrv, value: 50, timestamp: ts, into: context) + } + } + // Tonight is wildly different; it must not pull its own baseline toward itself. + insertNight(0, into: context) + for index in 0..<3 { + let ts = calendar.date(byAdding: .hour, value: index, to: at(-1, 0)) ?? reference + TestSupport.insertMeasurement(kind: .hrv, value: 20, timestamp: ts, into: context) + } + + let inputs = ReadinessService.inputs(for: day(0), context: context) + XCTAssertEqual(try XCTUnwrap(inputs.hrv), 20, accuracy: 0.001) + let baseline = try XCTUnwrap(inputs.hrvBaseline) + XCTAssertTrue(baseline.isEstablished) + XCTAssertEqual(baseline.median, 50, accuracy: 0.001, "tonight's 20 ms leaked into its own baseline") + } + + func testUnestablishedBaselineWritesNoRow() throws { + let context = try TestSupport.makeContext() + // Only three nights of HRV — far short of the establishment gate, and no resting baseline. + for offset in 0...2 { + insertNight(-offset, into: context) + TestSupport.insertMeasurement(kind: .hrv, value: 50, timestamp: at(-1, -offset), into: context) + } + let outcome = ReadinessService.refresh(day: day(0), context: context, now: reference) + XCTAssertEqual(outcome, .unavailable(.baselineLearning)) + XCTAssertNil(ReadinessRepository.row(on: day(0), context: context)) + } + + // MARK: - Persistence + + func testRefreshUpsertsASingleRow() throws { + let context = try TestSupport.makeContext() + seedScoreableDay(0, into: context) + setRestingBaseline(55, into: context) + + ReadinessService.refresh(day: day(0), context: context, now: reference) + ReadinessService.refresh(day: day(0), context: context, now: reference.addingTimeInterval(60)) + + let rows = try context.fetch(FetchDescriptor()) + XCTAssertEqual(rows.count, 1, "a second refresh must update the row, not insert another") + XCTAssertEqual(rows.first?.algorithmVersion, ReadinessScore.algorithmVersion) + XCTAssertFalse(rows.first?.contributors.isEmpty ?? true, "the breakdown must round-trip") + } + + func testStoredContributorsRoundTrip() throws { + let context = try TestSupport.makeContext() + seedScoreableDay(0, into: context) + setRestingBaseline(55, into: context) + ReadinessService.refresh(day: day(0), context: context, now: reference) + + let row = try XCTUnwrap(ReadinessRepository.row(on: day(0), context: context)) + let kinds = Set(row.contributors.compactMap(\.kind)) + XCTAssertEqual(kinds, [.sleep, .restingHeartRate]) + XCTAssertEqual(row.availablePoints, 55) + XCTAssertEqual(row.coverage, 0.55, accuracy: 0.0001) + for record in row.contributors { + XCTAssertFalse(record.detail.isEmpty) + } + } + + func testThrottleSkipsAFreshRow() throws { + let context = try TestSupport.makeContext() + seedScoreableDay(0, into: context) + setRestingBaseline(55, into: context) + ReadinessService.refresh(day: day(0), context: context, now: reference) + let computedAt = try XCTUnwrap(ReadinessRepository.row(on: day(0), context: context)).computedAt + + // One hour later — inside the 3h throttle. + ReadinessService.refreshIfStale(context: context, now: reference.addingTimeInterval(3600)) + XCTAssertEqual(try XCTUnwrap(ReadinessRepository.row(on: day(0), context: context)).computedAt, computedAt) + + // Four hours later — past it. + ReadinessService.refreshIfStale(context: context, now: reference.addingTimeInterval(4 * 3600)) + XCTAssertNotEqual(try XCTUnwrap(ReadinessRepository.row(on: day(0), context: context)).computedAt, computedAt) + } + + /// A version bump must beat the throttle, or old scores would linger under new weights. + func testAlgorithmVersionMismatchForcesRecomputeInsideTheThrottle() throws { + let context = try TestSupport.makeContext() + seedScoreableDay(0, into: context) + setRestingBaseline(55, into: context) + ReadinessService.refresh(day: day(0), context: context, now: reference) + + let row = try XCTUnwrap(ReadinessRepository.row(on: day(0), context: context)) + row.algorithmVersion = ReadinessScore.algorithmVersion - 1 + try? context.save() + + ReadinessService.refreshIfStale(context: context, now: reference.addingTimeInterval(60)) + let refreshed = try XCTUnwrap(ReadinessRepository.row(on: day(0), context: context)) + XCTAssertEqual(refreshed.algorithmVersion, ReadinessScore.algorithmVersion) + } + + /// If a night's sleep is corrected away, yesterday's score must not stay on screen. + func testRefreshDeletesTheRowWhenTheOutcomeBecomesUnavailable() throws { + let context = try TestSupport.makeContext() + let session = insertNight(0, into: context) + insertOvernightHR(0, values: [58, 56, 55], into: context) + setRestingBaseline(55, into: context) + ReadinessService.refresh(day: day(0), context: context, now: reference) + XCTAssertNotNil(ReadinessRepository.row(on: day(0), context: context)) + + // Delete the night and every overnight reading — nothing left to score. + context.delete(session) + for row in try context.fetch(FetchDescriptor()) { + context.delete(row) + } + try? context.save() + + let outcome = ReadinessService.refresh(day: day(0), context: context, now: reference) + XCTAssertEqual(outcome, .unavailable(.noSignals)) + XCTAssertNil(ReadinessRepository.row(on: day(0), context: context), + "a stale score must be cleared, not left behind") + } + + // MARK: - Backfill + + func testBackfillScoresOnlyDaysWithDataAndIsIdempotent() throws { + let context = try TestSupport.makeContext() + setRestingBaseline(55, into: context) + for offset in [0, -1, -3] { + seedScoreableDay(offset, into: context) + } + + ReadinessService.backfill(days: 7, context: context, now: reference) + let first = try context.fetch(FetchDescriptor()) + XCTAssertEqual(first.count, 3, "days with no night must not get a row") + let stamps = first.map(\.computedAt) + + ReadinessService.backfill(days: 7, context: context, now: reference.addingTimeInterval(600)) + let second = try context.fetch(FetchDescriptor()) + XCTAssertEqual(second.count, 3, "a second backfill must not duplicate rows") + XCTAssertEqual(second.map(\.computedAt).sorted(), stamps.sorted(), + "rows already at the current version must be skipped, not rewritten") + } + + func testMasterToggleOffSkipsComputationEntirely() throws { + let context = try TestSupport.makeContext() + seedScoreableDay(0, into: context) + setRestingBaseline(55, into: context) + + var prefs = ReadinessPrefs.default + prefs.masterEnabled = false + ReadinessPrefsStore.shared.prefs = prefs + + ReadinessService.refreshIfStale(context: context, now: reference) + ReadinessService.backfill(days: 7, context: context, now: reference) + XCTAssertTrue(try context.fetch(FetchDescriptor()).isEmpty) + } + + // MARK: - Training load + + /// A tracked run usually also generates active minutes. Summing them would double-count the + /// same hour of effort, so the day's load is the larger of the two, never their sum. + func testLoadMinutesTakesTheMaxNotTheSum() throws { + let context = try TestSupport.makeContext() + TestSupport.insertActivity(date: day(-1), activeMinutes: 45, into: context) + let session = ActivitySession(type: "run", status: .finished, startedAt: at(9, -1)) + session.endedAt = calendar.date(byAdding: .minute, value: 30, to: at(9, -1)) + context.insert(session) + try? context.save() + + let load = try XCTUnwrap(ReadinessService.loadMinutes(on: day(-1), context: context)) + XCTAssertEqual(load, 45, accuracy: 0.001, "expected max(45, 30), not 75") + } + + func testLoadMinutesSubtractsPausedTime() throws { + let context = try TestSupport.makeContext() + let session = ActivitySession(type: "run", status: .finished, startedAt: at(9, -1)) + session.endedAt = calendar.date(byAdding: .minute, value: 60, to: at(9, -1)) + session.totalPauseSeconds = 600 // 10 minutes paused + context.insert(session) + try? context.save() + + let load = try XCTUnwrap(ReadinessService.loadMinutes(on: day(-1), context: context)) + XCTAssertEqual(load, 50, accuracy: 0.001) + } + + func testLoadBaselineNeedsEnoughDaysBeforeItIsTrusted() throws { + let context = try TestSupport.makeContext() + // Three days of history — below `minLoadBaselineDays`. + for offset in 2...4 { + TestSupport.insertActivity(date: day(-offset), activeMinutes: 40, into: context) + } + XCTAssertNil(ReadinessService.loadBaseline(before: day(-1), context: context)) + + TestSupport.insertActivity(date: day(-5), activeMinutes: 40, into: context) + let baseline = try XCTUnwrap(ReadinessService.loadBaseline(before: day(-1), context: context)) + XCTAssertEqual(baseline, 40, accuracy: 0.001) + } + + func testLoadBaselineExcludesTheDayBeingJudged() throws { + let context = try TestSupport.makeContext() + // A huge spike on the day itself, steady history before it. + TestSupport.insertActivity(date: day(-1), activeMinutes: 300, into: context) + for offset in 2...6 { + TestSupport.insertActivity(date: day(-offset), activeMinutes: 40, into: context) + } + let baseline = try XCTUnwrap(ReadinessService.loadBaseline(before: day(-1), context: context)) + XCTAssertEqual(baseline, 40, accuracy: 0.001, "the spike day leaked into its own baseline") + } + + // MARK: - Demo data + + /// The seeded demo store must produce a real readiness history, or the tile and its trend chart + /// are empty for anyone evaluating the app without a ring (`-seedDemo YES`). + func testDemoSeedProducesReadinessHistory() throws { + let context = try TestSupport.makeContext() + SeedData.seedDemo(context) + let rows = try context.fetch(FetchDescriptor()) + XCTAssertGreaterThan(rows.count, 5, "demo data produced too little readiness history to chart") + // A spread, not a flat line — the demo store should exercise more than one band. + XCTAssertGreaterThan(Set(rows.map(\.band)).count, 1) + for row in rows { + XCTAssertTrue((0...100).contains(row.score)) + XCTAssertFalse(row.contributors.isEmpty, "a seeded score must carry its breakdown") + } + } +} diff --git a/docs/project/readiness.md b/docs/project/readiness.md new file mode 100644 index 00000000..eec7d862 --- /dev/null +++ b/docs/project/readiness.md @@ -0,0 +1,158 @@ +--- +title: Readiness score +description: How PulseLoop computes your daily readiness score — every contributor, weight, and threshold, documented. +--- + +# Readiness score + +Readiness answers one question each morning: **how recovered are you today?** It is a single +number from 0 to 100, computed entirely on your device from data your ring already collects. + +This page documents the whole algorithm. That is deliberate — PulseLoop's principles commit to +"documented metrics and an auditable coach, no black boxes", and a recovery score you can't +inspect is exactly the thing competitors charge a subscription for. + +The implementation lives in [`ReadinessScore.swift`](https://github.com/saksham2001/PulseLoopiOS/blob/main/PulseLoop/Services/ReadinessScore.swift) +(pure maths) and [`ReadinessService.swift`](https://github.com/saksham2001/PulseLoopiOS/blob/main/PulseLoop/Services/ReadinessService.swift) +(reading your data). Both are covered by unit tests that lock every number on this page. + +## Bands + +| Score | Band | +|---|---| +| 85–100 | Primed | +| 70–84 | Ready | +| 55–69 | Moderate | +| 0–54 | Rest needed | + +## Contributors + +Five signals, worth 100 points between them. Four are judged against **your own baseline**, not +against a population average — what counts as a good HRV for you is not what counts as a good HRV +for anyone else. + +| Contributor | Points | What's measured | Compared against | +|---|---|---|---| +| HRV | 30 | Mean HRV across the night (ms) | Your 30-day overnight median | +| Resting heart rate | 25 | The night's 10th-percentile HR (bpm) | Your learned resting-HR baseline | +| Sleep | 30 | Your sleep score for that night (0–100) | Absolute | +| Skin temperature | 10 | Mean skin temperature across the night (°C) | Your 30-day overnight median | +| Training load | 5 | Yesterday's active/workout minutes | Your trailing 7-day average | + +Sleep is the one absolute contributor, because the [sleep score](https://github.com/saksham2001/PulseLoopiOS/blob/main/PulseLoop/Services/SleepInsights.swift) +already encodes population-normal ranges for duration and stage balance. Scoring it against a +personal baseline as well would double-count the same normalisation. + +### Thresholds + +Each contributor earns full points at or better than **ideal**, 55% of its points at **soft**, and +zero at or beyond **hard**, interpolating linearly between those knots. + +| Contributor | ideal | soft | hard | +|---|---|---|---| +| HRV | at or above baseline | 15% below | 40% below | +| Resting heart rate | at or below baseline | 5 bpm above | 12 bpm above | +| Sleep | score ≥ 88 | score 65 | score 30 | +| Skin temperature | within 0.2 °C | 0.6 °C off | 1.2 °C off | +| Training load | ≤ 1.2× your usual | 1.8× | 3.0× | + +Two deliberate asymmetries: + +- **HRV above your baseline and resting HR below it are never penalised.** Some recovery models + treat an unusually high HRV as a warning sign (parasympathetic overshoot). That isn't + falsifiable from a consumer ring, so PulseLoop doesn't guess. +- **Skin temperature is symmetric.** A deviation in either direction is a signal, so the score + uses the absolute difference. + +The 55% knee is harsher than the sleep score's 65%. A recovery score that never drops below 65 +tells you nothing on the days you most need it to. + +## Missing data is never scored as zero + +This is the most important rule in the algorithm. + +If your ring didn't capture a signal last night, that signal is **removed from the denominator** +rather than scored as zero: + +``` +score = 100 × (points earned) ÷ (points available) +``` + +So a night where skin temperature is missing is scored out of 90 points, not penalised 10. The +score also reports its **coverage** — what fraction of the full 100-point picture it was based on — +so a 78 from a partial night is never silently presented as equivalent to a 78 from a complete one. + +A score is only produced when **at least 50 points are available** *and* at least one of HRV or +sleep is present. Resting heart rate and temperature qualify recovery; they don't describe it. + +What that means per device: + +| Situation | Available | Result | +|---|---|---| +| Colmi, all baselines established | 100 | Full-fidelity score | +| Colmi, no temperature reading that night | 90 | Scored, coverage 0.90 | +| Ring with sleep + HR but no HRV | 55 | Scored, coverage 0.55 | +| Any ring in its first week | < 50 | "Learning your baseline" | +| Ring with neither HRV nor sleep | — | No readiness tile at all | + +## Baselines + +A deviation is only meaningful once there's something to deviate from. PulseLoop reuses the +existing `BaselineStats` machinery, which considers a baseline established after roughly **a week +of wear with at least 20 samples**. + +Until then the contributor is treated as **missing, not as "at baseline"** — scoring a deviation +against three days of data would look authoritative while being noise. + +| Baseline | Window | Notes | +|---|---|---| +| HRV | 30 days of overnight readings | Excludes the night being scored | +| Skin temperature | 30 days of overnight readings | Excludes the night being scored | +| Resting heart rate | Learned separately, 30-day 10th percentile | Shared with the auto heart-rate zones | +| Training load | Trailing 7 days | Needs ≥ 4 days of history; excludes the day being judged | + +Every baseline window **excludes the day it is judging**. Otherwise a night would be partly +averaged into its own baseline and could never deviate from it. + +## The overnight window + +Daytime readings describe what you were doing, not how you recovered, so every overnight signal is +read from the night itself: + +- **Normally**: the span of that night's sleep session — which is resolved to the *longest* session + of the day, so a nap is never mistaken for the night. +- **If sleep wasn't decoded**: a fixed 22:00–08:00 window. A ring that captured HRV and heart rate + overnight but failed the sleep decode should still produce a score. + +Resting heart rate uses the night's 10th percentile rather than its mean — the same statistic the +baseline it's compared against uses, so the two are like for like. + +## Training load + +Yesterday's load is the **larger** of the day's active minutes and its recorded workout time — +never their sum. A tracked run usually also generates active minutes, and adding them would +double-count the same hour of effort. Workout time excludes any paused periods. + +## Storage and versioning + +Each morning's score is stored with its full contributor breakdown, so history keeps its *why* and +the trend chart doesn't recompute months of data on every render. Recomputing an old morning +against today's baseline would produce a different — and wrong — answer. + +Every stored row records the `algorithmVersion` that produced it. Changing any weight or threshold +on this page requires bumping that version, which invalidates stored rows so they recompute, +rather than silently reinterpreting old scores under new rules. + +Readiness scores are included in the full-data JSON export (format version 2 and later). + +## Known limitations + +Stated plainly, because the point of this page is that you can judge the number for yourself: + +- **The resting-HR baseline is an all-day 10th percentile**, not an overnight-only one. It's + dominated by sleep values in practice — your lowest heart rate of the day *is* during sleep — and + reusing it avoids a second baseline pipeline. An overnight-only variant is a candidate refinement. +- **The weights are informed judgement, not a validated clinical model.** They're documented here + precisely so they can be argued with and improved. +- **Training load is a blunt instrument** at 5 points: minutes only, with no notion of intensity. + A proper training-load model is separate roadmap work. diff --git a/docs/project/roadmap.md b/docs/project/roadmap.md index 2f2c0b31..9679df7f 100644 --- a/docs/project/roadmap.md +++ b/docs/project/roadmap.md @@ -50,7 +50,8 @@ These guide what we build and what we say no to. ### Metrics you can trust - **Performance & recovery**: readiness, training/cardio load, HRV and resting-HR - trends, VO₂max. + trends, VO₂max. The [readiness score](readiness.md) is documented in full — every + contributor, weight, and threshold. - **Health signals**: illness early-warning from shifts in skin temperature, resting heart rate, and respiration. - **Cycle tracking**: menstrual cycle and BBT from skin temperature, computed on-device. diff --git a/mkdocs.yml b/mkdocs.yml index 949efe1d..48f06c6b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -120,6 +120,7 @@ nav: - Project: - Roadmap: project/roadmap.md - Architecture: project/architecture.md + - Readiness score: project/readiness.md - Contributing: project/contributing.md - Contributors: project/contributors.md - Privacy: project/privacy.md From 3f1d6189b6150654e7e23803816772fe01dfaf4a Mon Sep 17 00:00:00 2001 From: ak710 Date: Fri, 31 Jul 2026 21:03:10 -0400 Subject: [PATCH 2/6] Add pinned readiness card to Today, plus its Settings screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second readiness PR (#103), building on the scoring engine. Puts the score on screen as a full-width card pinned directly under the Today hero, above the metric grid, with its own Settings page. Deliberately NOT a tile in the reorderable grid. Every other tile reports one measurement; readiness is a verdict *over* those measurements - HRV, resting HR, sleep, temperature and yesterday's load collapsed into a single number. Placing it beside a peer tile framed it as a sibling metric, which is the wrong mental model, and half a tile's width could not carry the reasoning that stops it being a black box. So it carries no MetricKey at all: it is pinned by design, and visibility is the Settings toggle's job rather than the drag tray's. The width earns its keep. The card shows the score, its band, the share of signals it is based on, and the top two contributors holding it back - each with its own explanation and what it cost, e.g. "Skin temperature 0.5 °C above your baseline, −3.4 pts". The empty state counts down instead of repeating an instruction. Readiness cannot say anything until it knows what *your* normal looks like, and a flat "wear your ring overnight" gives no sense of whether that means one more night or two more weeks - on a device with a month of history it reads as broken. The card now shows a filling ring with "3 of 7 nights" and "4 more nights to go", so the feature visibly works before it can produce a score. Progress counts nights that actually produced overnight signal, not days since install: someone who wore the ring twice in a month is two nights along, and saying otherwise would promise a score that isn't coming. A night whose sleep decode failed still counts if vitals were captured. And because nights are a proxy for BaselineStats.isEstablished - which also requires enough individual readings - the copy never claims "0 more nights" while still showing no score; it says it is still gathering readings. A test pins the advertised night count against the gate itself, so the countdown cannot drift from what actually unblocks a score. This also fixes a real gap the previous copy had: it keyed off the pairing calibration state, so a long-established user who simply lacked a readiness baseline saw a bare "No score yet" with no path forward. The card is gated on its master toggle plus "can this ring measure recovery at all" - HRV or sleep, the two signals ReadinessScore requires. A ring with neither could never produce a score, so the card is absent rather than permanently empty. Band zones live in ReadinessZones rather than on a view, because the card, the detail hero and the trend chart all need them and none should depend on another's type name. A test pins them against ReadinessScore.band. TodayStore needed two changes. ReadinessService.refreshIfStale runs BEFORE the signature is captured, not after - otherwise the write lands after the snapshot and forces a second, wasted rebuild. And the signature gained a readiness clause; without it a freshly scored night sits in the database while the card keeps showing yesterday's. Progress is computed only while there is no score, so it never runs on the happy path. Also adds an -openReadinessSettings launch arg, matching the existing nutrition test tooling. 25 new tests. Layout verified in the simulator against seeded demo data. Co-Authored-By: Claude Opus 5 --- PulseLoop/App/AppTheme.swift | 1 + .../DesignSystem/ReadinessSummaryCard.swift | 236 ++++++++++++++++++ PulseLoop/Services/DerivedSummaries.swift | 35 +++ PulseLoop/Services/PulseServices.swift | 13 + PulseLoop/Services/ReadinessProgress.swift | 136 ++++++++++ PulseLoop/ViewModels/TodayStore.swift | 17 +- PulseLoop/Views/RootViews.swift | 5 + .../Settings/ReadinessSettingsView.swift | 71 ++++++ PulseLoop/Views/SettingsView.swift | 6 + PulseLoop/Views/TodayView.swift | 22 ++ PulseLoopTests/ReadinessCardTests.swift | 201 +++++++++++++++ PulseLoopTests/ReadinessProgressTests.swift | 236 ++++++++++++++++++ 12 files changed, 978 insertions(+), 1 deletion(-) create mode 100644 PulseLoop/DesignSystem/ReadinessSummaryCard.swift create mode 100644 PulseLoop/Services/ReadinessProgress.swift create mode 100644 PulseLoop/Views/Settings/ReadinessSettingsView.swift create mode 100644 PulseLoopTests/ReadinessCardTests.swift create mode 100644 PulseLoopTests/ReadinessProgressTests.swift diff --git a/PulseLoop/App/AppTheme.swift b/PulseLoop/App/AppTheme.swift index 8ffa0682..568293d4 100644 --- a/PulseLoop/App/AppTheme.swift +++ b/PulseLoop/App/AppTheme.swift @@ -27,6 +27,7 @@ enum AppRoute: Hashable { case settingsPrivacyData case settingsAbout case settingsNutrition + case settingsReadiness case nutrition case mealDetail(UUID) case pairing diff --git a/PulseLoop/DesignSystem/ReadinessSummaryCard.swift b/PulseLoop/DesignSystem/ReadinessSummaryCard.swift new file mode 100644 index 00000000..5ce8e532 --- /dev/null +++ b/PulseLoop/DesignSystem/ReadinessSummaryCard.swift @@ -0,0 +1,236 @@ +import SwiftUI + +/// Band colouring for readiness, shared by the summary card, the detail hero, and the trend chart so +/// a score is never drawn in one band while being labelled another. Thresholds mirror +/// `ReadinessScore.band`; `ReadinessTileTests` asserts they agree. +/// +/// Lives outside any view because three different surfaces need it and none of them should depend +/// on another's type name. +enum ReadinessZones { + static let all: [MetricZone] = [ + MetricZone(id: "rest", label: "Rest needed", lower: 0, upper: 55, + severity: .high, colorToken: .orange, + explanation: "Your body is still recovering. Keep today easy."), + MetricZone(id: "moderate", label: "Moderate", lower: 55, upper: 70, + severity: .watch, colorToken: .amber, + explanation: "Partial recovery. Moderate effort is fine; hold back on intensity."), + MetricZone(id: "ready", label: "Ready", lower: 70, upper: 85, + severity: .normal, colorToken: .cyan, + explanation: "Recovered. A normal training day."), + MetricZone(id: "primed", label: "Primed", lower: 85, upper: 101, + severity: .optimal, colorToken: .mint, + explanation: "Well recovered — a good day to push.") + ] + + static func color(for score: Int) -> Color { + all.first { $0.contains(Double(score)) }?.color ?? PulseColors.readiness + } +} + +/// Readiness as a **full-width** card, pinned directly under the Today hero. +/// +/// Deliberately not a tile in the reorderable grid. Every other tile reports one measurement; +/// readiness is a verdict *over* those measurements — HRV, resting HR, sleep, temperature and +/// yesterday's load collapsed into one number. Sitting it beside a peer tile framed it as a sibling +/// metric, which is the wrong mental model, and half a tile's width couldn't carry the reasoning +/// that stops it being a black box. +/// +/// The extra width buys the top two contributors instead of one truncated line, so the card answers +/// "how recovered am I, and why" without a tap. +struct ReadinessSummaryCard: View { + let readiness: ReadinessSnapshot? + /// Progress toward a first score. Drives the empty state so it counts down instead of + /// repeating an open-ended instruction. + let progress: ReadinessProgress? + let calibration: CalibrationState + var onTap: () -> Void + + /// How many contributor lines the width affords before it starts to read as a list. + private static let maxReasons = 2 + + var body: some View { + Button(action: onTap) { + VStack(alignment: .leading, spacing: 12) { + header + if let readiness { + scored(readiness) + } else { + empty + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + .accessibilityAddTraits(.isButton) + } + + private var header: some View { + HStack(spacing: 8) { + Circle().fill(PulseColors.readiness).frame(width: 8, height: 8) + .shadow(color: PulseColors.readiness.opacity(0.7), radius: 5) + Text("READINESS") + .font(PulseFont.caption2) + .tracking(0.6) + .foregroundStyle(PulseColors.textMuted) + Spacer(minLength: 0) + // Coverage is surfaced, never hidden: a score from a partial night is a weaker claim + // than the same number from a complete one. + if let readiness, readiness.coverage < 1 { + Text("\(Int((readiness.coverage * 100).rounded()))% of signals") + .font(PulseFont.micro) + .foregroundStyle(PulseColors.textMuted) + } + } + } + + @ViewBuilder + private func scored(_ readiness: ReadinessSnapshot) -> some View { + HStack(alignment: .center, spacing: 18) { + VitalRingGauge( + value: Double(readiness.score), + domain: 0...100, + zones: ReadinessZones.all, + valueColor: ReadinessZones.color(for: readiness.score), + centerValue: "\(readiness.score)", + centerStatus: readiness.band.rawValue, + size: 112, + lineWidth: 10 + ) + + VStack(alignment: .leading, spacing: 8) { + ForEach(reasons(readiness), id: \.kindRaw) { record in + reasonRow(record) + } + if reasons(readiness).isEmpty { + Text("Every signal at or above your baseline.") + .font(PulseFont.caption) + .foregroundStyle(PulseColors.textMuted) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + /// One reason line: a band-coloured dot, the contributor's own explanation, and how much it cost. + private func reasonRow(_ record: ReadinessContributorRecord) -> some View { + HStack(alignment: .top, spacing: 8) { + Circle() + .fill(dragColor(record)) + .frame(width: 6, height: 6) + .padding(.top, 5) + VStack(alignment: .leading, spacing: 1) { + Text(record.detail) + .font(PulseFont.caption) + .foregroundStyle(PulseColors.textSecondary) + .fixedSize(horizontal: false, vertical: true) + Text("−\(formatted(record.drag)) pts") + .font(PulseFont.micro) + .foregroundStyle(PulseColors.textMuted) + .monospacedDigit() + } + } + } + + /// The contributors actually holding the score back, worst first. Nothing is listed on a clean + /// night rather than manufacturing a reason. + private func reasons(_ readiness: ReadinessSnapshot) -> [ReadinessContributorRecord] { + readiness.contributors + .filter { $0.drag > 0.5 } + .sorted { $0.drag > $1.drag } + .prefix(Self.maxReasons) + .map { $0 } + } + + /// Coloured by how much of its own points the contributor lost, so the eye lands on the problem. + private func dragColor(_ record: ReadinessContributorRecord) -> Color { + guard record.maxPoints > 0 else { return PulseColors.textMuted } + let lost = record.drag / record.maxPoints + if lost >= 0.45 { return PulseColors.zoneOrange } + if lost >= 0.15 { return PulseColors.zoneAmber } + return PulseColors.zoneMint + } + + @ViewBuilder + private var empty: some View { + HStack(spacing: 14) { + // Nights collected, as a ring — the same visual language as a score, so the card reads + // as "filling up" rather than as an error. + ZStack { + Circle() + .stroke(PulseColors.textMuted.opacity(0.15), lineWidth: 8) + Circle() + .trim(from: 0, to: max(0.02, progress?.fraction ?? 0)) + .stroke(PulseColors.readiness.opacity(0.75), + style: StrokeStyle(lineWidth: 8, lineCap: .round)) + .rotationEffect(.degrees(-90)) + if let progress, progress.nightsCollected > 0 { + VStack(spacing: -2) { + Text("\(progress.nightsCollected)") + .font(PulseFont.numberL) + .monospacedDigit() + .foregroundStyle(PulseColors.textPrimary) + Text(progress.centerCaption) + .font(PulseFont.micro) + .lineLimit(1) + .minimumScaleFactor(0.7) + .foregroundStyle(PulseColors.textMuted) + } + } else { + Image(systemName: "bolt.heart") + .font(.system(size: 26, weight: .light)) + .foregroundStyle(PulseColors.readiness.opacity(0.6)) + } + } + .frame(width: 82, height: 82) + .padding(.leading, 15) + + VStack(alignment: .leading, spacing: 3) { + Text(emptyTitle) + .font(PulseFont.callout.weight(.semibold)) + .foregroundStyle(PulseColors.textPrimary) + Text(emptyDetail) + .font(PulseFont.caption) + .foregroundStyle(PulseColors.textMuted) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + /// Prefers the readiness-specific night count over the generic pairing calibration: a user can + /// be long past first-sync calibration and still be days away from a baseline, which is exactly + /// the state the old copy described as a flat "No score yet". + private var emptyTitle: String { + if let progress { return progress.title } + return calibration.isCalibrating ? "Learning your baseline" : "No score yet" + } + + private var emptyDetail: String { + if let progress { return progress.detail } + return calibration.isCalibrating + ? "Day \(calibration.day) of \(calibration.totalDays). Readiness needs about a week of nights before it can compare tonight to your normal." + : "Wear your ring overnight to get a readiness score." + } + + /// VoiceOver gets the number, the band, and the reasons — "93" alone is meaningless spoken. + private var accessibilityLabel: String { + guard let readiness else { return "Readiness. \(emptyTitle). \(emptyDetail)" } + let why = reasons(readiness).map(\.detail).joined(separator: ". ") + let coverage = readiness.coverage < 1 + ? " Based on \(Int((readiness.coverage * 100).rounded())) percent of signals." + : "" + return "Readiness \(readiness.score) out of 100, \(readiness.band.rawValue)." + + coverage + + (why.isEmpty ? " Every signal at or above your baseline." : " \(why).") + } + + private func formatted(_ value: Double) -> String { + value == value.rounded() ? "\(Int(value))" : String(format: "%.1f", value) + } +} diff --git a/PulseLoop/Services/DerivedSummaries.swift b/PulseLoop/Services/DerivedSummaries.swift index 754926cb..2217365d 100644 --- a/PulseLoop/Services/DerivedSummaries.swift +++ b/PulseLoop/Services/DerivedSummaries.swift @@ -224,6 +224,35 @@ struct LatestReading: Equatable { } } +/// A flattened, storage-free view of one morning's readiness row. Holds plain values (no live +/// SwiftData object) for the same reason as `LatestReading`: `TodaySummary` is cached and passed +/// around, so it must not carry model references. +struct ReadinessSnapshot: Equatable { + var date: Date + var score: Int + var band: ReadinessBand + var availablePoints: Double + var contributors: [ReadinessContributorRecord] + + var coverage: Double { availablePoints > 0 ? availablePoints / 100 : 0 } + + /// The contributor that cost the most points — what the tile shows as its one-line "why". + /// `ReadinessScore` already sorts by drag, but sort defensively so a hand-edited or + /// older-format row can't mislabel itself. + var topDrag: ReadinessContributorRecord? { + contributors.filter { $0.drag > 0 }.max { $0.drag < $1.drag } + } + + @MainActor + init(_ row: ReadinessDaily) { + date = row.date + score = row.score + band = row.band + availablePoints = row.availablePoints + contributors = row.contributors + } +} + struct TodaySummary { var date: Date var steps: Int? @@ -245,6 +274,12 @@ struct TodaySummary { var isDemo: Bool /// Consumed nutrition for the day. nil when the nutrition feature is disabled. var nutrition: NutritionDayTotals? = nil + /// This morning's readiness. nil when the feature is off, or when the night couldn't be scored + /// (still learning a baseline, or too little captured) — the card renders its own empty state. + var readiness: ReadinessSnapshot? = nil + /// How far along the user is toward a first score. Populated only while `readiness` is nil, so + /// the empty state can say "3 of 7 nights" instead of an open-ended "wear your ring". + var readinessProgress: ReadinessProgress? = nil var sevenDaySteps: [DailyMetricPoint] { trends.steps7d } } diff --git a/PulseLoop/Services/PulseServices.swift b/PulseLoop/Services/PulseServices.swift index 56d685bc..a9faee63 100644 --- a/PulseLoop/Services/PulseServices.swift +++ b/PulseLoop/Services/PulseServices.swift @@ -61,6 +61,11 @@ enum MetricsService { // 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 + let readinessEnabled = ReadinessPrefsStore.shared.prefs.masterEnabled + let readinessSnapshot = readinessEnabled + ? ReadinessRepository.row(on: isDemo ? anchorDate : Date(), context: context).map(ReadinessSnapshot.init) + : nil + return TodaySummary( date: today?.date ?? calendar.startOfDay(for: Date()), steps: today?.steps, @@ -84,6 +89,14 @@ enum MetricsService { // every consumer (tiles, cards, widgets, coach) inherits the master-toggle gate. nutrition: NutritionPrefsStore.shared.prefs.masterEnabled ? NutritionRepository.dayTotals(on: Date(), context: context) + : nil, + // Same master-toggle gate as nutrition. Anchored on `anchorDate` rather than `Date()` + // so a demo store — whose "today" is its newest seeded day — still finds its score. + readiness: readinessSnapshot, + // Only computed when there's no score to show — it walks the baseline window, so it + // must not run on the happy path. + readinessProgress: readinessEnabled && readinessSnapshot == nil + ? ReadinessService.progress(context: context) : nil ) } diff --git a/PulseLoop/Services/ReadinessProgress.swift b/PulseLoop/Services/ReadinessProgress.swift new file mode 100644 index 00000000..fbb54b7a --- /dev/null +++ b/PulseLoop/Services/ReadinessProgress.swift @@ -0,0 +1,136 @@ +import Foundation +import SwiftData + +/// How far along a user is toward their first readiness score. +/// +/// Readiness can't say anything useful until it has enough nights to know what *your* normal looks +/// like. Without this, the empty state is a dead end — "wear your ring overnight" gives no sense of +/// whether that means one more night or two more weeks, and a user with a month of history has no +/// way to tell the feature is working rather than broken. +struct ReadinessProgress: Equatable { + /// Nights inside the baseline window that contributed any overnight signal. + let nightsCollected: Int + /// Nights needed before a personal baseline is trustworthy. + let nightsNeeded: Int + /// Why there's no score. nil once one exists. + let reason: ReadinessUnavailableReason? + + var nightsRemaining: Int { max(0, nightsNeeded - nightsCollected) } + var hasEnoughNights: Bool { nightsCollected >= nightsNeeded } + + /// 0–1, for a progress bar. Clamped so a long-running user doesn't overflow it. + var fraction: Double { + guard nightsNeeded > 0 else { return 1 } + return min(1, Double(nightsCollected) / Double(nightsNeeded)) + } + + /// Headline for the empty state. + var title: String { + if reason == nil { return "Readiness" } + return nightsCollected == 0 ? "No score yet" : "Learning your baseline" + } + + /// One line saying exactly where the user stands and what unblocks a score. + /// + /// The `hasEnoughNights` branch matters: nights are a proxy for `BaselineStats.isEstablished`, + /// which *also* requires enough individual readings. Claiming "0 more nights" while still + /// showing no score would be a broken promise, so that case says something honest instead. + var detail: String { + guard reason != nil else { return "" } + if nightsCollected == 0 { + return "Wear your ring overnight. Readiness needs about \(nightsNeeded) nights before it can compare a night to your normal." + } + if hasEnoughNights { + return "\(nightsCollected) nights collected. Still gathering enough overnight readings — keep wearing your ring while you sleep." + } + let nights = nightsRemaining == 1 ? "night" : "nights" + return "\(nightsCollected) of \(nightsNeeded) nights collected · \(nightsRemaining) more \(nights) to go" + } + + /// Caption under the night count in the progress ring. + /// + /// Once the night target is met the "of N" is dropped: a user who has worn the ring thirty + /// nights should not be told "30 of 7", which reads as a broken counter rather than as + /// progress. The remaining blocker is explained in `detail` instead. + var centerCaption: String { + hasEnoughNights ? (nightsCollected == 1 ? "night" : "nights") : "of \(nightsNeeded) nights" + } + + /// Compact form for the half-height card footer. + var shortDetail: String { + guard reason != nil else { return "" } + if nightsCollected == 0 { return "Wear your ring overnight" } + if hasEnoughNights { return "Gathering overnight readings" } + return "\(nightsCollected) of \(nightsNeeded) nights" + } +} + +extension ReadinessService { + /// Nights needed before `BaselineStats` trusts a personal baseline. + /// + /// Mirrors `BaselineStats.isEstablished`, which requires `spanDays >= 7`. Pinned by a test so + /// the number a user is counting down against can't drift from the one that actually gates the + /// score. + static var baselineNightsNeeded: Int { 7 } + + /// Count the nights that have contributed usable overnight signal, and say why there's no score. + /// + /// Counts *nights with data*, not calendar days since install: a user who wore the ring five + /// nights out of thirty is five nights along, not thirty, and telling them otherwise would + /// promise a score that isn't coming. + @MainActor + static func progress(context: ModelContext, now: Date = Date()) -> ReadinessProgress { + let calendar = Calendar.current + let today = calendar.startOfDay(for: now) + let outcome = ReadinessScore.evaluate(inputs(for: today, context: context)) + + var reason: ReadinessUnavailableReason? + if case .unavailable(let why) = outcome { reason = why } + + var nights = 0 + for offset in 0.. Bool { + if let sleep = SleepService.sleepForDate(day, context: context), sleep.session.totalMinutes > 0 { + return true + } + + let window = overnightWindow(for: day, context: context) + var timestamps: [Date] = [] + for kind: MeasurementKind in [.hrv, .heartRate, .temperature] { + timestamps += MetricsRepository + .measurements(kind: kind, start: window.start, end: window.end, + limit: fetchLimit, context: context) + .filter { $0.value > 0 } + .map(\.timestamp) + } + + guard timestamps.count >= minOvernightSamples, + let first = timestamps.min(), let last = timestamps.max() else { return false } + return last.timeIntervalSince(first) >= minOvernightSpanHours * 3600 + } +} diff --git a/PulseLoop/ViewModels/TodayStore.swift b/PulseLoop/ViewModels/TodayStore.swift index 97ba4189..1bed54d9 100644 --- a/PulseLoop/ViewModels/TodayStore.swift +++ b/PulseLoop/ViewModels/TodayStore.swift @@ -71,6 +71,7 @@ final class TodayStore { /// Rebuild only if the underlying data changed since the last build. Cheap to call every appear. func refreshIfNeeded() { + ReadinessService.refreshIfStale(context: modelContext) let sig = Self.currentSignature(context: modelContext, profile: profile) guard sig != signature else { return } rebuild(signature: sig) @@ -78,6 +79,7 @@ final class TodayStore { /// Force a rebuild regardless of signature (used by the coalesced sync-changed signal). func invalidate() { + ReadinessService.refreshIfStale(context: modelContext) rebuild(signature: Self.currentSignature(context: modelContext, profile: profile)) } @@ -183,13 +185,26 @@ final class TodayStore { nutritionSig = "off" } + // Readiness inputs (HRV/HR/temp/sleep/activity) are already in the signature below, so this + // clause only has to catch the prefs toggle and the recomputed row itself. Note the refresh + // in `refreshIfNeeded`/`invalidate` runs BEFORE this is read — otherwise the write would + // land after the signature was captured and force a second, wasted rebuild. + let rPrefs = ReadinessPrefsStore.shared.prefs + let readinessSig: String + if rPrefs.masterEnabled { + let row = ReadinessRepository.latest(context: context) + readinessSig = "r\(rPrefs.showOnToday)/" + (row.map { "\($0.score)@\(stamp($0.updatedAt))" } ?? "·") + } else { + readinessSig = "off" + } + return [ latest(.heartRate), latest(.spo2), latest(.stress), latest(.hrv), latest(.temperature), latest(.bloodPressureSystolic), latest(.bloodPressureDiastolic), latest(.bloodSugar), latest(.fatigue), activity.map { "\($0.steps)/\(Int($0.distanceMeters))/\($0.activeMinutes)@\(stamp($0.syncedAt))" } ?? "·", sleep.map { "\($0.totalMinutes)@\(stamp($0.syncedAt))" } ?? "·", device.map { "\($0.batteryPercent)/\($0.state.rawValue)@\(stamp($0.lastSyncAt))" } ?? "·", - calSig, profileSig, prefSig, goalSig, nutritionSig, + calSig, profileSig, prefSig, goalSig, nutritionSig, readinessSig, ].joined(separator: "|") } } diff --git a/PulseLoop/Views/RootViews.swift b/PulseLoop/Views/RootViews.swift index 3a168860..83578d92 100644 --- a/PulseLoop/Views/RootViews.swift +++ b/PulseLoop/Views/RootViews.swift @@ -86,6 +86,9 @@ struct RootAppView: View { if UserDefaults.standard.bool(forKey: "openNutritionSettings") { path.append(AppRoute.settingsNutrition) } + if UserDefaults.standard.bool(forKey: "openReadinessSettings") { + path.append(AppRoute.settingsReadiness) + } // Test tooling: deep-link straight to a seeded workout's detail (route map). if UserDefaults.standard.bool(forKey: "openWorkout"), let session = ActivityRepository.sessions(context: modelContext).first(where: { $0.status == .finished && $0.useGps }) { @@ -163,6 +166,8 @@ struct RootAppView: View { AboutSettingsView(path: $path) case .settingsNutrition: NutritionSettingsView() + case .settingsReadiness: + ReadinessSettingsView() case .nutrition: NutritionView(path: $path) case let .mealDetail(id): diff --git a/PulseLoop/Views/Settings/ReadinessSettingsView.swift b/PulseLoop/Views/Settings/ReadinessSettingsView.swift new file mode 100644 index 00000000..7b7416bd --- /dev/null +++ b/PulseLoop/Views/Settings/ReadinessSettingsView.swift @@ -0,0 +1,71 @@ +import SwiftUI +import SwiftData + +/// Settings → Readiness: the master toggle for the daily recovery score plus its sub-settings. +/// +/// Structure mirrors `NutritionSettingsView` 1:1 — every group is ALWAYS rendered and gated with +/// `.disabled/.opacity` rather than conditionally inserted, because glass surfaces that appear and +/// disappear morph through capsule shapes on device. +/// +/// Unlike nutrition, the master toggle defaults **on**: readiness adds no permission, no network +/// egress, and stores nothing the ring wasn't already collecting. See `ReadinessPrefsStore`. +struct ReadinessSettingsView: View { + @Environment(\.modelContext) private var modelContext + @State private var store = ReadinessPrefsStore.shared + + private var prefs: Binding { + Binding(get: { store.prefs }, set: { store.prefs = $0 }) + } + + private var masterOn: Bool { store.prefs.masterEnabled } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 22) { + SettingsGroup( + footer: "A daily 0–100 recovery score from your overnight HRV, resting heart rate, sleep, skin temperature, and yesterday's activity. Computed entirely on this device." + ) { + FormToggleRow(title: "Daily readiness score", isOn: prefs.masterEnabled) + } + + SettingsGroup(header: "Display") { + FormToggleRow(title: "Show on Today & widgets", isOn: prefs.showOnToday) + } + .disabled(!masterOn) + .opacity(masterOn ? 1 : 0.5) + + SettingsGroup( + header: "AI Coach", + footer: "The coach sees your score and which signals drove it, so it can explain a change rather than guess at one." + ) { + FormToggleRow(title: "Share readiness with Coach", isOn: prefs.shareWithCoach) + FormToggleRow(title: "Mention in check-ins", isOn: prefs.includeInNotifications) + .disabled(!store.prefs.shareWithCoach) + .opacity(store.prefs.shareWithCoach ? 1 : 0.5) + } + .disabled(!masterOn) + .opacity(masterOn ? 1 : 0.5) + + SettingsGroup( + footer: "Every contributor, weight, and threshold is documented — readiness is not a black box. See docs/project/readiness.md." + ) { + EmptyView() + } + } + .padding() + } + .background(PulseColors.background) + .pageChrome("Readiness") + .onChange(of: store.prefs.masterEnabled) { _, isOn in + // Turning it on should not leave an empty tile until the next sync — score what's + // already there. Backfill skips days already at the current algorithm version. + if isOn { + ReadinessService.backfill(days: 30, context: modelContext) + } + PulseDataChange.shared.notify() + } + .onChange(of: store.prefs.showOnToday) { _, _ in + PulseDataChange.shared.notify() + } + } +} diff --git a/PulseLoop/Views/SettingsView.swift b/PulseLoop/Views/SettingsView.swift index 18ad1071..21fa4c60 100644 --- a/PulseLoop/Views/SettingsView.swift +++ b/PulseLoop/Views/SettingsView.swift @@ -124,6 +124,12 @@ struct SettingsView: View { trailingValue: NutritionPrefsStore.shared.prefs.masterEnabled ? "On" : "Off" ) { path.append(AppRoute.settingsNutrition) + }, + SettingsRowItem( + icon: "bolt.heart", tint: PulseColors.readiness, title: "Readiness", + trailingValue: ReadinessPrefsStore.shared.prefs.masterEnabled ? "On" : "Off" + ) { + path.append(AppRoute.settingsReadiness) } ] } diff --git a/PulseLoop/Views/TodayView.swift b/PulseLoop/Views/TodayView.swift index c6390cf8..7230f27d 100644 --- a/PulseLoop/Views/TodayView.swift +++ b/PulseLoop/Views/TodayView.swift @@ -46,6 +46,16 @@ struct TodayView: View { return prefs.masterEnabled && prefs.showOnToday } + /// Whether the pinned readiness card belongs on screen: its own master toggle and "show on + /// Today" pref, plus "can this ring measure recovery at all" — HRV or sleep, the two signals + /// `ReadinessScore` requires. A ring with neither could never produce a score, so the card is + /// absent rather than permanently empty. + private func readinessCardAvailable(_ store: TodayStore) -> Bool { + let prefs = ReadinessPrefsStore.shared.prefs + guard prefs.masterEnabled, prefs.showOnToday else { return false } + return store.capabilities.contains(.hrv) || store.capabilities.contains(.sleep) + } + private var summaryService: CoachSummaryService { CoachSummaryService(modelContext: modelContext) } private var coachEnabled: Bool { coachStore.settings.coachMasterEnabled } private var units: UnitsPreference { profiles.first?.units ?? .metric } @@ -97,6 +107,18 @@ struct TodayView: View { HeroInsightCardView(title: hero.title, summary: hero.summary, chips: hero.chips) } + // Readiness sits between the hero and the grid, full width and never reorderable. + // It summarizes the tiles below it rather than standing alongside them, so it is + // pinned by design — visibility is the Settings toggle's job, not the drag tray's. + if readinessCardAvailable(activeStore) { + ReadinessSummaryCard( + readiness: summary.readiness, + progress: summary.readinessProgress, + calibration: summary.calibration, + onTap: {} + ) + } + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { tiles(activeStore) } diff --git a/PulseLoopTests/ReadinessCardTests.swift b/PulseLoopTests/ReadinessCardTests.swift new file mode 100644 index 00000000..643a2093 --- /dev/null +++ b/PulseLoopTests/ReadinessCardTests.swift @@ -0,0 +1,201 @@ +import XCTest +import SwiftData +@testable import PulseLoop + +/// The view-model layer behind the pinned Today readiness card: summary plumbing, the +/// master-toggle gate, snapshot derivation, band agreement, and — most importantly — that `TodayStore`'s cheap +/// signature actually notices readiness changing. +/// +/// The rendered card itself is not covered; this project has no UI test target. +@MainActor +final class ReadinessCardTests: XCTestCase { + + private var savedPrefs: ReadinessPrefs? + + override func setUp() async throws { + try await super.setUp() + savedPrefs = ReadinessPrefsStore.shared.prefs + var prefs = ReadinessPrefs.default + prefs.masterEnabled = true + ReadinessPrefsStore.shared.prefs = prefs + } + + override func tearDown() async throws { + if let savedPrefs { ReadinessPrefsStore.shared.prefs = savedPrefs } + try await super.tearDown() + } + + @discardableResult + private func insertScore( + _ dayOffset: Int = 0, + score: Int = 74, + band: ReadinessBand = .ready, + availablePoints: Double = 90, + contributorsJSON: String = #"[{"kindRaw":"hrv","earned":18,"maxPoints":30,"value":44,"baseline":50,"deviation":-12,"detail":"HRV 12% below your baseline"},{"kindRaw":"sleep","earned":28,"maxPoints":30,"value":85,"detail":"Sleep score 85"}]"#, + into context: ModelContext + ) -> ReadinessDaily { + let row = ReadinessDaily( + date: TestSupport.day(dayOffset), + score: score, band: band, + availablePoints: availablePoints, + contributorsJSON: contributorsJSON + ) + context.insert(row) + try? context.save() + return row + } + + // MARK: - Summary plumbing + + func testTodaySummaryCarriesTheReadinessSnapshot() throws { + let context = try TestSupport.makeContext() + insertScore(into: context) + + let summary = MetricsService.buildTodaySummary(context: context, scope: .today) + let readiness = try XCTUnwrap(summary.readiness, "buildTodaySummary should surface the day's score") + XCTAssertEqual(readiness.score, 74) + XCTAssertEqual(readiness.band, .ready) + XCTAssertEqual(readiness.coverage, 0.9, accuracy: 0.0001) + } + + func testMasterToggleOffKeepsReadinessOutOfTheSummary() throws { + let context = try TestSupport.makeContext() + insertScore(into: context) + + var prefs = ReadinessPrefs.default + prefs.masterEnabled = false + ReadinessPrefsStore.shared.prefs = prefs + + let summary = MetricsService.buildTodaySummary(context: context, scope: .today) + XCTAssertNil(summary.readiness, "every consumer must inherit the master-toggle gate") + } + + func testSummaryIsNilWhenTheDayWasNeverScored() throws { + let context = try TestSupport.makeContext() + let summary = MetricsService.buildTodaySummary(context: context, scope: .today) + XCTAssertNil(summary.readiness) + } + + // MARK: - Snapshot + + /// Drives the card's lead reason. Picking the wrong contributor would explain the score wrongly. + func testTopDragIsTheContributorThatCostTheMostPoints() throws { + let context = try TestSupport.makeContext() + insertScore(into: context) + let readiness = try XCTUnwrap( + MetricsService.buildTodaySummary(context: context, scope: .today).readiness + ) + let top = try XCTUnwrap(readiness.topDrag) + XCTAssertEqual(top.kind, .hrv, "HRV lost 12 points; sleep lost 2") + XCTAssertEqual(top.detail, "HRV 12% below your baseline") + } + + /// A perfect night has nothing to blame, and the card must not invent something. + func testTopDragIsNilWhenEveryContributorEarnedFullMarks() throws { + let context = try TestSupport.makeContext() + insertScore( + score: 100, band: .primed, availablePoints: 60, + contributorsJSON: #"[{"kindRaw":"sleep","earned":30,"maxPoints":30,"value":92,"detail":"Sleep score 92"},{"kindRaw":"restingHeartRate","earned":25,"maxPoints":25,"value":54,"baseline":55,"deviation":-1,"detail":"Resting HR at your baseline"}]"#, + into: context + ) + let readiness = try XCTUnwrap( + MetricsService.buildTodaySummary(context: context, scope: .today).readiness + ) + XCTAssertNil(readiness.topDrag) + } + + /// A row whose JSON is unreadable is still a usable score — the breakdown degrades, not the card. + func testUnreadableContributorsDegradeToAnEmptyBreakdown() throws { + let context = try TestSupport.makeContext() + insertScore(contributorsJSON: "not json at all", into: context) + let readiness = try XCTUnwrap( + MetricsService.buildTodaySummary(context: context, scope: .today).readiness + ) + XCTAssertEqual(readiness.score, 74, "the score must survive an unreadable breakdown") + XCTAssertTrue(readiness.contributors.isEmpty) + XCTAssertNil(readiness.topDrag) + } + + // MARK: - TodayStore signature + + /// The signature is what decides whether the grid rebuilds. If readiness isn't in it, a new + /// score lands in the database and the card keeps showing yesterday's until an unrelated sync + /// happens to bump something else. + func testStoreRebuildsWhenAScoreIsWritten() throws { + let context = try TestSupport.makeContext() + let store = TodayStore(modelContext: context) + XCTAssertNil(store.summary.readiness) + + insertScore(score: 81, band: .ready, into: context) + store.refreshIfNeeded() + + XCTAssertEqual(store.summary.readiness?.score, 81) + } + + func testStoreRebuildsWhenTheScoreChanges() throws { + let context = try TestSupport.makeContext() + let row = insertScore(score: 60, band: .moderate, into: context) + let store = TodayStore(modelContext: context) + XCTAssertEqual(store.summary.readiness?.score, 60) + + row.score = 88 + row.bandRaw = ReadinessBand.primed.rawValue + row.updatedAt = Date().addingTimeInterval(60) + try? context.save() + store.refreshIfNeeded() + + XCTAssertEqual(store.summary.readiness?.score, 88) + XCTAssertEqual(store.summary.readiness?.band, .primed) + } + + /// Toggling visibility in Settings must take effect on the tab immediately, not on next sync. + func testStoreRebuildsWhenThePrefsToggleChanges() throws { + let context = try TestSupport.makeContext() + insertScore(into: context) + let store = TodayStore(modelContext: context) + XCTAssertNotNil(store.summary.readiness) + + var prefs = ReadinessPrefsStore.shared.prefs + prefs.masterEnabled = false + ReadinessPrefsStore.shared.prefs = prefs + store.refreshIfNeeded() + + XCTAssertNil(store.summary.readiness) + } + + // MARK: - Not a grid metric + + /// Readiness is a verdict over the other metrics, not a metric of its own, so it is pinned + /// above the grid rather than living in it. Guard against it being reintroduced as a grid tile: + /// that would make it reorderable and hideable via the tray, contradicting the pinning. + func testReadinessIsNotAGridMetric() { + XCTAssertNil(MetricKey(rawValue: "readiness")) + XCTAssertFalse(MetricKey.allCases.contains { $0.rawValue == "readiness" }) + } + + // MARK: - Card bands + + /// The card's arc colouring must agree with `ReadinessScore.band`, or a score can be labelled + /// "Ready" while being drawn in the amber "Moderate" band. The detail hero and the trend chart + /// read the same zones, so this pins all three surfaces at once. + func testZonesAgreeWithTheScoreBands() { + let expected: [(Int, String)] = [ + (0, "Rest needed"), (54, "Rest needed"), + (55, "Moderate"), (69, "Moderate"), + (70, "Ready"), (84, "Ready"), + (85, "Primed"), (100, "Primed") + ] + for (score, label) in expected { + let zone = ReadinessZones.all.first { $0.contains(Double(score)) } + XCTAssertEqual(zone?.label, label, "score \(score) fell in the wrong band") + XCTAssertEqual(zone?.label, ReadinessScore.band(score).rawValue, + "tile band disagrees with ReadinessScore.band at \(score)") + } + } + + func testEveryTileZoneHasAnExplanation() { + for zone in ReadinessZones.all { + XCTAssertFalse(zone.explanation.isEmpty, "\(zone.label) has no explanation") + } + } +} diff --git a/PulseLoopTests/ReadinessProgressTests.swift b/PulseLoopTests/ReadinessProgressTests.swift new file mode 100644 index 00000000..9ec2dabc --- /dev/null +++ b/PulseLoopTests/ReadinessProgressTests.swift @@ -0,0 +1,236 @@ +import XCTest +import SwiftData +@testable import PulseLoop + +/// Progress toward a first readiness score: the night count, and the copy that reports it. +/// +/// The number shown to a user is a countdown they will hold the feature to, so these tests pin it +/// against the gate that actually blocks scoring rather than against a hard-coded 7. +@MainActor +final class ReadinessProgressTests: XCTestCase { + + private var savedPrefs: ReadinessPrefs? + + override func setUp() async throws { + try await super.setUp() + savedPrefs = ReadinessPrefsStore.shared.prefs + ReadinessPrefsStore.shared.prefs = ReadinessPrefs.default + } + + override func tearDown() async throws { + if let savedPrefs { ReadinessPrefsStore.shared.prefs = savedPrefs } + try await super.tearDown() + } + + private var calendar: Calendar { Calendar.current } + + private func day(_ offset: Int) -> Date { TestSupport.day(offset) } + + /// A night belonging to the morning of `dayOffset`, 23:00 → +`minutes`. + @discardableResult + private func insertNight(_ dayOffset: Int, minutes: Int = 420, into context: ModelContext) -> SleepSession { + let start = calendar.date(byAdding: .hour, value: -1, to: day(dayOffset)) ?? day(dayOffset) + let end = calendar.date(byAdding: .minute, value: minutes, to: start) ?? start + let session = SleepSession(date: day(dayOffset), startAt: start, endAt: end, + totalMinutes: minutes, syncedAt: start) + context.insert(session) + context.insert(SleepStageBlock(sessionId: session.id, startAt: start, + startMinute: 0, durationMinutes: minutes, stage: .light)) + try? context.save() + return session + } + + /// One overnight HRV reading on the night of `dayOffset`, with no sleep session. + private func insertOvernightHRV(_ dayOffset: Int, into context: ModelContext) { + let ts = calendar.date(byAdding: .hour, value: 2, to: day(dayOffset)) ?? day(dayOffset) // 02:00 + TestSupport.insertMeasurement(kind: .hrv, value: 48, timestamp: ts, into: context) + } + + /// Readings spread across the night at the given hours after midnight, with no sleep session. + private func insertOvernightRun(_ dayOffset: Int, hours: [Int], into context: ModelContext) { + for hour in hours { + let ts = calendar.date(byAdding: .hour, value: hour, to: day(dayOffset)) ?? day(dayOffset) + TestSupport.insertMeasurement(kind: .hrv, value: 48, timestamp: ts, into: context) + } + } + + // MARK: - The number the user counts down against + + /// If `BaselineStats.isEstablished` ever changes its span requirement, the countdown shown to + /// the user becomes a lie. This fails loudly in that case. + func testAdvertisedNightsMatchTheBaselineGate() { + let needed = ReadinessService.baselineNightsNeeded + + func stats(spanDays: Double) -> BaselineStats { + BaselineStats(mean: 50, median: 50, standardDeviation: 5, p25: 45, p75: 55, + sampleCount: 40, spanDays: spanDays) + } + XCTAssertTrue(stats(spanDays: Double(needed)).isEstablished, + "a baseline spanning the advertised \\(needed) nights must be established") + XCTAssertFalse(stats(spanDays: Double(needed) - 0.1).isEstablished, + "one night short must NOT be established") + } + + // MARK: - Counting + + func testCountsOnlyNightsThatProducedSignal() throws { + let context = try TestSupport.makeContext() + for offset in [0, -1, -4] { insertNight(offset, into: context) } + + let progress = ReadinessService.progress(context: context) + XCTAssertEqual(progress.nightsCollected, 3, "gaps must not be counted as worn nights") + XCTAssertEqual(progress.nightsNeeded, ReadinessService.baselineNightsNeeded) + XCTAssertEqual(progress.nightsRemaining, ReadinessService.baselineNightsNeeded - 3) + XCTAssertFalse(progress.hasEnoughNights) + } + + /// A night whose sleep decode failed still counts if the ring was clearly worn through it — + /// otherwise the countdown would stall for a user who is in fact wearing it every night. + func testANightWithSustainedVitalsButNoSleepStillCounts() throws { + let context = try TestSupport.makeContext() + insertOvernightRun(0, hours: [0, 2, 4], into: context) + XCTAssertEqual(ReadinessService.progress(context: context).nightsCollected, 1) + } + + /// Regression, reported from a real device: the counter read "6 of 7 nights" for a user with + /// two nights of actual sleep data. + /// + /// These rings log heart rate all day and the fallback overnight window is 22:00–08:00, so + /// wearing the ring until 22:30 or putting it on at 07:30 dropped a reading inside the window + /// and marked the whole day "collected". The countdown would have reached 7 and still produced + /// no score — the exact broken promise this indicator exists to prevent. + func testEveningAndMorningWearDoesNotCountAsASleptNight() throws { + let context = try TestSupport.makeContext() + // Two nights genuinely slept in the ring. + insertNight(-1, into: context) + insertNight(-2, into: context) + // Four days where it was only worn around the edges of the overnight window. + for offset in [-3, -4, -5, -6] { + let evening = calendar.date(byAdding: .hour, value: -1, to: day(offset)) ?? day(offset) // 23:00 + let morning = calendar.date(byAdding: .hour, value: 7, to: day(offset)) ?? day(offset) // 07:00 + TestSupport.insertMeasurement(kind: .heartRate, value: 68, timestamp: evening, into: context) + TestSupport.insertMeasurement(kind: .heartRate, value: 72, timestamp: morning, into: context) + } + + let progress = ReadinessService.progress(context: context) + XCTAssertEqual(progress.nightsCollected, 2, + "only nights actually slept in the ring count; edge-of-window wear does not") + XCTAssertEqual(progress.nightsRemaining, ReadinessService.baselineNightsNeeded - 2) + } + + /// A single stray reading is not a night, however isolated. + func testOneStrayOvernightReadingIsNotANight() throws { + let context = try TestSupport.makeContext() + insertOvernightHRV(0, into: context) + XCTAssertEqual(ReadinessService.progress(context: context).nightsCollected, 0) + } + + /// Readings clustered into a few minutes are someone checking their ring, not sleeping in it. + func testReadingsMustSpanEnoughOfTheNight() throws { + let context = try TestSupport.makeContext() + for minute in [0, 5, 10, 15] { + let ts = calendar.date(byAdding: .minute, value: 120 + minute, to: day(0)) ?? day(0) + TestSupport.insertMeasurement(kind: .heartRate, value: 60, timestamp: ts, into: context) + } + XCTAssertEqual(ReadinessService.progress(context: context).nightsCollected, 0, + "four readings inside 15 minutes is not a night's wear") + } + + func testNoDataReportsZeroNights() throws { + let context = try TestSupport.makeContext() + let progress = ReadinessService.progress(context: context) + XCTAssertEqual(progress.nightsCollected, 0) + XCTAssertEqual(progress.reason, .noSignals) + } + + /// Progress counts nights *with data*, not days since install. Someone who wore the ring twice + /// in a month is two nights along, and saying otherwise promises a score that isn't coming. + func testSparseWearIsNotInflatedByElapsedTime() throws { + let context = try TestSupport.makeContext() + insertNight(-1, into: context) + insertNight(-25, into: context) + XCTAssertEqual(ReadinessService.progress(context: context).nightsCollected, 2) + } + + // MARK: - Copy + + func testCountdownReadsCorrectly() { + let p = ReadinessProgress(nightsCollected: 3, nightsNeeded: 7, reason: .baselineLearning) + XCTAssertEqual(p.title, "Learning your baseline") + XCTAssertEqual(p.detail, "3 of 7 nights collected · 4 more nights to go") + XCTAssertEqual(p.shortDetail, "3 of 7 nights") + XCTAssertEqual(p.fraction, 3.0 / 7.0, accuracy: 0.0001) + } + + func testSingularNightIsNotPluralised() { + let p = ReadinessProgress(nightsCollected: 6, nightsNeeded: 7, reason: .baselineLearning) + XCTAssertEqual(p.detail, "6 of 7 nights collected · 1 more night to go") + } + + func testZeroNightsAsksForTheFirstOne() { + let p = ReadinessProgress(nightsCollected: 0, nightsNeeded: 7, reason: .noSignals) + XCTAssertEqual(p.title, "No score yet") + XCTAssertTrue(p.detail.contains("Wear your ring overnight")) + XCTAssertEqual(p.fraction, 0) + } + + /// Nights are a proxy for a gate that also needs enough individual readings. Claiming "0 more + /// nights" while still showing no score would be a broken promise, so this case says so. + func testEnoughNightsButStillUnscoredDoesNotPromiseZeroMore() { + let p = ReadinessProgress(nightsCollected: 9, nightsNeeded: 7, reason: .baselineLearning) + XCTAssertTrue(p.hasEnoughNights) + XCTAssertFalse(p.detail.contains("0 more")) + XCTAssertTrue(p.detail.contains("Still gathering")) + XCTAssertEqual(p.fraction, 1, "the bar must not overflow past full") + } + + /// Regression: the ring first shipped reading "30 of 7 nights", which looks like a broken + /// counter rather than progress. Past the target the fraction is dropped entirely. + func testRingCaptionDropsTheFractionOnceTheTargetIsMet() { + XCTAssertEqual( + ReadinessProgress(nightsCollected: 3, nightsNeeded: 7, reason: .baselineLearning).centerCaption, + "of 7 nights" + ) + XCTAssertEqual( + ReadinessProgress(nightsCollected: 30, nightsNeeded: 7, reason: .baselineLearning).centerCaption, + "nights" + ) + XCTAssertEqual( + ReadinessProgress(nightsCollected: 1, nightsNeeded: 1, reason: .baselineLearning).centerCaption, + "night" + ) + } + + // MARK: - Summary plumbing + + func testSummaryCarriesProgressOnlyWhileThereIsNoScore() throws { + let context = try TestSupport.makeContext() + insertNight(0, into: context) + + let unscored = MetricsService.buildTodaySummary(context: context, scope: .today) + XCTAssertNil(unscored.readiness) + XCTAssertNotNil(unscored.readinessProgress, "the empty state needs its countdown") + + // Now give the day a real score. + context.insert(ReadinessDaily(date: day(0), score: 80, band: .ready, + availablePoints: 100, contributorsJSON: "[]")) + try? context.save() + + let scored = MetricsService.buildTodaySummary(context: context, scope: .today) + XCTAssertNotNil(scored.readiness) + XCTAssertNil(scored.readinessProgress, "progress must not be computed on the happy path") + } + + func testSummaryOmitsProgressWhenTheFeatureIsOff() throws { + let context = try TestSupport.makeContext() + insertNight(0, into: context) + + var prefs = ReadinessPrefs.default + prefs.masterEnabled = false + ReadinessPrefsStore.shared.prefs = prefs + + let summary = MetricsService.buildTodaySummary(context: context, scope: .today) + XCTAssertNil(summary.readiness) + XCTAssertNil(summary.readinessProgress) + } +} From 07e609d23dc620575f35682a5c2aa52896fba8cf Mon Sep 17 00:00:00 2001 From: ak710 Date: Mon, 3 Aug 2026 00:26:28 -0400 Subject: [PATCH 3/6] Lift the readiness card test fixtures out of their one-liners Two inline contributor JSON literals ran past 200 characters, which is SwiftLint's error threshold and was failing CI. Hoist both into named multiline constants, matching how the other readiness tests already carry their fixtures. Co-Authored-By: Claude Opus 5 --- PulseLoopTests/ReadinessCardTests.swift | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/PulseLoopTests/ReadinessCardTests.swift b/PulseLoopTests/ReadinessCardTests.swift index 643a2093..7d73d143 100644 --- a/PulseLoopTests/ReadinessCardTests.swift +++ b/PulseLoopTests/ReadinessCardTests.swift @@ -12,6 +12,18 @@ final class ReadinessCardTests: XCTestCase { private var savedPrefs: ReadinessPrefs? + /// HRV is 12 points down, sleep 2 — so HRV is unambiguously the top drag. + private static let draggingContributors = #""" + [{"kindRaw":"hrv","earned":18,"maxPoints":30,"value":44,"baseline":50,"deviation":-12,"detail":"HRV 12% below your baseline"}, + {"kindRaw":"sleep","earned":28,"maxPoints":30,"value":85,"detail":"Sleep score 85"}] + """# + + /// Every contributor at full marks, so there is nothing for the card to blame. + private static let perfectContributors = #""" + [{"kindRaw":"sleep","earned":30,"maxPoints":30,"value":92,"detail":"Sleep score 92"}, + {"kindRaw":"restingHeartRate","earned":25,"maxPoints":25,"value":54,"baseline":55,"deviation":-1,"detail":"Resting HR at your baseline"}] + """# + override func setUp() async throws { try await super.setUp() savedPrefs = ReadinessPrefsStore.shared.prefs @@ -31,7 +43,7 @@ final class ReadinessCardTests: XCTestCase { score: Int = 74, band: ReadinessBand = .ready, availablePoints: Double = 90, - contributorsJSON: String = #"[{"kindRaw":"hrv","earned":18,"maxPoints":30,"value":44,"baseline":50,"deviation":-12,"detail":"HRV 12% below your baseline"},{"kindRaw":"sleep","earned":28,"maxPoints":30,"value":85,"detail":"Sleep score 85"}]"#, + contributorsJSON: String = draggingContributors, into context: ModelContext ) -> ReadinessDaily { let row = ReadinessDaily( @@ -95,7 +107,7 @@ final class ReadinessCardTests: XCTestCase { let context = try TestSupport.makeContext() insertScore( score: 100, band: .primed, availablePoints: 60, - contributorsJSON: #"[{"kindRaw":"sleep","earned":30,"maxPoints":30,"value":92,"detail":"Sleep score 92"},{"kindRaw":"restingHeartRate","earned":25,"maxPoints":25,"value":54,"baseline":55,"deviation":-1,"detail":"Resting HR at your baseline"}]"#, + contributorsJSON: Self.perfectContributors, into: context ) let readiness = try XCTUnwrap( From de13d563fa55b849fb8ef9739ae17ac65f91a86e Mon Sep 17 00:00:00 2001 From: ak710 Date: Fri, 31 Jul 2026 21:25:11 -0400 Subject: [PATCH 4/6] Add readiness detail screen with contributor breakdown and trend Third readiness PR (#103). The tile can only name the single biggest drag; this screen accounts for every contributor, so a user can reconstruct the arithmetic behind the number. That is what "documented metrics, no black boxes" has to mean in practice. Layout: hero ring with an explicit coverage line, the full breakdown sorted by drag (each contributor showing its earned/possible points as a bar plus its own explanation), what wasn't measured, a 7/30/90-day trend chart, and an explainer pointing at docs/project/readiness.md. Two deliberate choices about honesty: - Coverage is stated on the hero whenever it is below 100%. A 74 from a partial night is not the same claim as a 74 from a complete one, and showing only the number would quietly equate them. - Absent signals are named, not omitted. "Your temperature was fine" and "your temperature wasn't measured" are different statements; a "Not measured last night" section says which one applies, with a line explaining they are left out of the score rather than counted as zero. The trend chart is the first chart in this app to be accessible to VoiceOver. It ships an AXChartDescriptor plus per-bar labels, so the rotor can step through days and hear "24 July, 91, Primed" instead of just "chart". Charts.swift, VitalsCharts.swift and ActivityCharts.swift are all still opaque; this is the pattern to back-port. Hardening that path found a real crash. The axis description closure is called by the framework with values the app doesn't control, and Int(Double) traps on infinity and NaN - so an unguarded conversion took the whole app down, and only ever for VoiceOver users. Now guarded and clamped, with a regression test that feeds it infinity, NaN and out-of-range values. missingKinds moved from the view onto ReadinessSnapshot: it is not presentation logic, it is a fact about the score, and PR 4's coach tool needs the same answer. Also adds an -openReadiness launch arg, matching the existing test tooling. 12 new tests. 938 total, 0 failures. Verified rendering in the simulator against seeded demo data. Co-Authored-By: Claude Opus 5 --- PulseLoop/App/AppTheme.swift | 1 + PulseLoop/DesignSystem/ReadinessCharts.swift | 224 +++++++++++++++++ PulseLoop/Services/DerivedSummaries.swift | 8 + PulseLoop/Views/ReadinessDetailView.swift | 252 +++++++++++++++++++ PulseLoop/Views/RootViews.swift | 5 + PulseLoop/Views/TodayView.swift | 2 +- PulseLoopTests/ReadinessDetailTests.swift | 174 +++++++++++++ 7 files changed, 665 insertions(+), 1 deletion(-) create mode 100644 PulseLoop/DesignSystem/ReadinessCharts.swift create mode 100644 PulseLoop/Views/ReadinessDetailView.swift create mode 100644 PulseLoopTests/ReadinessDetailTests.swift diff --git a/PulseLoop/App/AppTheme.swift b/PulseLoop/App/AppTheme.swift index 568293d4..d17ccd6c 100644 --- a/PulseLoop/App/AppTheme.swift +++ b/PulseLoop/App/AppTheme.swift @@ -28,6 +28,7 @@ enum AppRoute: Hashable { case settingsAbout case settingsNutrition case settingsReadiness + case readinessDetail case nutrition case mealDetail(UUID) case pairing diff --git a/PulseLoop/DesignSystem/ReadinessCharts.swift b/PulseLoop/DesignSystem/ReadinessCharts.swift new file mode 100644 index 00000000..80e591f9 --- /dev/null +++ b/PulseLoop/DesignSystem/ReadinessCharts.swift @@ -0,0 +1,224 @@ +import SwiftUI +import Charts + +/// One scored morning, flattened for charting. +struct ReadinessTrendPoint: Identifiable, Equatable { + var id: Date { date } + let date: Date + let score: Int + let band: ReadinessBand +} + +/// Readiness over time: one bar per scored morning, coloured by band, with a 7-day rolling mean +/// laid over it so a single rough night reads as noise rather than a trend. +/// +/// **This chart is accessible to VoiceOver**, via `accessibilityChartDescriptor` plus per-bar +/// labels. No other chart in the app is, today — `Charts.swift`, `VitalsCharts.swift` and +/// `ActivityCharts.swift` are all opaque to screen readers. This is the pattern to back-port. +struct ReadinessTrendChart: View { + let points: [ReadinessTrendPoint] + var height: CGFloat = 220 + + /// Trailing 7-point mean. Emitted only once there are enough points behind it to mean + /// something, so the line doesn't start by tracking the bars exactly. + private var rollingMean: [(date: Date, value: Double)] { + guard points.count >= 3 else { return [] } + let window = 7 + return points.indices.compactMap { index in + let lower = max(0, index - window + 1) + let slice = points[lower...index] + guard slice.count >= 3 else { return nil } + let mean = Double(slice.reduce(0) { $0 + $1.score }) / Double(slice.count) + return (points[index].date, mean) + } + } + + private func color(_ band: ReadinessBand) -> Color { + ReadinessZones.all.first { $0.label == band.rawValue }?.color ?? PulseColors.readiness + } + + var body: some View { + Chart { + ForEach(points) { point in + BarMark( + x: .value("Day", point.date, unit: .day), + y: .value("Readiness", point.score) + ) + .foregroundStyle(color(point.band).opacity(0.85)) + .cornerRadius(3) + .accessibilityLabel(Self.dayFormatter.string(from: point.date)) + .accessibilityValue("\(point.score), \(point.band.rawValue)") + } + + ForEach(rollingMean, id: \.date) { entry in + LineMark( + x: .value("Day", entry.date, unit: .day), + y: .value("7-day average", entry.value) + ) + .foregroundStyle(PulseColors.textPrimary.opacity(0.55)) + .lineStyle(StrokeStyle(lineWidth: 2)) + .interpolationMethod(.catmullRom) + } + } + .chartYScale(domain: 0...100) + .chartYAxis { + AxisMarks(values: [0, 55, 70, 85, 100]) { value in + AxisGridLine().foregroundStyle(PulseColors.textMuted.opacity(0.15)) + AxisValueLabel { + if let score = value.as(Int.self) { + Text("\(score)") + .font(PulseFont.micro) + .foregroundStyle(PulseColors.textMuted) + } + } + } + } + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: 4)) { value in + AxisValueLabel { + if let date = value.as(Date.self) { + Text(Self.axisFormatter.string(from: date)) + .font(PulseFont.micro) + .foregroundStyle(PulseColors.textMuted) + } + } + } + } + .frame(height: height) + .accessibilityChartDescriptor(self) + } + + private static let dayFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "EEEE d MMMM" + return f + }() + + private static let axisFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "d MMM" + return f + }() +} + +/// Makes the trend chart navigable with the VoiceOver rotor — swipe through days and hear each +/// score rather than being told only "chart". +extension ReadinessTrendChart: AXChartDescriptorRepresentable { + func makeChartDescriptor() -> AXChartDescriptor { + let scores = points.map { Double($0.score) } + let dates = points.map(\.date) + + let xAxis = AXCategoricalDataAxisDescriptor( + title: "Day", + categoryOrder: dates.map { Self.dayFormatter.string(from: $0) } + ) + + let yAxis = AXNumericDataAxisDescriptor( + title: "Readiness", + range: 0...100, + gridlinePositions: [55, 70, 85] + ) { value in + // The framework probes this closure with values we don't control, including non-finite + // ones. `Int(someDouble)` traps on infinity and NaN, which would crash the app outright + // — and only ever for VoiceOver users, who are the last people who should hit it. + guard value.isFinite else { return "No value" } + let score = Int(min(100, max(0, value.rounded()))) + return "\(score) out of 100, \(ReadinessScore.band(score).rawValue)" + } + + let series = AXDataSeriesDescriptor( + name: "Readiness", + isContinuous: false, + dataPoints: zip(dates, scores).map { date, score in + AXDataPoint( + x: Self.dayFormatter.string(from: date), + y: score, + additionalValues: [], + label: ReadinessScore.band(Int(score)).rawValue + ) + } + ) + + return AXChartDescriptor( + title: "Readiness over time", + summary: summaryText, + xAxis: xAxis, + yAxis: yAxis, + additionalAxes: [], + series: [series] + ) + } + + /// Spoken before the data — the shape of the trend, so a screen-reader user gets the point + /// without stepping through every bar. + private var summaryText: String { + guard !points.isEmpty else { return "No readiness scores yet." } + let scores = points.map(\.score) + let mean = scores.reduce(0, +) / scores.count + return "\(points.count) scored days, averaging \(mean) out of 100, " + + "ranging from \(scores.min() ?? 0) to \(scores.max() ?? 0)." + } +} + +/// One contributor's share of the score: name, its explanation, and an earned/possible bar. +/// The bar is what makes the arithmetic legible — "18 of 30" is abstract; a two-thirds-filled bar +/// next to a full one is not. +struct ReadinessContributorRow: View { + let record: ReadinessContributorRecord + + private var fraction: Double { + guard record.maxPoints > 0 else { return 0 } + return max(0, min(1, record.earned / record.maxPoints)) + } + + /// Green when it earned nearly everything, amber mid, orange when it's the thing holding the + /// score down. Colouring by *share earned* rather than by contributor identity means the eye + /// lands on the problem. + private var barColor: Color { + if fraction >= 0.85 { return PulseColors.zoneMint } + if fraction >= 0.55 { return PulseColors.zoneAmber } + return PulseColors.zoneOrange + } + + private var title: String { + record.kind?.title ?? record.kindRaw.capitalized + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Text(title) + .font(PulseFont.callout.weight(.semibold)) + .foregroundStyle(PulseColors.textPrimary) + Spacer(minLength: 8) + Text("\(formatted(record.earned)) of \(formatted(record.maxPoints))") + .font(PulseFont.caption) + .foregroundStyle(PulseColors.textMuted) + .monospacedDigit() + } + + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(PulseColors.textMuted.opacity(0.15)) + Capsule() + .fill(barColor) + .frame(width: max(2, geo.size.width * fraction)) + } + } + .frame(height: 6) + + Text(record.detail) + .font(PulseFont.caption) + .foregroundStyle(PulseColors.textMuted) + .fixedSize(horizontal: false, vertical: true) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(title). \(record.detail). Earned \(formatted(record.earned)) of \(formatted(record.maxPoints)) points.") + } + + /// Points are fractional but rarely interestingly so — trim a trailing ".0". + private func formatted(_ value: Double) -> String { + value == value.rounded() ? "\(Int(value))" : String(format: "%.1f", value) + } +} diff --git a/PulseLoop/Services/DerivedSummaries.swift b/PulseLoop/Services/DerivedSummaries.swift index 2217365d..609454d6 100644 --- a/PulseLoop/Services/DerivedSummaries.swift +++ b/PulseLoop/Services/DerivedSummaries.swift @@ -243,6 +243,14 @@ struct ReadinessSnapshot: Equatable { contributors.filter { $0.drag > 0 }.max { $0.drag < $1.drag } } + /// Contributors with no row in the stored breakdown — i.e. what the ring didn't capture. Named + /// explicitly rather than left implicit: "your temperature was fine" and "your temperature + /// wasn't measured" are different claims, and only one of them is true here. + var missingKinds: [ReadinessContributor.Kind] { + let present = Set(contributors.compactMap(\.kind)) + return ReadinessContributor.Kind.allCases.filter { !present.contains($0) } + } + @MainActor init(_ row: ReadinessDaily) { date = row.date diff --git a/PulseLoop/Views/ReadinessDetailView.swift b/PulseLoop/Views/ReadinessDetailView.swift new file mode 100644 index 00000000..00f91bd3 --- /dev/null +++ b/PulseLoop/Views/ReadinessDetailView.swift @@ -0,0 +1,252 @@ +import SwiftUI +import SwiftData + +/// Tap-through detail for the readiness score: the hero ring, the full contributor breakdown, what +/// couldn't be measured, a trend chart, and an explainer. +/// +/// The breakdown is the reason this screen exists. The tile can only show the single biggest drag; +/// here every contributor is accounted for, with its earned/possible points and its own +/// explanation, plus an explicit list of what wasn't measured. A user should be able to reconstruct +/// the arithmetic — that's what "documented metrics, no black boxes" has to mean in practice. +struct ReadinessDetailView: View { + @Binding var path: NavigationPath + @Environment(\.modelContext) private var modelContext + + @State private var period: DetailPeriod = .month + @State private var latest: ReadinessSnapshot? + /// Progress toward a first score, loaded only while there is none. + @State private var progress: ReadinessProgress? + @State private var points: [ReadinessTrendPoint] = [] + /// Observed so the screen re-reads when a background sync scores a new night while it's open. + @State private var dataChange = PulseDataChange.shared + + enum DetailPeriod: String, CaseIterable, Identifiable { + case week = "Week" + case month = "Month" + case quarter = "90 Days" + var id: String { rawValue } + var days: Int { + switch self { + case .week: return 7 + case .month: return 30 + case .quarter: return 90 + } + } + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + hero + if let latest, !latest.contributors.isEmpty { + breakdown(latest) + } + if let latest, !latest.missingKinds.isEmpty { + notMeasured(latest.missingKinds) + } + periodSelector + trendSection + explainer + } + .padding(16) + .padding(.bottom, 40) + .pulseGlassContainer(spacing: 18) + } + .background(PulseColors.background) + .pageChrome("Readiness") + .task(id: period) { reload() } + .onChange(of: dataChange.token) { _, _ in reload() } + } + + // MARK: - Hero + + @ViewBuilder + private var hero: some View { + VStack(spacing: 10) { + if let latest { + VitalRingGauge( + value: Double(latest.score), + domain: 0...100, + zones: ReadinessZones.all, + valueColor: ReadinessZones.color(for: latest.score), + centerValue: "\(latest.score)", + centerStatus: latest.band.rawValue, + size: 190, + lineWidth: 16 + ) + // Coverage is stated, never hidden: a score from a partial night is not the same + // claim as one from a complete night, even at the same number. + if latest.coverage < 1 { + Text("Based on \(Int((latest.coverage * 100).rounded()))% of the full picture") + .font(PulseFont.caption) + .foregroundStyle(PulseColors.textMuted) + } + } else { + // Counting up to the first score. Shown as a ring for the same reason the card + // does: it reads as filling rather than as an error state. + ZStack { + Circle() + .stroke(PulseColors.textMuted.opacity(0.15), lineWidth: 14) + Circle() + .trim(from: 0, to: max(0.02, progress?.fraction ?? 0)) + .stroke(PulseColors.readiness.opacity(0.75), + style: StrokeStyle(lineWidth: 14, lineCap: .round)) + .rotationEffect(.degrees(-90)) + if let progress, progress.nightsCollected > 0 { + VStack(spacing: 0) { + Text("\(progress.nightsCollected)") + .font(PulseFont.numberHero) + .monospacedDigit() + .foregroundStyle(PulseColors.textPrimary) + Text(progress.centerCaption) + .font(PulseFont.caption) + .foregroundStyle(PulseColors.textMuted) + } + } else { + Image(systemName: "bolt.heart") + .font(.system(size: 44, weight: .light)) + .foregroundStyle(PulseColors.readiness.opacity(0.6)) + } + } + .frame(width: 170, height: 170) + .accessibilityElement(children: .ignore) + .accessibilityLabel(progressAccessibilityLabel) + + Text(emptyTitle) + .font(PulseFont.title3.weight(.semibold)) + .foregroundStyle(PulseColors.textPrimary) + Text(emptyDetail) + .font(PulseFont.footnote) + .foregroundStyle(PulseColors.textMuted) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + .padding(.horizontal, 14) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) + } + + private var emptyTitle: String { progress?.title ?? "No score yet" } + + private var emptyDetail: String { + progress?.detail + ?? "Wear your ring overnight. Readiness needs about a week of nights before it can compare tonight to your normal." + } + + private var progressAccessibilityLabel: String { + guard let progress, progress.nightsCollected > 0 else { return emptyDetail } + return "\(progress.nightsCollected) of \(progress.nightsNeeded) nights collected. \(progress.detail)" + } + + // MARK: - Breakdown + + private func breakdown(_ readiness: ReadinessSnapshot) -> some View { + VStack(alignment: .leading, spacing: 14) { + sectionHeader("What drove it") + // Already sorted biggest-drag-first by `ReadinessScore`; re-sorted defensively so an + // older stored row can't present itself out of order. + ForEach(readiness.contributors.sorted { $0.drag > $1.drag }, id: \.kindRaw) { record in + ReadinessContributorRow(record: record) + } + } + .padding(14) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) + } + + /// Naming what's absent matters as much as scoring what's present — it's the difference between + /// "your temperature was fine" and "your temperature wasn't measured". + private func notMeasured(_ kinds: [ReadinessContributor.Kind]) -> some View { + VStack(alignment: .leading, spacing: 6) { + sectionHeader("Not measured last night") + Text(kinds.map(\.title).joined(separator: ", ")) + .font(PulseFont.footnote) + .foregroundStyle(PulseColors.textMuted) + .fixedSize(horizontal: false, vertical: true) + Text("Missing signals are left out of the score rather than counted as zero.") + .font(PulseFont.caption) + .foregroundStyle(PulseColors.textMuted.opacity(0.8)) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) + } + + // MARK: - Trend + + private var periodSelector: some View { + Picker("Period", selection: $period) { + ForEach(DetailPeriod.allCases) { Text($0.rawValue).tag($0) } + } + .pickerStyle(.segmented) + } + + @ViewBuilder + private var trendSection: some View { + VStack(alignment: .leading, spacing: 8) { + sectionHeader("Trend") + if points.count < 2 { + Text("Not enough scored days for this period.") + .font(PulseFont.footnote) + .foregroundStyle(PulseColors.textMuted) + .frame(maxWidth: .infinity, minHeight: 120, alignment: .center) + } else { + ReadinessTrendChart(points: points) + Text("The line is your 7-day average, so one rough night reads as noise rather than a trend.") + .font(PulseFont.caption) + .foregroundStyle(PulseColors.textMuted) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) + } + + // MARK: - Explainer + + private var explainer: some View { + VStack(alignment: .leading, spacing: 8) { + sectionHeader("How this is calculated") + Text( + "Readiness combines your overnight HRV, resting heart rate, sleep, skin temperature, and yesterday's " + + "activity — each compared against your own baseline rather than a population average." + ) + .font(PulseFont.footnote) + .foregroundStyle(PulseColors.textMuted) + .fixedSize(horizontal: false, vertical: true) + Text("Every weight and threshold is documented in the project docs (docs/project/readiness.md). It is computed on this device and never leaves it.") + .font(PulseFont.caption) + .foregroundStyle(PulseColors.textMuted.opacity(0.8)) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) + } + + private func sectionHeader(_ text: String) -> some View { + Text(text.uppercased()) + .font(PulseFont.caption2) + .tracking(0.6) + .foregroundStyle(PulseColors.textMuted) + } + + // MARK: - Data + + private func reload() { + latest = ReadinessRepository.latest(context: modelContext).map(ReadinessSnapshot.init) + // Only while there is nothing to show — this walks the baseline window. + progress = latest == nil ? ReadinessService.progress(context: modelContext) : nil + + let calendar = Calendar.current + // Anchor on the newest scored day, not `Date()`, so a demo store (whose "today" is its + // newest seeded day) and a phone that hasn't synced today both still show their history. + let anchor = latest?.date ?? calendar.startOfDay(for: Date()) + let start = calendar.date(byAdding: .day, value: -(period.days - 1), to: anchor) ?? anchor + points = ReadinessRepository.rows(from: start, to: anchor, context: modelContext) + .map { ReadinessTrendPoint(date: $0.date, score: $0.score, band: $0.band) } + } +} diff --git a/PulseLoop/Views/RootViews.swift b/PulseLoop/Views/RootViews.swift index 83578d92..f4806b84 100644 --- a/PulseLoop/Views/RootViews.swift +++ b/PulseLoop/Views/RootViews.swift @@ -89,6 +89,9 @@ struct RootAppView: View { if UserDefaults.standard.bool(forKey: "openReadinessSettings") { path.append(AppRoute.settingsReadiness) } + if UserDefaults.standard.bool(forKey: "openReadiness") { + path.append(AppRoute.readinessDetail) + } // Test tooling: deep-link straight to a seeded workout's detail (route map). if UserDefaults.standard.bool(forKey: "openWorkout"), let session = ActivityRepository.sessions(context: modelContext).first(where: { $0.status == .finished && $0.useGps }) { @@ -168,6 +171,8 @@ struct RootAppView: View { NutritionSettingsView() case .settingsReadiness: ReadinessSettingsView() + case .readinessDetail: + ReadinessDetailView(path: $path) case .nutrition: NutritionView(path: $path) case let .mealDetail(id): diff --git a/PulseLoop/Views/TodayView.swift b/PulseLoop/Views/TodayView.swift index 7230f27d..0b86a9d4 100644 --- a/PulseLoop/Views/TodayView.swift +++ b/PulseLoop/Views/TodayView.swift @@ -115,7 +115,7 @@ struct TodayView: View { readiness: summary.readiness, progress: summary.readinessProgress, calibration: summary.calibration, - onTap: {} + onTap: { path.append(AppRoute.readinessDetail) } ) } diff --git a/PulseLoopTests/ReadinessDetailTests.swift b/PulseLoopTests/ReadinessDetailTests.swift new file mode 100644 index 00000000..1251d07b --- /dev/null +++ b/PulseLoopTests/ReadinessDetailTests.swift @@ -0,0 +1,174 @@ +import XCTest +import SwiftData +@testable import PulseLoop + +/// The data and accessibility layer behind the readiness detail screen: history fetching, the +/// missing-contributor list, and the trend chart's VoiceOver descriptor. +/// +/// The chart descriptor is worth testing precisely because it's invisible — a broken +/// `AXChartDescriptor` looks perfect on screen and leaves the chart unusable with VoiceOver. +@MainActor +final class ReadinessDetailTests: XCTestCase { + + /// Sleep and HRV only — the other three kinds are deliberately absent. + private static let partialContributors = #""" + [{"kindRaw":"sleep","earned":30,"maxPoints":30,"value":92,"detail":"Sleep score 92"}, + {"kindRaw":"hrv","earned":24,"maxPoints":30,"value":47,"baseline":50,"deviation":-6,"detail":"HRV 6% below your baseline"}] + """# + + private func point(_ dayOffset: Int, _ score: Int) -> ReadinessTrendPoint { + ReadinessTrendPoint( + date: TestSupport.day(dayOffset), + score: score, + band: ReadinessScore.band(score) + ) + } + + @discardableResult + private func insertScore(_ dayOffset: Int, score: Int, into context: ModelContext) -> ReadinessDaily { + let row = ReadinessDaily( + date: TestSupport.day(dayOffset), + score: score, + band: ReadinessScore.band(score), + availablePoints: 100, + contributorsJSON: "[]" + ) + context.insert(row) + try? context.save() + return row + } + + // MARK: - History fetching + + func testRowsAreReturnedOldestFirstForALeftToRightAxis() throws { + let context = try TestSupport.makeContext() + for offset in [0, -2, -5, -1] { + insertScore(offset, score: 70 + abs(offset), into: context) + } + let rows = ReadinessRepository.rows( + from: TestSupport.day(-6), to: TestSupport.day(0), context: context + ) + XCTAssertEqual(rows.count, 4) + XCTAssertEqual(rows.map(\.date), rows.map(\.date).sorted(), "chart axis needs ascending dates") + } + + func testRowsRespectTheRequestedWindow() throws { + let context = try TestSupport.makeContext() + insertScore(0, score: 80, into: context) + insertScore(-3, score: 70, into: context) + insertScore(-40, score: 60, into: context) + + let week = ReadinessRepository.rows( + from: TestSupport.day(-6), to: TestSupport.day(0), context: context + ) + XCTAssertEqual(week.count, 2, "the 40-day-old score must not appear in a 7-day window") + } + + func testLatestReturnsTheMostRecentlyScoredMorning() throws { + let context = try TestSupport.makeContext() + insertScore(-3, score: 61, into: context) + insertScore(0, score: 92, into: context) + insertScore(-1, score: 75, into: context) + XCTAssertEqual(ReadinessRepository.latest(context: context)?.score, 92) + } + + // MARK: - Missing contributors + + func testMissingKindsNamesEverySignalWithoutARow() throws { + let context = try TestSupport.makeContext() + let row = ReadinessDaily( + date: TestSupport.day(0), score: 74, band: .ready, availablePoints: 60, + contributorsJSON: Self.partialContributors + ) + context.insert(row) + try? context.save() + + let snapshot = ReadinessSnapshot(row) + XCTAssertEqual(snapshot.missingKinds, [.restingHeartRate, .skinTemperature, .trainingLoad], + "missing kinds must be reported in canonical order") + } + + func testMissingKindsIsEmptyOnAFullNight() throws { + let context = try TestSupport.makeContext() + let all = ReadinessContributor.Kind.allCases.map { + #"{"kindRaw":"\#($0.rawValue)","earned":1,"maxPoints":1,"value":1,"detail":"x"}"# + }.joined(separator: ",") + let row = ReadinessDaily( + date: TestSupport.day(0), score: 100, band: .primed, availablePoints: 100, + contributorsJSON: "[\(all)]" + ) + context.insert(row) + try? context.save() + XCTAssertTrue(ReadinessSnapshot(row).missingKinds.isEmpty) + } + + // MARK: - Chart accessibility + + /// No other chart in this app exposes a descriptor. This one must, or the trend is opaque to + /// VoiceOver — which for a screen whose whole purpose is explaining a number would be perverse. + func testChartDescriptorExposesEveryDay() { + let points = [point(-2, 91), point(-1, 62), point(0, 74)] + let descriptor = ReadinessTrendChart(points: points).makeChartDescriptor() + + XCTAssertEqual(descriptor.title, "Readiness over time") + let series = try? XCTUnwrap(descriptor.series.first) + XCTAssertEqual(series?.dataPoints.count, 3, "every scored day must be reachable by the rotor") + // Each point is labelled with its band, so the rotor speaks meaning and not just a number. + XCTAssertEqual(series?.dataPoints.map(\.label), ["Primed", "Moderate", "Ready"]) + + // The x axis carries one readable category per day, in chart order. + let xAxis = descriptor.xAxis as? AXCategoricalDataAxisDescriptor + XCTAssertEqual(xAxis?.categoryOrder.count, 3) + for category in xAxis?.categoryOrder ?? [] { + XCTAssertFalse(category.isEmpty, "each day needs a spoken label") + } + } + + func testChartDescriptorSummaryDescribesTheShape() { + let points = [point(-2, 90), point(-1, 60), point(0, 75)] + let summary = ReadinessTrendChart(points: points).makeChartDescriptor().summary + XCTAssertEqual(summary, "3 scored days, averaging 75 out of 100, ranging from 60 to 90.") + } + + func testChartDescriptorHandlesNoData() { + let summary = ReadinessTrendChart(points: []).makeChartDescriptor().summary + XCTAssertEqual(summary, "No readiness scores yet.") + } + + func testChartYAxisIsPinnedToTheScoreRangeAndBandEdges() { + let descriptor = ReadinessTrendChart(points: [point(0, 74)]).makeChartDescriptor() + let yAxis = descriptor.yAxis as? AXNumericDataAxisDescriptor + XCTAssertEqual(yAxis?.range, 0...100) + XCTAssertEqual(yAxis?.gridlinePositions, [55, 70, 85], "gridlines should sit on the band edges") + } + + /// The spoken value for a score must agree with the band the bar is drawn in. + func testChartAxisValueDescriptionMatchesTheBand() { + let descriptor = ReadinessTrendChart(points: [point(0, 74)]).makeChartDescriptor() + let yAxis = try? XCTUnwrap(descriptor.yAxis as? AXNumericDataAxisDescriptor) + XCTAssertEqual(yAxis?.valueDescriptionProvider(86), "86 out of 100, Primed") + XCTAssertEqual(yAxis?.valueDescriptionProvider(74), "74 out of 100, Ready") + XCTAssertEqual(yAxis?.valueDescriptionProvider(40), "40 out of 100, Rest needed") + } + + /// Regression: the axis description closure is called by the framework with values we don't + /// control. `Int(someDouble)` traps on infinity and NaN, so an unguarded conversion crashed the + /// app outright — and only ever for VoiceOver users. + func testChartAxisValueDescriptionSurvivesNonFiniteInput() { + let descriptor = ReadinessTrendChart(points: [point(0, 74)]).makeChartDescriptor() + let yAxis = try? XCTUnwrap(descriptor.yAxis as? AXNumericDataAxisDescriptor) + for hostile: Double in [.infinity, -.infinity, .nan, .greatestFiniteMagnitude, -1, 1_000_000] { + let spoken = yAxis?.valueDescriptionProvider(hostile) + XCTAssertFalse(spoken?.isEmpty ?? true, "no spoken value for \(hostile)") + } + } + + // MARK: - Periods + + func testDetailPeriodsCoverTheIntendedWindows() { + XCTAssertEqual(ReadinessDetailView.DetailPeriod.week.days, 7) + XCTAssertEqual(ReadinessDetailView.DetailPeriod.month.days, 30) + XCTAssertEqual(ReadinessDetailView.DetailPeriod.quarter.days, 90) + XCTAssertEqual(ReadinessDetailView.DetailPeriod.allCases.count, 3) + } +} From c063557e21499136b0500e5df833806a5cab5b6a Mon Sep 17 00:00:00 2001 From: ak710 Date: Fri, 31 Jul 2026 21:59:34 -0400 Subject: [PATCH 5/6] Give the coach readiness data and a get_readiness tool Fourth readiness PR (#103). Puts the score and its breakdown in front of the coach, gated on the "Share readiness with Coach" toggle from PR 2. Two surfaces, both read-only: - Context packet: this morning's score, band, coverage, contributors and what wasn't measured, so the coach opens a conversation already knowing how recovered the user is. - get_readiness(start_date, end_date): daily scores over a range, defaulting to the last 7 days and clamped to 31. The contributor breakdown is the entire point. Without it the model can see a 74 and has to invent a reason for it; with it, it can say "your HRV was 12% below your baseline" because that is what the app actually computed. The tool description says so explicitly - use the contributors, never guess - and contributors arrive sorted by drag so the model leads with what mattered. not_measured is carried for the same reason. "Your temperature was fine" and "your temperature wasn't measured" are different claims, and a model that cannot distinguish them will confidently assert the first when the second is true. An empty range likewise returns an explanatory note rather than a bare empty list, since "no scores" would otherwise read as "poor recovery". Read-only by design: a readiness score is derived from measurements the ring took, so there is nothing for the model to write. A test guards against a write tool appearing later by accident. A reversed date range is normalized rather than returning nothing. Models occasionally swap the bounds, and an empty result is a materially wrong answer here. Gating mirrors nutrition exactly: readinessContextEnabled requires the feature on AND shared, the tool is absent from the registry otherwise, and the packet omits the key entirely rather than sending null. The tool also re-checks the flag itself, so it refuses even if it were somehow reachable. 12 new tests. 947 total, 0 failures. Co-Authored-By: Claude Opus 5 --- .../Coach/Config/CoachFeatureFlags.swift | 8 + .../Coach/Context/CoachContextBuilder.swift | 32 ++- .../Coach/Context/CoachContextPacket.swift | 23 ++ PulseLoop/Coach/Tools/ReadinessTools.swift | 147 ++++++++++++ PulseLoop/Coach/Tools/ToolRegistry.swift | 3 + .../Coach/ViewModels/CoachViewModel.swift | 3 +- .../Views/Nutrition/MealAnalysisSheet.swift | 3 +- PulseLoopTests/ReadinessToolsTests.swift | 223 ++++++++++++++++++ 8 files changed, 438 insertions(+), 4 deletions(-) create mode 100644 PulseLoop/Coach/Tools/ReadinessTools.swift create mode 100644 PulseLoopTests/ReadinessToolsTests.swift diff --git a/PulseLoop/Coach/Config/CoachFeatureFlags.swift b/PulseLoop/Coach/Config/CoachFeatureFlags.swift index 5f732305..d1534fb8 100644 --- a/PulseLoop/Coach/Config/CoachFeatureFlags.swift +++ b/PulseLoop/Coach/Config/CoachFeatureFlags.swift @@ -10,6 +10,9 @@ struct CoachFeatureFlags { /// composes with the coach gates. Defaulted so existing construction sites and /// tests keep compiling (default = feature off). var nutritionPrefs: NutritionPrefs = .default + /// Snapshot of the readiness feature's prefs, bridged in the same way as `nutritionPrefs`. + /// Defaulted so existing construction sites and tests keep compiling. + var readinessPrefs: ReadinessPrefs = .default /// User-facing master switch — when off, the coach tab, summaries and /// notifications are all hidden. This is the gate the UI checks; the @@ -46,6 +49,11 @@ struct CoachFeatureFlags { /// The coach may log/edit meals: nutrition context is shared AND write tools are on. var nutritionWriteEnabled: Bool { nutritionContextEnabled && writeToolsEnabled } + /// Readiness may reach the coach (context packet + read tool): the feature is on AND the user + /// shares it. Read-only — there is no write path, because a readiness score is derived and the + /// model has no business editing one. + var readinessContextEnabled: Bool { readinessPrefs.masterEnabled && readinessPrefs.shareWithCoach } + var maxToolCalls: Int { max(1, settings.maxToolCalls) } var maxRounds: Int { max(1, settings.maxRounds) } var model: String { settings.model } diff --git a/PulseLoop/Coach/Context/CoachContextBuilder.swift b/PulseLoop/Coach/Context/CoachContextBuilder.swift index 9b7a42af..e2d0e94c 100644 --- a/PulseLoop/Coach/Context/CoachContextBuilder.swift +++ b/PulseLoop/Coach/Context/CoachContextBuilder.swift @@ -12,7 +12,8 @@ enum CoachContextBuilder { now: Date = Date(), budget: CoachContextBudget = .full, environment: CoachContextPacket.EnvironmentContext? = nil, - includeNutrition: Bool = true + includeNutrition: Bool = true, + includeReadiness: Bool = true ) -> CoachContextPacket { let summary = MetricsService.buildTodaySummary(context: context) let profile = ProfileRepository.profile(context: context) @@ -44,6 +45,10 @@ enum CoachContextBuilder { let nutritionPrefs = NutritionPrefsStore.shared.prefs let shareNutrition = includeNutrition && nutritionPrefs.masterEnabled && nutritionPrefs.shareWithCoach + // Same shape for readiness: on, shared, and not opted out by the caller. + let readinessPrefs = ReadinessPrefsStore.shared.prefs + let shareReadiness = includeReadiness && readinessPrefs.masterEnabled && readinessPrefs.shareWithCoach + let goals = CoachContextPacket.GoalContext( stepsDaily: summary.goals.stepsDaily, activeMinutesDaily: summary.goals.activeMinutesDaily, @@ -125,7 +130,30 @@ enum CoachContextBuilder { conversationSummary: cap(conversationSummary, to: budget.conversationSummaryCap), dataQualityWarnings: Array(warnings.prefix(budget.maxWarnings)), environment: environment, - nutrition: shareNutrition ? nutritionContext(summary: summary, context: context, now: now) : nil + nutrition: shareNutrition ? nutritionContext(summary: summary, context: context, now: now) : nil, + readiness: shareReadiness ? readinessContext(summary: summary) : nil + ) + } + + /// Flatten the stored readiness snapshot for the packet. Reads `summary.readiness`, which + /// already inherits the master-toggle gate, so a disabled feature can't leak through here. + private static func readinessContext(summary: TodaySummary) -> CoachContextPacket.ReadinessContext? { + guard let readiness = summary.readiness else { return nil } + return CoachContextPacket.ReadinessContext( + score: readiness.score, + band: readiness.band.rawValue, + coverage: readiness.coverage, + contributors: readiness.contributors + .sorted { $0.drag > $1.drag } + .map { + CoachContextPacket.ReadinessContext.ContributorBrief( + signal: $0.kind?.rawValue ?? $0.kindRaw, + pointsEarned: $0.earned, + pointsPossible: $0.maxPoints, + detail: $0.detail + ) + }, + notMeasured: readiness.missingKinds.map(\.rawValue) ) } diff --git a/PulseLoop/Coach/Context/CoachContextPacket.swift b/PulseLoop/Coach/Context/CoachContextPacket.swift index 5bfebc84..50c5f768 100644 --- a/PulseLoop/Coach/Context/CoachContextPacket.swift +++ b/PulseLoop/Coach/Context/CoachContextPacket.swift @@ -28,6 +28,9 @@ struct CoachContextPacket: Encodable { /// Opt-in nutrition tracking summary. Nil when the feature is off or the user /// doesn't share it with the coach — absent from the JSON entirely. var nutrition: NutritionContext? + /// Opt-in readiness score + the contributors behind it. Nil when the feature is off or not + /// shared — absent from the JSON entirely. + var readiness: ReadinessContext? struct ProfileContext: Encodable { var name: String? @@ -139,6 +142,26 @@ struct CoachContextPacket: Encodable { } } + /// This morning's readiness score with the breakdown that produced it. The contributors are + /// the point: with them the coach can say "your HRV is 12% below baseline", and without them it + /// would have to guess at a reason for a number it can see. + struct ReadinessContext: Encodable { + var score: Int + var band: String + /// Share of the full 100-point picture this score is based on, 0–1. + var coverage: Double + var contributors: [ContributorBrief] + /// Signals the ring didn't capture. Named so the coach doesn't read absence as normality. + var notMeasured: [String] + + struct ContributorBrief: Encodable { + var signal: String + var pointsEarned: Double + var pointsPossible: Double + var detail: String + } + } + /// City-level location + current/forecast weather. City-only privacy: never a /// street, never coordinates. Any field may be nil when weather degrades to a /// city-only or stale result. diff --git a/PulseLoop/Coach/Tools/ReadinessTools.swift b/PulseLoop/Coach/Tools/ReadinessTools.swift new file mode 100644 index 00000000..913e4494 --- /dev/null +++ b/PulseLoop/Coach/Tools/ReadinessTools.swift @@ -0,0 +1,147 @@ +import Foundation +import SwiftData + +/// Readiness retrieval. Read-only by design: a readiness score is derived from measurements the +/// user's ring took, so there is nothing for the model to write. Available whenever readiness is +/// enabled and shared with the coach. +/// +/// Every row carries its contributor breakdown, which is the whole reason this tool exists. Without +/// it the model can see a 74 and would have to invent a reason for it; with it the model can say +/// "your HRV was 12% below your baseline" because that is what the app actually computed. +@MainActor +enum ReadinessTools { + static var readTools: [AnyCoachTool] { [getReadiness] } + + /// A month is enough for "how has my recovery been lately" without flooding the context window. + private static let maxDays = 31 + + private struct RangeArgs: Decodable { + let startDate: String? + let endDate: String? + enum CodingKeys: String, CodingKey { + case startDate = "start_date" + case endDate = "end_date" + } + } + + private struct ContributorPayload: Encodable { + let signal: String + let pointsEarned: Double + let pointsPossible: Double + let detail: String + } + + private struct DayPayload: Encodable { + let date: String + let score: Int + let band: String + let coverage: Double + let contributors: [ContributorPayload] + let notMeasured: [String] + } + + private struct Result: Encodable { + let days: [DayPayload] + let averageScore: Int? + /// Pinned so a stored score is never reinterpreted under weights it wasn't computed with. + let algorithmVersion: Int + /// Present only when there is genuinely nothing to report, so the model says "no scores yet" + /// rather than inferring poor recovery from an empty list. + let note: String? + } + + /// Parse a `YYYY-MM-DD` argument to the start of that local day. nil for absent or unparseable + /// input, so the caller can fall back to its default window rather than erroring. + private static func startOfDay(_ value: String?, calendar: Calendar) -> Date? { + guard let value, !value.isEmpty else { return nil } + guard let parsed = CoachDataAccess.parseLocalDate(value) else { return nil } + return calendar.startOfDay(for: parsed) + } + + /// Flatten one stored row for the model. Split out of the tool body because the nested + /// map-inside-initializer defeated the type checker. + private static func payload(for row: ReadinessDaily) -> DayPayload { + let snapshot = ReadinessSnapshot(row) + let ranked = snapshot.contributors.sorted { $0.drag > $1.drag } + var contributors: [ContributorPayload] = [] + contributors.reserveCapacity(ranked.count) + for record in ranked { + contributors.append( + ContributorPayload( + signal: record.kind?.rawValue ?? record.kindRaw, + pointsEarned: record.earned, + pointsPossible: record.maxPoints, + detail: record.detail + ) + ) + } + return DayPayload( + date: CoachDataAccess.localDateString(row.date), + score: row.score, + band: row.band.rawValue, + coverage: snapshot.coverage, + contributors: contributors, + notMeasured: snapshot.missingKinds.map(\.rawValue) + ) + } + + // MARK: get_readiness + + private static var getReadiness: AnyCoachTool { + .make( + name: "get_readiness", + label: "Checking your readiness", + description: "Get daily readiness (recovery) scores with the contributor breakdown that " + + "produced each one. Readiness is 0–100 from overnight HRV, resting heart rate, " + + "sleep, skin temperature, and the previous day's training load, each compared " + + "against the user's own baseline. Use the contributors to explain a score — never " + + "guess at a reason. Signals in not_measured were not captured and are excluded " + + "from the score rather than counted as zero. Defaults to the last 7 days.", + parameters: JSONSchema.object([ + "start_date": ["type": ["string", "null"]], + "end_date": ["type": ["string", "null"]], + ], required: ["start_date", "end_date"]), + argsType: RangeArgs.self + ) { args, ctx in + guard ctx.flags.readinessContextEnabled else { + return .error("readiness is not enabled or not shared with the coach") + } + + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + + // Inlined rather than a nested func: a nested `func` inside this closure would not + // inherit its main-actor isolation, and `CoachDataAccess` is main-actor bound. + let parsedEnd: Date? = Self.startOfDay(args.endDate, calendar: calendar) + let requestedEnd: Date = parsedEnd ?? today + let defaultStart: Date = calendar.date(byAdding: .day, value: -6, to: requestedEnd) ?? requestedEnd + let parsedStart: Date? = Self.startOfDay(args.startDate, calendar: calendar) + let requestedStart: Date = parsedStart ?? defaultStart + + // Tolerate a reversed range rather than returning nothing — the model occasionally + // swaps them, and an empty result would read as "no recovery data". + let start = min(requestedStart, requestedEnd) + let end = max(requestedStart, requestedEnd) + + // Clamp the window so a wide request can't blow the context budget. + let earliest = calendar.date(byAdding: .day, value: -(maxDays - 1), to: end) ?? end + let clampedStart = max(start, earliest) + + let rows = ReadinessRepository.rows(from: clampedStart, to: end, context: ctx.modelContext) + let days = rows.map { payload(for: $0) } + + let average = days.isEmpty ? nil : days.reduce(0) { $0 + $1.score } / days.count + return .encoding( + Result( + days: days, + averageScore: average, + algorithmVersion: ReadinessScore.algorithmVersion, + note: days.isEmpty + ? "No readiness scores in this range. Readiness needs about a week of " + + "overnight wear before it can compare a night to the user's baseline." + : nil + ) + ) + } + } +} diff --git a/PulseLoop/Coach/Tools/ToolRegistry.swift b/PulseLoop/Coach/Tools/ToolRegistry.swift index 8975003c..e9136cad 100644 --- a/PulseLoop/Coach/Tools/ToolRegistry.swift +++ b/PulseLoop/Coach/Tools/ToolRegistry.swift @@ -20,6 +20,9 @@ struct ToolRegistry { if flags.nutritionContextEnabled { all += NutritionTools.readTools } + if flags.readinessContextEnabled { + all += ReadinessTools.readTools + } if flags.nutritionWriteEnabled { all += NutritionTools.writeTools } diff --git a/PulseLoop/Coach/ViewModels/CoachViewModel.swift b/PulseLoop/Coach/ViewModels/CoachViewModel.swift index d722a03c..65368b7b 100644 --- a/PulseLoop/Coach/ViewModels/CoachViewModel.swift +++ b/PulseLoop/Coach/ViewModels/CoachViewModel.swift @@ -62,7 +62,8 @@ final class CoachViewModel { let (apiKey, activeClient) = resolveClient() let flags = CoachFeatureFlags( settings: settingsStore.settings, hasAPIKey: apiKey != nil, - nutritionPrefs: NutritionPrefsStore.shared.prefs) + nutritionPrefs: NutritionPrefsStore.shared.prefs, + readinessPrefs: ReadinessPrefsStore.shared.prefs) let budget = flags.contextBudget let environment = await CoachEnvironmentContextService.shared.snapshot() let packet = CoachContextBuilder.build(context: context, budget: budget, environment: environment) diff --git a/PulseLoop/Views/Nutrition/MealAnalysisSheet.swift b/PulseLoop/Views/Nutrition/MealAnalysisSheet.swift index 3c9b41d2..dbdc2de9 100644 --- a/PulseLoop/Views/Nutrition/MealAnalysisSheet.swift +++ b/PulseLoop/Views/Nutrition/MealAnalysisSheet.swift @@ -372,7 +372,8 @@ enum MealEstimator { ) let flags = CoachFeatureFlags( settings: settings, hasAPIKey: apiKey != nil, - nutritionPrefs: NutritionPrefsStore.shared.prefs) + nutritionPrefs: NutritionPrefsStore.shared.prefs, + readinessPrefs: ReadinessPrefsStore.shared.prefs) guard flags.coachEnabled else { return .failure(EstimateError(message: "AI analysis needs the coach enabled with a cloud provider (Settings → AI Coach). You can still search the database or enter the meal manually.")) } diff --git a/PulseLoopTests/ReadinessToolsTests.swift b/PulseLoopTests/ReadinessToolsTests.swift new file mode 100644 index 00000000..776b1326 --- /dev/null +++ b/PulseLoopTests/ReadinessToolsTests.swift @@ -0,0 +1,223 @@ +import XCTest +import SwiftData +@testable import PulseLoop + +/// The `get_readiness` coach tool and the readiness slice of the context packet. +/// +/// The contributor breakdown is what these tests really guard. A tool that returned only a score +/// would leave the model to invent a reason for it, which is exactly the failure mode the whole +/// feature is designed to avoid. +@MainActor +final class ReadinessToolsTests: XCTestCase { + + private var savedPrefs: ReadinessPrefs? + + override func setUp() async throws { + try await super.setUp() + savedPrefs = ReadinessPrefsStore.shared.prefs + ReadinessPrefsStore.shared.prefs = ReadinessPrefs.default // on + shared + } + + override func tearDown() async throws { + if let savedPrefs { ReadinessPrefsStore.shared.prefs = savedPrefs } + try await super.tearDown() + } + + // MARK: - Harness + + private func flags(enabled: Bool = true, share: Bool = true) -> CoachFeatureFlags { + var s = CoachSettings.default + s.coachMasterEnabled = true + var r = ReadinessPrefs.default + r.masterEnabled = enabled + r.shareWithCoach = share + return CoachFeatureFlags(settings: s, hasAPIKey: true, readinessPrefs: r) + } + + private func tool(_ name: String, enabled: Bool = true, share: Bool = true) throws -> AnyCoachTool { + try XCTUnwrap(ToolRegistry(flags: flags(enabled: enabled, share: share)).tool(named: name)) + } + + private func ctx(_ c: ModelContext, enabled: Bool = true, share: Bool = true) -> ToolExecutionContext { + ToolExecutionContext(modelContext: c, flags: flags(enabled: enabled, share: share)) + } + + private func parse(_ result: ToolResult) throws -> [String: Any] { + try XCTUnwrap(try JSONSerialization.jsonObject(with: Data(result.jsonString.utf8)) as? [String: Any]) + } + + private let sampleContributors = #""" + [{"kindRaw":"hrv","earned":18,"maxPoints":30,"value":44,"baseline":50,"deviation":-12,"detail":"HRV 12% below your baseline"}, + {"kindRaw":"sleep","earned":28,"maxPoints":30,"value":85,"detail":"Sleep score 85"}, + {"kindRaw":"restingHeartRate","earned":25,"maxPoints":25,"value":54,"baseline":55,"deviation":-1,"detail":"Resting HR at your baseline"}] + """# + + @discardableResult + private func insertScore(_ dayOffset: Int, score: Int = 74, into context: ModelContext) -> ReadinessDaily { + let row = ReadinessDaily( + date: TestSupport.day(dayOffset), + score: score, + band: ReadinessScore.band(score), + availablePoints: 85, + contributorsJSON: sampleContributors + ) + context.insert(row) + try? context.save() + return row + } + + private func isoDay(_ offset: Int) -> String { + CoachDataAccess.localDateString(TestSupport.day(offset)) + } + + // MARK: - Registration + + func testToolIsRegisteredOnlyWhenSharedWithTheCoach() { + XCTAssertNotNil(ToolRegistry(flags: flags()).tool(named: "get_readiness")) + XCTAssertNil(ToolRegistry(flags: flags(enabled: false)).tool(named: "get_readiness"), + "feature off must remove the tool entirely") + XCTAssertNil(ToolRegistry(flags: flags(share: false)).tool(named: "get_readiness"), + "sharing off must remove the tool entirely") + } + + /// The tool is read-only on purpose: a readiness score is derived, so there is nothing for the + /// model to write. Guard against a write tool appearing later by accident. + func testThereIsNoReadinessWriteTool() { + let registry = ToolRegistry(flags: flags()) + for name in ["set_readiness", "log_readiness", "update_readiness", "delete_readiness"] { + XCTAssertNil(registry.tool(named: name)) + } + } + + // MARK: - Payload + + func testReturnsScoresWithTheContributorBreakdown() async throws { + let context = try TestSupport.makeContext() + insertScore(0, score: 74, into: context) + + let result = try await tool("get_readiness").run( + Data(#"{"start_date":null,"end_date":null}"#.utf8), ctx(context) + ) + XCTAssertFalse(result.isError) + let json = try parse(result) + let days = try XCTUnwrap(json["days"] as? [[String: Any]]) + XCTAssertEqual(days.count, 1) + + let day = try XCTUnwrap(days.first) + XCTAssertEqual(day["score"] as? Int, 74) + XCTAssertEqual(day["band"] as? String, "Ready") + XCTAssertEqual(day["coverage"] as? Double ?? 0, 0.85, accuracy: 0.0001) + + let contributors = try XCTUnwrap(day["contributors"] as? [[String: Any]]) + XCTAssertEqual(contributors.count, 3) + // Biggest drag first, so the model leads with the thing that actually mattered. + XCTAssertEqual(contributors.first?["signal"] as? String, "hrv") + XCTAssertEqual(contributors.first?["detail"] as? String, "HRV 12% below your baseline") + XCTAssertEqual(json["algorithm_version"] as? Int, ReadinessScore.algorithmVersion) + } + + /// Absent signals must be named. If the model can't tell "temperature was normal" from + /// "temperature wasn't measured", it will confidently report the first when the second is true. + func testNotMeasuredSignalsAreNamed() async throws { + let context = try TestSupport.makeContext() + insertScore(0, into: context) + + let result = try await tool("get_readiness").run( + Data(#"{"start_date":null,"end_date":null}"#.utf8), ctx(context) + ) + let days = try XCTUnwrap(try parse(result)["days"] as? [[String: Any]]) + let notMeasured = try XCTUnwrap(days.first?["not_measured"] as? [String]) + XCTAssertEqual(Set(notMeasured), ["skinTemperature", "trainingLoad"]) + } + + func testRespectsAnExplicitDateRange() async throws { + let context = try TestSupport.makeContext() + insertScore(0, score: 80, into: context) + insertScore(-2, score: 70, into: context) + insertScore(-20, score: 60, into: context) + + let args = #"{"start_date":"\#(isoDay(-3))","end_date":"\#(isoDay(0))"}"# + let result = try await tool("get_readiness").run(Data(args.utf8), ctx(context)) + let days = try XCTUnwrap(try parse(result)["days"] as? [[String: Any]]) + XCTAssertEqual(days.count, 2, "the 20-day-old score is outside the requested range") + XCTAssertEqual(try parse(result)["average_score"] as? Int, 75) + } + + /// The model occasionally swaps the bounds. Returning nothing would read as "no recovery data", + /// which is a materially wrong answer, so a reversed range is normalized instead. + func testReversedRangeIsNormalizedRatherThanReturningNothing() async throws { + let context = try TestSupport.makeContext() + insertScore(-1, score: 66, into: context) + + let args = #"{"start_date":"\#(isoDay(0))","end_date":"\#(isoDay(-3))"}"# + let result = try await tool("get_readiness").run(Data(args.utf8), ctx(context)) + let days = try XCTUnwrap(try parse(result)["days"] as? [[String: Any]]) + XCTAssertEqual(days.count, 1) + } + + /// An empty result must say so explicitly, or "no scores" reads as "bad recovery". + func testEmptyRangeCarriesAnExplanatoryNote() async throws { + let context = try TestSupport.makeContext() + let result = try await tool("get_readiness").run( + Data(#"{"start_date":null,"end_date":null}"#.utf8), ctx(context) + ) + let json = try parse(result) + XCTAssertEqual((json["days"] as? [[String: Any]])?.count, 0) + XCTAssertNil(json["average_score"] as? Int) + let note = try XCTUnwrap(json["note"] as? String) + XCTAssertTrue(note.localizedCaseInsensitiveContains("no readiness scores")) + } + + /// Belt and braces: even if the tool were somehow reachable with sharing off, it must refuse. + func testToolRefusesWhenSharingIsOff() async throws { + let context = try TestSupport.makeContext() + insertScore(0, into: context) + let result = try await tool("get_readiness").run( + Data(#"{"start_date":null,"end_date":null}"#.utf8), + ctx(context, share: false) + ) + XCTAssertTrue(result.isError) + } + + // MARK: - Context packet + + func testContextPacketCarriesReadinessWhenShared() throws { + let context = try TestSupport.makeContext() + insertScore(0, score: 74, into: context) + + let packet = CoachContextBuilder.build(context: context) + let readiness = try XCTUnwrap(packet.readiness) + XCTAssertEqual(readiness.score, 74) + XCTAssertEqual(readiness.band, "Ready") + XCTAssertEqual(readiness.contributors.first?.signal, "hrv") + XCTAssertEqual(Set(readiness.notMeasured), ["skinTemperature", "trainingLoad"]) + } + + func testContextPacketOmitsReadinessWhenSharingIsOff() throws { + let context = try TestSupport.makeContext() + insertScore(0, into: context) + + var prefs = ReadinessPrefs.default + prefs.shareWithCoach = false + ReadinessPrefsStore.shared.prefs = prefs + + XCTAssertNil(CoachContextBuilder.build(context: context).readiness) + } + + func testContextPacketOmitsReadinessWhenTheCallerOptsOut() throws { + let context = try TestSupport.makeContext() + insertScore(0, into: context) + XCTAssertNil(CoachContextBuilder.build(context: context, includeReadiness: false).readiness) + } + + /// Absent from the JSON entirely, not present-and-null — the model should see no readiness key + /// at all rather than something it might try to reason about. + func testReadinessIsAbsentFromEncodedJSONWhenNotShared() throws { + let context = try TestSupport.makeContext() + insertScore(0, into: context) + let packet = CoachContextBuilder.build(context: context, includeReadiness: false) + let data = try JSONEncoder().encode(packet) + let json = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertNil(json["readiness"]) + } +} From 49ab364ec32aa4c441a56702c2beb934cbf349c4 Mon Sep 17 00:00:00 2001 From: ak710 Date: Fri, 31 Jul 2026 22:12:57 -0400 Subject: [PATCH 6/6] Add readiness as a configurable home-screen widget metric Fifth and final readiness PR (#103). Readiness joins the metrics a user can put on the configurable single- and dual-metric widgets. The widget shows the band-coloured arc, the score, and the one thing holding it back - the same standard the in-app card is held to, since a bare number on the home screen would be exactly the black box the project rules out. The band zones ride in the payload as VitalColorToken strings, reusing the lossless round-trip the vitals zones already use. That means the extension draws the identical arc without the scoring engine being compiled into it - no new files in the widget target's membership list, and no duplicated threshold constants that could drift from ReadinessScore.band. readiness is Optional on WidgetSnapshot and decoded with decodeIfPresent, matching nutrition: a snapshot written before this shipped must still decode, or the widget goes blank for anyone who hasn't relaunched the app since updating. A test pins that with a literal legacy JSON payload. After midnight the tile withholds the score rather than relabelling it, because a readiness score describes the night that just ended - the same rollover rule the activity and sleep tiles already follow. With no data it renders the "open the app to sync" placeholder rather than a zero, which would read as terrible recovery instead of no data. 3 new tests covering the payload round trip, zone colour fidelity across the process boundary, and legacy-snapshot decode. 950 total, 0 failures. Verification note: the widget extension compiles and the snapshot contract is unit-tested, but the widget was not rendered end-to-end - an unsigned simulator build has no app-group container, so no snapshot file is written. The publisher's build path has no test coverage here for any metric, readiness included. Co-Authored-By: Claude Opus 5 --- .../Services/WidgetSnapshotPublisher.swift | 21 ++++++- PulseLoop/Shared/WidgetSnapshot.swift | 30 ++++++++- PulseLoopTests/WidgetSnapshotTests.swift | 62 +++++++++++++++++++ PulseLoopWidgets/WidgetMetric.swift | 9 ++- PulseLoopWidgets/WidgetTiles.swift | 53 ++++++++++++++++ 5 files changed, 170 insertions(+), 5 deletions(-) diff --git a/PulseLoop/Services/WidgetSnapshotPublisher.swift b/PulseLoop/Services/WidgetSnapshotPublisher.swift index 4a1cee8b..7a07f783 100644 --- a/PulseLoop/Services/WidgetSnapshotPublisher.swift +++ b/PulseLoop/Services/WidgetSnapshotPublisher.swift @@ -147,7 +147,26 @@ final class WidgetSnapshotPublisher { activity: activityPayload(summary, units: units), sleep: sleepPayload(summary.sleep), metrics: metrics, - nutrition: nutritionPayload(summary) + nutrition: nutritionPayload(summary), + readiness: readinessPayload(summary) + ) + } + + /// Readiness tile payload. Nil unless the feature is on and shown on Today/widgets, or the + /// morning wasn't scored — the widget then renders its "open the app" placeholder rather than + /// a zero, which would read as terrible recovery instead of no data. + /// + /// The band zones ride along as color tokens so the extension draws the identical arc without + /// the scoring engine being compiled into it, matching how vitals zones already cross over. + private func readinessPayload(_ summary: TodaySummary) -> WidgetReadinessPayload? { + guard ReadinessPrefsStore.shared.prefs.showOnToday, + let readiness = summary.readiness else { return nil } + return WidgetReadinessPayload( + score: readiness.score, + band: readiness.band.rawValue, + coverage: readiness.coverage, + zones: ReadinessZones.all.map(WidgetZonePayload.init), + topReason: readiness.topDrag?.detail ?? "" ) } diff --git a/PulseLoop/Shared/WidgetSnapshot.swift b/PulseLoop/Shared/WidgetSnapshot.swift index 008d3a03..bb61490c 100644 --- a/PulseLoop/Shared/WidgetSnapshot.swift +++ b/PulseLoop/Shared/WidgetSnapshot.swift @@ -48,17 +48,24 @@ struct WidgetSnapshot: Codable { /// Calorie-intake tracking. Optional + defaulted so snapshots written by older builds decode; /// nil while the nutrition feature (or its Today/widget toggle) is off. var nutrition: WidgetNutritionPayload? + /// Daily readiness. Optional + defaulted for the same reason as `nutrition`: snapshots written + /// by older builds must still decode, and nil covers both "feature off" and "not scored yet". + var readiness: WidgetReadinessPayload? - enum CodingKeys: String, CodingKey { case generatedAt, dayStart, activity, sleep, metrics, nutrition } + enum CodingKeys: String, CodingKey { + case generatedAt, dayStart, activity, sleep, metrics, nutrition, readiness + } init(generatedAt: Date, dayStart: Date, activity: WidgetActivityPayload?, sleep: WidgetSleepPayload?, - metrics: [String: WidgetMetricPayload], nutrition: WidgetNutritionPayload? = nil) { + metrics: [String: WidgetMetricPayload], nutrition: WidgetNutritionPayload? = nil, + readiness: WidgetReadinessPayload? = nil) { self.generatedAt = generatedAt self.dayStart = dayStart self.activity = activity self.sleep = sleep self.metrics = metrics self.nutrition = nutrition + self.readiness = readiness } init(from decoder: Decoder) throws { @@ -69,9 +76,28 @@ struct WidgetSnapshot: Codable { sleep = try c.decodeIfPresent(WidgetSleepPayload.self, forKey: .sleep) metrics = try c.decode([String: WidgetMetricPayload].self, forKey: .metrics) nutrition = try c.decodeIfPresent(WidgetNutritionPayload.self, forKey: .nutrition) + readiness = try c.decodeIfPresent(WidgetReadinessPayload.self, forKey: .readiness) } } +// MARK: - Readiness (recovery score tile) + +/// Daily readiness for the widget. Ships its own band zones as color tokens — the same lossless +/// round-trip the vitals payloads use — so the widget draws the identical arc without needing the +/// scoring engine compiled into the extension. +struct WidgetReadinessPayload: Codable { + var score: Int + var band: String + /// Share of the full 100-point picture behind the score, 0–1. Below 1 the widget marks the + /// score as partial rather than presenting it as equivalent to a complete night. + var coverage: Double + /// The band zones, in ascending order, for the gauge arc. + var zones: [WidgetZonePayload] + /// The single biggest drag on the score, already phrased ("HRV 12% below your baseline"). Empty + /// when nothing held it back — the widget then says so rather than inventing a reason. + var topReason: String +} + // MARK: - Nutrition (calorie intake tile) struct WidgetNutritionPayload: Codable { diff --git a/PulseLoopTests/WidgetSnapshotTests.swift b/PulseLoopTests/WidgetSnapshotTests.swift index 78ee27e2..f96d31ab 100644 --- a/PulseLoopTests/WidgetSnapshotTests.swift +++ b/PulseLoopTests/WidgetSnapshotTests.swift @@ -112,4 +112,66 @@ final class WidgetSnapshotTests: XCTestCase { XCTAssertEqual(payload.lineColor(forValue: thresholds[0]), Color(hex: hexes[1])) XCTAssertEqual(payload.lineColor(forValue: thresholds.last! + 5), Color(hex: hexes.last!)) } + + // MARK: - Readiness + + private func readinessPayload(score: Int = 74, coverage: Double = 0.9, + reason: String = "HRV 12% below your baseline") -> WidgetReadinessPayload { + WidgetReadinessPayload( + score: score, + band: ReadinessScore.band(score).rawValue, + coverage: coverage, + zones: ReadinessZones.all.map(WidgetZonePayload.init), + topReason: reason + ) + } + + func testReadinessPayloadRoundTrips() throws { + let snapshot = WidgetSnapshot( + generatedAt: Date(timeIntervalSince1970: 1_700_000_000), + dayStart: Date(timeIntervalSince1970: 1_699_999_000), + activity: nil, sleep: nil, metrics: [:], + readiness: readinessPayload() + ) + let readiness = try XCTUnwrap(try roundTrip(snapshot).readiness) + XCTAssertEqual(readiness.score, 74) + XCTAssertEqual(readiness.band, "Ready") + XCTAssertEqual(readiness.coverage, 0.9, accuracy: 0.0001) + XCTAssertEqual(readiness.topReason, "HRV 12% below your baseline") + XCTAssertEqual(readiness.zones.count, ReadinessZones.all.count) + } + + /// The widget draws its arc from these zones, so they must survive the process boundary with + /// their colours intact — otherwise a Primed score could render in the "Rest needed" orange. + func testReadinessZonesSurviveTheColorTokenBridge() throws { + let snapshot = WidgetSnapshot( + generatedAt: Date(), dayStart: Date(), + activity: nil, sleep: nil, metrics: [:], + readiness: readinessPayload() + ) + let zones = try XCTUnwrap(try roundTrip(snapshot).readiness).zones.map(\.metricZone) + XCTAssertEqual(zones.map(\.label), ReadinessZones.all.map(\.label)) + for (rebuilt, original) in zip(zones, ReadinessZones.all) { + XCTAssertEqual(rebuilt.colorToken, original.colorToken, "\(original.label) lost its colour") + XCTAssertEqual(rebuilt.lower, original.lower) + XCTAssertEqual(rebuilt.upper, original.upper) + } + } + + /// A snapshot written before readiness existed must still decode — the widget would otherwise + /// go blank for anyone who hasn't yet relaunched the app after updating. + func testSnapshotWithoutReadinessStillDecodes() throws { + let legacy = """ + {"generatedAt":1700000000,"dayStart":1699999000,"metrics":{}} + """ + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + let snapshot = try decoder.decode(WidgetSnapshot.self, from: Data(legacy.utf8)) + XCTAssertNil(snapshot.readiness) + XCTAssertNil(snapshot.nutrition) + } + + // Note: `WidgetMetric` and the tile views live in the PulseLoopWidgets extension, which this + // test target does not import — only the shared `WidgetSnapshot.swift` contract is reachable + // from here. The extension is covered by compiling it, as with every other widget metric. } diff --git a/PulseLoopWidgets/WidgetMetric.swift b/PulseLoopWidgets/WidgetMetric.swift index a8bc7395..44670f29 100644 --- a/PulseLoopWidgets/WidgetMetric.swift +++ b/PulseLoopWidgets/WidgetMetric.swift @@ -5,6 +5,7 @@ import SwiftUI /// values are stable identifiers persisted in the user's widget configuration; don't rename them. enum WidgetMetric: String, CaseIterable, AppEnum { case activity + case readiness case nutrition case sleep case heartRate @@ -20,6 +21,7 @@ enum WidgetMetric: String, CaseIterable, AppEnum { static let caseDisplayRepresentations: [WidgetMetric: DisplayRepresentation] = [ .activity: "Activity", + .readiness: "Readiness", .nutrition: "Nutrition", .sleep: "Sleep", .heartRate: "Heart Rate", @@ -35,7 +37,7 @@ enum WidgetMetric: String, CaseIterable, AppEnum { /// The vitals kind whose payload backs this tile; nil for the two non-vitals tiles. var metricKind: MetricKind? { switch self { - case .activity, .nutrition, .sleep: return nil + case .activity, .readiness, .nutrition, .sleep: return nil case .heartRate: return .heartRate case .spo2: return .spo2 case .hrv: return .hrv @@ -49,12 +51,13 @@ enum WidgetMetric: String, CaseIterable, AppEnum { /// Which Today tile visual this metric renders as (mirrors `TodayView.tiles`). enum TileStyle { - case rings, nutrition, sleep, chart, gauge, bloodPressure + case rings, readiness, nutrition, sleep, chart, gauge, bloodPressure } var tileStyle: TileStyle { switch self { case .activity: return .rings + case .readiness: return .readiness case .nutrition: return .nutrition case .sleep: return .sleep case .heartRate, .spo2, .hrv, .temperature: return .chart @@ -67,6 +70,7 @@ enum WidgetMetric: String, CaseIterable, AppEnum { var headerLabel: String { switch self { case .activity: return "Activity" + case .readiness: return "Readiness" case .nutrition: return "Nutrition" case .sleep: return "Sleep" default: return metricKind?.title ?? rawValue @@ -76,6 +80,7 @@ enum WidgetMetric: String, CaseIterable, AppEnum { var accentColor: Color { switch self { case .activity: return PulseColors.steps + case .readiness: return PulseColors.readiness case .nutrition: return PulseColors.calories case .sleep: return PulseColors.sleep default: return metricKind?.accentColor ?? PulseColors.accent diff --git a/PulseLoopWidgets/WidgetTiles.swift b/PulseLoopWidgets/WidgetTiles.swift index c195a824..65bd0d58 100644 --- a/PulseLoopWidgets/WidgetTiles.swift +++ b/PulseLoopWidgets/WidgetTiles.swift @@ -118,6 +118,57 @@ struct WidgetActivityContent: View { // MARK: - Nutrition (kcal headline + macro "fuel bar", from `NutritionTileView`) +/// Readiness on the home screen: the band-coloured arc, the score, and — when it fits — the one +/// thing holding it back. The reason is what stops the widget being a bare number, which is the +/// same standard the in-app card is held to. +/// +/// The arc uses zones shipped in the payload as colour tokens, so the extension never needs the +/// scoring engine compiled into it. +struct WidgetReadinessContent: View { + let payload: WidgetReadinessPayload? + let rolledOver: Bool + + /// After midnight a score describes *yesterday's* night, so it is withheld rather than + /// relabelled as today's — the same rule the activity and sleep tiles follow. + private var active: WidgetReadinessPayload? { rolledOver ? nil : payload } + + var body: some View { + if let payload = active { + HStack(spacing: 12) { + VitalRingGauge( + value: Double(payload.score), + domain: 0...100, + zones: payload.zones.map(\.metricZone), + valueColor: payload.zones.map(\.metricZone) + .first { $0.contains(Double(payload.score)) }?.color ?? PulseColors.readiness, + centerValue: "\(payload.score)", + centerStatus: payload.band, + size: 74, + lineWidth: 7 + ) + if !payload.topReason.isEmpty { + Text(payload.topReason) + .font(.system(size: 11)) + .foregroundStyle(PulseColors.textMuted) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityElement(children: .combine) + .accessibilityLabel( + "Readiness \(payload.score) out of 100, \(payload.band)." + + (payload.topReason.isEmpty ? "" : " \(payload.topReason).") + ) + } else { + WidgetEmptyMessage(systemImage: "bolt.heart", + message: rolledOver ? "Sync for today" : "Open PulseLoop to sync", + color: PulseColors.readiness) + } + } +} + struct WidgetNutritionContent: View { let payload: WidgetNutritionPayload? let rolledOver: Bool @@ -417,6 +468,8 @@ struct WidgetMetricTileView: View { switch metric.tileStyle { case .rings: WidgetActivityContent(payload: entry.snapshot?.activity, rolledOver: entry.rolledOver) + case .readiness: + WidgetReadinessContent(payload: entry.snapshot?.readiness, rolledOver: entry.rolledOver) case .nutrition: WidgetNutritionContent(payload: entry.snapshot?.nutrition, rolledOver: entry.rolledOver) case .sleep: