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) { 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) } } }