feat: implement Haptika PPG and Watch V1

This commit is contained in:
Sacha
2026-07-31 16:19:42 +02:00
parent 6b7d4e0728
commit b9930b7378
249 changed files with 46475 additions and 122 deletions
+134
View File
@@ -0,0 +1,134 @@
import Combine
import Foundation
@MainActor
public final class WatchStore: ObservableObject {
@Published public private(set) var measurements: [WatchMeasurementSummaryV1] = []
@Published public private(set) var sessions: [WatchRegulationSessionV1] = []
@Published public private(set) var outbox: [WatchSyncEnvelopeV1] = []
@Published public private(set) var tombstones: [WatchDeletionTombstoneV1] = []
@Published public var preferences: WatchPreferencesV1 {
didSet { persist() }
}
public var baseline: WatchBaselineSnapshot { .init(measurements: measurements) }
private let defaults: UserDefaults
private let storageKey: String
private struct Snapshot: Codable {
var measurements: [WatchMeasurementSummaryV1]
var sessions: [WatchRegulationSessionV1]
var outbox: [WatchSyncEnvelopeV1]
var tombstones: [WatchDeletionTombstoneV1]
var preferences: WatchPreferencesV1
}
public init(defaults: UserDefaults = .standard, storageKey: String = "haptika.watch.store.v1") {
self.defaults = defaults
self.storageKey = storageKey
if let data = defaults.data(forKey: storageKey),
let snapshot = try? WatchCoding.decoder.decode(Snapshot.self, from: data) {
measurements = snapshot.measurements
sessions = snapshot.sessions
outbox = snapshot.outbox
tombstones = snapshot.tombstones
preferences = snapshot.preferences
} else {
preferences = .init()
}
}
public func upsert(_ measurement: WatchMeasurementSummaryV1, enqueue envelope: WatchSyncEnvelopeV1? = nil) {
if let tombstone = tombstones.first(where: { $0.entityID == measurement.id }),
tombstone.updatedAt >= measurement.updatedAt { return }
measurements.removeAll { $0.id == measurement.id && $0.updatedAt <= measurement.updatedAt }
if !measurements.contains(where: { $0.id == measurement.id }) {
measurements.append(measurement)
}
measurements.sort { $0.startedAt > $1.startedAt }
measurements = Array(measurements.prefix(30))
if let envelope { enqueue(envelope) }
persist()
}
public func upsert(_ session: WatchRegulationSessionV1, enqueue envelope: WatchSyncEnvelopeV1? = nil) {
if let tombstone = tombstones.first(where: { $0.entityID == session.id }),
tombstone.updatedAt >= session.updatedAt { return }
sessions.removeAll { $0.id == session.id && $0.updatedAt <= session.updatedAt }
if !sessions.contains(where: { $0.id == session.id }) {
sessions.append(session)
}
sessions.sort { $0.startedAt > $1.startedAt }
sessions = Array(sessions.prefix(20))
if let envelope { enqueue(envelope) }
persist()
}
@discardableResult
public func deleteMeasurement(id: UUID, envelope: WatchSyncEnvelopeV1? = nil) -> WatchDeletionTombstoneV1 {
measurements.removeAll { $0.id == id }
let tombstone = WatchDeletionTombstoneV1(entityID: id, entityKind: "measurement")
tombstones.removeAll { $0.entityID == id }
tombstones.append(tombstone)
if let envelope { enqueue(envelope) }
persist()
return tombstone
}
@discardableResult
public func deleteSession(id: UUID, envelope: WatchSyncEnvelopeV1? = nil) -> WatchDeletionTombstoneV1 {
sessions.removeAll { $0.id == id }
let tombstone = WatchDeletionTombstoneV1(entityID: id, entityKind: "regulationSession")
tombstones.removeAll { $0.entityID == id }
tombstones.append(tombstone)
if let envelope { enqueue(envelope) }
persist()
return tombstone
}
public func apply(_ tombstone: WatchDeletionTombstoneV1) {
if tombstone.entityKind == "measurement" {
measurements.removeAll { $0.id == tombstone.entityID }
} else if tombstone.entityKind == "regulationSession" {
sessions.removeAll { $0.id == tombstone.entityID }
}
if let existing = tombstones.first(where: { $0.entityID == tombstone.entityID }),
existing.updatedAt >= tombstone.updatedAt { return }
tombstones.removeAll { $0.entityID == tombstone.entityID }
tombstones.append(tombstone)
persist()
}
public func enqueue(_ envelope: WatchSyncEnvelopeV1) {
guard !outbox.contains(where: { $0.eventID == envelope.eventID }) else { return }
outbox.append(envelope)
persist()
}
public func acknowledge(eventIDs: Set<UUID>) {
outbox.removeAll { eventIDs.contains($0.eventID) }
persist()
}
public func replaceFromCompanion(
measurements incomingMeasurements: [WatchMeasurementSummaryV1],
sessions incomingSessions: [WatchRegulationSessionV1]
) {
for measurement in incomingMeasurements { upsert(measurement) }
for session in incomingSessions { upsert(session) }
}
private func persist() {
let snapshot = Snapshot(
measurements: measurements,
sessions: sessions,
outbox: outbox,
tombstones: tombstones,
preferences: preferences
)
if let data = try? WatchCoding.encoder.encode(snapshot) {
defaults.set(data, forKey: storageKey)
}
}
}