Showing 4 changed files with 201 additions and 69 deletions
+4 -3
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 | Move Snapshots/Data Types from SwiftData previews to archive/cache DTOs |
29 29
 | Core Data cache | Initial programmatic Core Data model, full-cache rebuild service, read DTOs for observation/type/health rows, and Dashboard archive-cache status wiring are in place | Move Snapshots/Data Types 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 | Treat as disposable prototype data; reset/ignore during v2 transition |
31
-| UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker. Data Types list prefers Core Data type summaries plus SQLite `diffSummary` when archive observation ids exist, and data type detail/drill-down uses SQLite `diffSummary`/`diffRecords`, with SwiftData detail cache as transition fallback | Move observation timeline fully to Core Data cache DTOs |
31
+| UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker. Snapshots timeline and Data Types list prefer Core Data cache rows when archive observation ids exist; data type detail/drill-down uses SQLite `diffSummary`/`diffRecords`, with SwiftData detail cache as transition fallback | Move observation/detail screens to cached summaries plus paged SQLite DTOs |
32 32
 | Diff/change explanation | Prototype/legacy anomaly logic exists | Move heavy diffing into SQLite and use neutral change classifications |
33 33
 | Export | Prototype scoped JSON export exists | Add recovery-compatible manifests and streaming/paged export |
34 34
 | Legacy device support | Not implemented | Remove SwiftData dependency and simplify heavy views for low-memory devices |
@@ -49,8 +49,8 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m
49 49
 - SwiftData currently blocks iOS 15-era device support.
50 50
 - Existing `Anomaly*` model/service names are legacy language.
51 51
 - Some screens still imply snapshot-count monitoring rather than Time Machine inspection.
52
-- Current UI/cache layers still depend on SwiftData prototype models.
53
-- Data Types list rows and record drill-down are archive-backed for new archive v2 observations when cache rows exist, but the observation timeline still begins from SwiftData snapshots.
52
+- Current UI/cache layers still depend on SwiftData prototype models for navigation handles and detail screens.
53
+- Snapshots timeline, Data Types list rows, and record drill-down are archive/cache-backed for new archive v2 observations when cache rows exist, but observation/detail navigation still uses SwiftData snapshot handles during the transition.
54 54
 - Legacy SwiftData-only snapshots can show diffs without archive-backed values; they are now reset for archive v2 test installs rather than migrated.
55 55
 - Existing implementation may decode or cache too much data for low-end devices.
56 56
 - Old prototype database compatibility is no longer required.
@@ -62,6 +62,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m
62 62
 - [x] SQLite archive v2 can reconstruct records visible at observation T.
63 63
 - [ ] No recurring complete snapshot copies are written for high-volume types.
64 64
 - [x] SQL diff between two observations runs without loading full datasets into Swift arrays.
65
+- [x] Snapshots timeline rows use Core Data cached observation counts/change summaries when cache rows are available.
65 66
 - [x] Data Types list rows use Core Data cached counts plus SQLite diff summaries when archive observation ids are available.
66 67
 - [x] Data type added/disappeared drill-down pages records from SQLite diff queries when archive observation ids are available.
67 68
 - [x] Expensive counts used by reports/UI are cached and rebuildable.
+1 -1
HealthProbe/Doc/04-project/Refactoring-Plan.md
@@ -222,7 +222,7 @@ Acceptance:
222 222
 Checklist:
223 223
 - [ ] Replace direct SwiftData `@Query` dependencies for target screens.
224 224
 - [ ] Dashboard reads Core Data cache.
225
-- [ ] Observation timeline reads Core Data cache.
225
+- [x] Observation timeline rows read Core Data cache when available, while retaining SwiftData handles for detail navigation during transition.
226 226
 - [ ] Observation detail uses cached summaries plus paged SQLite DTOs.
227 227
 - [x] Data Types list rows prefer Core Data cached counts plus SQLite `diffSummary` when archive observation ids exist.
