Showing 5 changed files with 247 additions and 171 deletions
+2 -2
HealthProbe/Doc/04-project/IMPLEMENTATION_STATUS.md
@@ -28,7 +28,7 @@ There are no real deployments, only test installations. Existing prototype datab
28 28
 | SQLite archive | Archive v2 schema, snapshot-level observation grouping, differential write path, v2 verification/delete bookkeeping, daily aggregate rebuilds, integrity report, v2 record reads, SQL diff/count/aggregate/provenance/consolidation-evidence APIs, large synthetic diff pagination coverage, formal timing/memory metrics, and XCTest coverage are in place; the legacy `archive_samples` mirror has been removed | Continue moving capture/Dashboard actions to archive/cache DTOs |
29 29
 | Core Data cache | Initial programmatic Core Data model, full-cache rebuild service, read DTOs for observation/type/diff/health rows, and Dashboard archive-cache status wiring are in place | Move remaining export/report paths to cache DTOs and add targeted partial invalidation |
30 30
 | SwiftData cache | Exists; test builds now reset legacy prototype UI/archive/cache stores once for archive v2 so old SwiftData-only snapshots are not treated as backed-up observations. Metric timeout calibration, local device profile settings, operation logging, ContentView preview, Settings data maintenance, legacy detail/PDF views, unused legacy repair/observer services, Dashboard view/view-model access, and legacy anomaly/count-drop review have moved outside SwiftData or been removed. Remaining SwiftData imports are inventoried in [`SwiftData-Retirement-Inventory.md`](SwiftData-Retirement-Inventory.md) | Treat as disposable prototype data; stop returning/storing `HealthSnapshot` bridge handles before removing `ModelContainer` |
31
-| UI | Prototype exists; Dashboard status reads archive/cache observation rows and shows cache health, and Dashboard view/view-model code no longer imports SwiftData or reads `ModelContext`; capture/review actions still route through a legacy bridge isolated in `HealthKitService`. Snapshots and Data Types tab roots no longer import SwiftData, load Core Data cached observation rows, and open archive/cache-backed detail rows; `SnapshotArchiveDetailView` and `DataTypeArchiveDetailView` read Core Data type/diff summaries and page record drill-down through SQLite; unused legacy SwiftData snapshot/type detail and PDF views have been deleted; record-change evolution and temporal distribution screens now receive DTO rows/cache input instead of querying SwiftData directly; export preview reads the archive export API before showing/exporting JSON; simplified detail mode replaces heavy charts with summary rows on small/accessibility layouts or when enabled in Settings; visible change labels now use neutral new/missing/change-review language | Stop returning/storing prototype `HealthSnapshot` handles from capture/review |
31
+| UI | Prototype exists; Dashboard status reads archive/cache observation rows and shows cache health, and Dashboard view/view-model code no longer imports SwiftData or reads `ModelContext`; capture/review actions now route through DTOs and snapshot ids, with the remaining legacy bridge isolated in `HealthKitService`. Snapshots and Data Types tab roots no longer import SwiftData, load Core Data cached observation rows, and open archive/cache-backed detail rows; `SnapshotArchiveDetailView` and `DataTypeArchiveDetailView` read Core Data type/diff summaries and page record drill-down through SQLite; unused legacy SwiftData snapshot/type detail and PDF views have been deleted; record-change evolution and temporal distribution screens now receive DTO rows/cache input instead of querying SwiftData directly; export preview reads the archive export API before showing/exporting JSON; simplified detail mode replaces heavy charts with summary rows on small/accessibility layouts or when enabled in Settings; visible change labels now use neutral new/missing/change-review language | Stop writing prototype `HealthSnapshot` bridge rows during capture/review |
32 32
 | Diff/change explanation | SQL diff summaries, paged diff records, aggregate comparisons, and consolidation-evidence labels exist; legacy anomaly/count-drop review has been removed from active flows; the old direct `HealthSnapshot.typeCounts` diff helper has been retired | Keep active diff/count views on archive/cache DTOs |
33 33
 | Export | SQLite export preview, paged JSON writing, SHA256 manifest hashing, and `export_manifests` rows are in place for selected records and observation diffs | Fill remaining recovery-compatible envelope metadata, CSV export, relationship preservation, and reproducibility checks |
34 34
 | Legacy device support | Simplified detail UI mode is implemented for small/accessibility layouts and as a Settings toggle | Remove SwiftData dependency and validate lower deployment targets |
@@ -38,7 +38,7 @@ There are no real deployments, only test installations. Existing prototype datab
38 38
 
39 39
 Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.md).
40 40
 
41
-1. Stop returning/storing prototype `HealthSnapshot` handles from capture/review.
41
+1. Stop writing prototype `HealthSnapshot` bridge rows during capture/review.
42 42
 2. Add targeted cache invalidation for affected observation/type ranges.
43 43
 3. Finish remaining UI language cleanup from anomaly/status to observation/diff/export where legacy model names still leak into active flows.
