Showing 6 changed files with 225 additions and 26 deletions
+3 -2
HealthProbe/Doc/04-project/IMPLEMENTATION_STATUS.md
@@ -26,9 +26,9 @@ 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 | 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 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
-| 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 |
29
+| Core Data cache | Initial programmatic Core Data model, full-cache rebuild service, read DTOs for observation/type/diff/health rows, and Dashboard archive-cache status wiring are in place | Move remaining export/report paths to cache DTOs and add targeted partial invalidation |
30 30
 | SwiftData cache | Exists; test builds now reset legacy prototype UI/archive/cache stores once for archive v2 so old SwiftData-only snapshots are not treated as backed-up observations | 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 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`; visible change labels now use neutral new/missing/change-review language, 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 reads Core Data type/diff summaries and uses SQLite `diffRecords` for paged drill-down; visible change labels now use neutral new/missing/change-review language, with SwiftData detail cache as transition fallback | Finish 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 |
@@ -66,6 +66,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m
66 66
 - [x] Snapshot detail summary/type rows use Core Data cached summaries plus SQLite diff summaries when archive observation ids are available.
67 67
 - [x] Data Types list rows use Core Data cached counts plus SQLite diff summaries when archive observation ids are available.
68 68
 - [x] Data type new/missing drill-down pages records from SQLite diff queries when archive observation ids are available.
69
+- [x] Data type diff detail and evolution summaries prefer Core Data cache rows when archive observation ids are available.
69 70
 - [x] Expensive counts used by reports/UI are cached and rebuildable.
70 71
 - [x] Deleting Core Data cache and rebuilding from SQLite restores UI/report summaries.
71 72
 - [x] Dashboard surfaces SQLite/Core Data cache health, cache schema, cache errors, and latest archive observation counts.
+1 -1
HealthProbe/Doc/04-project/Refactoring-Plan.md
@@ -227,7 +227,7 @@ Checklist:
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.
229 229
 - [x] Data type new/missing drill-down pages through SQLite `diffRecords` when archive observation ids exist.
230
-- [ ] Diff detail fully uses cached summary plus paged SQLite DTOs.
230
+- [x] Diff detail fully uses cached summary plus paged SQLite DTOs.
231 231
 - [x] Data type screens use target change labels.
232 232
 - [ ] Export preview uses export query/manifest APIs.
233 233
 - [x] Archive status reflects SQLite/Core Data cache health.
+54 -0
HealthProbe/Services/CoreDataArchiveCacheStore.swift
@@ -54,6 +54,22 @@ struct CachedArchiveTypeSummary: Equatable, Identifiable, Sendable {
54 54
     var id: String { "\(observationID)|\(sampleTypeIdentifier)" }
55 55
 }
56 56
 
57
+struct CachedArchiveDiffSummary: Equatable, Identifiable, Sendable {
58
+    let fromObservationID: Int64
59
+    let toObservationID: Int64
60
+    let sampleTypeIdentifier: String?
61
+    let appearedCount: Int
62
+    let disappearedCount: Int
63
+    let representationChangedCount: Int
64
+    let consolidationLikely: Bool
65
+    let uncertaintyReason: String?
66
+    let computedAt: Date
67
+
68
+    var id: String {
69
+        "\(fromObservationID)|\(toObservationID)|\(sampleTypeIdentifier ?? "*")"
70
+    }
71
+}
72
+
57 73
 struct CachedArchiveHealthStatus: Equatable, Sendable {
58 74
     let archiveSchemaVersion: Int
59 75
     let cacheSchemaVersion: Int
@@ -165,6 +181,30 @@ final class CoreDataArchiveCacheStore {
165 181
         return try container.viewContext.fetch(request).map(Self.typeSummary)
166 182
     }
167 183
 
184
+    func diffSummary(
185
+        fromObservationID: Int64,
186
+        toObservationID: Int64,
187
+        sampleTypeIdentifier: String?
188
+    ) throws -> CachedArchiveDiffSummary? {
189
+        let request = NSFetchRequest<NSManagedObject>(entityName: "CachedDiffSummary")
190
+        if let sampleTypeIdentifier {
191
+            request.predicate = NSPredicate(
192
+                format: "fromObservationID == %lld AND toObservationID == %lld AND sampleTypeIdentifier == %@",
193
+                fromObservationID,
194
+                toObservationID,
195
+                sampleTypeIdentifier
196
+            )
197
+        } else {
198
+            request.predicate = NSPredicate(
199
+                format: "fromObservationID == %lld AND toObservationID == %lld AND sampleTypeIdentifier == nil",
200
+                fromObservationID,
201
+                toObservationID
202
+            )
203
+        }
204
+        request.fetchLimit = 1
205
+        return try container.viewContext.fetch(request).first.map(Self.diffSummary)
206
+    }
207
+
168 208
     func latestArchiveHealthStatus() throws -> CachedArchiveHealthStatus? {
169 209
         let request = NSFetchRequest<NSManagedObject>(entityName: "CachedArchiveHealth")
170 210
         request.sortDescriptors = [NSSortDescriptor(key: "computedAt", ascending: false)]
@@ -489,6 +529,20 @@ private extension CoreDataArchiveCacheStore {
489 529
         )
490 530
     }
491 531
 
532
+    nonisolated static func diffSummary(_ object: NSManagedObject) -> CachedArchiveDiffSummary {
533
+        CachedArchiveDiffSummary(
534
+            fromObservationID: object.value(forKey: "fromObservationID") as? Int64 ?? 0,
535
+            toObservationID: object.value(forKey: "toObservationID") as? Int64 ?? 0,
536
+            sampleTypeIdentifier: object.value(forKey: "sampleTypeIdentifier") as? String,
537
+            appearedCount: Int(object.value(forKey: "appearedCount") as? Int64 ?? 0),
538
+            disappearedCount: Int(object.value(forKey: "disappearedCount") as? Int64 ?? 0),
539
+            representationChangedCount: Int(object.value(forKey: "representationChangedCount") as? Int64 ?? 0),
540
+            consolidationLikely: object.value(forKey: "consolidationLikely") as? Bool ?? false,
541
+            uncertaintyReason: object.value(forKey: "uncertaintyReason") as? String,
542
+            computedAt: object.value(forKey: "computedAt") as? Date ?? Date(timeIntervalSince1970: 0)
543
+        )
544
+    }
545
+
492 546
     nonisolated static func archiveHealthStatus(_ object: NSManagedObject) -> CachedArchiveHealthStatus {
493 547
         CachedArchiveHealthStatus(
494 548
             archiveSchemaVersion: Int(object.value(forKey: "archiveSchemaVersion") as? Int64 ?? 0),
+82 -10
HealthProbe/Views/DataTypes/RecordChangeEvolutionChart.swift
@@ -8,6 +8,8 @@ struct RecordChangeEvolutionChart: View {
8 8
     let displayName: String
9 9
 
10 10
     @Query private var allDeltas: [SnapshotDelta]
11
+    @State private var cachedCountsByObservationID: [Int64: Int] = [:]
12
+    @State private var cachedDiffsByPair: [ArchiveDiffKey: RecordChangeDiff]?
11 13
 
12 14
     private var sortedSnapshots: [HealthSnapshot] {
13 15
         snapshots.sorted(by: HealthSnapshot.timelineSort)
@@ -25,23 +27,24 @@ struct RecordChangeEvolutionChart: View {
25 27
     }
26 28
 
27 29
     private var diffTaskID: String {
28
-        ([typeIdentifier, currentSnapshotID.uuidString] + contextSnapshots.map { $0.id.uuidString }).joined(separator: "|")
30
+        (
31
+            [typeIdentifier, currentSnapshotID.uuidString]
32
+            + contextSnapshots.map { "\($0.id.uuidString):\($0.archiveObservationID ?? -1)" }
33
+        )
34
+        .joined(separator: "|")
29 35
     }
30 36
 
31 37
     private var maxCount: Int {
32
-        let counts = contextSnapshots.compactMap { snapshot in
33
-            snapshot.typeCounts?.first { $0.typeIdentifier == typeIdentifier }?.count
34
-        }
38
+        let counts = contextSnapshots.map(countForSnapshot)
35 39
         return counts.max() ?? 1
36 40
     }
37 41
 
38 42
     private var chartPoints: [ChartPoint] {
39 43
         contextSnapshots.map { snapshot in
40
-            let typeCount = snapshot.typeCounts?.first { $0.typeIdentifier == typeIdentifier }
41 44
             let previousSnapshot = snapshot.previousInTimeline(sortedSnapshots)
42 45
             return ChartPoint(
43 46
                 snapshot: snapshot,
44
-                count: max(typeCount?.count ?? 0, 0),
47
+                count: max(countForSnapshot(snapshot), 0),
45 48
                 diff: recordDiff(current: snapshot, previous: previousSnapshot)
46 49
             )
47 50
         }
@@ -61,6 +64,13 @@ struct RecordChangeEvolutionChart: View {
61 64
     private func recordDiff(current: HealthSnapshot, previous: HealthSnapshot?) -> RecordChangeDiff {
62 65
         guard let previous = previous else { return RecordChangeDiff() }
63 66
 
67
+        if let currentObservationID = current.archiveObservationID,
68
+           let previousObservationID = previous.archiveObservationID,
69
+           let cachedDiffsByPair {
70
+            let key = ArchiveDiffKey(fromObservationID: previousObservationID, toObservationID: currentObservationID)
71
+            return cachedDiffsByPair[key] ?? RecordChangeDiff(isExact: true)
72
+        }
73
+
64 74
         if let typeCount = current.typeCounts?.first(where: { $0.typeIdentifier == typeIdentifier }),
65 75
            let cache = typeCount.detailCache,
66 76
            cache.matchesBaseline(previous.id) {
@@ -102,6 +112,9 @@ struct RecordChangeEvolutionChart: View {
102 112
             emptyState
103 113
         } else {
104 114
             evolutionView
115
+                .task(id: diffTaskID) {
116
+                    await loadArchiveEvolutionCache()
117
+                }
105 118
         }
106 119
     }
107 120
 
@@ -145,7 +158,7 @@ struct RecordChangeEvolutionChart: View {
145 158
                     Text("Min")
146 159
                         .font(.caption)
147 160
                         .foregroundStyle(.secondary)
148
-                    Text("\(contextSnapshots.compactMap { $0.typeCounts?.first { $0.typeIdentifier == typeIdentifier }?.count }.min() ?? 0)")
161
+                    Text("\(contextSnapshots.map(countForSnapshot).min() ?? 0)")
149 162
                         .font(.caption.weight(.semibold))
150 163
                         .monospacedDigit()
151 164
                 }
@@ -156,9 +169,8 @@ struct RecordChangeEvolutionChart: View {
156 169
                     Text("Current")
157 170
                         .font(.caption)
158 171
                         .foregroundStyle(.secondary)
159
-                    if let current = contextSnapshots.first(where: { $0.id == currentSnapshotID }),
160
-                       let count = current.typeCounts?.first(where: { $0.typeIdentifier == typeIdentifier })?.count {
161
-                        Text("\(count)")
172
+                    if let current = contextSnapshots.first(where: { $0.id == currentSnapshotID }) {
173
+                        Text("\(countForSnapshot(current))")
162 174
                             .font(.caption.weight(.semibold))
163 175
                             .monospacedDigit()
164 176
                             .foregroundStyle(Color.accentColor)
@@ -302,6 +314,61 @@ struct RecordChangeEvolutionChart: View {
302 314
             Text(label)
303 315
         }
304 316
     }
317
+
318
+    private func countForSnapshot(_ snapshot: HealthSnapshot) -> Int {
319
+        if let observationID = snapshot.archiveObservationID,
320
+           let cachedCount = cachedCountsByObservationID[observationID] {
321
+            return cachedCount
322
+        }
323
+
324
+        return snapshot.typeCounts?.first { $0.typeIdentifier == typeIdentifier }?.count ?? 0
325
+    }
326
+
327
+    @MainActor
328
+    private func loadArchiveEvolutionCache() async {
329
+        let archiveSnapshots = contextSnapshots.filter { $0.archiveObservationID != nil }
330
+        guard !archiveSnapshots.isEmpty else {
331
+            cachedCountsByObservationID = [:]
332
+            cachedDiffsByPair = nil
333
+            return
334
+        }
335
+
336
+        do {
337
+            let cache = try CoreDataArchiveCacheStore()
338
+            var counts: [Int64: Int] = [:]
339
+            var diffs: [ArchiveDiffKey: RecordChangeDiff] = [:]
340
+
341
+            for snapshot in archiveSnapshots {
342
+                guard let observationID = snapshot.archiveObservationID else { continue }
343
+                let summary = try cache.typeSummaries(observationID: observationID)
344
+                    .first { $0.sampleTypeIdentifier == typeIdentifier }
345
+                counts[observationID] = summary?.visibleRecordCount ?? 0
346
+
347
+                guard let previous = snapshot.previousInTimeline(sortedSnapshots),
348
+                      let previousObservationID = previous.archiveObservationID else {
349
+                    continue
350
+                }
351
+
352
+                if let diff = try cache.diffSummary(
353
+                    fromObservationID: previousObservationID,
354
+                    toObservationID: observationID,
355
+                    sampleTypeIdentifier: typeIdentifier
356
+                ) {
357
+                    diffs[ArchiveDiffKey(fromObservationID: previousObservationID, toObservationID: observationID)] = RecordChangeDiff(
358
+                        added: diff.appearedCount,
359
+                        disappeared: diff.disappearedCount,
360
+                        isExact: true
361
+                    )
362
+                }
363
+            }
364
+
365
+            cachedCountsByObservationID = counts
366
+            cachedDiffsByPair = diffs
367
+        } catch {
368
+            cachedCountsByObservationID = [:]
369
+            cachedDiffsByPair = nil
370
+        }
371
+    }
305 372
 }
306 373
 
307 374
 private struct ChartPoint {
@@ -326,6 +393,11 @@ private struct RecordChangeDiff {
326 393
     }
327 394
 }
328 395
 
396
+private struct ArchiveDiffKey: Hashable {
397
+    let fromObservationID: Int64
398
+    let toObservationID: Int64
399
+}
400
+
329 401
 #Preview {
330 402
     let mockSnapshots = (0..<7).map { idx in
331 403
         let snapshot = HealthSnapshot(
+76 -13
HealthProbe/Views/Snapshots/DataTypeSnapshotDetailView.swift
@@ -16,6 +16,8 @@ struct DataTypeSnapshotDetailView: View {
16 16
     @State private var showDisappearedRecords = false
17 17
     @State private var showTemporalDistribution = false
18 18
     @State private var detailCacheDiagnostic: String?
19
+    @State private var currentCachedTypeSummary: CachedArchiveTypeSummary?
20
+    @State private var previousCachedTypeSummary: CachedArchiveTypeSummary?
19 21
 
20 22
     private var currentSnapshot: HealthSnapshot {
21 23
         displayedSnapshot ?? snapshot
@@ -91,11 +93,11 @@ struct DataTypeSnapshotDetailView: View {
91 93
     }
92 94
 
93 95
     private var quickCurrentCountValue: Int {
94
-        max(currentTypeCount?.count ?? 0, 0)
96
+        max(currentCachedTypeSummary?.visibleRecordCount ?? currentTypeCount?.count ?? 0, 0)
95 97
     }
96 98
 
97 99
     private var quickPreviousCountValue: Int {
98
-        max(previousTypeCount?.count ?? 0, 0)
100
+        max(previousCachedTypeSummary?.visibleRecordCount ?? previousTypeCount?.count ?? 0, 0)
99 101
     }
100 102
 
101 103
     private var quickAddedDisappeared: (added: Int, disappeared: Int, exact: Bool) {
@@ -117,12 +119,20 @@ struct DataTypeSnapshotDetailView: View {
117 119
         previousSnapshot?.archiveObservationID
118 120
     }
119 121
 
122
+    private var isTypeTrackedInCurrentContext: Bool {
123
+        currentTypeCount != nil ||
124
+        previousTypeCount != nil ||
125
+        currentCachedTypeSummary != nil ||
126
+        previousCachedTypeSummary != nil ||
127
+        currentArchiveObservationID != nil
128
+    }
129
+
120 130
     var body: some View {
121 131
         ScrollView {
122 132
             VStack(spacing: 16) {
123 133
                 if previousSnapshot == nil {
124 134
                     emptyStateContent("No baseline available for this device.", icon: "clock.badge.questionmark")
125
-                } else if currentTypeCount == nil && previousTypeCount == nil {
135
+                } else if !isTypeTrackedInCurrentContext {
126 136
                     emptyStateContent("Data type not tracked in selected snapshots.", icon: "eye.slash")
127 137
                 } else {
128 138
                     dataRangeSection
@@ -149,6 +159,7 @@ struct DataTypeSnapshotDetailView: View {
149 159
             }
150 160
         }
151 161
         .task(id: diffTaskID) {
162
+            await loadArchiveTypeSummaries()
152 163
             await loadRecordDiff()
153 164
         }
154 165
         .navigationDestination(isPresented: $showTemporalDistribution) {
@@ -231,10 +242,10 @@ struct DataTypeSnapshotDetailView: View {
231 242
 
232 243
     @ViewBuilder
233 244
     private var dataRangeSection: some View {
234
-        if currentTypeCount != nil {
245
+        if currentTypeCount != nil || currentCachedTypeSummary != nil {
235 246
             DataTypeRangeIndicator(
236
-                earliestDate: currentTypeCount?.earliestDate,
237
-                latestDate: currentTypeCount?.latestDate,
247
+                earliestDate: currentCachedTypeSummary?.earliestStartDate ?? currentTypeCount?.earliestDate,
248
+                latestDate: currentCachedTypeSummary?.latestEndDate ?? currentTypeCount?.latestDate,
238 249
                 quality: currentTypeCount?.quality ?? .complete
239 250
             )
240 251
         }
@@ -247,12 +258,12 @@ struct DataTypeSnapshotDetailView: View {
247 258
             case .loaded(let diff):
248 259
                 RecordChangeComparisonCard(
249 260
                     displayName: displayName,
250
-                    currentCount: currentTypeCount?.count ?? 0,
251
-                    previousCount: previousTypeCount?.count,
261
+                    currentCount: quickCurrentCountValue,
262
+                    previousCount: previousCachedTypeSummary?.visibleRecordCount ?? previousTypeCount?.count,
252 263
                     addedCount: diff.addedCount,
253 264
                     disappearedCount: diff.disappearedCount,
254
-                    isCurrentValid: (currentTypeCount?.count ?? 0) >= 0,
255
-                    isPreviousTracked: previousTypeCount != nil,
265
+                    isCurrentValid: quickCurrentCountValue >= 0,
266
+                    isPreviousTracked: previousTypeCount != nil || previousCachedTypeSummary != nil,
256 267
                     onAddedTap: {
257 268
                         if diff.addedCount > 0 {
258 269
                             showAddedRecords = true
@@ -365,7 +376,7 @@ struct DataTypeSnapshotDetailView: View {
365 376
 
366 377
     @ViewBuilder
367 378
     private var recordChangeEvolutionSection: some View {
368
-        if previousSnapshot != nil, currentTypeCount != nil {
379
+        if previousSnapshot != nil, currentTypeCount != nil || currentCachedTypeSummary != nil {
369 380
             RecordChangeEvolutionChart(
370 381
                 snapshots: timelineSnapshots,
371 382
                 currentSnapshotID: currentSnapshot.id,
@@ -514,8 +525,8 @@ struct DataTypeSnapshotDetailView: View {
514 525
             return
515 526
         }
516 527
 
517
-        let currentCount = currentTypeCount?.count ?? 0
518
-        let previousCount = previousTypeCount?.count ?? 0
528
+        let currentCount = currentCachedTypeSummary?.visibleRecordCount ?? currentTypeCount?.count ?? 0
529
+        let previousCount = previousCachedTypeSummary?.visibleRecordCount ?? previousTypeCount?.count ?? 0
519 530
 
520 531
         guard currentCount >= 0, previousCount >= 0 else {
521 532
             detailCacheDiagnostic = "counts-invalid current=\(currentCount) previous=\(previousCount)"
@@ -525,6 +536,21 @@ struct DataTypeSnapshotDetailView: View {
525 536
 
526 537
         if let previousArchiveObservationID,
527 538
            let currentArchiveObservationID {
539
+            do {
540
+                let cache = try CoreDataArchiveCacheStore()
541
+                if let cached = try cache.diffSummary(
542
+                    fromObservationID: previousArchiveObservationID,
543
+                    toObservationID: currentArchiveObservationID,
544
+                    sampleTypeIdentifier: typeIdentifier
545
+                ) {
546
+                    detailCacheDiagnostic = "resolver-v6 phase=core-data-diff-cache"
547
+                    diffState = .loaded(DataTypeRecordDiff(cached: cached))
548
+                    return
549
+                }
550
+            } catch {
551
+                detailCacheDiagnostic = "core-data-diff-cache-failed \(error.localizedDescription)"
552
+            }
553
+
528 554
             do {
529 555
                 let summary = try await SQLiteHealthArchiveStore.shared.diffSummary(HealthArchiveDiffRequest(
530 556
                     fromObservationID: previousArchiveObservationID,
@@ -549,6 +575,36 @@ struct DataTypeSnapshotDetailView: View {
549 575
         diffState = .loaded(DataTypeRecordDiff(cache: cache))
550 576
     }
551 577
 
578
+    @MainActor
579
+    private func loadArchiveTypeSummaries() async {
580
+        guard currentArchiveObservationID != nil || previousArchiveObservationID != nil else {
581
+            currentCachedTypeSummary = nil
582
+            previousCachedTypeSummary = nil
583
+            return
584
+        }
585
+
586
+        do {
587
+            let cache = try CoreDataArchiveCacheStore()
588
+            if let currentArchiveObservationID {
589
+                currentCachedTypeSummary = try cache.typeSummaries(observationID: currentArchiveObservationID)
590
+                    .first { $0.sampleTypeIdentifier == typeIdentifier }
591
+            } else {
592
+                currentCachedTypeSummary = nil
593
+            }
594
+
595
+            if let previousArchiveObservationID {
596
+                previousCachedTypeSummary = try cache.typeSummaries(observationID: previousArchiveObservationID)
597
+                    .first { $0.sampleTypeIdentifier == typeIdentifier }
598
+            } else {
599
+                previousCachedTypeSummary = nil
600
+            }
601
+        } catch {
602
+            currentCachedTypeSummary = nil
603
+            previousCachedTypeSummary = nil
604
+            detailCacheDiagnostic = "core-data-type-cache-failed \(error.localizedDescription)"
605
+        }
606
+    }
607
+
552 608
     @MainActor
553 609
     private func currentDetailCacheResolution() -> TypeCountDetailCacheResolution? {
554 610
         if isCurrentTypeContentAliasToPrevious {
@@ -628,6 +684,13 @@ private struct DataTypeRecordDiff: Equatable, Sendable {
628 684
         self.disappearedRecords = []
629 685
     }
630 686
 
687
+    init(cached: CachedArchiveDiffSummary) {
688
+        self.addedCount = cached.appearedCount
689
+        self.disappearedCount = cached.disappearedCount
690
+        self.addedRecords = []
691
+        self.disappearedRecords = []
692
+    }
693
+
631 694
     var isPreviewLimited: Bool {
632 695
         addedCount > Self.previewLimit || disappearedCount > Self.previewLimit
633 696
     }
+9 -0
HealthProbeTests/CoreDataArchiveCacheStoreTests.swift
@@ -66,6 +66,15 @@ final class CoreDataArchiveCacheStoreTests: XCTestCase {
66 66
         XCTAssertEqual(summaries.count, 1)
67 67
         XCTAssertEqual(summaries.first?.sampleTypeIdentifier, HKQuantityTypeIdentifier.stepCount.rawValue)
68 68
 
69
+        let diff = try XCTUnwrap(cache.diffSummary(
70
+            fromObservationID: 1,
71
+            toObservationID: 2,
72
+            sampleTypeIdentifier: HKQuantityTypeIdentifier.stepCount.rawValue
73
+        ))
74
+        XCTAssertEqual(diff.appearedCount, 1)
75
+        XCTAssertEqual(diff.disappearedCount, 0)
76
+        XCTAssertEqual(diff.representationChangedCount, 0)
77
+
69 78
         let health = try XCTUnwrap(cache.latestArchiveHealthStatus())
70 79
         XCTAssertEqual(health.archiveSchemaVersion, 2)
71 80
         XCTAssertEqual(health.lastIntegrityStatus, "ok")