228 228
 - [x] Data type detail uses SQLite `diffSummary` when archive observation ids exist.
+15 -0
HealthProbe/ViewModels/SnapshotsViewModel.swift
@@ -25,6 +25,21 @@ enum ComparisonMode: Hashable {
25 25
 final class SnapshotsViewModel {
26 26
     var comparisonMode: ComparisonMode = .previous
27 27
     var selectedBaseline: HealthSnapshot?
28
+    var archiveRows: [CachedArchiveObservationRow]?
29
+    var archiveRowsError: String?
30
+
31
+    @MainActor
32
+    func loadArchiveRows(limit: Int = 200) async {
33
+        do {
34
+            let cache = try CoreDataArchiveCacheStore()
35
+            let rows = try cache.observationRows(limit: limit)
36
+            archiveRows = rows.isEmpty ? nil : rows
37
+            archiveRowsError = nil
38
+        } catch {
39
+            archiveRows = nil
40
+            archiveRowsError = error.localizedDescription
41
+        }
42
+    }
28 43
 
29 44
     func baselines(for snapshots: [HealthSnapshot]) -> [UUID: HealthSnapshot] {
30 45
         let orderedDescending = snapshots.sorted { $0.timestamp > $1.timestamp }
+181 -65
HealthProbe/Views/Snapshots/SnapshotsView.swift
@@ -19,16 +19,46 @@ struct SnapshotsView: View {
19 19
         return allSnapshots.filter { $0.deviceID == deviceID }
20 20
     }
21 21
 
22
+    private var hasTimelineRows: Bool {
23
+        !(viewModel.archiveRows?.isEmpty ?? true) || !displayedSnapshots.isEmpty
24
+    }
25
+
26
+    private var timelineReloadID: String {
27
+        [
28
+            String(allSnapshots.count),
29
+            allSnapshots.compactMap(\.archiveObservationID).map(String.init).joined(separator: ","),
30
+            String(allDeltas.count)
31
+        ].joined(separator: "|")
32
+    }
33
+
22 34
     private var snapshotItems: [SnapshotListItem] {
23 35
         let baselines = viewModel.baselines(for: displayedSnapshots)
24 36
         let deltaSummaryBySnapshotID = allDeltas.reduce(into: [UUID: SnapshotDeltaListSummary]()) { partial, delta in
25 37
             partial[delta.toSnapshotID] = delta.listSummary
26 38
         }
27 39
 
40
+        if let archiveRows = viewModel.archiveRows {
41
+            let snapshotsByObservationID = Dictionary(uniqueKeysWithValues: displayedSnapshots.compactMap { snapshot in
42
+                snapshot.archiveObservationID.map { ($0, snapshot) }
43
+            })
44
+
45
+            return archiveRows.map { row in
46
+                let snapshot = snapshotsByObservationID[row.observationID]
47
+                return SnapshotListItem(
48
+                    snapshot: snapshot,
49
+                    baseline: snapshot.flatMap { baselines[$0.id] },
50
+                    archiveRow: row,
51
+                    deltaSummary: snapshot.flatMap { deltaSummaryBySnapshotID[$0.id] },
52
+                    showsDeltaSummary: viewModel.comparisonMode == .previous
53
+                )
54
+            }
55
+        }
56
+
28 57
         return displayedSnapshots.map { snapshot in
29 58
             SnapshotListItem(
30 59
                 snapshot: snapshot,
31 60
                 baseline: baselines[snapshot.id] ?? nil,
61
+                archiveRow: nil,
32 62
                 deltaSummary: deltaSummaryBySnapshotID[snapshot.id],
33 63
                 showsDeltaSummary: viewModel.comparisonMode == .previous
34 64
             )
@@ -47,7 +77,7 @@ struct SnapshotsView: View {
47 77
     var body: some View {
48 78
         NavigationStack {
49 79
             Group {
50
-                if displayedSnapshots.isEmpty {
80
+                if !hasTimelineRows {
51 81
                     EmptyStateView(
52 82
                         icon: "clock.arrow.circlepath",
53 83
                         title: "No Snapshots",
@@ -62,6 +92,9 @@ struct SnapshotsView: View {
62 92
             .task(id: allDeltas.count) {
63 93
                 repairDeltaListSummariesIfNeeded()
64 94
             }
95
+            .task(id: timelineReloadID) {
96
+                await viewModel.loadArchiveRows()
97
+            }
65 98
         }
66 99
     }
67 100
 
@@ -69,44 +102,57 @@ struct SnapshotsView: View {
69 102
 
70 103
     private var snapshotList: some View {
71 104
         List(snapshotItems) { item in
72
-            NavigationLink {
73
-                SnapshotDetailView(
74
-                    snapshot: item.snapshot,
75
-                    baseline: item.baseline,
76
-                    profile: profileMap[item.snapshot.deviceID]
77
-                )
78
-            } label: {
79
-                SnapshotRow(
80
-                    snapshot: item.snapshot,
81
-                    baseline: item.baseline,
82
-                    deltaSummary: item.deltaSummary,
83
-                    showsDeltaSummary: item.showsDeltaSummary,
84
-                    isSelectedBaseline: viewModel.selectedBaseline?.id == item.snapshot.id,
85
-                    profile: profileMap[item.snapshot.deviceID]
86
-                )
87
-            }
88
-            .swipeActions(edge: .leading) {
89
-                Button {
90
-                    viewModel.toggleBaseline(item.snapshot)
91
-                    viewModel.comparisonMode = .selected
105
+            if let snapshot = item.snapshot {
106
+                NavigationLink {
107
+                    SnapshotDetailView(
108
+                        snapshot: snapshot,
109
+                        baseline: item.baseline,
110
+                        profile: profileMap[snapshot.deviceID]
111
+                    )
92 112
                 } label: {
93
-                    Label(
94
-                        viewModel.selectedBaseline?.id == item.snapshot.id ? "Unset Baseline" : "Set as Baseline",
95
-                        systemImage: viewModel.selectedBaseline?.id == item.snapshot.id ? "pin.slash" : "pin"
113
+                    SnapshotRow(
114
+                        snapshot: snapshot,
115
+                        baseline: item.baseline,
116
+                        archiveRow: item.archiveRow,
117
+                        deltaSummary: item.deltaSummary,
118
+                        showsDeltaSummary: item.showsDeltaSummary,
119
+                        isSelectedBaseline: viewModel.selectedBaseline?.id == snapshot.id,
120
+                        profile: profileMap[snapshot.deviceID]
96 121
                     )
97 122
                 }
98
-                .tint(.indigo)
99
-            }
100
-            .swipeActions(edge: .trailing) {
101
-                Button(role: .destructive) {
102
-                    do {
103
-                        try SnapshotLifecycleService.delete(item.snapshot, context: modelContext)
104
-                    } catch {
105
-                        // Failure is surfaced via the navigation stack; no silent discard
123
+                .swipeActions(edge: .leading) {
124
+                    Button {
125
+                        viewModel.toggleBaseline(snapshot)
126
+                        viewModel.comparisonMode = .selected
127
+                    } label: {
128
+                        Label(
129
+                            viewModel.selectedBaseline?.id == snapshot.id ? "Unset Baseline" : "Set as Baseline",
130
+                            systemImage: viewModel.selectedBaseline?.id == snapshot.id ? "pin.slash" : "pin"
131
+                        )
132
+                    }
133
+                    .tint(.indigo)
134
+                }
135
+                .swipeActions(edge: .trailing) {
136
+                    Button(role: .destructive) {
137
+                        do {
138
+                            try SnapshotLifecycleService.delete(snapshot, context: modelContext)
139
+                        } catch {
140
+                            // Keep the list responsive; delete failures can be retried.
141
+                        }
142
+                    } label: {
143
+                        Label("Delete", systemImage: "trash")
106 144
                     }
107
-                } label: {
108
-                    Label("Delete", systemImage: "trash")
109 145
                 }
146
+            } else {
147
+                SnapshotRow(
148
+                    snapshot: nil,
149
+                    baseline: item.baseline,
150
+                    archiveRow: item.archiveRow,
151
+                    deltaSummary: item.deltaSummary,
152
+                    showsDeltaSummary: item.showsDeltaSummary,
153
+                    isSelectedBaseline: false,
154
+                    profile: nil
155
+                )
110 156
             }
111 157
         }
112 158
     }
@@ -143,19 +189,26 @@ struct SnapshotsView: View {
143 189
 }
144 190
 
145 191
 private struct SnapshotListItem: Identifiable {
146
-    let snapshot: HealthSnapshot
192
+    let snapshot: HealthSnapshot?
147 193
     let baseline: HealthSnapshot?
194
+    let archiveRow: CachedArchiveObservationRow?
148 195
     let deltaSummary: SnapshotDeltaListSummary?
149 196
     let showsDeltaSummary: Bool
150 197
 
151
-    var id: UUID { snapshot.id }
198
+    var id: String {
199
+        if let archiveRow {
200
+            return "archive-\(archiveRow.observationID)"
201
+        }
202
+        return snapshot?.id.uuidString ?? "missing-snapshot-row"
203
+    }
152 204
 }
153 205
 
154 206
 // MARK: - Row
155 207
 
156 208
 private struct SnapshotRow: View {
157
-    let snapshot: HealthSnapshot
209
+    let snapshot: HealthSnapshot?
158 210
     let baseline: HealthSnapshot?
211
+    let archiveRow: CachedArchiveObservationRow?
159 212
     let deltaSummary: SnapshotDeltaListSummary?
160 213
     let showsDeltaSummary: Bool
161 214
     let isSelectedBaseline: Bool
@@ -168,8 +221,13 @@ private struct SnapshotRow: View {
168 221
         return f
169 222
     }()
170 223
 
224
+    private var observedAt: Date {
225
+        archiveRow?.observedAt ?? snapshot?.timestamp ?? Date(timeIntervalSince1970: 0)
226
+    }
227
+
171 228
     private var deviceDisplayName: String {
172 229
         if let name = profile?.name, !name.isEmpty { return name }
230
+        guard let snapshot else { return "Local archive" }
173 231
         return snapshot.deviceName.isEmpty ? "Unknown device" : snapshot.deviceName
174 232
     }
175 233
 
@@ -178,11 +236,39 @@ private struct SnapshotRow: View {
178 236
     }
179 237
 
180 238
     private var metricCountLabel: String? {
239
+        if let archiveRow {
240
+            return archiveRow.trackedTypeCount == 1
241
+                ? "1 metric"
242
+                : "\(archiveRow.trackedTypeCount) metrics"
243
+        }
244
+
245
+        guard let snapshot else { return nil }
181 246
         guard snapshot.hasCurrentCachedSummary else { return nil }
182 247
         return snapshot.cachedTypeCount == 1 ? "1 metric" : "\(snapshot.cachedTypeCount) metrics"
183 248
     }
184 249
 
250
+    private var recordCountLabel: String? {
251
+        guard let archiveRow else { return nil }
252
+        return archiveRow.visibleRecordCount == 1
253
+            ? "1 record"
254
+            : "\(archiveRow.visibleRecordCount) records"
255
+    }
256
+
185 257
     private var deltaSummaryText: String? {
258
+        if let archiveRow {
259
+            let appeared = archiveRow.appearedCount
260
+            let disappeared = archiveRow.disappearedCount
261
+            let changed = archiveRow.representationChangedCount
262
+            let total = appeared + disappeared + changed
263
+            guard total > 0 else { return "No record changes" }
264
+
265
+            var parts: [String] = []
266
+            if appeared > 0 { parts.append("\(appeared) added") }
267
+            if disappeared > 0 { parts.append("\(disappeared) disappeared") }
268
+            if changed > 0 { parts.append("\(changed) changed") }
269
+            return parts.joined(separator: " • ")
270
+        }
271
+
186 272
         guard let deltaSummary else { return nil }
187 273
 
188 274
         var parts: [String] = []
@@ -211,14 +297,33 @@ private struct SnapshotRow: View {
211 297
     }
212 298
 
213 299
     private var deltaSummaryColor: Color {
300
+        if let archiveRow {
301
+            if archiveRow.disappearedCount > 0 { return Color.criticalRed }
302
+            if archiveRow.appearedCount + archiveRow.representationChangedCount > 0 { return Color.warningAmber }
303
+            return Color.healthyGreen
304
+        }
305
+
214 306
         guard let deltaSummary else { return .secondary }
215 307
         if deltaSummary.disappearedMetricCount > 0 { return Color.criticalRed }
216 308
         if deltaSummary.hasChanges { return Color.warningAmber }
217 309
         return Color.healthyGreen
218 310
     }
219 311
 
312
+    private var deltaSummaryIconName: String {
313
+        if let archiveRow {
314
+            let hasChanges = archiveRow.appearedCount
315
+                + archiveRow.disappearedCount
316
+                + archiveRow.representationChangedCount > 0
317
+            return hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle"
318
+        }
319
+
320
+        return deltaSummary == nil || deltaSummary?.hasChanges == false
321
+            ? "checkmark.circle"
322
+            : "arrow.triangle.2.circlepath"
323
+    }
324
+
220 325
     private var hasOSVersionChange: Bool {
221
-        guard let baseline else { return false }
326
+        guard let snapshot, let baseline else { return false }
222 327
         let currentVersion = snapshot.osVersion.trimmingCharacters(in: .whitespacesAndNewlines)
223 328
         let baselineVersion = baseline.osVersion.trimmingCharacters(in: .whitespacesAndNewlines)
224 329
         return !currentVersion.isEmpty && !baselineVersion.isEmpty && currentVersion != baselineVersion
@@ -227,7 +332,7 @@ private struct SnapshotRow: View {
227 332
     var body: some View {
228 333
         VStack(alignment: .leading, spacing: 4) {
229 334
             HStack {
230
-                Text(Self.dateFormatter.string(from: snapshot.timestamp))
335
+                Text(Self.dateFormatter.string(from: observedAt))
231 336
                     .font(.subheadline.weight(.semibold))
232 337
                 Spacer()
233 338
                 if isSelectedBaseline {
@@ -236,7 +341,7 @@ private struct SnapshotRow: View {
236 341
                         .font(.caption)
237 342
                         .accessibilityLabel("Selected as comparison baseline")
238 343
                 }
239
-                if !snapshot.anomalyFlags.isEmpty {
344
+                if let snapshot, !snapshot.anomalyFlags.isEmpty {
240 345
                     Image(systemName: "exclamationmark.triangle.fill")
241 346
                         .foregroundStyle(Color.warningAmber)
242 347
                         .font(.caption)
@@ -256,18 +361,23 @@ private struct SnapshotRow: View {
256 361
                         .font(.caption)
257 362
                         .foregroundStyle(.secondary)
258 363
                 }
364
+                if let recordCountLabel {
365
+                    Label(recordCountLabel, systemImage: "doc.text.magnifyingglass")
366
+                        .font(.caption)
367
+                        .foregroundStyle(.secondary)
368
+                }
259 369
                 if hasOSVersionChange {
260
-                    Label("OS \(snapshot.osVersion)", systemImage: "gearshape.fill")
370
+                    Label("OS \(snapshot?.osVersion ?? "")", systemImage: "gearshape.fill")
261 371
                         .font(.caption)
262 372
                         .foregroundStyle(Color.warningAmber)
263
-                        .accessibilityLabel("OS version changed to \(snapshot.osVersion)")
373
+                        .accessibilityLabel("OS version changed to \(snapshot?.osVersion ?? "")")
264 374
                 }
265 375
             }
266 376
 
267 377
             // Chain indicators
268 378
             chainIndicators
269 379
 
270
-            if snapshot.snapshotQuality != SnapshotQuality.complete {
380
+            if let snapshot, snapshot.snapshotQuality != SnapshotQuality.complete {
271 381
                 Label("Incomplete snapshot", systemImage: "exclamationmark.triangle")
272 382
                     .font(.caption)
273 383
                     .foregroundStyle(Color.warningAmber)
@@ -276,9 +386,7 @@ private struct SnapshotRow: View {
276 386
             if showsDeltaSummary,
277 387
                let deltaSummaryText {
278 388
                 HStack(spacing: 4) {
279
-                    Image(systemName: deltaSummary == nil || deltaSummary?.hasChanges == false
280
-                          ? "checkmark.circle"
281
-                          : "arrow.triangle.2.circlepath")
389
+                    Image(systemName: deltaSummaryIconName)
282 390
                     Text(deltaSummaryText)
283 391
                 }
284 392
                 .font(.caption)
@@ -291,28 +399,36 @@ private struct SnapshotRow: View {
291 399
 
292 400
     @ViewBuilder
293 401
     private var chainIndicators: some View {
294
-        if snapshot.isChainStart && snapshot.recoveredDeviceID {
295
-            Label("DB reset / recovered device ID", systemImage: "arrow.clockwise.icloud")
296
-                .font(.caption)
297
-                .foregroundStyle(.secondary)
298
-        } else if snapshot.isChainStart {
299
-            Label("Chain start", systemImage: "link.badge.plus")
402
+        if let archiveRow, snapshot == nil {
403
+            Label("Archive observation \(archiveRow.observationID)", systemImage: "externaldrive")
300 404
                 .font(.caption)
301 405
                 .foregroundStyle(.secondary)
302 406
         }
303
-        if snapshot.isPostRestore && !snapshot.isPostRestoreInferred {
304
-            Label("Post-restore baseline", systemImage: "clock.arrow.circlepath")
305
-                .font(.caption)
306
-                .foregroundStyle(.secondary)
307
-        } else if snapshot.isPostRestore && snapshot.isPostRestoreInferred {
308
-            Label("Post-restore baseline (inferred)", systemImage: "clock.arrow.circlepath")
309
-                .font(.caption)
310
-                .foregroundStyle(.secondary)
311
-        }
312
-        if snapshot.triggerReason == "observerCallback" {
313
-            Label("Observer-triggered snapshot", systemImage: "waveform")
314
-                .font(.caption)
315
-                .foregroundStyle(.secondary)
407
+
408
+        if let snapshot {
409
+            if snapshot.isChainStart && snapshot.recoveredDeviceID {
410
+                Label("DB reset / recovered device ID", systemImage: "arrow.clockwise.icloud")
411
+                    .font(.caption)
412
+                    .foregroundStyle(.secondary)
413
+            } else if snapshot.isChainStart {
414
+                Label("Chain start", systemImage: "link.badge.plus")
415
+                    .font(.caption)
416
+                    .foregroundStyle(.secondary)
417
+            }
418
+            if snapshot.isPostRestore && !snapshot.isPostRestoreInferred {
419
+                Label("Post-restore baseline", systemImage: "clock.arrow.circlepath")
420
+                    .font(.caption)
421
+                    .foregroundStyle(.secondary)
422
+            } else if snapshot.isPostRestore && snapshot.isPostRestoreInferred {
423
+                Label("Post-restore baseline (inferred)", systemImage: "clock.arrow.circlepath")
424
+                    .font(.caption)
425
+                    .foregroundStyle(.secondary)
426
+            }
427
+            if snapshot.triggerReason == "observerCallback" {
428
+                Label("Observer-triggered snapshot", systemImage: "waveform")
429
+                    .font(.caption)
430
+                    .foregroundStyle(.secondary)
431
+            }
316 432
         }
317 433
     }
318 434
 }