Showing 6 changed files with 200 additions and 9 deletions
+6 -7
HealthProbe/Doc/04-project/IMPLEMENTATION_STATUS.md
@@ -26,7 +26,7 @@ There are no real deployments, only test installations. Existing prototype datab
26 26
 | Product docs | Updated | Keep `HealthProbe/Doc/README.md` as canonical index |
27 27
 | HealthKit capture | Prototype exists | Adapt capture to write differential SQLite observations first |
28 28
 | SQLite archive | Archive v2 schema, 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 | Start Core Data cache work |
29
-| Core Data cache | Initial programmatic Core Data model and full-cache rebuild service are in place for observation rows, type summaries, daily aggregates, diff summaries, export manifest rows, and archive health | Wire cache reads into UI-facing view models and add targeted partial invalidation |
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 | Treat as disposable prototype data; reset/ignore during v2 transition |
31 31
 | UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker, and record-change detail uses a separate preview/paged list view with archive-value enrichment and scoped export action | Reframe remaining screens around observations, diffs, export, archive status |
32 32
 | Diff/change explanation | Prototype/legacy anomaly logic exists | Move heavy diffing into SQLite and use neutral change classifications |
@@ -38,12 +38,11 @@ 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. Wire Core Data cache reads into UI-facing view models.
42
-2. Replace SwiftData UI dependencies with Core Data/cache DTOs.
43
-3. Add targeted cache invalidation for affected observation/type ranges.
44
-4. Update UI language from anomaly/status to observation/diff/export.
45
-5. Add streaming exports with manifests.
46
-6. Validate on low-memory/legacy-class devices.
41
+1. Move Snapshots/Data Types from SwiftData model reads to Core Data/cache DTOs.
42
+2. Add targeted cache invalidation for affected observation/type ranges.
43
+3. Update UI language from anomaly/status to observation/diff/export.
44
+4. Add streaming exports with manifests.
45
+5. Validate on low-memory/legacy-class devices.
47 46
 
48 47
 ## Known Prototype Mismatches
49 48
 
+129 -0
HealthProbe/Services/CoreDataArchiveCacheStore.swift
@@ -18,6 +18,53 @@ struct CoreDataArchiveCacheRebuildSummary: Equatable, Sendable {
18 18
     let archiveHealthRows: Int
19 19
 }
20 20
 
21
+struct CachedArchiveObservationRow: Equatable, Identifiable, Sendable {
22
+    let observationID: Int64
23
+    let observedAt: Date
24
+    let status: String
25
+    let triggerReason: String
26
+    let timeZoneIdentifier: String?
27
+    let trackedTypeCount: Int
28
+    let visibleRecordCount: Int
29
+    let appearedCount: Int
30
+    let disappearedCount: Int
31
+    let representationChangedCount: Int
32
+    let archiveSchemaVersion: Int
33
+    let cacheSchemaVersion: Int
34
+    let computedAt: Date
35
+
36
+    var id: Int64 { observationID }
37
+}
38
+
39
+struct CachedArchiveTypeSummary: Equatable, Identifiable, Sendable {
40
+    let observationID: Int64
41
+    let sampleTypeIdentifier: String
42
+    let displayName: String?
43
+    let visibleRecordCount: Int
44
+    let appearedCount: Int
45
+    let disappearedCount: Int
46
+    let representationChangedCount: Int
47
+    let earliestStartDate: Date?
48
+    let latestEndDate: Date?
49
+    let valueSum: Double?
50
+    let valueMax: Double?
51
+    let aggregateHash: String?
52
+    let computedAt: Date
53
+
54
+    var id: String { "\(observationID)|\(sampleTypeIdentifier)" }
55
+}
56
+
57
+struct CachedArchiveHealthStatus: Equatable, Sendable {
58
+    let archiveSchemaVersion: Int
59
+    let cacheSchemaVersion: Int
60
+    let lastIntegrityCheckAt: Date
61
+    let lastIntegrityStatus: String
62
+    let lastErrorKind: String?
63
+    let lastErrorMessageHash: String?
64
+    let cacheBuildID: String
65
+    let computedAt: Date
66
+}
67
+
21 68
 // Interface updated 2026-05-24 — see AGENTS.md
22 69
 final class CoreDataArchiveCacheStore {
23 70
     static let cacheSchemaVersion = 1
@@ -94,6 +141,37 @@ final class CoreDataArchiveCacheStore {
94 141
         }
95 142
     }
96 143
 
144
+    func observationRows(limit: Int = 50) throws -> [CachedArchiveObservationRow] {
145
+        let request = NSFetchRequest<NSManagedObject>(entityName: "CachedObservationRow")
146
+        request.sortDescriptors = [NSSortDescriptor(key: "observationID", ascending: false)]
147
+        request.fetchLimit = max(limit, 0)
148
+        return try container.viewContext.fetch(request).map(Self.observationRow)
149
+    }
150
+
151
+    func latestObservationRow() throws -> CachedArchiveObservationRow? {
152
+        try observationRows(limit: 1).first
153
+    }
154
+
155
+    func typeSummaries(observationID: Int64, limit: Int? = nil) throws -> [CachedArchiveTypeSummary] {
156
+        let request = NSFetchRequest<NSManagedObject>(entityName: "CachedTypeSummary")
157
+        request.predicate = NSPredicate(format: "observationID == %lld", observationID)
158
+        request.sortDescriptors = [
159
+            NSSortDescriptor(key: "displayName", ascending: true),
160
+            NSSortDescriptor(key: "sampleTypeIdentifier", ascending: true)
161
+        ]
162
+        if let limit {
163
+            request.fetchLimit = max(limit, 0)
164
+        }
165
+        return try container.viewContext.fetch(request).map(Self.typeSummary)
166
+    }
167
+
168
+    func latestArchiveHealthStatus() throws -> CachedArchiveHealthStatus? {
169
+        let request = NSFetchRequest<NSManagedObject>(entityName: "CachedArchiveHealth")
170
+        request.sortDescriptors = [NSSortDescriptor(key: "computedAt", ascending: false)]
171
+        request.fetchLimit = 1
172
+        return try container.viewContext.fetch(request).first.map(Self.archiveHealthStatus)
173
+    }
174
+
97 175
     private func resetCache(context: NSManagedObjectContext) throws {
98 176
         for entityName in Self.cacheEntityNames {
99 177
             let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
@@ -374,6 +452,57 @@ final class CoreDataArchiveCacheStore {
374 452
     }
375 453
 }
376 454
 
455
+private extension CoreDataArchiveCacheStore {
456
+    nonisolated static func observationRow(_ object: NSManagedObject) -> CachedArchiveObservationRow {
457
+        CachedArchiveObservationRow(
458
+            observationID: object.value(forKey: "observationID") as? Int64 ?? 0,
459
+            observedAt: object.value(forKey: "observedAt") as? Date ?? Date(timeIntervalSince1970: 0),
460
+            status: object.value(forKey: "status") as? String ?? "unknown",
461
+            triggerReason: object.value(forKey: "triggerReason") as? String ?? "unknown",
462
+            timeZoneIdentifier: object.value(forKey: "timeZoneIdentifier") as? String,
463
+            trackedTypeCount: Int(object.value(forKey: "trackedTypeCount") as? Int64 ?? 0),
464
+            visibleRecordCount: Int(object.value(forKey: "visibleRecordCount") as? Int64 ?? 0),
465
+            appearedCount: Int(object.value(forKey: "appearedCount") as? Int64 ?? 0),
466
+            disappearedCount: Int(object.value(forKey: "disappearedCount") as? Int64 ?? 0),
467
+            representationChangedCount: Int(object.value(forKey: "representationChangedCount") as? Int64 ?? 0),
468
+            archiveSchemaVersion: Int(object.value(forKey: "archiveSchemaVersion") as? Int64 ?? 0),
469
+            cacheSchemaVersion: Int(object.value(forKey: "cacheSchemaVersion") as? Int64 ?? 0),
470
+            computedAt: object.value(forKey: "computedAt") as? Date ?? Date(timeIntervalSince1970: 0)
471
+        )
472
+    }
473
+
474
+    nonisolated static func typeSummary(_ object: NSManagedObject) -> CachedArchiveTypeSummary {
475
+        CachedArchiveTypeSummary(
476
+            observationID: object.value(forKey: "observationID") as? Int64 ?? 0,
477
+            sampleTypeIdentifier: object.value(forKey: "sampleTypeIdentifier") as? String ?? "",
478
+            displayName: object.value(forKey: "displayName") as? String,
479
+            visibleRecordCount: Int(object.value(forKey: "visibleRecordCount") as? Int64 ?? 0),
480
+            appearedCount: Int(object.value(forKey: "appearedCount") as? Int64 ?? 0),
481
+            disappearedCount: Int(object.value(forKey: "disappearedCount") as? Int64 ?? 0),
482
+            representationChangedCount: Int(object.value(forKey: "representationChangedCount") as? Int64 ?? 0),
483
+            earliestStartDate: object.value(forKey: "earliestStartDate") as? Date,
484
+            latestEndDate: object.value(forKey: "latestEndDate") as? Date,
485
+            valueSum: object.value(forKey: "valueSum") as? Double,
486
+            valueMax: object.value(forKey: "valueMax") as? Double,
487
+            aggregateHash: object.value(forKey: "aggregateHash") as? String,
488
+            computedAt: object.value(forKey: "computedAt") as? Date ?? Date(timeIntervalSince1970: 0)
489
+        )
490
+    }
491
+
492
+    nonisolated static func archiveHealthStatus(_ object: NSManagedObject) -> CachedArchiveHealthStatus {
493
+        CachedArchiveHealthStatus(
494
+            archiveSchemaVersion: Int(object.value(forKey: "archiveSchemaVersion") as? Int64 ?? 0),
495
+            cacheSchemaVersion: Int(object.value(forKey: "cacheSchemaVersion") as? Int64 ?? 0),
496
+            lastIntegrityCheckAt: object.value(forKey: "lastIntegrityCheckAt") as? Date ?? Date(timeIntervalSince1970: 0),
497
+            lastIntegrityStatus: object.value(forKey: "lastIntegrityStatus") as? String ?? "unknown",
498
+            lastErrorKind: object.value(forKey: "lastErrorKind") as? String,
499
+            lastErrorMessageHash: object.value(forKey: "lastErrorMessageHash") as? String,
500
+            cacheBuildID: object.value(forKey: "cacheBuildID") as? String ?? "",
501
+            computedAt: object.value(forKey: "computedAt") as? Date ?? Date(timeIntervalSince1970: 0)
502
+        )
503
+    }
504
+}
505
+
377 506
 private extension CoreDataArchiveCacheStore {
378 507
     static let cacheEntityNames = [
379 508
         "CachedObservationRow",
+2 -2
HealthProbe/Services/SQLiteHealthArchiveStore.swift
@@ -13,6 +13,7 @@ private enum SQLiteHealthArchiveStoreError: Error {
13 13
 // Interface updated 2026-05-18 — see AGENTS.md
14 14
 actor SQLiteHealthArchiveStore: HealthArchiveStore {
15 15
     static let shared = SQLiteHealthArchiveStore()
16
+    nonisolated static let defaultDatabaseURL = URL.applicationSupportDirectory.appending(path: "HealthProbeArchive.sqlite")
16 17
     nonisolated private static let archiveSchemaVersion = 2
17 18
     nonisolated private static let requiredArchiveV2Tables: [String] = [
18 19
         "schema_migrations",
@@ -40,8 +41,7 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
40 41
     private var didPrepareSchema = false
41 42
 
42 43
     init(databaseURL: URL? = nil) {
43
-        let supportURL = URL.applicationSupportDirectory
44
-        self.databaseURL = databaseURL ?? supportURL.appending(path: "HealthProbeArchive.sqlite")
44
+        self.databaseURL = databaseURL ?? Self.defaultDatabaseURL
45 45
     }
46 46
 
47 47
     func upsertSamples(_ samples: [HKSample], observedAt: Date) async throws -> HealthArchiveWriteSummary {
+31 -0
HealthProbe/ViewModels/DashboardViewModel.swift
@@ -23,6 +23,9 @@ final class DashboardViewModel {
23 23
     var completedSnapshotTriggerReason: String? = nil
24 24
     var completedSnapshotRetryOfSnapshotID: UUID? = nil
25 25
     var ambiguousDisappearedMetrics: [AmbiguousDisappearedMetric] = []
26
+    var latestArchiveObservation: CachedArchiveObservationRow?
27
+    var archiveHealthStatus: CachedArchiveHealthStatus?
28
+    var archiveCacheError: String?
26 29
 
27 30
     private let healthKit = HealthKitService.shared
28 31
     private let diffService = SnapshotDiffService.shared
@@ -197,6 +200,7 @@ final class DashboardViewModel {
197 200
             }
198 201
 
199 202
             snapshotProgress = .complete
203
+            refreshArchiveCache()
200 204
         } catch is CancellationError {
201 205
             snapshotError = "Snapshot creation exceeded the operation timeout. Individual metric timeouts are adaptive; retry failed metrics with an extended timeout when available."
202 206
             snapshotProgress = .idle
@@ -307,6 +311,7 @@ final class DashboardViewModel {
307 311
                 monitoredTypeSetHash: saved.monitoredTypeSetHash,
308 312
                 monitoredRegistryVersion: saved.monitoredRegistryVersion
309 313
             )
314
+            refreshArchiveCache()
310 315
         } catch {
311 316
             snapshotError = "Failed to save partial snapshot: \(error.localizedDescription)"
312 317
             showProgressSheet = true
@@ -349,6 +354,19 @@ final class DashboardViewModel {
349 354
         diffService.totalAbsoluteChange(current: latest, baseline: previous)
350 355
     }
351 356
 
357
+    func loadArchiveCacheStatus() {
358
+        do {
359
+            let cache = try CoreDataArchiveCacheStore()
360
+            latestArchiveObservation = try cache.latestObservationRow()
361
+            archiveHealthStatus = try cache.latestArchiveHealthStatus()
362
+            archiveCacheError = nil
363
+        } catch {
364
+            latestArchiveObservation = nil
365
+            archiveHealthStatus = nil
366
+            archiveCacheError = error.localizedDescription
367
+        }
368
+    }
369
+
352 370
     private func withTimeout<T>(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T {
353 371
         try await withThrowingTaskGroup(of: T.self) { group in
354 372
             group.addTask { try await operation() }
@@ -438,6 +456,19 @@ final class DashboardViewModel {
438 456
         fetchProgress = nil
439 457
         showProgressSheet = false
440 458
         snapshotProgress = .idle
459
+        refreshArchiveCache()
460
+    }
461
+
462
+    private func refreshArchiveCache() {
463
+        do {
464
+            let cache = try CoreDataArchiveCacheStore()
465
+            _ = try cache.rebuild(fromArchiveAt: SQLiteHealthArchiveStore.defaultDatabaseURL)
466
+            latestArchiveObservation = try cache.latestObservationRow()
467
+            archiveHealthStatus = try cache.latestArchiveHealthStatus()
468
+            archiveCacheError = nil
469
+        } catch {
470
+            archiveCacheError = error.localizedDescription
471
+        }
441 472
     }
442 473
 }
443 474
 
+19 -0
HealthProbe/Views/Dashboard/DashboardView.swift
@@ -62,6 +62,7 @@ struct DashboardView: View {
62 62
             progressSheet
63 63
         }
64 64
         .task {
65
+            viewModel.loadArchiveCacheStatus()
65 66
             if !didAutoRequestPermissions && !HealthKitService.shared.hasRequestedPermissionsBefore {
66 67
                 didAutoRequestPermissions = true
67 68
                 await viewModel.requestAuthorization()
@@ -1154,6 +1155,24 @@ struct DashboardView: View {
1154 1155
                     .foregroundStyle(.secondary)
1155 1156
             }
1156 1157
 
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)")
1165
+                        .foregroundStyle(.secondary)
1166
+                }
1167
+            }
1168
+
1169
+            if let archiveHealthStatus = viewModel.archiveHealthStatus,
1170
+               archiveHealthStatus.lastIntegrityStatus != "ok" {
1171
+                Label("Archive cache integrity: \(archiveHealthStatus.lastIntegrityStatus)", systemImage: "exclamationmark.triangle")
1172
+                    .font(.caption)
1173
+                    .foregroundStyle(Color.warningAmber)
1174
+            }
1175
+
1157 1176
             if let latest, let previous {
1158 1177
                 let delta = viewModel.totalChanges(latest: latest, previous: previous)
1159 1178
                 InfoRow(label: "Changes vs Previous") {
+13 -0
HealthProbeTests/CoreDataArchiveCacheStoreTests.swift
@@ -56,6 +56,19 @@ final class CoreDataArchiveCacheStoreTests: XCTestCase {
56 56
         )
57 57
         XCTAssertEqual(latestObservation?.value(forKey: "visibleRecordCount") as? Int64, 1)
58 58
         XCTAssertEqual(latestObservation?.value(forKey: "cacheSchemaVersion") as? Int64, Int64(CoreDataArchiveCacheStore.cacheSchemaVersion))
59
+
60
+        let latestRow = try XCTUnwrap(cache.latestObservationRow())
61
+        XCTAssertEqual(latestRow.observationID, 4)
62
+        XCTAssertEqual(latestRow.visibleRecordCount, 1)
63
+        XCTAssertEqual(latestRow.cacheSchemaVersion, CoreDataArchiveCacheStore.cacheSchemaVersion)
64
+
65
+        let summaries = try cache.typeSummaries(observationID: latestRow.observationID)
66
+        XCTAssertEqual(summaries.count, 1)
67
+        XCTAssertEqual(summaries.first?.sampleTypeIdentifier, HKQuantityTypeIdentifier.stepCount.rawValue)
68
+
69
+        let health = try XCTUnwrap(cache.latestArchiveHealthStatus())
70
+        XCTAssertEqual(health.archiveSchemaVersion, 2)
71
+        XCTAssertEqual(health.lastIntegrityStatus, "ok")
59 72
     }
60 73
 
61 74
     func testDeletingCacheDoesNotDeleteSQLiteArchiveAndRebuildRestoresRows() async throws {