feat: implement Haptika PPG and Watch V1
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user