@@ -253,15 +253,15 @@ final class TypeDistributionBin {
|
||
| 253 | 253 |
// in memory, so snapshot list/detail screens never recompute them by traversing |
| 254 | 254 |
// snapshot.typeCounts on the UI thread. |
| 255 | 255 |
|
| 256 |
-// Interface updated 2026-05-17 — see AGENTS.md |
|
| 257 |
-// Models/SnapshotDelta stores cached list/detail summary scalars derived from TypeDelta. |
|
| 258 |
-// Overview screens consume these scalars and type-delta summaries directly instead of |
|
| 259 |
-// recalculating per-snapshot changes from HealthSnapshot.typeCounts. |
|
| 256 |
+// Interface updated 2026-05-26 — see AGENTS.md |
|
| 257 |
+// SnapshotDelta/TypeDelta and their post-save DeltaService were deleted. |
|
| 258 |
+// Active overview and detail screens consume SQLite/Core Data archive/cache |
|
| 259 |
+// observation diff summaries instead of recalculating changes from |
|
| 260 |
+// HealthSnapshot.typeCounts. |
|
| 260 | 261 |
|
| 261 | 262 |
// Interface updated 2026-05-23 — see AGENTS.md |
| 262 | 263 |
// Future UI/domain naming should prefer "change" or "observation diff" over |
| 263 |
-// "anomaly". Existing AnomalyRecord/AnomalyType code is legacy naming until the |
|
| 264 |
-// model replacement/refactor is implemented. |
|
| 264 |
+// "anomaly". The legacy AnomalyRecord/AnomalyType models have been removed. |
|
| 265 | 265 |
enum ChangeClassification: String, Codable {
|
| 266 | 266 |
case appeared |
| 267 | 267 |
case disappeared |
@@ -48,7 +48,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m |
||
| 48 | 48 |
|
| 49 | 49 |
- SwiftData currently blocks iOS 15-era device support. |
| 50 | 50 |
- Some screens still imply snapshot-count monitoring rather than Time Machine inspection. |
| 51 |
-- Current UI/cache layers still depend on 12 SwiftData-backed files for launch container, capture review actions, legacy delta creation, and model definitions. |
|
| 51 |
+- Current UI/cache layers still depend on 9 SwiftData-backed files for launch container, capture review actions, capture bridge writes, and remaining model definitions. |
|
| 52 | 52 |
- Snapshots timeline/detail rows, Data Types list/detail rows, and record drill-down are archive/cache-backed for new archive v2 observations when cache rows exist. |
| 53 | 53 |
- Legacy SwiftData-only snapshots are reset for archive v2 test installs rather than migrated. |
| 54 | 54 |
- Capture strategy and some legacy SwiftData transition paths may still decode or cache too much data for low-end devices. |
@@ -235,6 +235,8 @@ Checklist: |
||
| 235 | 235 |
- [x] Delete unused legacy SwiftData snapshot/type detail views and the PDF |
| 236 | 236 |
exporter tied to those views. |
| 237 | 237 |
- [x] Delete unused legacy SwiftData lifecycle/observer/repair services. |
| 238 |
+- [x] Stop writing legacy SwiftData `SnapshotDelta`/`TypeDelta` rows and delete |
|
| 239 |
+ the unused delta service/models. |
|
| 238 | 240 |
- [x] Data type detail uses Core Data/SQLite `diffSummary` when archive observation ids exist and no longer queries `SnapshotDelta`/`TypeDelta` or rebuilds legacy detail caches from the UI. |
| 239 | 241 |
- [x] Remove unused legacy Type Evolution timeline and direct `HealthSnapshot.typeCounts` diff helper. |
| 240 | 242 |
- [x] Data type new/missing drill-down pages through SQLite `diffRecords` when archive observation ids exist. |
@@ -266,8 +268,8 @@ Checklist: |
||
| 266 | 268 |
anomaly/count-drop review has been deleted; Snapshots/Data Types tab roots no |
| 267 | 269 |
longer import SwiftData; unused legacy snapshot/type detail and PDF views have |
| 268 | 270 |
been deleted; unused legacy lifecycle/observer/repair services have been |
| 269 |
- deleted; Dashboard capture/review actions and capture-time legacy delta |
|
| 270 |
- creation remain. |
|
| 271 |
+ deleted; unused legacy delta service/models have been deleted; Dashboard |
|
| 272 |
+ capture/review actions and capture bridge writes remain. |
|
| 271 | 273 |
- [ ] Remove/disable `ModelContainer` as required for target builds. |
| 272 | 274 |
- [x] Add prototype-store ignore/delete/reset path for test installs. |
| 273 | 275 |
- [ ] Verify no old-store compatibility layer remains in active flows. |
@@ -10,9 +10,9 @@ local settings stored outside SwiftData where needed. |
||
| 10 | 10 |
## Current Count |
| 11 | 11 |
|
| 12 | 12 |
After moving the Snapshots and Data Types tab roots to archive/cache |
| 13 |
-observations and deleting unused legacy repair/detail services, 12 app files |
|
| 14 |
-still have SwiftData imports because capture, Dashboard review actions, legacy |
|
| 15 |
-delta creation, and model definitions still use prototype snapshot handles. |
|
| 13 |
+observations and deleting unused legacy repair/detail/delta services, 9 app |
|
| 14 |
+files still have SwiftData imports because capture, Dashboard review actions, |
|
| 15 |
+and remaining model definitions still use prototype snapshot handles. |
|
| 16 | 16 |
|
| 17 | 17 |
## Launch Container |
| 18 | 18 |
|
@@ -31,15 +31,13 @@ block: |
||
| 31 | 31 |
|
| 32 | 32 |
- `HealthProbe/Models/HealthRecord.swift` |
| 33 | 33 |
- `HealthProbe/Models/HealthSnapshot.swift` |
| 34 |
-- `HealthProbe/Models/SnapshotDelta.swift` |
|
| 35 | 34 |
- `HealthProbe/Models/TypeCount.swift` |
| 36 |
-- `HealthProbe/Models/TypeDelta.swift` |
|
| 37 | 35 |
- `HealthProbe/Models/TypeDistributionBin.swift` |
| 38 | 36 |
- `HealthProbe/Models/YearlyCount.swift` |
| 39 | 37 |
|
| 40 | 38 |
Retirement path: |
| 41 |
-- replace `HealthSnapshot`, `TypeCount`, `SnapshotDelta`, `TypeDelta`, |
|
| 42 |
- `YearlyCount`, `TypeDistributionBin`, and `HealthRecord` active reads with |
|
| 39 |
+- replace `HealthSnapshot`, `TypeCount`, `YearlyCount`, |
|
| 40 |
+ `TypeDistributionBin`, and `HealthRecord` active reads/writes with |
|
| 43 | 41 |
archive/cache DTOs; |
| 44 | 42 |
- retire active reads/writes before removing the launch container. |
| 45 | 43 |
|
@@ -47,14 +45,11 @@ Retirement path: |
||
| 47 | 45 |
|
| 48 | 46 |
These services still write/read legacy SwiftData transition models: |
| 49 | 47 |
|
| 50 |
-- `HealthProbe/Services/DeltaService.swift` |
|
| 51 | 48 |
- `HealthProbe/Services/HealthKitService.swift` |
| 52 | 49 |
|
| 53 | 50 |
Retirement path: |
| 54 | 51 |
- make capture persist archive observations without writing prototype |
| 55 |
- `HealthSnapshot` bridge rows; |
|
| 56 |
-- replace or remove `DeltaService` once capture no longer writes prototype |
|
| 57 |
- `SnapshotDelta` rows. |
|
| 52 |
+ `HealthSnapshot` bridge rows. |
|
| 58 | 53 |
|
| 59 | 54 |
## UI And View Models |
| 60 | 55 |
|
@@ -131,6 +126,13 @@ The following SwiftData dependencies were removed from active flows: |
||
| 131 | 126 |
`HealthProbe/Utilities/TypeCountArchiveRepair.swift` were deleted. Active |
| 132 | 127 |
observation deletion/repair/background-capture policy now belongs to the |
| 133 | 128 |
SQLite archive/cache design, not the old SwiftData chain. |
| 129 |
+- The unused legacy delta stack |
|
| 130 |
+ `HealthProbe/Services/DeltaService.swift`, |
|
| 131 |
+ `HealthProbe/Models/SnapshotDelta.swift`, |
|
| 132 |
+ `HealthProbe/Models/TypeDelta.swift`, and |
|
| 133 |
+ `HealthProbe/Models/TypeDeltaClassification.swift` was deleted. |
|
| 134 |
+ Capture no longer writes prototype `SnapshotDelta`/`TypeDelta` rows after the |
|
| 135 |
+ archive observation is saved. |
|
| 134 | 136 |
- The unused `HealthProbe/Views/DataTypes/TypeEvolutionTimeline.swift` legacy |
| 135 | 137 |
chart was deleted, and the remaining `TypeDiff`/`DiffFilter` DTOs now live in |
| 136 | 138 |
`HealthProbe/Models/TypeDiff.swift` instead of the removed |
@@ -28,7 +28,6 @@ struct HealthProbeApp: App {
|
||
| 28 | 28 |
|
| 29 | 29 |
let fullSchema = Schema([ |
| 30 | 30 |
HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self, |
| 31 |
- SnapshotDelta.self, TypeDelta.self, |
|
| 32 | 31 |
]) |
| 33 | 32 |
|
| 34 | 33 |
let appSupportURL = URL.applicationSupportDirectory |
@@ -37,7 +36,6 @@ struct HealthProbeApp: App {
|
||
| 37 | 36 |
|
| 38 | 37 |
let uiCacheModels = Schema([ |
| 39 | 38 |
HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self, |
| 40 |
- SnapshotDelta.self, TypeDelta.self, |
|
| 41 | 39 |
]) |
| 42 | 40 |
|
| 43 | 41 |
let uiCacheConfig = ModelConfiguration( |
@@ -1,54 +0,0 @@ |
||
| 1 |
-import Foundation |
|
| 2 |
-import SwiftData |
|
| 3 |
- |
|
| 4 |
-@Model final class SnapshotDelta {
|
|
| 5 |
- var id: UUID = UUID() |
|
| 6 |
- var fromSnapshotID: UUID = UUID() |
|
| 7 |
- var toSnapshotID: UUID = UUID() |
|
| 8 |
- var deviceID: String = "" |
|
| 9 |
- var computedAt: Date = Date.now |
|
| 10 |
- var checksumBefore: String = "" |
|
| 11 |
- var checksumAfter: String = "" |
|
| 12 |
- var listSummaryVersion: Int = 0 |
|
| 13 |
- var absoluteRecordChangeCount: Int = 0 |
|
| 14 |
- var changedMetricCount: Int = 0 |
|
| 15 |
- var appearedMetricCount: Int = 0 |
|
| 16 |
- var disappearedMetricCount: Int = 0 |
|
| 17 |
- @Relationship(deleteRule: .cascade, inverse: \TypeDelta.delta) |
|
| 18 |
- var typeDeltas: [TypeDelta]? = [] |
|
| 19 |
- |
|
| 20 |
- init(fromSnapshotID: UUID, toSnapshotID: UUID, deviceID: String) {
|
|
| 21 |
- self.id = UUID() |
|
| 22 |
- self.fromSnapshotID = fromSnapshotID |
|
| 23 |
- self.toSnapshotID = toSnapshotID |
|
| 24 |
- self.deviceID = deviceID |
|
| 25 |
- } |
|
| 26 |
-} |
|
| 27 |
- |
|
| 28 |
-struct SnapshotDeltaListSummary {
|
|
| 29 |
- let absoluteRecordChangeCount: Int |
|
| 30 |
- let changedMetricCount: Int |
|
| 31 |
- let appearedMetricCount: Int |
|
| 32 |
- let disappearedMetricCount: Int |
|
| 33 |
- |
|
| 34 |
- var affectedMetricCount: Int {
|
|
| 35 |
- changedMetricCount + appearedMetricCount + disappearedMetricCount |
|
| 36 |
- } |
|
| 37 |
- |
|
| 38 |
- var hasChanges: Bool {
|
|
| 39 |
- absoluteRecordChangeCount > 0 || affectedMetricCount > 0 |
|
| 40 |
- } |
|
| 41 |
-} |
|
| 42 |
- |
|
| 43 |
-extension SnapshotDelta {
|
|
| 44 |
- static let currentListSummaryVersion = 1 |
|
| 45 |
- |
|
| 46 |
- var listSummary: SnapshotDeltaListSummary {
|
|
| 47 |
- SnapshotDeltaListSummary( |
|
| 48 |
- absoluteRecordChangeCount: absoluteRecordChangeCount, |
|
| 49 |
- changedMetricCount: changedMetricCount, |
|
| 50 |
- appearedMetricCount: appearedMetricCount, |
|
| 51 |
- disappearedMetricCount: disappearedMetricCount |
|
| 52 |
- ) |
|
| 53 |
- } |
|
| 54 |
-} |
|
@@ -1,42 +0,0 @@ |
||
| 1 |
-import Foundation |
|
| 2 |
-import SwiftData |
|
| 3 |
- |
|
| 4 |
-@Model final class TypeDelta {
|
|
| 5 |
- var id: UUID = UUID() |
|
| 6 |
- var typeIdentifier: String = "" |
|
| 7 |
- var displayName: String = "" |
|
| 8 |
- var countDelta: Int = 0 |
|
| 9 |
- var hashBefore: String = "" |
|
| 10 |
- var hashAfter: String = "" |
|
| 11 |
- var qualityBeforeRaw: String? |
|
| 12 |
- var qualityAfterRaw: String? |
|
| 13 |
- var transitionRaw: String = "unchanged" |
|
| 14 |
- var reasonRaw: String = "normal" |
|
| 15 |
- var yearlyCountNote: String = "" |
|
| 16 |
- var delta: SnapshotDelta? |
|
| 17 |
- |
|
| 18 |
- init(typeIdentifier: String, displayName: String) {
|
|
| 19 |
- self.id = UUID() |
|
| 20 |
- self.typeIdentifier = typeIdentifier |
|
| 21 |
- self.displayName = displayName |
|
| 22 |
- } |
|
| 23 |
-} |
|
| 24 |
- |
|
| 25 |
-extension TypeDelta {
|
|
| 26 |
- var transition: TypeTransition {
|
|
| 27 |
- get { TypeTransition(rawValue: transitionRaw) ?? .unchanged }
|
|
| 28 |
- set { transitionRaw = newValue.rawValue }
|
|
| 29 |
- } |
|
| 30 |
- var reason: TypeDeltaReason {
|
|
| 31 |
- get { TypeDeltaReason(rawValue: reasonRaw) ?? .unknown }
|
|
| 32 |
- set { reasonRaw = newValue.rawValue }
|
|
| 33 |
- } |
|
| 34 |
- var qualityBefore: SnapshotQuality? {
|
|
| 35 |
- get { qualityBeforeRaw.flatMap { SnapshotQuality(rawValue: $0) } }
|
|
| 36 |
- set { qualityBeforeRaw = newValue?.rawValue }
|
|
| 37 |
- } |
|
| 38 |
- var qualityAfter: SnapshotQuality? {
|
|
| 39 |
- get { qualityAfterRaw.flatMap { SnapshotQuality(rawValue: $0) } }
|
|
| 40 |
- set { qualityAfterRaw = newValue?.rawValue }
|
|
| 41 |
- } |
|
| 42 |
-} |
|
@@ -1,16 +0,0 @@ |
||
| 1 |
-import Foundation |
|
| 2 |
- |
|
| 3 |
-enum TypeTransition: String, Codable {
|
|
| 4 |
- case unchanged |
|
| 5 |
- case changed |
|
| 6 |
- case appeared |
|
| 7 |
- case disappeared |
|
| 8 |
-} |
|
| 9 |
- |
|
| 10 |
-enum TypeDeltaReason: String, Codable {
|
|
| 11 |
- case normal |
|
| 12 |
- case registryChanged |
|
| 13 |
- case authorizationChanged |
|
| 14 |
- case unsupported |
|
| 15 |
- case unknown |
|
| 16 |
-} |
|
@@ -1,409 +0,0 @@ |
||
| 1 |
-import Foundation |
|
| 2 |
-import SwiftData |
|
| 3 |
-import os.log |
|
| 4 |
- |
|
| 5 |
-private let logger = Logger(subsystem: "ro.xdev.healthprobe", category: "DeltaService") |
|
| 6 |
- |
|
| 7 |
-enum DeltaService {
|
|
| 8 |
- @discardableResult |
|
| 9 |
- static func computeAndSave(current: HealthSnapshot, context: ModelContext) throws -> SnapshotDelta? {
|
|
| 10 |
- // No previous snapshot → chain start, no delta to compute |
|
| 11 |
- guard let prevID = current.previousSnapshotID else { return nil }
|
|
| 12 |
- |
|
| 13 |
- let prevDescriptor = FetchDescriptor<HealthSnapshot>( |
|
| 14 |
- predicate: #Predicate<HealthSnapshot> { $0.id == prevID }
|
|
| 15 |
- ) |
|
| 16 |
- guard let previous = try context.fetch(prevDescriptor).first else {
|
|
| 17 |
- logger.error("DeltaService: previousSnapshotID \(prevID) not found")
|
|
| 18 |
- return nil |
|
| 19 |
- } |
|
| 20 |
- |
|
| 21 |
- let prevByID = Dictionary( |
|
| 22 |
- uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
|
|
| 23 |
- ) |
|
| 24 |
- let currByID = Dictionary( |
|
| 25 |
- uniqueKeysWithValues: (current.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
|
|
| 26 |
- ) |
|
| 27 |
- |
|
| 28 |
- let delta = SnapshotDelta( |
|
| 29 |
- fromSnapshotID: previous.id, |
|
| 30 |
- toSnapshotID: current.id, |
|
| 31 |
- deviceID: current.deviceID |
|
| 32 |
- ) |
|
| 33 |
- delta.checksumBefore = HashService.snapshotChecksum(typeCounts: Array(prevByID.values)) |
|
| 34 |
- delta.checksumAfter = HashService.snapshotChecksum(typeCounts: Array(currByID.values)) |
|
| 35 |
- |
|
| 36 |
- if current.isContentAlias, |
|
| 37 |
- current.contentEquivalentSnapshotID == previous.contentRepresentativeSnapshotID {
|
|
| 38 |
- delta.typeDeltas = [] |
|
| 39 |
- updateListSummary(for: delta, typeDeltas: []) |
|
| 40 |
- context.insert(delta) |
|
| 41 |
- try context.save() |
|
| 42 |
- return delta |
|
| 43 |
- } |
|
| 44 |
- |
|
| 45 |
- let allTypeIDs = Set(prevByID.keys).union(currByID.keys) |
|
| 46 |
- var typeDeltas: [TypeDelta] = [] |
|
| 47 |
- |
|
| 48 |
- for typeID in allTypeIDs {
|
|
| 49 |
- let prev = prevByID[typeID] |
|
| 50 |
- let curr = currByID[typeID] |
|
| 51 |
- |
|
| 52 |
- if let prev, |
|
| 53 |
- let curr, |
|
| 54 |
- curr.contentEquivalentTypeCountID == prev.contentRepresentativeTypeCountID {
|
|
| 55 |
- continue |
|
| 56 |
- } |
|
| 57 |
- |
|
| 58 |
- let effectivePrev = historicalBaselinePreviousTypeCount( |
|
| 59 |
- typeID: typeID, |
|
| 60 |
- prev: prev, |
|
| 61 |
- curr: curr, |
|
| 62 |
- previousSnapshot: previous, |
|
| 63 |
- context: context |
|
| 64 |
- ) ?? prev |
|
| 65 |
- |
|
| 66 |
- let td = buildTypeDelta( |
|
| 67 |
- typeID: typeID, |
|
| 68 |
- prev: effectivePrev, |
|
| 69 |
- curr: curr, |
|
| 70 |
- previous: previous, |
|
| 71 |
- current: current |
|
| 72 |
- ) |
|
| 73 |
- td.delta = delta |
|
| 74 |
- typeDeltas.append(td) |
|
| 75 |
- context.insert(td) |
|
| 76 |
- } |
|
| 77 |
- |
|
| 78 |
- delta.typeDeltas = typeDeltas |
|
| 79 |
- updateListSummary(for: delta, typeDeltas: typeDeltas) |
|
| 80 |
- context.insert(delta) |
|
| 81 |
- try context.save() |
|
| 82 |
- return delta |
|
| 83 |
- } |
|
| 84 |
- |
|
| 85 |
- @discardableResult |
|
| 86 |
- static func rebuildMissingListSummaries(context: ModelContext, maxCount: Int) throws -> Bool {
|
|
| 87 |
- guard maxCount > 0 else { return false }
|
|
| 88 |
- |
|
| 89 |
- let summaryVersion = SnapshotDelta.currentListSummaryVersion |
|
| 90 |
- var descriptor = FetchDescriptor<SnapshotDelta>( |
|
| 91 |
- predicate: #Predicate<SnapshotDelta> { $0.listSummaryVersion < summaryVersion }
|
|
| 92 |
- ) |
|
| 93 |
- descriptor.fetchLimit = maxCount |
|
| 94 |
- |
|
| 95 |
- let deltas = try context.fetch(descriptor) |
|
| 96 |
- guard !deltas.isEmpty else { return false }
|
|
| 97 |
- |
|
| 98 |
- for delta in deltas {
|
|
| 99 |
- updateListSummary(for: delta, typeDeltas: delta.typeDeltas ?? []) |
|
| 100 |
- } |
|
| 101 |
- |
|
| 102 |
- try context.save() |
|
| 103 |
- return true |
|
| 104 |
- } |
|
| 105 |
- |
|
| 106 |
- // MARK: - Delta merge (for intermediate snapshot deletion) |
|
| 107 |
- |
|
| 108 |
- // snapshotBefore and snapshotAfter are the real surrounding snapshots (N-1 and N+1). |
|
| 109 |
- // Their typeCounts are used to recompute fresh checksums. |
|
| 110 |
- static func mergeDeltas( |
|
| 111 |
- d1: SnapshotDelta, |
|
| 112 |
- d2: SnapshotDelta, |
|
| 113 |
- snapshotBefore: HealthSnapshot, |
|
| 114 |
- snapshotAfter: HealthSnapshot |
|
| 115 |
- ) -> SnapshotDelta {
|
|
| 116 |
- let merged = SnapshotDelta( |
|
| 117 |
- fromSnapshotID: d1.fromSnapshotID, |
|
| 118 |
- toSnapshotID: d2.toSnapshotID, |
|
| 119 |
- deviceID: d1.deviceID |
|
| 120 |
- ) |
|
| 121 |
- // Always recompute from the actual surrounding snapshots — never copy old checksums |
|
| 122 |
- merged.checksumBefore = HashService.snapshotChecksum(typeCounts: snapshotBefore.typeCounts ?? []) |
|
| 123 |
- merged.checksumAfter = HashService.snapshotChecksum(typeCounts: snapshotAfter.typeCounts ?? []) |
|
| 124 |
- |
|
| 125 |
- let d1Map = Dictionary(uniqueKeysWithValues: (d1.typeDeltas ?? []).map { ($0.typeIdentifier, $0) })
|
|
| 126 |
- let d2Map = Dictionary(uniqueKeysWithValues: (d2.typeDeltas ?? []).map { ($0.typeIdentifier, $0) })
|
|
| 127 |
- let allIDs = Set(d1Map.keys).union(d2Map.keys) |
|
| 128 |
- |
|
| 129 |
- var mergedTypeDeltas: [TypeDelta] = [] |
|
| 130 |
- for typeID in allIDs {
|
|
| 131 |
- let td1 = d1Map[typeID] |
|
| 132 |
- let td2 = d2Map[typeID] |
|
| 133 |
- if let merged1 = td1, let merged2 = td2 {
|
|
| 134 |
- // Type present in both deltas |
|
| 135 |
- if merged1.transition == .appeared && merged2.transition == .disappeared {
|
|
| 136 |
- // Existed only in deleted snapshot N — remove from merged delta |
|
| 137 |
- continue |
|
| 138 |
- } |
|
| 139 |
- let td = mergeTypeDelta(d1td: merged1, d2td: merged2) |
|
| 140 |
- td.delta = merged |
|
| 141 |
- mergedTypeDeltas.append(td) |
|
| 142 |
- } else if let only1 = td1 {
|
|
| 143 |
- let td = copyTypeDelta(only1) |
|
| 144 |
- td.delta = merged |
|
| 145 |
- mergedTypeDeltas.append(td) |
|
| 146 |
- } else if let only2 = td2 {
|
|
| 147 |
- let td = copyTypeDelta(only2) |
|
| 148 |
- td.delta = merged |
|
| 149 |
- mergedTypeDeltas.append(td) |
|
| 150 |
- } |
|
| 151 |
- } |
|
| 152 |
- merged.typeDeltas = mergedTypeDeltas |
|
| 153 |
- updateListSummary(for: merged, typeDeltas: mergedTypeDeltas) |
|
| 154 |
- return merged |
|
| 155 |
- } |
|
| 156 |
- |
|
| 157 |
- // MARK: - Private helpers |
|
| 158 |
- |
|
| 159 |
- private static func buildTypeDelta( |
|
| 160 |
- typeID: String, |
|
| 161 |
- prev: TypeCount?, |
|
| 162 |
- curr: TypeCount?, |
|
| 163 |
- previous: HealthSnapshot, |
|
| 164 |
- current: HealthSnapshot |
|
| 165 |
- ) -> TypeDelta {
|
|
| 166 |
- let displayName = curr?.displayName ?? prev?.displayName ?? typeID |
|
| 167 |
- let td = TypeDelta(typeIdentifier: typeID, displayName: displayName) |
|
| 168 |
- td.qualityBefore = prev?.quality |
|
| 169 |
- td.qualityAfter = curr?.quality |
|
| 170 |
- |
|
| 171 |
- let prevCount = prev?.count ?? 0 |
|
| 172 |
- let currCount = curr?.count ?? 0 |
|
| 173 |
- let prevHash = prev?.contentHash ?? "" |
|
| 174 |
- let currHash = curr?.contentHash ?? "" |
|
| 175 |
- |
|
| 176 |
- if let prev, let curr {
|
|
| 177 |
- // Type present in both snapshots |
|
| 178 |
- // If either count is -1, do not compute a numeric delta |
|
| 179 |
- if prev.count == -1 || curr.count == -1 {
|
|
| 180 |
- td.countDelta = 0 |
|
| 181 |
- } else {
|
|
| 182 |
- td.countDelta = currCount - prevCount |
|
| 183 |
- } |
|
| 184 |
- td.hashBefore = prevHash |
|
| 185 |
- td.hashAfter = currHash |
|
| 186 |
- td.transition = (prevHash == currHash && prevCount == currCount) ? .unchanged : .changed |
|
| 187 |
- } else if let curr {
|
|
| 188 |
- // Type appeared — missing in previous |
|
| 189 |
- td.countDelta = curr.count == -1 ? 0 : curr.count |
|
| 190 |
- td.hashBefore = "" |
|
| 191 |
- td.hashAfter = currHash |
|
| 192 |
- td.transition = .appeared |
|
| 193 |
- } else if let prev {
|
|
| 194 |
- // Type disappeared — missing in current |
|
| 195 |
- td.countDelta = prev.count == -1 ? 0 : -prev.count |
|
| 196 |
- td.hashBefore = prevHash |
|
| 197 |
- td.hashAfter = "" |
|
| 198 |
- td.transition = .disappeared |
|
| 199 |
- } |
|
| 200 |
- |
|
| 201 |
- // Reason assignment — explicit priority order (highest wins): |
|
| 202 |
- // 1. authorizationChanged — type quality == .unauthorized |
|
| 203 |
- // 2. unsupported — type cannot be instantiated by HK factory |
|
| 204 |
- // 3. registryChanged — type appeared/disappeared AND monitoredTypeSetHash changed |
|
| 205 |
- // 4. unknown — type quality == .failed for other reasons |
|
| 206 |
- // 5. normal — none of the above |
|
| 207 |
- td.reason = assignReason( |
|
| 208 |
- prevQuality: prev?.quality, |
|
| 209 |
- currQuality: curr?.quality, |
|
| 210 |
- prevUnsupported: prev?.isUnsupported ?? false, |
|
| 211 |
- currUnsupported: curr?.isUnsupported ?? false, |
|
| 212 |
- transition: td.transition, |
|
| 213 |
- typeSetHashChanged: previous.monitoredTypeSetHash != current.monitoredTypeSetHash |
|
| 214 |
- ) |
|
| 215 |
- |
|
| 216 |
- // YearlyCount timezone guard |
|
| 217 |
- if previous.yearlyCountTimezoneIdentifier != current.yearlyCountTimezoneIdentifier {
|
|
| 218 |
- td.yearlyCountNote = "yearly attribution unreliable — timezone changed between snapshots" |
|
| 219 |
- } |
|
| 220 |
- |
|
| 221 |
- return td |
|
| 222 |
- } |
|
| 223 |
- |
|
| 224 |
- private static func updateListSummary(for delta: SnapshotDelta, typeDeltas: [TypeDelta]) {
|
|
| 225 |
- var absoluteRecordChangeCount = 0 |
|
| 226 |
- var changedMetricCount = 0 |
|
| 227 |
- var appearedMetricCount = 0 |
|
| 228 |
- var disappearedMetricCount = 0 |
|
| 229 |
- |
|
| 230 |
- for typeDelta in typeDeltas {
|
|
| 231 |
- switch typeDelta.transition {
|
|
| 232 |
- case .unchanged: |
|
| 233 |
- continue |
|
| 234 |
- case .changed: |
|
| 235 |
- changedMetricCount += 1 |
|
| 236 |
- if typeDelta.qualityBefore == .complete, |
|
| 237 |
- typeDelta.qualityAfter == .complete {
|
|
| 238 |
- absoluteRecordChangeCount += abs(typeDelta.countDelta) |
|
| 239 |
- } |
|
| 240 |
- case .appeared: |
|
| 241 |
- appearedMetricCount += 1 |
|
| 242 |
- case .disappeared: |
|
| 243 |
- disappearedMetricCount += 1 |
|
| 244 |
- } |
|
| 245 |
- } |
|
| 246 |
- |
|
| 247 |
- delta.absoluteRecordChangeCount = absoluteRecordChangeCount |
|
| 248 |
- delta.changedMetricCount = changedMetricCount |
|
| 249 |
- delta.appearedMetricCount = appearedMetricCount |
|
| 250 |
- delta.disappearedMetricCount = disappearedMetricCount |
|
| 251 |
- delta.listSummaryVersion = SnapshotDelta.currentListSummaryVersion |
|
| 252 |
- } |
|
| 253 |
- |
|
| 254 |
- private static func historicalBaselinePreviousTypeCount( |
|
| 255 |
- typeID: String, |
|
| 256 |
- prev: TypeCount?, |
|
| 257 |
- curr: TypeCount?, |
|
| 258 |
- previousSnapshot: HealthSnapshot, |
|
| 259 |
- context: ModelContext |
|
| 260 |
- ) -> TypeCount? {
|
|
| 261 |
- guard let prev, |
|
| 262 |
- let curr, |
|
| 263 |
- prev.quality == .unauthorized, |
|
| 264 |
- curr.quality == .complete, |
|
| 265 |
- curr.count > 0 else {
|
|
| 266 |
- return nil |
|
| 267 |
- } |
|
| 268 |
- |
|
| 269 |
- return findLastCompleteValuedTypeCount( |
|
| 270 |
- typeID: typeID, |
|
| 271 |
- before: previousSnapshot, |
|
| 272 |
- context: context |
|
| 273 |
- ) |
|
| 274 |
- } |
|
| 275 |
- |
|
| 276 |
- private static func findLastCompleteValuedTypeCount( |
|
| 277 |
- typeID: String, |
|
| 278 |
- before snapshot: HealthSnapshot, |
|
| 279 |
- context: ModelContext |
|
| 280 |
- ) -> TypeCount? {
|
|
| 281 |
- var visited: Set<UUID> = [] |
|
| 282 |
- var cursorID = snapshot.previousSnapshotID |
|
| 283 |
- |
|
| 284 |
- while let snapshotID = cursorID, !visited.contains(snapshotID) {
|
|
| 285 |
- visited.insert(snapshotID) |
|
| 286 |
- |
|
| 287 |
- guard let historicalSnapshot = fetchSnapshot(id: snapshotID, context: context) else {
|
|
| 288 |
- break |
|
| 289 |
- } |
|
| 290 |
- |
|
| 291 |
- if let candidate = historicalSnapshot.typeCounts?.first(where: { $0.typeIdentifier == typeID }),
|
|
| 292 |
- candidate.quality == .complete, |
|
| 293 |
- candidate.count > 0 {
|
|
| 294 |
- return candidate |
|
| 295 |
- } |
|
| 296 |
- |
|
| 297 |
- cursorID = historicalSnapshot.previousSnapshotID |
|
| 298 |
- } |
|
| 299 |
- |
|
| 300 |
- return nil |
|
| 301 |
- } |
|
| 302 |
- |
|
| 303 |
- private static func fetchSnapshot(id: UUID, context: ModelContext) -> HealthSnapshot? {
|
|
| 304 |
- let descriptor = FetchDescriptor<HealthSnapshot>( |
|
| 305 |
- predicate: #Predicate<HealthSnapshot> { $0.id == id }
|
|
| 306 |
- ) |
|
| 307 |
- return try? context.fetch(descriptor).first |
|
| 308 |
- } |
|
| 309 |
- |
|
| 310 |
- private static func assignReason( |
|
| 311 |
- prevQuality: SnapshotQuality?, |
|
| 312 |
- currQuality: SnapshotQuality?, |
|
| 313 |
- prevUnsupported: Bool, |
|
| 314 |
- currUnsupported: Bool, |
|
| 315 |
- transition: TypeTransition, |
|
| 316 |
- typeSetHashChanged: Bool |
|
| 317 |
- ) -> TypeDeltaReason {
|
|
| 318 |
- // Priority 1: authorizationChanged |
|
| 319 |
- if prevQuality == SnapshotQuality.unauthorized || currQuality == SnapshotQuality.unauthorized {
|
|
| 320 |
- return .authorizationChanged |
|
| 321 |
- } |
|
| 322 |
- // Priority 2: unsupported |
|
| 323 |
- if prevUnsupported || currUnsupported {
|
|
| 324 |
- return .unsupported |
|
| 325 |
- } |
|
| 326 |
- // Priority 3: registryChanged (only for appeared/disappeared transitions) |
|
| 327 |
- if (transition == .appeared || transition == .disappeared) && typeSetHashChanged {
|
|
| 328 |
- return .registryChanged |
|
| 329 |
- } |
|
| 330 |
- // Priority 4: unknown (failed) |
|
| 331 |
- if prevQuality == SnapshotQuality.failed || currQuality == SnapshotQuality.failed {
|
|
| 332 |
- return .unknown |
|
| 333 |
- } |
|
| 334 |
- return .normal |
|
| 335 |
- } |
|
| 336 |
- |
|
| 337 |
- private static func mergeTypeDelta(d1td: TypeDelta, d2td: TypeDelta) -> TypeDelta {
|
|
| 338 |
- let td = TypeDelta(typeIdentifier: d1td.typeIdentifier, displayName: d1td.displayName) |
|
| 339 |
- |
|
| 340 |
- if d1td.transition == .disappeared && d2td.transition == .appeared {
|
|
| 341 |
- // Type disappeared in N, reappeared in N+1 → treat as changed |
|
| 342 |
- td.transition = .changed |
|
| 343 |
- td.hashBefore = d1td.hashBefore |
|
| 344 |
- td.hashAfter = d2td.hashAfter |
|
| 345 |
- td.qualityBefore = d1td.qualityBefore |
|
| 346 |
- td.qualityAfter = d2td.qualityAfter |
|
| 347 |
- // Unavailable count guard: if either source has quality != complete, force countDelta = 0 |
|
| 348 |
- let d1Impaired = (d1td.qualityBefore != SnapshotQuality.complete) |
|
| 349 |
- let d2Impaired = (d2td.qualityAfter != SnapshotQuality.complete) |
|
| 350 |
- td.countDelta = (d1Impaired || d2Impaired) ? 0 : d1td.countDelta + d2td.countDelta |
|
| 351 |
- } else {
|
|
| 352 |
- // Both transitions are the same type (e.g. both unchanged, both changed) |
|
| 353 |
- td.transition = deriveTransition(hashBefore: d1td.hashBefore, hashAfter: d2td.hashAfter, |
|
| 354 |
- d1: d1td, d2: d2td) |
|
| 355 |
- td.hashBefore = d1td.hashBefore |
|
| 356 |
- td.hashAfter = d2td.hashAfter |
|
| 357 |
- td.qualityBefore = d1td.qualityBefore |
|
| 358 |
- td.qualityAfter = d2td.qualityAfter |
|
| 359 |
- // Unavailable count guard |
|
| 360 |
- let anyImpaired = (d1td.qualityBefore != SnapshotQuality.complete) || |
|
| 361 |
- (d1td.qualityAfter != SnapshotQuality.complete) || |
|
| 362 |
- (d2td.qualityBefore != SnapshotQuality.complete) || |
|
| 363 |
- (d2td.qualityAfter != SnapshotQuality.complete) |
|
| 364 |
- td.countDelta = anyImpaired ? 0 : d1td.countDelta + d2td.countDelta |
|
| 365 |
- } |
|
| 366 |
- |
|
| 367 |
- // Reason: apply same priority table; use highest-priority reason from both source deltas |
|
| 368 |
- td.reason = highestPriorityReason(d1td.reason, d2td.reason) |
|
| 369 |
- |
|
| 370 |
- // Timezone note: carry forward if either source had it |
|
| 371 |
- if !d1td.yearlyCountNote.isEmpty || !d2td.yearlyCountNote.isEmpty {
|
|
| 372 |
- td.yearlyCountNote = "yearly attribution unreliable — timezone changed between snapshots" |
|
| 373 |
- } |
|
| 374 |
- |
|
| 375 |
- return td |
|
| 376 |
- } |
|
| 377 |
- |
|
| 378 |
- private static func deriveTransition( |
|
| 379 |
- hashBefore: String, hashAfter: String, |
|
| 380 |
- d1: TypeDelta, d2: TypeDelta |
|
| 381 |
- ) -> TypeTransition {
|
|
| 382 |
- // Infer transition from the merged hash pair |
|
| 383 |
- if hashBefore.isEmpty && !hashAfter.isEmpty { return .appeared }
|
|
| 384 |
- if !hashBefore.isEmpty && hashAfter.isEmpty { return .disappeared }
|
|
| 385 |
- if hashBefore == hashAfter && d1.countDelta + d2.countDelta == 0 { return .unchanged }
|
|
| 386 |
- return .changed |
|
| 387 |
- } |
|
| 388 |
- |
|
| 389 |
- private static func highestPriorityReason(_ a: TypeDeltaReason, _ b: TypeDeltaReason) -> TypeDeltaReason {
|
|
| 390 |
- // Priority: authorizationChanged > unsupported > registryChanged > unknown > normal |
|
| 391 |
- let priority: [TypeDeltaReason] = [.authorizationChanged, .unsupported, .registryChanged, .unknown, .normal] |
|
| 392 |
- let aIdx = priority.firstIndex(of: a) ?? priority.count |
|
| 393 |
- let bIdx = priority.firstIndex(of: b) ?? priority.count |
|
| 394 |
- return priority[min(aIdx, bIdx)] |
|
| 395 |
- } |
|
| 396 |
- |
|
| 397 |
- private static func copyTypeDelta(_ source: TypeDelta) -> TypeDelta {
|
|
| 398 |
- let td = TypeDelta(typeIdentifier: source.typeIdentifier, displayName: source.displayName) |
|
| 399 |
- td.countDelta = source.countDelta |
|
| 400 |
- td.hashBefore = source.hashBefore |
|
| 401 |
- td.hashAfter = source.hashAfter |
|
| 402 |
- td.qualityBefore = source.qualityBefore |
|
| 403 |
- td.qualityAfter = source.qualityAfter |
|
| 404 |
- td.transition = source.transition |
|
| 405 |
- td.reason = source.reason |
|
| 406 |
- td.yearlyCountNote = source.yearlyCountNote |
|
| 407 |
- return td |
|
| 408 |
- } |
|
| 409 |
-} |
|
@@ -279,8 +279,6 @@ final class HealthKitService {
|
||
| 279 | 279 |
snapshot.typeCounts = typeCounts |
| 280 | 280 |
|
| 281 | 281 |
try context.save() |
| 282 |
- |
|
| 283 |
- try await runPostSavePipeline(snapshot: snapshot, context: context) |
|
| 284 | 282 |
} |
| 285 | 283 |
|
| 286 | 284 |
private func updateSnapshotSummaryCache( |
@@ -534,16 +532,6 @@ final class HealthKitService {
|
||
| 534 | 532 |
} |
| 535 | 533 |
} |
| 536 | 534 |
|
| 537 |
- // MARK: - Post-save pipeline |
|
| 538 |
- |
|
| 539 |
- private func runPostSavePipeline( |
|
| 540 |
- snapshot: HealthSnapshot, |
|
| 541 |
- context: ModelContext |
|
| 542 |
- ) async throws {
|
|
| 543 |
- guard snapshot.previousSnapshotID != nil else { return }
|
|
| 544 |
- _ = try DeltaService.computeAndSave(current: snapshot, context: context) |
|
| 545 |
- } |
|
| 546 |
- |
|
| 547 | 535 |
// MARK: - Per-type fetch pipeline |
| 548 | 536 |
|
| 549 | 537 |
// Fetches sequentially to prevent race conditions and resource exhaustion. |