Showing 4 changed files with 59 additions and 19 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 | 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. Dashboard shows archive/cache health; Snapshots timeline, snapshot detail summaries/type rows, 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 | Finish remaining detail charts/export previews on paged SQLite DTOs |
31
+| UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker. Dashboard status prefers archive/cache observation rows and shows cache health; Snapshots timeline, snapshot detail summaries/type rows, 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 | Finish remaining detail charts/export previews on 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,7 +49,7 @@ 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 for navigation handles, some charts, and export/PDF paths.
52
+- Current UI/cache layers still depend on SwiftData prototype models for capture review actions, navigation handles, some charts, and export/PDF paths.
53 53
 - Snapshots timeline, snapshot detail summary/type rows, Data Types list rows, and record drill-down are archive/cache-backed for new archive v2 observations when cache rows exist, but 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.
+1 -1
HealthProbe/Doc/04-project/Refactoring-Plan.md
@@ -221,7 +221,7 @@ Acceptance:
221 221
 
222 222
 Checklist:
223 223
 - [ ] Replace direct SwiftData `@Query` dependencies for target screens.
224
-- [ ] Dashboard reads Core Data cache.
224
+- [x] Dashboard status reads Core Data cached observation rows and cache health, with SwiftData retained for capture review actions.
225 225
 - [x] Observation timeline rows read Core Data cache when available, while retaining SwiftData handles for detail navigation during transition.
226 226
 - [x] Observation detail uses cached summary/type rows plus SQLite diff summaries when archive observation ids exist.
227 227
 - [x] Data Types list rows prefer Core Data cached counts plus SQLite `diffSummary` when archive observation ids exist.
