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
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)
}
}
10 changes: 9 additions & 1 deletion PulseLoop/Persistence/DataArchive.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Expand Down
22 changes: 18 additions & 4 deletions PulseLoop/Persistence/DataArchiveService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -166,6 +169,7 @@ enum DataArchiveService {
batterySamples: batterySamples,
sleepSessions: sleepSessions,
sleepStageBlocks: sleepStageBlocks,
readinessDailies: readinessDailies,
rawPackets: rawPackets,
derivedUpdates: derivedUpdates,
userProfiles: userProfiles,
Expand Down Expand Up @@ -286,16 +290,22 @@ 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<T: PersistentModel>(_ type: T.Type) -> Bool {
((try? context.fetchCount(FetchDescriptor<T>())) ?? 0) > 0
}
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)
Expand All @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand All @@ -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 {}
Expand Down
1 change: 1 addition & 0 deletions PulseLoop/Persistence/ModelContainerFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ enum ModelContainerFactory {
BatterySample.self,
SleepSession.self,
SleepStageBlock.self,
ReadinessDaily.self,
RawPacketRow.self,
DerivedUpdateRow.self,
UserProfile.self,
Expand Down
6 changes: 6 additions & 0 deletions PulseLoop/Persistence/SeedData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions PulseLoop/PulseLoopApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading