diff --git a/PulseLoop/App/AppTheme.swift b/PulseLoop/App/AppTheme.swift index 8ffa068..568293d 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 0000000..5ce8e53 --- /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/Models/PulseModels.swift b/PulseLoop/Models/PulseModels.swift index 76aa345..467e2a3 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 0000000..b127a74 --- /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 8dddac3..12103ae 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 e53ce01..e8eddbe 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 6eef7a2..17aa9ff 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 145cfd4..4efb0bb 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 34153ed..ec7fefe 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/DerivedSummaries.swift b/PulseLoop/Services/DerivedSummaries.swift index 754926c..2217365 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 56d685b..a9faee6 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 0000000..fbb54b7 --- /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/Services/ReadinessScore.swift b/PulseLoop/Services/ReadinessScore.swift new file mode 100644 index 0000000..c27eb22 --- /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 0000000..efebb79 --- /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 07d7b1b..cc8b604 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 0000000..07ede50 --- /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/PulseLoop/ViewModels/TodayStore.swift b/PulseLoop/ViewModels/TodayStore.swift index 97ba418..1bed54d 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 3a16886..83578d9 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 0000000..7b7416b --- /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 18ad107..21fa4c6 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 c6390cf..7230f27 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/DataArchiveTests.swift b/PulseLoopTests/DataArchiveTests.swift index a9b7ad3..5285fa0 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/ReadinessCardTests.swift b/PulseLoopTests/ReadinessCardTests.swift new file mode 100644 index 0000000..7d73d14 --- /dev/null +++ b/PulseLoopTests/ReadinessCardTests.swift @@ -0,0 +1,213 @@ +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? + + /// 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 + 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 = draggingContributors, + 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: Self.perfectContributors, + 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 0000000..9ec2dab --- /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) + } +} diff --git a/PulseLoopTests/ReadinessScoreTests.swift b/PulseLoopTests/ReadinessScoreTests.swift new file mode 100644 index 0000000..e6ff645 --- /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 0000000..d037696 --- /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 0000000..eec7d86 --- /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 2f2c0b3..9679df7 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 949efe1..48f06c6 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