+8 -2
HealthProbe/ViewModels/DashboardViewModel.swift
@@ -24,6 +24,7 @@ final class DashboardViewModel {
24 24
     var completedSnapshotRetryOfSnapshotID: UUID? = nil
25 25
     var ambiguousDisappearedMetrics: [AmbiguousDisappearedMetric] = []
26 26
     var latestArchiveObservation: CachedArchiveObservationRow?
27
+    var archiveObservationRows: [CachedArchiveObservationRow] = []
27 28
     var archiveHealthStatus: CachedArchiveHealthStatus?
28 29
     var archiveCacheError: String?
29 30
 
@@ -357,10 +358,12 @@ final class DashboardViewModel {
357 358
     func loadArchiveCacheStatus() {
358 359
         do {
359 360
             let cache = try CoreDataArchiveCacheStore()
360
-            latestArchiveObservation = try cache.latestObservationRow()
361
+            archiveObservationRows = try cache.observationRows(limit: 2)
362
+            latestArchiveObservation = archiveObservationRows.first
361 363
             archiveHealthStatus = try cache.latestArchiveHealthStatus()
362 364
             archiveCacheError = nil
363 365
         } catch {
366
+            archiveObservationRows = []
364 367
             latestArchiveObservation = nil
365 368
             archiveHealthStatus = nil
366 369
             archiveCacheError = error.localizedDescription
@@ -463,10 +466,13 @@ final class DashboardViewModel {
463 466
         do {
464 467
             let cache = try CoreDataArchiveCacheStore()
465 468
             _ = try cache.rebuild(fromArchiveAt: SQLiteHealthArchiveStore.defaultDatabaseURL)
466
-            latestArchiveObservation = try cache.latestObservationRow()
469
+            archiveObservationRows = try cache.observationRows(limit: 2)
470
+            latestArchiveObservation = archiveObservationRows.first
467 471
             archiveHealthStatus = try cache.latestArchiveHealthStatus()
468 472
             archiveCacheError = nil
469 473
         } catch {
474
+            archiveObservationRows = []
475
+            latestArchiveObservation = nil
470 476
             archiveCacheError = error.localizedDescription
471 477
         }
472 478
     }
+48 -14
HealthProbe/Views/Dashboard/DashboardView.swift
@@ -27,14 +27,21 @@ struct DashboardView: View {
27 27
 
28 28
     private var latest: HealthSnapshot?   { snapshots.first }
29 29
     private var previous: HealthSnapshot? { snapshots.dropFirst().first }
30
+    private var latestArchiveObservation: CachedArchiveObservationRow? {
31
+        viewModel.latestArchiveObservation
32
+    }
33
+    private var previousArchiveObservation: CachedArchiveObservationRow? {
34
+        guard viewModel.archiveObservationRows.count > 1 else { return nil }
35
+        return viewModel.archiveObservationRows[1]
36
+    }
30 37
     private var currentDeviceProfile: DeviceProfile? {
31 38
         deviceProfiles.first { $0.deviceID == AppSettings.currentDeviceID }
32 39
     }
33 40
 
34 41
     private var currentDeviceDisplayName: String {
35 42
         if let name = currentDeviceProfile?.name, !name.isEmpty { return name }
36
-        guard let latest, !latest.deviceName.isEmpty else { return "Unknown device" }
37
-        return latest.deviceName
43
+        if let latest, !latest.deviceName.isEmpty { return latest.deviceName }
44
+        return "Local device"
38 45
     }
39 46
 
40 47
     private var latestUnavailableMetricCount: Int {
@@ -42,6 +49,16 @@ struct DashboardView: View {
42 49
         return (latest.typeCounts ?? []).filter { $0.quality == .unauthorized }.count
43 50
     }
44 51
 
52
+    private var latestArchiveChangeCount: Int? {
53
+        guard let latestArchiveObservation,
54
+              previousArchiveObservation != nil else {
55
+            return nil
56
+        }
57
+        return latestArchiveObservation.appearedCount
58
+            + latestArchiveObservation.disappearedCount
59
+            + latestArchiveObservation.representationChangedCount
60
+    }
61
+
45 62
     var body: some View {
46 63
         NavigationStack {
47 64
             List {
@@ -1125,13 +1142,22 @@ struct DashboardView: View {
1125 1142
 
1126 1143
     private var statusSection: some View {
1127 1144
         Section("Status") {
1128
-            if let latest {
1145
+            if let latestArchiveObservation {
1129 1146
                 InfoRow(label: "Last Snapshot") {
1130
-                    Text(latest.timestamp, style: .relative)
1147
+                    Text(latestArchiveObservation.observedAt, style: .relative)
1131 1148
                         .foregroundStyle(.secondary)
1132 1149
                 }
1133
-                InfoRow(label: "Device") {
1134
-                    Text(currentDeviceDisplayName)
1150
+                InfoRow(label: "Records") {
1151
+                    Text("\(latestArchiveObservation.visibleRecordCount)")
1152
+                        .foregroundStyle(.secondary)
1153
+                }
1154
+                InfoRow(label: "Types") {
1155
+                    Text("\(latestArchiveObservation.trackedTypeCount)")
1156
+                        .foregroundStyle(.secondary)
1157
+                }
1158
+            } else if let latest {
1159
+                InfoRow(label: "Last Snapshot") {
1160
+                    Text(latest.timestamp, style: .relative)
1135 1161
                         .foregroundStyle(.secondary)
1136 1162
                 }
1137 1163
                 if latest.snapshotQuality != SnapshotQuality.complete {
@@ -1150,18 +1176,21 @@ struct DashboardView: View {
1150 1176
                     .foregroundStyle(.secondary)
1151 1177
             }
1152 1178
 
1179
+            if latest != nil || latestArchiveObservation != nil {
1180
+                InfoRow(label: "Device") {
1181
+                    Text(currentDeviceDisplayName)
1182
+                        .foregroundStyle(.secondary)
1183
+                }
1184
+            }
1185
+
1153 1186
             InfoRow(label: "Monitored Types") {
1154 1187
                 Text("\(appSettings.selectedTypeIDs.count)")
1155 1188
                     .foregroundStyle(.secondary)
1156 1189
             }
1157 1190
 
1158
-            if let archiveObservation = viewModel.latestArchiveObservation {
1159
-                InfoRow(label: "Archive Observation") {
1160
-                    Text(archiveObservation.observedAt, style: .relative)
1161
-                        .foregroundStyle(.secondary)
1162
-                }
1163
-                InfoRow(label: "Archive Records") {
1164
-                    Text("\(archiveObservation.visibleRecordCount)")
1191
+            if let archiveObservation = latestArchiveObservation {
1192
+                InfoRow(label: "Observation ID") {
1193
+                    Text("\(archiveObservation.observationID)")
1165 1194
                         .foregroundStyle(.secondary)
1166 1195
                 }
1167 1196
             }
@@ -1189,7 +1218,12 @@ struct DashboardView: View {
1189 1218
                     .foregroundStyle(.secondary)
1190 1219
             }
1191 1220
 
1192
-            if let latest, let previous {
1221
+            if let archiveDelta = latestArchiveChangeCount {
1222
+                InfoRow(label: "Changes vs Previous") {
1223
+                    Text(archiveDelta == 0 ? "None" : "\(archiveDelta) records")
1224
+                        .foregroundStyle(archiveDelta == 0 ? Color.healthyGreen : Color.warningAmber)
1225
+                }
1226
+            } else if let latest, let previous {
1193 1227
                 let delta = viewModel.totalChanges(latest: latest, previous: previous)
1194 1228
                 InfoRow(label: "Changes vs Previous") {
1195 1229
                     Text(delta == 0 ? "None" : "\(delta) records")