44 44
 4. Complete recovery-compatible export metadata, CSV output, and reproducibility checks.
+5 -4
HealthProbe/Doc/04-project/Refactoring-Plan.md
@@ -225,8 +225,8 @@ Checklist:
225 225
 - [ ] Replace direct SwiftData `@Query` dependencies for target screens.
226 226
 - [x] Dashboard status reads Core Data cached observation rows and cache health,
227 227
   and Dashboard view/view-model code no longer imports SwiftData or reads
228
-  `ModelContext`. Capture/review actions still route through the legacy bridge
229
-  isolated in `HealthKitService`.
228
+  `ModelContext`. Capture/review actions now route through DTOs and snapshot ids
229
+  while the legacy bridge remains isolated in `HealthKitService`.
230 230
 - [x] Observation timeline rows read Core Data cache when available and no
231 231
   longer query `SnapshotDelta` list summaries.
232 232
 - [x] Observation root and archive detail use cached summary/type rows plus
@@ -273,8 +273,9 @@ Checklist:
273 273
   views have been deleted; unused legacy lifecycle/observer/repair services have
274 274
   been deleted; unused legacy delta service/models have been deleted;
275 275
   `HealthRecord`, `YearlyCount`, and `TypeDistributionBin` are no longer
276
-  SwiftData models; capture/review still returns/stores prototype
277
-  `HealthSnapshot` handles through the legacy bridge.
276
+  SwiftData models; capture/review no longer returns/stores prototype
277
+  `HealthSnapshot` handles outside `HealthKitService`, but the service-side
278
+  legacy bridge still writes those rows.
278 279
 - [ ] Remove/disable `ModelContainer` as required for target builds.
279 280
 - [x] Add prototype-store ignore/delete/reset path for test installs.
280 281
 - [ ] Verify no old-store compatibility layer remains in active flows.
+11 -10
HealthProbe/Doc/04-project/SwiftData-Retirement-Inventory.md
@@ -50,12 +50,12 @@ Retirement path:
50 50
 ## UI And View Models
51 51
 
52 52
 No active UI/view-model file currently imports SwiftData. Dashboard
53
-capture/review actions still hold legacy `HealthSnapshot` handles while the
54
-bridge exists, but `ModelContext` and `FetchDescriptor` access is isolated in
55
-`HealthKitService`.
53
+capture/review actions now pass only DTOs and snapshot ids while the bridge
54
+exists; `HealthSnapshot` instances, `ModelContext`, and `FetchDescriptor`
55
+access are isolated in `HealthKitService`.
56 56
 
57 57
 Retirement path:
58
-- stop returning/storing prototype `HealthSnapshot` handles from capture/review
58
+- stop writing prototype `HealthSnapshot` bridge rows during capture/review
59 59
   flows;
60 60
 - keep status/report rows on archive/cache APIs.
61 61
 
@@ -88,8 +88,9 @@ The following SwiftData dependencies were removed from active flows:
88 88
   or reads `ModelContext`. Capture/review actions now route through the
89 89
   Dashboard view model while the legacy bridge is retired.
90 90
 - `HealthProbe/ViewModels/DashboardViewModel.swift` no longer imports SwiftData
91
-  or reads `ModelContext`/`FetchDescriptor`. Legacy snapshot-cache operations
92
-  are isolated in `HealthKitService`.
91
+  or reads `ModelContext`/`FetchDescriptor`. It now keeps only snapshot ids and
92
+  DTO review state; legacy snapshot-cache operations are isolated in
93
+  `HealthKitService`.
93 94
 - `HealthProbe/Views/DataTypes/RecordChangeEvolutionChart.swift` now accepts a
94 95
   small `RecordChangeEvolutionSnapshot` DTO and loads archive/cache counts
95 96
   without importing SwiftData or querying `SnapshotDelta`.
@@ -152,7 +153,7 @@ The following SwiftData dependencies were removed from active flows:
152 153
 
153 154
 ## Next Recommended Slices
154 155
 
