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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions PulseLoop/App/AppTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ enum AppRoute: Hashable {
case settingsPrivacyData
case settingsAbout
case settingsNutrition
case settingsReadiness
case nutrition
case mealDetail(UUID)
case pairing
Expand Down
236 changes: 236 additions & 0 deletions PulseLoop/DesignSystem/ReadinessSummaryCard.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
87 changes: 87 additions & 0 deletions PulseLoop/Models/PulseModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions PulseLoop/Persistence/DataArchive+Readiness.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading