Showing 6 changed files with 439 additions and 299 deletions
+7 -7
HealthProbe/Doc/04-project/IMPLEMENTATION_STATUS.md
@@ -25,10 +25,10 @@ There are no real deployments, only test installations. Existing prototype datab
25 25
 |------|----------------|--------------------|
26 26
 | Product docs | Updated | Keep `HealthProbe/Doc/README.md` as canonical index |
27 27
 | HealthKit capture | Capture now opens one archive observation per user-visible snapshot and attaches HealthKit pages, deleted-object evidence, and type verification to that observation id before finishing it | Continue moving UI/cache reads to archive-backed observation ids |
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 | Move Snapshots/Data Types from SwiftData previews to archive/cache DTOs |
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 replacing remaining transition detail/PDF paths with 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
-| 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, 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; replace capture review actions and navigation handles before removing `ModelContainer` |
31
-| UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker. Dashboard status reads archive/cache observation rows and shows cache health, with SwiftData retained only for capture/review actions; Snapshots timeline reads archive/cache rows when available and no longer queries `SnapshotDelta` for list summaries; snapshot detail summaries/type rows require Core Data cache rows and no longer fall back to `SnapshotDelta`/`TypeDelta`; Data Types root no longer imports SwiftData and opens archive/cache-backed detail rows; data type detail reads Core Data type/diff summaries, uses SQLite `diffRecords` for paged drill-down, and no longer queries `SnapshotDelta`/`TypeDelta` or rebuilds legacy detail caches from the UI; 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, with already-existing SwiftData detail cache as transition fallback | Remove remaining SwiftData navigation handles |
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, 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; replace capture/review actions and transition detail/PDF paths before removing `ModelContainer` |
31
+| UI | Prototype exists; Dashboard status reads archive/cache observation rows and shows cache health, with SwiftData retained only for capture/review actions; 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; legacy `SnapshotDetailView`/`DataTypeSnapshotDetailView` remain as transition views outside the active tab roots; 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, with already-existing SwiftData detail cache as transition fallback | Remove or isolate remaining SwiftData transition detail/PDF paths |
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 | Continue moving remaining SwiftData fallback detail paths to 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. Move Snapshots/Data Types from SwiftData model reads to Core Data/cache DTOs.
41
+1. Remove or isolate remaining SwiftData transition detail/PDF paths.
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.
@@ -48,8 +48,8 @@ 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 18 SwiftData-backed files for launch container, capture review actions, Snapshots navigation handles, some transition detail paths, and PDF paths.
52
-- Snapshots timeline, snapshot detail summary/type rows, Data Types list/detail rows, and record drill-down are archive/cache-backed for new archive v2 observations when cache rows exist, but Snapshots navigation still uses SwiftData snapshot handles during the transition.
51
+- Current UI/cache layers still depend on 17 SwiftData-backed files for launch container, capture review actions, legacy transition detail paths, model definitions, and PDF paths.
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 can show diffs without archive-backed values; they are now 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.
55 55
 - Old prototype database compatibility is no longer required.
@@ -62,7 +62,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m
62 62
 - [ ] No recurring complete snapshot copies are written for high-volume types.
63 63
 - [x] SQL diff between two observations runs without loading full datasets into Swift arrays.
64 64
 - [x] Snapshots timeline rows use Core Data cached observation counts/change summaries when cache rows are available.
65
-- [x] Snapshot detail summary/type rows use Core Data cached summaries plus SQLite diff summaries when archive observation ids are available.
65
+- [x] Snapshot tab navigation/detail rows use Core Data cached summaries plus SQLite diff summaries when archive observation ids are available.
66 66
 - [x] Data Types list rows use Core Data cached counts plus SQLite diff summaries when archive observation ids are available.
67 67
 - [x] Data type new/missing drill-down pages records from SQLite diff queries when archive observation ids are available.
68 68
 - [x] Data type diff detail and evolution summaries prefer Core Data cache rows when archive observation ids are available.
+10 -8
HealthProbe/Doc/04-project/Refactoring-Plan.md
@@ -182,7 +182,9 @@ Checklist:
182 182
 - [x] Include archive schema/cache schema/version/hash fields on rebuilt rows.
183 183
 - [x] Implement delete-cache-and-rebuild flow.
184 184
 - [x] Add cache schema/version and rebuild tests.
185
-- [ ] Wire Core Data cache into UI-facing view models.
185
+- [ ] Wire Core Data cache into remaining UI-facing view models. Dashboard
186
+  status, Snapshots root/detail, Data Types root/detail, record-change
187
+  evolution, and temporal distribution now use cache/archive DTOs.
186 188
 - [ ] Add targeted partial invalidation for affected observation/type ranges.
187 189
 
188 190
 Acceptance:
@@ -224,13 +226,12 @@ Checklist:
224 226
 - [x] Dashboard status reads Core Data cached observation rows and cache health,
225 227
   with SwiftData retained only for capture/review actions.
226 228
 - [x] Observation timeline rows read Core Data cache when available and no
227
-  longer query `SnapshotDelta` list summaries, while retaining SwiftData handles
228
-  for detail navigation during transition.
229
-- [x] Observation detail uses cached summary/type rows plus SQLite diff
230
-  summaries and no longer falls back to legacy `SnapshotDelta`/`TypeDelta`
231
-  rows.
229
+  longer query `SnapshotDelta` list summaries.
230
+- [x] Observation root and archive detail use cached summary/type rows plus
231
+  SQLite diff summaries and no longer require SwiftData navigation handles.
232 232
 - [x] Data Types list rows use Core Data cached counts plus SQLite `diffSummary` and no longer fall back to SwiftData `TypeCount` traversal.
233 233
 - [x] Data Types root reads Core Data cached observation rows directly and no longer imports SwiftData.
234
+- [x] Snapshots root reads Core Data cached observation rows directly and no longer imports SwiftData.
234 235
 - [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.
235 236
 - [x] Remove unused legacy Type Evolution timeline and direct `HealthSnapshot.typeCounts` diff helper.
236 237
 - [x] Data type new/missing drill-down pages through SQLite `diffRecords` when archive observation ids exist.
@@ -259,8 +260,9 @@ Checklist:
259 260
   calibration, local device profile settings, and operation logging have been
260 261
   moved to local Codable stores and removed from `ModelContainer`; Settings
261 262
   data maintenance now uses the rebuildable Core Data cache; legacy
262
-  anomaly/count-drop review has been deleted; SwiftData snapshot/navigation
263
-  handles remain.
263
+  anomaly/count-drop review has been deleted; Snapshots/Data Types tab roots no
264
+  longer import SwiftData; Dashboard capture/review actions and legacy
265
+  transition detail/PDF paths remain.
264 266
 - [ ] Remove/disable `ModelContainer` as required for target builds.
265 267
 - [x] Add prototype-store ignore/delete/reset path for test installs.
266 268
 - [ ] Verify no old-store compatibility layer remains in active flows.
+10 -7
HealthProbe/Doc/04-project/SwiftData-Retirement-Inventory.md
@@ -9,9 +9,10 @@ local settings stored outside SwiftData where needed.
9 9
 
10 10
 ## Current Count
11 11
 
12
-After moving the Data Types root to archive/cache observations, 18 app files
13
-still have SwiftData imports because capture, Snapshots navigation, and legacy
14
-detail transition paths still use prototype snapshot handles.
12
+After moving the Snapshots and Data Types tab roots to archive/cache
13
+observations, 17 app files still have SwiftData imports because capture,
14
+Dashboard review actions, legacy detail transition paths, model definitions, and
15
+PDF/export transition paths still use prototype snapshot handles.
15 16
 
16 17
 ## Launch Container
17 18
 
@@ -70,11 +71,8 @@ types:
70 71
 - `HealthProbe/Views/Dashboard/DashboardView.swift`
71 72
 - `HealthProbe/Views/Snapshots/DataTypeSnapshotDetailView.swift`
72 73
 - `HealthProbe/Views/Snapshots/SnapshotDetailView.swift`
73
-- `HealthProbe/Views/Snapshots/SnapshotsView.swift`
74 74
 
75 75
 Retirement path:
76
-- replace tab-root `@Query` snapshot lists with Core Data cache observation
77
-  queries plus archive ids;
78 76
 - replace detail navigation parameters from SwiftData models to observation/type
79 77
   DTOs;
80 78
 - remove remaining snapshot/cache SwiftData rows from active flows;
@@ -127,6 +125,10 @@ The following SwiftData dependencies were removed from active flows:
127 125
   SwiftData or queries `HealthSnapshot`; it loads Core Data cached observation
128 126
   rows and opens `DataTypeArchiveDetailView`, an archive/cache-only detail view
129 127
   with paged SQLite new/missing record drill-down.
128
+- `HealthProbe/Views/Snapshots/SnapshotsView.swift` no longer imports
129
+  SwiftData or queries `HealthSnapshot`; it loads Core Data cached observation
130
+  rows and opens `SnapshotArchiveDetailView`, an archive/cache-only detail view
131
+  that feeds Data Type drill-down through observation ids and cached summaries.
130 132
 - `HealthProbe/Views/Snapshots/SnapshotDetailView.swift` no longer queries
131 133
   `SnapshotDelta`/`TypeDelta` or carries the old SwiftData type-delta/chart
132 134
   fallback. Snapshot detail type rows now require archive/cache summaries; the
@@ -150,4 +152,5 @@ The following SwiftData dependencies were removed from active flows:
150 152
 ## Next Recommended Slices
151 153
 
152 154
 1. Move `DashboardView` capture review actions away from `ModelContext`.
153
-2. Replace Snapshots/Data Types navigation handles with archive/cache DTOs.
155
+2. Delete or isolate unused SwiftData snapshot/type detail transition views once
156
+   PDF/export and any remaining preview paths have archive/cache replacements.
+29 -25
HealthProbe/ViewModels/SnapshotsViewModel.swift
@@ -24,7 +24,7 @@ enum ComparisonMode: Hashable {
24 24
 @Observable
25 25
 final class SnapshotsViewModel {
26 26
     var comparisonMode: ComparisonMode = .previous
27
-    var selectedBaseline: HealthSnapshot?
27
+    var selectedBaselineObservationID: Int64?
28 28
     var archiveRows: [CachedArchiveObservationRow]?
29 29
     var archiveRowsError: String?
30 30
 
@@ -41,49 +41,53 @@ final class SnapshotsViewModel {
41 41
         }
42 42
     }
43 43
 
44
-    func baselines(for snapshots: [HealthSnapshot]) -> [UUID: HealthSnapshot] {
45
-        let orderedDescending = snapshots.sorted { $0.timestamp > $1.timestamp }
44
+    func baselines(for rows: [CachedArchiveObservationRow]) -> [Int64: CachedArchiveObservationRow] {
45
+        let orderedDescending = rows.sorted { $0.observedAt > $1.observedAt }
46 46
 
47
-        return snapshots.reduce(into: [UUID: HealthSnapshot]()) { partial, snapshot in
48
-            partial[snapshot.id] = baseline(
49
-                for: snapshot,
50
-                in: snapshots,
47
+        return rows.reduce(into: [Int64: CachedArchiveObservationRow]()) { partial, row in
48
+            partial[row.observationID] = baseline(
49
+                for: row,
50
+                in: rows,
51 51
                 orderedDescending: orderedDescending
52 52
             )
53 53
         }
54 54
     }
55 55
 
56
-    func baseline(for snapshot: HealthSnapshot, in snapshots: [HealthSnapshot]) -> HealthSnapshot? {
56
+    func baseline(
57
+        for row: CachedArchiveObservationRow,
58
+        in rows: [CachedArchiveObservationRow]
59
+    ) -> CachedArchiveObservationRow? {
57 60
         baseline(
58
-            for: snapshot,
59
-            in: snapshots,
60
-            orderedDescending: snapshots.sorted { $0.timestamp > $1.timestamp }
61
+            for: row,
62
+            in: rows,
63
+            orderedDescending: rows.sorted { $0.observedAt > $1.observedAt }
61 64
         )
62 65
     }
63 66
 
64 67
     private func baseline(
65
-        for snapshot: HealthSnapshot,
66
-        in snapshots: [HealthSnapshot],
67
-        orderedDescending: [HealthSnapshot]
68
-    ) -> HealthSnapshot? {
68
+        for row: CachedArchiveObservationRow,
69
+        in rows: [CachedArchiveObservationRow],
70
+        orderedDescending: [CachedArchiveObservationRow]
71
+    ) -> CachedArchiveObservationRow? {
69 72
         switch comparisonMode {
70 73
         case .previous:
71
-            return orderedDescending.first { $0.timestamp < snapshot.timestamp }
74
+            return orderedDescending.first { $0.observedAt < row.observedAt }
72 75
         case .selected:
73
-            return selectedBaseline
76
+            guard let selectedBaselineObservationID else { return nil }
77
+            return rows.first { $0.observationID == selectedBaselineObservationID }
74 78
         case .relativeTime(let interval):
75
-            let target = snapshot.timestamp.addingTimeInterval(-interval)
76
-            return snapshots
77
-                .filter { $0.timestamp <= target }
78
-                .max { $0.timestamp < $1.timestamp }
79
+            let target = row.observedAt.addingTimeInterval(-interval)
80
+            return rows
81
+                .filter { $0.observedAt <= target }
82
+                .max { $0.observedAt < $1.observedAt }
79 83
         }
80 84
     }
81 85
 
82
-    func toggleBaseline(_ snapshot: HealthSnapshot) {
83
-        if selectedBaseline?.id == snapshot.id {
84
-            selectedBaseline = nil
86
+    func toggleBaseline(_ row: CachedArchiveObservationRow) {
87
+        if selectedBaselineObservationID == row.observationID {
88
+            selectedBaselineObservationID = nil
85 89
         } else {
86
-            selectedBaseline = snapshot
90
+            selectedBaselineObservationID = row.observationID
87 91
             comparisonMode = .selected
88 92
         }
89 93
     }
+305 -0
HealthProbe/Views/Snapshots/SnapshotArchiveDetailView.swift
@@ -0,0 +1,305 @@
1
+import SwiftUI
2
+
3
+struct SnapshotArchiveDetailView: View {
4
+    let row: CachedArchiveObservationRow
5
+    let baseline: CachedArchiveObservationRow?
6
+    let timelineRows: [CachedArchiveObservationRow]
7
+
8
+    @State private var typeRows: [SnapshotArchiveTypeSummaryRow] = []
9
+    @State private var loadError: String?
10
+
11
+    private var timelineContexts: [DataTypeSnapshotContext] {
12
+        timelineRows
13
+            .sorted { $0.observedAt > $1.observedAt }
14
+            .map {
15
+                DataTypeSnapshotContext(
16
+                    observationID: $0.observationID,
17
+                    observedAt: $0.observedAt
18
+                )
19
+            }
20
+    }
21
+
22
+    private var currentContext: DataTypeSnapshotContext {
23
+        DataTypeSnapshotContext(
24
+            observationID: row.observationID,
25
+            observedAt: row.observedAt
26
+        )
27
+    }
28
+
29
+    private var baselineContext: DataTypeSnapshotContext? {
30
+        baseline.map {
31
+            DataTypeSnapshotContext(
32
+                observationID: $0.observationID,
33
+                observedAt: $0.observedAt
34
+            )
35
+        }
36
+    }
37
+
38
+    private var dataRange: (earliest: Date?, latest: Date?) {
39
+        (
40
+            typeRows.compactMap(\.earliestStartDate).min(),
41
+            typeRows.compactMap(\.latestEndDate).max()
42
+        )
43
+    }
44
+
45
+    private var taskID: String {
46
+        "\(baseline?.observationID ?? -1)|\(row.observationID)"
47
+    }
48
+
49
+    var body: some View {
50
+        List {
51
+            summarySection
52
+            typeSection
53
+        }
54
+        .navigationTitle("Snapshot")
55
+        .navigationBarTitleDisplayMode(.inline)
56
+        .task(id: taskID) {
57
+            await loadTypeRows()
58
+        }
59
+    }
60
+
61
+    private var summarySection: some View {
62
+        Section {
63
+            DataTypeRangeIndicator(
64
+                earliestDate: dataRange.earliest,
65
+                latestDate: dataRange.latest,
66
+                quality: .complete
67
+            )
68
+            .listRowInsets(EdgeInsets())
69
+            .listRowBackground(Color.clear)
70
+
71
+            SnapshotArchiveSummaryRow(label: "Metrics", value: "\(row.trackedTypeCount)")
72
+            SnapshotArchiveSummaryRow(label: "Records", value: "\(row.visibleRecordCount)")
73
+
74
+            if let baseline {
75
+                SnapshotArchiveSummaryRow(
76
+                    label: "Baseline",
77
+                    value: baseline.observedAt.formatted(.dateTime.month().day().hour().minute())
78
+                )
79
+                SnapshotArchiveSummaryRow(
80
+                    label: "Record Changes",
81
+                    value: "\(row.appearedCount + row.disappearedCount + row.representationChangedCount)"
82
+                )
83
+            }
84
+
85
+            if let loadError {
86
+                Label(loadError, systemImage: "exclamationmark.triangle.fill")
87
+                    .font(.caption)
88
+                    .foregroundStyle(Color.warningAmber)
89
+            }
90
+        }
91
+    }
92
+
93
+    private var typeSection: some View {
94
+        Section("Data Types") {
95
+            if typeRows.isEmpty {
96
+                Text("No data types are available for this observation.")
97
+                    .foregroundStyle(.secondary)
98
+            } else {
99
+                ForEach(typeRows) { typeRow in
100
+                    if let baselineContext {
101
+                        NavigationLink {
102
+                            DataTypeArchiveDetailView(
103
+                                current: currentContext,
104
+                                baseline: baselineContext,
105
+                                timeline: timelineContexts,
106
+                                typeIdentifier: typeRow.typeIdentifier,
107
+                                displayName: typeRow.displayName,
108
+                                initialDiff: typeRow.typeDiff
109
+                            )
110
+                        } label: {
111
+                            SnapshotArchiveTypeSummaryRowView(row: typeRow, hasBaseline: true)
112
+                        }
113
+                    } else {
114
+                        SnapshotArchiveTypeSummaryRowView(row: typeRow, hasBaseline: false)
115
+                    }
116
+                }
117
+            }
118
+        }
119
+    }
120
+
121
+    @MainActor
122
+    private func loadTypeRows() async {
123
+        do {
124
+            let cache = try CoreDataArchiveCacheStore()
125
+            let currentSummaries = try cache.typeSummaries(observationID: row.observationID)
126
+            let previousSummaries: [CachedArchiveTypeSummary]
127
+            if let baseline {
128
+                previousSummaries = try cache.typeSummaries(observationID: baseline.observationID)
129
+            } else {
130
+                previousSummaries = []
131
+            }
132
+            let currentByType = Dictionary(uniqueKeysWithValues: currentSummaries.map { ($0.sampleTypeIdentifier, $0) })
133
+            let previousByType = Dictionary(uniqueKeysWithValues: previousSummaries.map { ($0.sampleTypeIdentifier, $0) })
134
+            let typeIdentifiers = Set(currentByType.keys).union(previousByType.keys)
135
+
136
+            var rows: [SnapshotArchiveTypeSummaryRow] = []
137
+            rows.reserveCapacity(typeIdentifiers.count)
138
+
139
+            for typeIdentifier in typeIdentifiers {
140
+                let summary = currentByType[typeIdentifier]
141
+                let previousSummary = previousByType[typeIdentifier]
142
+                let diffSummary: CachedArchiveDiffSummary?
143
+                if let baseline {
144
+                    diffSummary = try cache.diffSummary(
145
+                        fromObservationID: baseline.observationID,
146
+                        toObservationID: row.observationID,
147
+                        sampleTypeIdentifier: typeIdentifier
148
+                    )
149
+                } else {
150
+                    diffSummary = nil
151
+                }
152
+
153
+                rows.append(SnapshotArchiveTypeSummaryRow(
154
+                    typeIdentifier: typeIdentifier,
155
+                    displayName: summary?.displayName ?? previousSummary?.displayName ?? typeIdentifier,
156
+                    currentCount: summary?.visibleRecordCount ?? 0,
157
+                    previousCount: previousSummary?.visibleRecordCount,
158
+                    appearedCount: diffSummary?.appearedCount ?? summary?.appearedCount ?? 0,
159
+                    disappearedCount: diffSummary?.disappearedCount ?? summary?.disappearedCount ?? 0,
160
+                    representationChangedCount: diffSummary?.representationChangedCount ?? summary?.representationChangedCount ?? 0,
161
+                    earliestStartDate: summary?.earliestStartDate ?? previousSummary?.earliestStartDate,
162
+                    latestEndDate: summary?.latestEndDate ?? previousSummary?.latestEndDate
163
+                ))
164
+            }
165
+
166
+            typeRows = rows.sorted {
167
+                $0.displayName.localizedCompare($1.displayName) == .orderedAscending
168
+            }
169
+            loadError = nil
170
+        } catch {
171
+            typeRows = []
172
+            loadError = error.localizedDescription
173
+        }
174
+    }
175
+}
176
+
177
+private struct SnapshotArchiveSummaryRow: View {
178
+    let label: String
179
+    let value: String
180
+
181
+    var body: some View {
182
+        HStack {
183
+            Text(label)
184
+            Spacer()
185
+            Text(value)
186
+                .foregroundStyle(.secondary)
187
+                .monospacedDigit()
188
+        }
189
+    }
190
+}
191
+
192
+private struct SnapshotArchiveTypeSummaryRow: Identifiable {
193
+    let typeIdentifier: String
194
+    let displayName: String
195
+    let currentCount: Int
196
+    let previousCount: Int?
197
+    let appearedCount: Int
198
+    let disappearedCount: Int
199
+    let representationChangedCount: Int
200
+    let earliestStartDate: Date?
201
+    let latestEndDate: Date?
202
+
203
+    var id: String { typeIdentifier }
204
+
205
+    var currentDelta: Int {
206
+        guard let previousCount else { return currentCount }
207
+        return currentCount - previousCount
208
+    }
209
+
210
+    var recordChangeCount: Int {
211
+        appearedCount + disappearedCount + representationChangedCount
212
+    }
213
+
214
+    var hasChanges: Bool {
215
+        currentDelta != 0 || recordChangeCount > 0
216
+    }
217
+
218
+    var typeDiff: TypeDiff {
219
+        TypeDiff(
220
+            id: typeIdentifier,
221
+            typeIdentifier: typeIdentifier,
222
+            displayName: displayName,
223
+            currentCount: currentCount,
224
+            previousCount: previousCount ?? 0,
225
+            previousTracked: previousCount != nil,
226
+            appearedCount: appearedCount,
227
+            disappearedCount: disappearedCount,
228
+            representationChangedCount: representationChangedCount
229
+        )
230
+    }
231
+}
232
+
233
+private struct SnapshotArchiveTypeSummaryRowView: View {
234
+    let row: SnapshotArchiveTypeSummaryRow
235
+    let hasBaseline: Bool
236
+
237
+    private var changeLabel: String {
238
+        guard hasBaseline else { return "Stored" }
239
+        if row.disappearedCount > 0 { return "\(row.disappearedCount) missing" }
240
+        if row.appearedCount > 0 { return "\(row.appearedCount) new" }
241
+        if row.representationChangedCount > 0 { return "\(row.representationChangedCount) changed" }
242
+        if row.currentDelta != 0 {
243
+            let prefix = row.currentDelta > 0 ? "+" : ""
244
+            return "\(prefix)\(row.currentDelta) records"
245
+        }
246
+        return "No changes"
247
+    }
248
+
249
+    private var changeColor: Color {
250
+        guard hasBaseline else { return .secondary }
251
+        if row.disappearedCount > 0 { return .criticalRed }
252
+        if row.hasChanges { return .warningAmber }
253
+        return .secondary
254
+    }
255
+
256
+    var body: some View {
257
+        HStack(spacing: 12) {
258
+            VStack(alignment: .leading, spacing: 3) {
259
+                Text(row.displayName)
260
+                    .font(.subheadline)
261
+                Text(row.typeIdentifier)
262
+                    .font(.caption2)
263
+                    .foregroundStyle(.secondary)
264
+                    .lineLimit(1)
265
+                    .truncationMode(.middle)
266
+            }
267
+
268
+            Spacer()
269
+
270
+            VStack(alignment: .trailing, spacing: 4) {
271
+                Text("\(row.currentCount)")
272
+                    .font(.subheadline.monospacedDigit())
273
+                    .foregroundStyle(.primary)
274
+                Text(changeLabel)
275
+                    .font(.caption.weight(.semibold))
276
+                    .foregroundStyle(changeColor)
277
+            }
278
+        }
279
+        .accessibilityElement(children: .combine)
280
+    }
281
+}
282
+
283
+#Preview {
284
+    NavigationStack {
285
+        SnapshotArchiveDetailView(
286
+            row: CachedArchiveObservationRow(
287
+                observationID: 2,
288
+                observedAt: .now,
289
+                status: "completed",
290
+                triggerReason: "manual",
291
+                timeZoneIdentifier: nil,
292
+                trackedTypeCount: 12,
293
+                visibleRecordCount: 2000,
294
+                appearedCount: 40,
295
+                disappearedCount: 10,
296
+                representationChangedCount: 3,
297
+                archiveSchemaVersion: 2,
298
+                cacheSchemaVersion: 1,
299
+                computedAt: .now
300
+            ),
301
+            baseline: nil,
302
+            timelineRows: []
303
+        )
304
+    }
305
+}
+78 -252
HealthProbe/Views/Snapshots/SnapshotsView.swift
@@ -1,66 +1,27 @@
1 1
 import SwiftUI
2
-import SwiftData
3 2
 
4 3
 struct SnapshotsView: View {
5
-    @Environment(\.modelContext) private var modelContext
6
-    @Query(sort: \HealthSnapshot.timestamp, order: .reverse) private var allSnapshots: [HealthSnapshot]
7 4
     @State private var viewModel = SnapshotsViewModel()
8
-    @State private var profileMap: [String: LocalDeviceProfile] = [:]
9 5
 
10
-    private var displayedSnapshots: [HealthSnapshot] {
11
-        guard let deviceID = localDeviceID else { return [] }
12
-        return allSnapshots.filter { $0.deviceID == deviceID }
6
+    private var archiveRows: [CachedArchiveObservationRow] {
7
+        viewModel.archiveRows ?? []
13 8
     }
14 9
 
15 10
     private var hasTimelineRows: Bool {
16
-        !(viewModel.archiveRows?.isEmpty ?? true) || !displayedSnapshots.isEmpty
17
-    }
18
-
19
-    private var timelineReloadID: String {
20
-        [
21
-            String(allSnapshots.count),
22
-            allSnapshots.compactMap(\.archiveObservationID).map(String.init).joined(separator: ",")
23
-        ].joined(separator: "|")
11
+        !archiveRows.isEmpty
24 12
     }
25 13
 
26 14
     private var snapshotItems: [SnapshotListItem] {
27
-        let baselines = viewModel.baselines(for: displayedSnapshots)
28
-
29
-        if let archiveRows = viewModel.archiveRows {
30
-            let snapshotsByObservationID = Dictionary(uniqueKeysWithValues: displayedSnapshots.compactMap { snapshot in
31
-                snapshot.archiveObservationID.map { ($0, snapshot) }
32
-            })
33
-
34
-            return archiveRows.map { row in
35
-                let snapshot = snapshotsByObservationID[row.observationID]
36
-                return SnapshotListItem(
37
-                    snapshot: snapshot,
38
-                    baseline: snapshot.flatMap { baselines[$0.id] },
39
-                    archiveRow: row,
40
-                    showsDeltaSummary: viewModel.comparisonMode == .previous
41
-                )
42
-            }
43
-        }
44
-
45
-        return displayedSnapshots.map { snapshot in
15
+        let baselines = viewModel.baselines(for: archiveRows)
16
+        return archiveRows.map { row in
46 17
             SnapshotListItem(
47
-                snapshot: snapshot,
48
-                baseline: baselines[snapshot.id] ?? nil,
49
-                archiveRow: nil,
18
+                archiveRow: row,
19
+                baseline: baselines[row.observationID],
50 20
                 showsDeltaSummary: viewModel.comparisonMode == .previous
51 21
             )
52 22
         }
53 23
     }
54 24
 
55
-    private var localDeviceID: String? {
56
-        let currentID = AppSettings.currentDeviceID
57
-        if allSnapshots.contains(where: { $0.deviceID == currentID }) {
58
-            return currentID
59
-        }
60
-
61
-        return allSnapshots.first?.deviceID
62
-    }
63
-
64 25
     var body: some View {
65 26
         NavigationStack {
66 27
             Group {
@@ -76,72 +37,45 @@ struct SnapshotsView: View {
76 37
             }
77 38
             .navigationTitle("Snapshots")
78 39
             .toolbar { toolbarContent }
79
-            .task(id: timelineReloadID) {
80
-                loadDeviceProfiles()
40
+            .task {
81 41
                 await viewModel.loadArchiveRows()
82 42
             }
83 43
         }
84 44
     }
85 45
 
86
-    // MARK: - List
87
-
88 46
     private var snapshotList: some View {
89 47
         List(snapshotItems) { item in
90
-            if let snapshot = item.snapshot {
91
-                NavigationLink {
92
-                    SnapshotDetailView(
93
-                        snapshot: snapshot,
94
-                        baseline: item.baseline,
95
-                        profile: profileMap[snapshot.deviceID]
96
-                    )
97
-                } label: {
98
-                    SnapshotRow(
99
-                        snapshot: snapshot,
100
-                        baseline: item.baseline,
101
-                        archiveRow: item.archiveRow,
102
-                        showsDeltaSummary: item.showsDeltaSummary,
103
-                        isSelectedBaseline: viewModel.selectedBaseline?.id == snapshot.id,
104
-                        profile: profileMap[snapshot.deviceID]
105
-                    )
106
-                }
107
-                .swipeActions(edge: .leading) {
108
-                    Button {
109
-                        viewModel.toggleBaseline(snapshot)
110
-                        viewModel.comparisonMode = .selected
111
-                    } label: {
112
-                        Label(
113
-                            viewModel.selectedBaseline?.id == snapshot.id ? "Unset Baseline" : "Set as Baseline",
114
-                            systemImage: viewModel.selectedBaseline?.id == snapshot.id ? "pin.slash" : "pin"
115
-                        )
116
-                    }
117
-                    .tint(.indigo)
118
-                }
119
-                .swipeActions(edge: .trailing) {
120
-                    Button(role: .destructive) {
121
-                        do {
122
-                            try SnapshotLifecycleService.delete(snapshot, context: modelContext)
123
-                        } catch {
124
-                            // Keep the list responsive; delete failures can be retried.
125
-                        }
126
-                    } label: {
127
-                        Label("Delete", systemImage: "trash")
128
-                    }
129
-                }
130
-            } else {
131
-                SnapshotRow(
132
-                    snapshot: nil,
48
+            NavigationLink {
49
+                SnapshotArchiveDetailView(
50
+                    row: item.archiveRow,
133 51
                     baseline: item.baseline,
52
+                    timelineRows: archiveRows
53
+                )
54
+            } label: {
55
+                SnapshotRow(
134 56
                     archiveRow: item.archiveRow,
135 57
                     showsDeltaSummary: item.showsDeltaSummary,
136
-                    isSelectedBaseline: false,
137
-                    profile: nil
58
+                    isSelectedBaseline: viewModel.selectedBaselineObservationID == item.archiveRow.observationID
138 59
                 )
139 60
             }
61
+            .swipeActions(edge: .leading) {
62
+                Button {
63
+                    viewModel.toggleBaseline(item.archiveRow)
64
+                    viewModel.comparisonMode = .selected
65
+                } label: {
66
+                    Label(
67
+                        viewModel.selectedBaselineObservationID == item.archiveRow.observationID ? "Unset Baseline" : "Set as Baseline",
68
+                        systemImage: viewModel.selectedBaselineObservationID == item.archiveRow.observationID ? "pin.slash" : "pin"
69
+                    )
70
+                }
71
+                .tint(.indigo)
72
+            }
73
+        }
74
+        .refreshable {
75
+            await viewModel.loadArchiveRows()
140 76
         }
141 77
     }
142 78
 
143
-    // MARK: - Toolbar
144
-
145 79
     @ToolbarContentBuilder
146 80
     private var toolbarContent: some ToolbarContent {
147 81
         ToolbarItem(placement: .navigationBarTrailing) {
@@ -151,7 +85,7 @@ struct SnapshotsView: View {
151 85
                     ForEach(ComparisonMode.relativeOptions, id: \.interval) { opt in
152 86
                         Text(opt.label).tag(ComparisonMode.relativeTime(opt.interval))
153 87
                     }
154
-                    if viewModel.selectedBaseline != nil {
88
+                    if viewModel.selectedBaselineObservationID != nil {
155 89
                         Text("Selected Baseline").tag(ComparisonMode.selected)
156 90
                     }
157 91
                 }
@@ -161,129 +95,73 @@ struct SnapshotsView: View {
161 95
             }
162 96
         }
163 97
     }
164
-
165
-    private func loadDeviceProfiles() {
166
-        let profiles = LocalDeviceProfileStore.allProfiles()
167
-        profileMap = Dictionary(uniqueKeysWithValues: profiles.compactMap {
168
-            $0.deviceID.isEmpty ? nil : ($0.deviceID, $0)
169
-        })
170
-    }
171 98
 }
172 99
 
173 100
 private struct SnapshotListItem: Identifiable {
174
-    let snapshot: HealthSnapshot?
175
-    let baseline: HealthSnapshot?
176
-    let archiveRow: CachedArchiveObservationRow?
101
+    let archiveRow: CachedArchiveObservationRow
102
+    let baseline: CachedArchiveObservationRow?
177 103
     let showsDeltaSummary: Bool
178 104
 
179
-    var id: String {
180
-        if let archiveRow {
181
-            return "archive-\(archiveRow.observationID)"
182
-        }
183
-        return snapshot?.id.uuidString ?? "missing-snapshot-row"
105
+    var id: Int64 {
106
+        archiveRow.observationID
184 107
     }
185 108
 }
186 109
 
187
-// MARK: - Row
188
-
189 110
 private struct SnapshotRow: View {
190
-    let snapshot: HealthSnapshot?
191
-    let baseline: HealthSnapshot?
192
-    let archiveRow: CachedArchiveObservationRow?
111
+    let archiveRow: CachedArchiveObservationRow
193 112
     let showsDeltaSummary: Bool
194 113
     let isSelectedBaseline: Bool
195
-    let profile: LocalDeviceProfile?
196 114
 
197 115
     private static let dateFormatter: DateFormatter = {
198
-        let f = DateFormatter()
199
-        f.dateStyle = .medium
200
-        f.timeStyle = .short
201
-        return f
116
+        let formatter = DateFormatter()
117
+        formatter.dateStyle = .medium
118
+        formatter.timeStyle = .short
119
+        return formatter
202 120
     }()
203 121
 
204
-    private var observedAt: Date {
205
-        archiveRow?.observedAt ?? snapshot?.timestamp ?? Date(timeIntervalSince1970: 0)
206
-    }
207
-
208
-    private var deviceDisplayName: String {
209
-        if let name = profile?.name, !name.isEmpty { return name }
210
-        guard let snapshot else { return "Local archive" }
211
-        return snapshot.deviceName.isEmpty ? "Unknown device" : snapshot.deviceName
212
-    }
213
-
214
-    private var deviceColor: Color {
215
-        DeviceColor(rawValue: profile?.colorTag ?? "")?.color ?? .neutralGray
122
+    private var metricCountLabel: String {
123
+        archiveRow.trackedTypeCount == 1
124
+            ? "1 metric"
125
+            : "\(archiveRow.trackedTypeCount) metrics"
216 126
     }
217 127
 
218
-    private var metricCountLabel: String? {
219
-        if let archiveRow {
220
-            return archiveRow.trackedTypeCount == 1
221
-                ? "1 metric"
222
-                : "\(archiveRow.trackedTypeCount) metrics"
223
-        }
224
-
225
-        guard let snapshot else { return nil }
226
-        guard snapshot.hasCurrentCachedSummary else { return nil }
227
-        return snapshot.cachedTypeCount == 1 ? "1 metric" : "\(snapshot.cachedTypeCount) metrics"
228
-    }
229
-
230
-    private var recordCountLabel: String? {
231
-        guard let archiveRow else { return nil }
232
-        return archiveRow.visibleRecordCount == 1
128
+    private var recordCountLabel: String {
129
+        archiveRow.visibleRecordCount == 1
233 130
             ? "1 record"
234 131
             : "\(archiveRow.visibleRecordCount) records"
235 132
     }
236 133
 
237
-    private var deltaSummaryText: String? {
238
-        if let archiveRow {
239
-            let appeared = archiveRow.appearedCount
240
-            let disappeared = archiveRow.disappearedCount
241
-            let changed = archiveRow.representationChangedCount
242
-            let total = appeared + disappeared + changed
243
-            guard total > 0 else { return "No record changes" }
244
-
245
-            var parts: [String] = []
246
-            if appeared > 0 { parts.append("\(appeared) new") }
247
-            if disappeared > 0 { parts.append("\(disappeared) missing") }
248
-            if changed > 0 { parts.append("\(changed) changed") }
249
-            return parts.joined(separator: " • ")
250
-        }
134
+    private var deltaSummaryText: String {
135
+        let appeared = archiveRow.appearedCount
136
+        let disappeared = archiveRow.disappearedCount
137
+        let changed = archiveRow.representationChangedCount
138
+        let total = appeared + disappeared + changed
139
+        guard total > 0 else { return "No record changes" }
251 140
 
252
-        return nil
141
+        var parts: [String] = []
142
+        if appeared > 0 { parts.append("\(appeared) new") }
143
+        if disappeared > 0 { parts.append("\(disappeared) missing") }
144
+        if changed > 0 { parts.append("\(changed) changed") }
145
+        return parts.joined(separator: " • ")
253 146
     }
254 147
 
255 148
     private var deltaSummaryColor: Color {
256
-        if let archiveRow {
257
-            if archiveRow.disappearedCount > 0 { return Color.criticalRed }
258
-            if archiveRow.appearedCount + archiveRow.representationChangedCount > 0 { return Color.warningAmber }
259
-            return Color.healthyGreen
260
-        }
261
-
262
-        return .secondary
149
+        if archiveRow.disappearedCount > 0 { return Color.criticalRed }
150
+        if archiveRow.appearedCount + archiveRow.representationChangedCount > 0 { return Color.warningAmber }
151
+        return Color.healthyGreen
263 152
     }
264 153
 
265 154
     private var deltaSummaryIconName: String {
266
-        if let archiveRow {
267
-            let hasChanges = archiveRow.appearedCount
268
-                + archiveRow.disappearedCount
269
-                + archiveRow.representationChangedCount > 0
270
-            return hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle"
271
-        }
272
-
273
-        return "checkmark.circle"
274
-    }
275
-
276
-    private var hasOSVersionChange: Bool {
277
-        guard let snapshot, let baseline else { return false }
278
-        let currentVersion = snapshot.osVersion.trimmingCharacters(in: .whitespacesAndNewlines)
279
-        let baselineVersion = baseline.osVersion.trimmingCharacters(in: .whitespacesAndNewlines)
280
-        return !currentVersion.isEmpty && !baselineVersion.isEmpty && currentVersion != baselineVersion
155
+        let hasChanges = archiveRow.appearedCount
156
+            + archiveRow.disappearedCount
157
+            + archiveRow.representationChangedCount > 0
158
+        return hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle"
281 159
     }
282 160
 
283 161
     var body: some View {
284 162
         VStack(alignment: .leading, spacing: 4) {
285 163
             HStack {
286
-                Text(Self.dateFormatter.string(from: observedAt))
164
+                Text(Self.dateFormatter.string(from: archiveRow.observedAt))
287 165
                     .font(.subheadline.weight(.semibold))
288 166
                 Spacer()
289 167
                 if isSelectedBaseline {
@@ -296,40 +174,24 @@ private struct SnapshotRow: View {
296 174
 
297 175
             HStack(spacing: 6) {
298 176
                 Circle()
299
-                    .fill(deviceColor)
177
+                    .fill(Color.neutralGray)
300 178
                     .frame(width: 8, height: 8)
301
-                Text(deviceDisplayName)
179
+                Text("Local archive")
302 180
                     .font(.caption)
303 181
                     .foregroundStyle(.secondary)
304
-                if let metricCountLabel {
305
-                    Label(metricCountLabel, systemImage: "list.bullet.rectangle")
306
-                        .font(.caption)
307
-                        .foregroundStyle(.secondary)
308
-                }
309
-                if let recordCountLabel {
310
-                    Label(recordCountLabel, systemImage: "doc.text.magnifyingglass")
311
-                        .font(.caption)
312
-                        .foregroundStyle(.secondary)
313
-                }
314
-                if hasOSVersionChange {
315
-                    Label("OS \(snapshot?.osVersion ?? "")", systemImage: "gearshape.fill")
316
-                        .font(.caption)
317
-                        .foregroundStyle(Color.warningAmber)
318
-                        .accessibilityLabel("OS version changed to \(snapshot?.osVersion ?? "")")
319
-                }
320
-            }
321
-
322
-            // Chain indicators
323
-            chainIndicators
324
-
325
-            if let snapshot, snapshot.snapshotQuality != SnapshotQuality.complete {
326
-                Label("Incomplete snapshot", systemImage: "exclamationmark.triangle")
182
+                Label(metricCountLabel, systemImage: "list.bullet.rectangle")
327 183
                     .font(.caption)
328
-                    .foregroundStyle(Color.warningAmber)
184
+                    .foregroundStyle(.secondary)
185
+                Label(recordCountLabel, systemImage: "doc.text.magnifyingglass")
186
+                    .font(.caption)
187
+                    .foregroundStyle(.secondary)
329 188
             }
330 189
 
331
-            if showsDeltaSummary,
332
-               let deltaSummaryText {
190
+            Label("Archive observation \(archiveRow.observationID)", systemImage: "externaldrive")
191
+                .font(.caption)
192
+                .foregroundStyle(.secondary)
193
+
194
+            if showsDeltaSummary {
333 195
                 HStack(spacing: 4) {
334 196
                     Image(systemName: deltaSummaryIconName)
335 197
                     Text(deltaSummaryText)
@@ -341,45 +203,9 @@ private struct SnapshotRow: View {
341 203
         .padding(.vertical, 2)
342 204
         .accessibilityElement(children: .combine)
343 205
     }
344
-
345
-    @ViewBuilder
346
-    private var chainIndicators: some View {
347
-        if let archiveRow, snapshot == nil {
348
-            Label("Archive observation \(archiveRow.observationID)", systemImage: "externaldrive")
349
-                .font(.caption)
350
-                .foregroundStyle(.secondary)
351
-        }
352
-
353
-        if let snapshot {
354
-            if snapshot.isChainStart && snapshot.recoveredDeviceID {
355
-                Label("DB reset / recovered device ID", systemImage: "arrow.clockwise.icloud")
356
-                    .font(.caption)
357
-                    .foregroundStyle(.secondary)
358
-            } else if snapshot.isChainStart {
359
-                Label("Chain start", systemImage: "link.badge.plus")
360
-                    .font(.caption)
361
-                    .foregroundStyle(.secondary)
362
-            }
363
-            if snapshot.isPostRestore && !snapshot.isPostRestoreInferred {
364
-                Label("Post-restore baseline", systemImage: "clock.arrow.circlepath")
365
-                    .font(.caption)
366
-                    .foregroundStyle(.secondary)
367
-            } else if snapshot.isPostRestore && snapshot.isPostRestoreInferred {
368
-                Label("Post-restore baseline (inferred)", systemImage: "clock.arrow.circlepath")
369
-                    .font(.caption)
370
-                    .foregroundStyle(.secondary)
371
-            }
372
-            if snapshot.triggerReason == "observerCallback" {
373
-                Label("Observer-triggered snapshot", systemImage: "waveform")
374
-                    .font(.caption)
375
-                    .foregroundStyle(.secondary)
376
-            }
377
-        }
378
-    }
379 206
 }
380 207
 
381 208
 #Preview {
382 209
     SnapshotsView()
383
-    .modelContainer(for: [HealthSnapshot.self, TypeCount.self, HealthRecord.self, TypeDistributionBin.self], inMemory: true)
384 210
         .environment(AppSettings())
385 211
 }