155
-1. Stop writing prototype `HealthSnapshot` bridge rows during capture once
156
-   Dashboard actions no longer need them.
157
-2. Remove `HealthSnapshot`/`TypeCount` from active capture/review state and then
158
-   delete the launch `ModelContainer`.
156
+1. Stop writing prototype `HealthSnapshot` bridge rows during capture now that
157
+   Dashboard review state no longer stores those models.
158
+2. Remove `HealthSnapshot`/`TypeCount` from the remaining service-side bridge
159
+   flow and then delete the launch `ModelContainer`.
+165 -8
HealthProbe/Services/HealthKitService.swift
@@ -37,15 +37,52 @@ extension Array where Element == MonitoredType {
37 37
 
38 38
 enum LegacySwiftDataBridgeError: LocalizedError {
39 39
     case notConfigured
40
+    case snapshotNotSaved
40 41
 
41 42
     var errorDescription: String? {
42 43
         switch self {
43 44
         case .notConfigured:
44 45
             return "Legacy SwiftData bridge is not configured."
46
+        case .snapshotNotSaved:
47
+            return "Snapshot was not saved to database. This may indicate a HealthKit permission issue or data corruption. Try requesting health access again in the Actions section."
45 48
         }
46 49
     }
47 50
 }
48 51
 
52
+struct AmbiguousDisappearedMetric: Identifiable, Equatable, Sendable {
53
+    let id: String
54
+    let displayName: String
55
+    let previousCount: Int
56
+}
57
+
58
+struct LegacySnapshotTypeSummary: Sendable {
59
+    let typeIdentifier: String
60
+    let displayName: String
61
+    let count: Int
62
+    let quality: SnapshotQuality
63
+}
64
+
65
+struct LegacySnapshotOutcome: Sendable {
66
+    let snapshotID: UUID
67
+    let timestamp: Date
68
+    let deviceID: String
69
+    let triggerReason: String
70
+    let retryOfSnapshotID: UUID?
71
+    let previousSnapshotID: UUID?
72
+    let isChainStart: Bool
73
+    let snapshotChecksum: String
74
+    let monitoredTypeSetHash: String
75
+    let monitoredRegistryVersion: Int
76
+}
77
+
78
+struct LegacySnapshotCaptureResult: Sendable {
79
+    let outcome: LegacySnapshotOutcome
80
+    let snapshotQuality: SnapshotQuality
81
+    let typeSummaries: [LegacySnapshotTypeSummary]
82
+    let shouldAutoSaveKnownUnauthorizedPartial: Bool
83
+    let ambiguousDisappearedMetrics: [AmbiguousDisappearedMetric]
84
+}
85
+
49 86
 enum LegacySwiftDataBridge {
50 87
     private static var container: ModelContainer?
51 88
     private static var context: ModelContext?
@@ -64,8 +101,8 @@ enum LegacySwiftDataBridge {
64 101
         timeoutMultiplier: Double,
65 102
         reviewAmbiguousCompleteDisappearedTypes: Bool,
66 103
         progress: SnapshotFetchProgress?
67
-    ) async throws -> HealthSnapshot {
68
-        try await healthKit.createSnapshot(
104
+    ) async throws -> LegacySnapshotCaptureResult {
105
+        let snapshot = try await healthKit.createSnapshot(
69 106
             in: modelContext(),
70 107
             selectedTypeIDs: selectedTypeIDs,
71 108
             adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled,
@@ -75,20 +112,67 @@ enum LegacySwiftDataBridge {
75 112
             reviewAmbiguousCompleteDisappearedTypes: reviewAmbiguousCompleteDisappearedTypes,
76 113
             progress: progress
77 114
         )
115
+
116
+        let ambiguousDisappearedMetrics = snapshot.snapshotQuality == .complete
117
+            ? ambiguousDisappearedMetrics(for: snapshot)
118
+            : []
119
+
120
+        if snapshot.snapshotQuality == .complete, try !snapshotExists(id: snapshot.id) {
121
+            throw LegacySwiftDataBridgeError.snapshotNotSaved
122
+        }
123
+
124
+        return LegacySnapshotCaptureResult(
125
+            outcome: snapshotOutcome(from: snapshot),
126
+            snapshotQuality: snapshot.snapshotQuality,
127
+            typeSummaries: typeSummaries(from: snapshot),
128
+            shouldAutoSaveKnownUnauthorizedPartial: shouldAutoSaveKnownUnauthorizedPartial(snapshot: snapshot),
129
+            ambiguousDisappearedMetrics: ambiguousDisappearedMetrics
130
+        )
78 131
     }
79 132
 
80 133
     static func savePartialSnapshot(
81
-        _ snapshot: HealthSnapshot,
134
+        id snapshotID: UUID,
82 135
         using healthKit: HealthKitService
83
-    ) async throws -> HealthSnapshot {
84
-        try await healthKit.savePartialSnapshot(snapshot, in: modelContext())
136
+    ) async throws -> LegacySnapshotOutcome {
137
+        guard let snapshot = try fetchSnapshot(id: snapshotID) else {
138
+            throw LegacySwiftDataBridgeError.snapshotNotSaved
139
+        }
140
+        let saved = try await healthKit.savePartialSnapshot(snapshot, in: modelContext())
141
+        return snapshotOutcome(from: saved)
85 142
     }
86 143
 
87 144
     static func saveReviewedCompleteSnapshot(
88
-        _ snapshot: HealthSnapshot,
145
+        id snapshotID: UUID,
89 146
         using healthKit: HealthKitService
90
-    ) async throws -> HealthSnapshot {
91
-        try await healthKit.saveReviewedCompleteSnapshot(snapshot, in: modelContext())
147
+    ) async throws -> LegacySnapshotOutcome {
148
+        guard let snapshot = try fetchSnapshot(id: snapshotID) else {
149
+            throw LegacySwiftDataBridgeError.snapshotNotSaved
150
+        }
151
+        let saved = try await healthKit.saveReviewedCompleteSnapshot(snapshot, in: modelContext())
152
+        return snapshotOutcome(from: saved)
153
+    }
154
+
155
+    static func markMetricsUnauthorized(
156
+        snapshotID: UUID,
157
+        typeIdentifiers: Set<String>,
158
+        using healthKit: HealthKitService
159
+    ) async throws -> LegacySnapshotOutcome {
160
+        guard let snapshot = try fetchSnapshot(id: snapshotID) else {
161
+            throw LegacySwiftDataBridgeError.snapshotNotSaved
162
+        }
163
+
164
+        for typeCount in snapshot.typeCounts ?? [] where typeIdentifiers.contains(typeCount.typeIdentifier) {
165
+            typeCount.count = -1
166
+            typeCount.contentHash = ""
167
+            typeCount.earliestDate = nil
168
+            typeCount.latestDate = nil
169
+            typeCount.quality = .unauthorized
170
+            typeCount.yearlyCounts = []
171
+        }
172
+        snapshot.snapshotQuality = healthKit.deriveSnapshotQuality(from: snapshot.typeCounts ?? [])
173
+
174
+        let saved = try await healthKit.savePartialSnapshot(snapshot, in: modelContext())
175
+        return snapshotOutcome(from: saved)
92 176
     }
93 177
 
94 178
     static func snapshotExists(id: UUID) throws -> Bool {
@@ -118,6 +202,79 @@ enum LegacySwiftDataBridge {
118 202
         }
119 203
     }
120 204
 
205
+    private static func fetchSnapshot(id: UUID) throws -> HealthSnapshot? {
206
+        let context = try modelContext()
207
+        var descriptor = FetchDescriptor<HealthSnapshot>(
208
+            predicate: #Predicate<HealthSnapshot> { $0.id == id }
209
+        )
210
+        descriptor.fetchLimit = 1
211
+        return try context.fetch(descriptor).first
212
+    }
213
+
214
+    private static func ambiguousDisappearedMetrics(for snapshot: HealthSnapshot) -> [AmbiguousDisappearedMetric] {
215
+        guard let previous = previousSnapshot(for: snapshot) else { return [] }
216
+        let previousByType = Dictionary(
217
+            uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
218
+        )
219
+
220
+        return (snapshot.typeCounts ?? []).compactMap { current in
221
+            guard current.quality == .complete,
222
+                  current.count == 0,
223
+                  let previous = previousByType[current.typeIdentifier],
224
+                  previous.quality == .complete,
225
+                  previous.count > 0 else {
226
+                return nil
227
+            }
228
+            return AmbiguousDisappearedMetric(
229
+                id: current.typeIdentifier,
230
+                displayName: current.displayName,
231
+                previousCount: previous.count
232
+            )
233
+        }.sorted { $0.displayName < $1.displayName }
234
+    }
235
+
236
+    private static func shouldAutoSaveKnownUnauthorizedPartial(snapshot: HealthSnapshot) -> Bool {
237
+        let currentUnauthorized = (snapshot.typeCounts ?? []).filter { $0.quality == .unauthorized }
238
+        guard snapshot.snapshotQuality != .complete,
239
+              !currentUnauthorized.isEmpty,
240
+              !(snapshot.typeCounts ?? []).contains(where: { $0.quality == .failed }),
241
+              let previous = previousSnapshot(for: snapshot) else {
242
+            return false
243
+        }
244
+
245
+        let previousByType = Dictionary(
246
+            uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
247
+        )
248
+
249
+        return currentUnauthorized.allSatisfy { previousByType[$0.typeIdentifier]?.quality == .unauthorized }
250
+    }
251
+
252
+    private static func typeSummaries(from snapshot: HealthSnapshot) -> [LegacySnapshotTypeSummary] {
253
+        (snapshot.typeCounts ?? []).map { typeCount in
254
+            LegacySnapshotTypeSummary(
255
+                typeIdentifier: typeCount.typeIdentifier,
256
+                displayName: typeCount.displayName,
257
+                count: typeCount.count,
258
+                quality: typeCount.quality
259
+            )
260
+        }
261
+    }
262
+
263
+    private static func snapshotOutcome(from snapshot: HealthSnapshot) -> LegacySnapshotOutcome {
264
+        LegacySnapshotOutcome(
265
+            snapshotID: snapshot.id,
266
+            timestamp: snapshot.timestamp,
267
+            deviceID: snapshot.deviceID,
268
+            triggerReason: snapshot.triggerReason,
269
+            retryOfSnapshotID: snapshot.retryOfSnapshotID,
270
+            previousSnapshotID: snapshot.previousSnapshotID,
271
+            isChainStart: snapshot.isChainStart,
272
+            snapshotChecksum: HashService.snapshotChecksum(typeCounts: snapshot.typeCounts ?? []),
273
+            monitoredTypeSetHash: snapshot.monitoredTypeSetHash,
274
+            monitoredRegistryVersion: snapshot.monitoredRegistryVersion
275
+        )
276
+    }
277
+
121 278
     private static func modelContext() throws -> ModelContext {
122 279
         if let context { return context }
123 280
         guard let container else {
+64 -147
HealthProbe/ViewModels/DashboardViewModel.swift
@@ -28,8 +28,8 @@ final class DashboardViewModel {
28 28
     var archiveCacheError: String?
29 29
 
30 30
     private let healthKit = HealthKitService.shared
31
-    private var pendingPartialSnapshot: HealthSnapshot?
32
-    private var pendingAmbiguousSnapshot: HealthSnapshot?
31
+    private var pendingPartialSnapshotID: UUID?
32
+    private var pendingAmbiguousSnapshotID: UUID?
33 33
 
34 34
     func requestAuthorization() async {
35 35
         isRequestingAuth = true
@@ -66,8 +66,8 @@ final class DashboardViewModel {
66 66
         completedSnapshotDeviceID = nil
67 67
         completedSnapshotTriggerReason = nil
68 68
         completedSnapshotRetryOfSnapshotID = nil
69
-        pendingPartialSnapshot = nil
70
-        pendingAmbiguousSnapshot = nil
69
+        pendingPartialSnapshotID = nil
70
+        pendingAmbiguousSnapshotID = nil
71 71
         ambiguousDisappearedMetrics = []
72 72
         snapshotProgressMessage = ""
73 73
         snapshotProgressDetail = ""
@@ -92,7 +92,7 @@ final class DashboardViewModel {
92 92
             let historyImportTimeout = concurrentBatches * HealthKitService.fullHistoryImportTimeoutSeconds + 30
93 93
             let learnedMetricTimeout = Double(selectedTypeIDs.count) * HealthKitService.maximumTimeoutSeconds + 30
94 94
             let operationTimeout = max(120, historyImportTimeout, learnedMetricTimeout)
95
-            let snapshot = try await withTimeout(seconds: operationTimeout) {
95
+            let capture = try await withTimeout(seconds: operationTimeout) {
96 96
                 try await LegacySwiftDataBridge.createSnapshot(
97 97
                     using: self.healthKit,
98 98
                     selectedTypeIDs: selectedTypeIDs,
@@ -106,36 +106,31 @@ final class DashboardViewModel {
106 106
             }
107 107
 
108 108
             fetchDurationSeconds = fetchStartDate.map { Date().timeIntervalSince($0) }
109
-            completedSnapshotID = snapshot.id
110
-            completedSnapshotTimestamp = snapshot.timestamp
111
-            completedSnapshotDeviceID = snapshot.deviceID
112
-            completedSnapshotTriggerReason = snapshot.triggerReason
113
-            completedSnapshotRetryOfSnapshotID = snapshot.retryOfSnapshotID
109
+            applyOutcome(capture.outcome)
114 110
             fetchProgress?.updateChainContext(
115
-                previousSnapshotID: snapshot.previousSnapshotID,
116
-                isChainStart: snapshot.isChainStart,
117
-                snapshotChecksum: HashService.snapshotChecksum(typeCounts: snapshot.typeCounts ?? []),
118
-                monitoredTypeSetHash: snapshot.monitoredTypeSetHash,
119
-                monitoredRegistryVersion: snapshot.monitoredRegistryVersion
111
+                previousSnapshotID: capture.outcome.previousSnapshotID,
112
+                isChainStart: capture.outcome.isChainStart,
113
+                snapshotChecksum: capture.outcome.snapshotChecksum,
114
+                monitoredTypeSetHash: capture.outcome.monitoredTypeSetHash,
115
+                monitoredRegistryVersion: capture.outcome.monitoredRegistryVersion
120 116
             )
121 117
 
122
-            reflectUnavailableMetricsInProgress(snapshot: snapshot)
118
+            reflectUnavailableMetricsInProgress(typeSummaries: capture.typeSummaries)
123 119
 
124
-            if snapshot.snapshotQuality != SnapshotQuality.complete {
125
-                pendingPartialSnapshot = snapshot
120
+            if capture.snapshotQuality != .complete {
121
+                pendingPartialSnapshotID = capture.outcome.snapshotID
126 122
 
127
-                let typeCounts = snapshot.typeCounts ?? []
128
-                let unauthorizedCount = typeCounts.filter { $0.quality == SnapshotQuality.unauthorized }.count
129
-                let failedCount = typeCounts.filter { $0.quality == SnapshotQuality.failed }.count
123
+                let unauthorizedCount = capture.typeSummaries.filter { $0.quality == .unauthorized }.count
124
+                let failedCount = capture.typeSummaries.filter { $0.quality == .failed }.count
130 125
 
131
-                let failedTypes = typeCounts.filter { $0.quality == SnapshotQuality.failed }
132
-                let unauthorizedTypes = typeCounts.filter { $0.quality == SnapshotQuality.unauthorized }
126
+                let failedTypes = capture.typeSummaries.filter { $0.quality == .failed }
127
+                let unauthorizedTypes = capture.typeSummaries.filter { $0.quality == .unauthorized }
133 128
 
134 129
                 snapshotProgress = .incomplete
135 130
                 snapshotProgressMessage = "Incomplete snapshot"
136 131
                 permissionsAlreadyRequested = healthKit.hasRequestedPermissionsBefore
137 132
 
138
-                if shouldAutoSaveKnownUnauthorizedPartial(snapshot: snapshot, failedCount: failedCount) {
133
+                if capture.shouldAutoSaveKnownUnauthorizedPartial {
139 134
                     snapshotProgressMessage = "Known unavailable metrics"
140 135
                     snapshotProgressDetail = "Snapshot was auto-saved as partial because unavailable metrics were already confirmed in the previous snapshot."
141 136
                     await savePartialSnapshot(keepSheetOpenForReview: true)
@@ -167,7 +162,7 @@ final class DashboardViewModel {
167 162
                     """
168 163
                     canRetryWithPermissions = false
169 164
                 } else {
170
-                    snapshotProgressDetail = "Snapshot created with quality: \(snapshot.snapshotQuality.rawValue). Some data may be incomplete."
165
+                    snapshotProgressDetail = "Snapshot created with quality: \(capture.snapshotQuality.rawValue). Some data may be incomplete."
171 166
                     canRetryWithPermissions = false
172 167
                 }
173 168
 
@@ -175,10 +170,9 @@ final class DashboardViewModel {
175 170
                 return
176 171
             }
177 172
 
178
-            let ambiguousMetrics = findAmbiguousDisappearedMetrics(snapshot: snapshot)
179
-            if !ambiguousMetrics.isEmpty {
180
-                pendingAmbiguousSnapshot = snapshot
181
-                ambiguousDisappearedMetrics = ambiguousMetrics
173
+            if !capture.ambiguousDisappearedMetrics.isEmpty {
174
+                pendingAmbiguousSnapshotID = capture.outcome.snapshotID
175
+                ambiguousDisappearedMetrics = capture.ambiguousDisappearedMetrics
182 176
                 snapshotProgress = .requiresResolution
183 177
                 snapshotProgressMessage = "Metric access needs review"
184 178
                 snapshotProgressDetail = "One or more metrics returned zero samples after previously having data. Choose how HealthProbe should classify this before saving the snapshot."
@@ -186,23 +180,12 @@ final class DashboardViewModel {
186 180
                 return
187 181
             }
188 182
 
189
-            let snapshotID = snapshot.id
190
-            let exists = try LegacySwiftDataBridge.snapshotExists(id: snapshotID)
191
-
192
-            if !exists {
193
-                throw SnapshotCreationError.snapshotNotSaved
194
-            }
195
-
196 183
             snapshotProgress = .complete
197 184
             refreshArchiveCache()
198 185
         } catch is CancellationError {
199 186
             snapshotError = "Snapshot creation exceeded the operation timeout. Individual metric timeouts are adaptive; retry failed metrics with an extended timeout when available."
200 187
             snapshotProgress = .idle
201 188
             showProgressSheet = true
202
-        } catch let error as SnapshotCreationError {
203
-            snapshotError = error.message
204
-            snapshotProgress = .idle
205
-            showProgressSheet = true
206 189
         } catch {
207 190
             snapshotError = "Failed to create snapshot: \(error.localizedDescription)"
208 191
             snapshotProgress = .idle
@@ -211,23 +194,16 @@ final class DashboardViewModel {
211 194
     }
212 195
 
213 196
     func treatAmbiguousMetricsAsUnauthorized() async {
214
-        guard let snapshot = pendingAmbiguousSnapshot else { return }
215 197
         let ambiguousIDs = Set(ambiguousDisappearedMetrics.map(\.id))
216
-        let typeCounts = snapshot.typeCounts ?? []
217
-
218
-        for typeCount in typeCounts where ambiguousIDs.contains(typeCount.typeIdentifier) {
219
-            typeCount.count = -1
220
-            typeCount.contentHash = ""
221
-            typeCount.earliestDate = nil
222
-            typeCount.latestDate = nil
223
-            typeCount.quality = .unauthorized
224
-            typeCount.yearlyCounts = []
225
-        }
226
-        snapshot.snapshotQuality = healthKit.deriveSnapshotQuality(from: typeCounts)
198
+        guard let snapshotID = pendingAmbiguousSnapshotID else { return }
227 199
 
228 200
         do {
229
-            let saved = try await LegacySwiftDataBridge.savePartialSnapshot(snapshot, using: healthKit)
230
-            finishSavedReviewedSnapshot(saved)
201
+            let outcome = try await LegacySwiftDataBridge.markMetricsUnauthorized(
202
+                snapshotID: snapshotID,
203
+                typeIdentifiers: ambiguousIDs,
204
+                using: healthKit
205
+            )
206
+            finishSavedReviewedSnapshot(outcome)
231 207
         } catch {
232 208
             snapshotError = "Failed to save reviewed snapshot: \(error.localizedDescription)"
233 209
             showProgressSheet = true
@@ -235,11 +211,11 @@ final class DashboardViewModel {
235 211
     }
236 212
 
237 213
     func treatAmbiguousMetricsAsDeleted() async {
238
-        guard let snapshot = pendingAmbiguousSnapshot else { return }
214
+        guard let snapshotID = pendingAmbiguousSnapshotID else { return }
239 215
 
240 216
         do {
241
-            let saved = try await LegacySwiftDataBridge.saveReviewedCompleteSnapshot(snapshot, using: healthKit)
242
-            finishSavedReviewedSnapshot(saved)
217
+            let outcome = try await LegacySwiftDataBridge.saveReviewedCompleteSnapshot(id: snapshotID, using: healthKit)
218
+            finishSavedReviewedSnapshot(outcome)
243 219
         } catch {
244 220
             snapshotError = "Failed to save reviewed snapshot: \(error.localizedDescription)"
245 221
             showProgressSheet = true
@@ -279,7 +255,7 @@ final class DashboardViewModel {
279 255
     }
280 256
 
281 257
     func savePartialSnapshot(keepSheetOpenForReview: Bool = false) async {
282
-        guard let snapshot = pendingPartialSnapshot else {
258
+        guard let snapshotID = pendingPartialSnapshotID else {
283 259
             if !keepSheetOpenForReview {
284 260
                 fetchProgress = nil
285 261
                 showProgressSheet = false
@@ -289,19 +265,15 @@ final class DashboardViewModel {
289 265
         }
290 266
 
291 267
         do {
292
-            let saved = try await LegacySwiftDataBridge.savePartialSnapshot(snapshot, using: healthKit)
293
-            completedSnapshotID = saved.id
294
-            completedSnapshotTimestamp = saved.timestamp
295
-            completedSnapshotDeviceID = saved.deviceID
296
-            completedSnapshotTriggerReason = saved.triggerReason
297
-            completedSnapshotRetryOfSnapshotID = saved.retryOfSnapshotID
298
-            pendingPartialSnapshot = nil
268
+            let outcome = try await LegacySwiftDataBridge.savePartialSnapshot(id: snapshotID, using: healthKit)
269
+            applyOutcome(outcome)
270
+            pendingPartialSnapshotID = nil
299 271
             fetchProgress?.updateChainContext(
300
-                previousSnapshotID: saved.previousSnapshotID,
301
-                isChainStart: saved.isChainStart,
302
-                snapshotChecksum: HashService.snapshotChecksum(typeCounts: saved.typeCounts ?? []),
303
-                monitoredTypeSetHash: saved.monitoredTypeSetHash,
304
-                monitoredRegistryVersion: saved.monitoredRegistryVersion
272
+                previousSnapshotID: outcome.previousSnapshotID,
273
+                isChainStart: outcome.isChainStart,
274
+                snapshotChecksum: outcome.snapshotChecksum,
275
+                monitoredTypeSetHash: outcome.monitoredTypeSetHash,
276
+                monitoredRegistryVersion: outcome.monitoredRegistryVersion
305 277
             )
306 278
             refreshArchiveCache()
307 279
         } catch {
@@ -322,8 +294,8 @@ final class DashboardViewModel {
322 294
     }
323 295
 
324 296
     func discardSnapshot() async {
325
-        pendingPartialSnapshot = nil
326
-        pendingAmbiguousSnapshot = nil
297
+        pendingPartialSnapshotID = nil
298
+        pendingAmbiguousSnapshotID = nil
327 299
         ambiguousDisappearedMetrics = []
328 300
         if let snapshotID = completedSnapshotID {
329 301
             do {
@@ -364,70 +336,24 @@ final class DashboardViewModel {
364 336
         }
365 337
     }
366 338
 
367
-    private func findAmbiguousDisappearedMetrics(snapshot: HealthSnapshot) -> [AmbiguousDisappearedMetric] {
368
-        guard let previous = LegacySwiftDataBridge.previousSnapshot(for: snapshot) else { return [] }
369
-        let previousByType = Dictionary(
370
-            uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
371
-        )
372
-
373
-        return (snapshot.typeCounts ?? []).compactMap { current in
374
-            guard current.quality == .complete,
375
-                  current.count == 0,
376
-                  let previous = previousByType[current.typeIdentifier],
377
-                  previous.quality == .complete,
378
-                  previous.count > 0 else {
379
-                return nil
380
-            }
381
-            return AmbiguousDisappearedMetric(
382
-                id: current.typeIdentifier,
383
-                displayName: current.displayName,
384
-                previousCount: previous.count
385
-            )
386
-        }.sorted { $0.displayName < $1.displayName }
387
-    }
388
-
389
-    private func reflectUnavailableMetricsInProgress(snapshot: HealthSnapshot) {
339
+    private func reflectUnavailableMetricsInProgress(typeSummaries: [LegacySnapshotTypeSummary]) {
390 340
         guard let fetchProgress else { return }
391
-        for typeCount in snapshot.typeCounts ?? [] where typeCount.quality == .unauthorized {
392
-            fetchProgress.markUnavailable(typeCount.typeIdentifier)
341
+        for typeSummary in typeSummaries where typeSummary.quality == .unauthorized {
342
+            fetchProgress.markUnavailable(typeSummary.typeIdentifier)
393 343
         }
394 344
     }
395 345
 
396
-    private func shouldAutoSaveKnownUnauthorizedPartial(snapshot: HealthSnapshot, failedCount: Int) -> Bool {
397
-        guard failedCount == 0,
398
-              snapshot.previousSnapshotID != nil else {
399
-            return false
400
-        }
401
-
402
-        let currentUnauthorized = (snapshot.typeCounts ?? []).filter { $0.quality == .unauthorized }
403
-        guard !currentUnauthorized.isEmpty else { return false }
404
-
405
-        guard let previous = LegacySwiftDataBridge.previousSnapshot(for: snapshot) else {
406
-            return false
407
-        }
408
-
409
-        let previousByType = Dictionary(
410
-            uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
411
-        )
412
-
413
-        return currentUnauthorized.allSatisfy { previousByType[$0.typeIdentifier]?.quality == .unauthorized }
414
-    }
415
-
416
-    private func finishSavedReviewedSnapshot(_ snapshot: HealthSnapshot) {
417
-        completedSnapshotID = snapshot.id
418
-        completedSnapshotTimestamp = snapshot.timestamp
419
-        completedSnapshotDeviceID = snapshot.deviceID
420
-        completedSnapshotTriggerReason = snapshot.triggerReason
421
-        completedSnapshotRetryOfSnapshotID = snapshot.retryOfSnapshotID
422
-        pendingAmbiguousSnapshot = nil
423
-        pendingPartialSnapshot = nil
346
+    private func finishSavedReviewedSnapshot(_ outcome: LegacySnapshotOutcome) {
347
+        applyOutcome(outcome)
348
+        pendingAmbiguousSnapshotID = nil
349
+        pendingPartialSnapshotID = nil
424 350
         ambiguousDisappearedMetrics = []
425 351
         fetchProgress?.updateChainContext(
426
-            previousSnapshotID: snapshot.previousSnapshotID,
427
-            isChainStart: snapshot.isChainStart,
428
-            snapshotChecksum: HashService.snapshotChecksum(typeCounts: snapshot.typeCounts ?? []),
429
-            monitoredTypeSetHash: snapshot.monitoredTypeSetHash,
430
-            monitoredRegistryVersion: snapshot.monitoredRegistryVersion
352
+            previousSnapshotID: outcome.previousSnapshotID,
353
+            isChainStart: outcome.isChainStart,
354
+            snapshotChecksum: outcome.snapshotChecksum,
355
+            monitoredTypeSetHash: outcome.monitoredTypeSetHash,
356
+            monitoredRegistryVersion: outcome.monitoredRegistryVersion
431 357
         )
432 358
         fetchProgress = nil
433 359
         showProgressSheet = false
@@ -435,6 +361,14 @@ final class DashboardViewModel {
435 361
         refreshArchiveCache()
436 362
     }
437 363
 
364
+    private func applyOutcome(_ outcome: LegacySnapshotOutcome) {
365
+        completedSnapshotID = outcome.snapshotID
366
+        completedSnapshotTimestamp = outcome.timestamp
367
+        completedSnapshotDeviceID = outcome.deviceID
368
+        completedSnapshotTriggerReason = outcome.triggerReason
369
+        completedSnapshotRetryOfSnapshotID = outcome.retryOfSnapshotID
370
+    }
371
+
438 372
     private func refreshArchiveCache() {
439 373
         do {
440 374
             let cache = try CoreDataArchiveCacheStore()
@@ -451,12 +385,6 @@ final class DashboardViewModel {
451 385
     }
452 386
 }
453 387
 
454
-struct AmbiguousDisappearedMetric: Identifiable, Equatable {
455
-    let id: String
456
-    let displayName: String
457
-    let previousCount: Int
458
-}
459
-
460 388
 enum SnapshotProgress {
461 389
     case idle
462 390
     case fetching
@@ -483,14 +411,3 @@ enum SnapshotProgress {
483 411
         }
484 412
     }
485 413
 }
486
-
487
-enum SnapshotCreationError: Error {
488
-    case snapshotNotSaved
489
-
490
-    var message: String {
491
-        switch self {
492
-        case .snapshotNotSaved:
493
-            return "Snapshot was not saved to database. This may indicate a HealthKit permission issue or data corruption. Try requesting health access again in the Actions section."
494
-        }
495
-    }
496
-}