Showing 19 changed files with 1219 additions and 413 deletions
+12 -0
AGENTS.md
@@ -152,6 +152,18 @@ final class TypeDistributionBin {
152 152
 // snapshot to contain real changes for some metrics while long-stable metrics behave
153 153
 // as temporal aliases and skip per-type detail cache/diff work.
154 154
 
155
+// Interface updated 2026-05-17 — see AGENTS.md
156
+// Models/HealthSnapshot stores cached overview scalars for UI consumption:
157
+// tracked type count, aggregate record count, and overall oldest/newest record dates.
158
+// These values must be computed during snapshot save while TypeCount data is already
159
+// in memory, so snapshot list/detail screens never recompute them by traversing
160
+// snapshot.typeCounts on the UI thread.
161
+
162
+// Interface updated 2026-05-17 — see AGENTS.md
163
+// Models/SnapshotDelta stores cached list/detail summary scalars derived from TypeDelta.
164
+// Overview screens consume these scalars and type-delta summaries directly instead of
165
+// recalculating per-snapshot changes from HealthSnapshot.typeCounts.
166
+
155 167
 // Models/DetectedAnomaly.swift
156 168
 enum AnomalyType: String, Codable {
157 169
     case historicalInsertion = "historical_insertion"
+1 -48
HealthProbe/ContentView.swift
@@ -2,11 +2,6 @@ import SwiftUI
2 2
 import SwiftData
3 3
 
4 4
 struct ContentView: View {
5
-    @Environment(\.modelContext) private var modelContext
6
-    @AppStorage(AppSettings.typeDetailCacheBackfillVersionKey)
7
-    private var typeDetailCacheBackfillVersion: Int = 0
8
-    @State private var didAttemptTypeDetailCacheBackfill = false
9
-
10 5
     var body: some View {
11 6
         TabView {
12 7
             Tab("Dashboard", systemImage: "waveform.path.ecg") {
@@ -22,53 +17,11 @@ struct ContentView: View {
22 17
                 SettingsView()
23 18
             }
24 19
         }
25
-        .task {
26
-            await rebuildTypeDetailCachesIfNeeded()
27
-        }
28
-    }
29
-
30
-    @MainActor
31
-    private func rebuildTypeDetailCachesIfNeeded() async {
32
-        guard !didAttemptTypeDetailCacheBackfill,
33
-              typeDetailCacheBackfillVersion < AppSettings.currentTypeDetailCacheBackfillVersion else {
34
-            return
35
-        }
36
-        didAttemptTypeDetailCacheBackfill = true
37
-
38
-        try? await Task.sleep(for: .seconds(2))
39
-        MemoryLog.log("typeDetailCacheBackfill.begin", metadata: [
40
-            "storedVersion": "\(typeDetailCacheBackfillVersion)",
41
-            "targetVersion": "\(AppSettings.currentTypeDetailCacheBackfillVersion)"
42
-        ])
43
-        let memoryPulse = MemoryLog.startPulse("typeDetailCacheBackfill", metadata: [
44
-            "maxTypeCounts": "1"
45
-        ])
46
-        defer {
47
-            memoryPulse.cancel()
48
-            MemoryLog.log("typeDetailCacheBackfill.end", metadata: [
49
-                "storedVersion": "\(typeDetailCacheBackfillVersion)"
50
-            ])
51
-        }
52
-
53
-        do {
54
-            let isComplete = try SnapshotLifecycleService.rebuildMissingDetailCaches(
55
-                context: modelContext,
56
-                maxTypeCounts: 1
57
-            )
58
-            MemoryLog.log("typeDetailCacheBackfill.result", metadata: [
59
-                "isComplete": "\(isComplete)"
60
-            ])
61
-            if isComplete {
62
-                typeDetailCacheBackfillVersion = AppSettings.currentTypeDetailCacheBackfillVersion
63
-            }
64
-        } catch {
65
-            assertionFailure("Failed to rebuild type detail caches: \(error)")
66
-        }
67 20
     }
68 21
 }
69 22
 
70 23
 #Preview {
71 24
     ContentView()
72
-        .modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self, DeviceProfile.self], inMemory: true)
25
+    .modelContainer(for: [HealthSnapshot.self, SnapshotDelta.self, TypeDelta.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self, DeviceProfile.self], inMemory: true)
73 26
         .environment(AppSettings())
74 27
 }
+18 -3
HealthProbe/Models/HealthRecord.swift
@@ -14,6 +14,21 @@ struct HealthRecordValue: Codable, Hashable, Identifiable, Sendable {
14 14
 enum HealthRecordArchive {
15 15
     private static let compactMagic = Data([0x48, 0x50, 0x52, 0x41, 0x32]) // HPRA2
16 16
 
17
+    static func isCompact(_ data: Data) -> Bool {
18
+        data.starts(with: compactMagic)
19
+    }
20
+
21
+    static func compactedIfNeeded(_ data: Data) -> Data? {
22
+        if isCompact(data) {
23
+            return data
24
+        }
25
+
26
+        guard let decoded = decode(data) else {
27
+            return nil
28
+        }
29
+        return encode(decoded)
30
+    }
31
+
17 32
     static func encode(_ values: [HealthRecordValue]) -> Data? {
18 33
         guard let typeIdentifier = values.first?.typeIdentifier else {
19 34
             return encodeCompact(typeIdentifier: "", values: [])
@@ -29,7 +44,7 @@ enum HealthRecordArchive {
29 44
     }
30 45
 
31 46
     static func forEachRecord(in data: Data, _ body: (HealthRecordValue) -> Void) -> Bool {
32
-        if data.starts(with: compactMagic) {
47
+        if isCompact(data) {
33 48
             return forEachCompactRecord(in: data, body)
34 49
         }
35 50
 
@@ -47,9 +62,9 @@ enum HealthRecordArchive {
47 62
 
48 63
         MemoryLog.log("healthRecordArchive.fingerprintSet.begin", metadata: [
49 64
             "archive": MemoryLog.format(UInt64(data.count)),
50
-            "format": data.starts(with: compactMagic) ? "compact" : "plist"
65
+            "format": isCompact(data) ? "compact" : "plist"
51 66
         ])
52
-        if data.starts(with: compactMagic) {
67
+        if isCompact(data) {
53 68
             let fingerprints = compactFingerprintSet(from: data)
54 69
             MemoryLog.log("healthRecordArchive.fingerprintSet.end", metadata: [
55 70
                 "archive": MemoryLog.format(UInt64(data.count)),
+45 -0
HealthProbe/Models/HealthSnapshot.swift
@@ -1,6 +1,7 @@
1 1
 import Foundation
2 2
 import SwiftData
3 3
 
4
+// Interface updated 2026-05-17 — see AGENTS.md
4 5
 @Model final class HealthSnapshot {
5 6
     var id: UUID = UUID()
6 7
     var timestamp: Date = Date.now
@@ -24,6 +25,11 @@ import SwiftData
24 25
     var monitoredTypeSetHash: String = ""
25 26
     var monitoredRegistryVersion: Int = 0
26 27
     var yearlyCountTimezoneIdentifier: String = ""
28
+    var cachedSummaryVersion: Int = 0
29
+    var cachedTypeCount: Int = 0
30
+    var cachedRecordCount: Int = 0
31
+    var cachedEarliestRecordDate: Date?
32
+    var cachedLatestRecordDate: Date?
27 33
     @Relationship(deleteRule: .cascade, inverse: \TypeCount.snapshot)
28 34
     var typeCounts: [TypeCount]? = []
29 35
 
@@ -38,6 +44,41 @@ import SwiftData
38 44
 }
39 45
 
40 46
 extension HealthSnapshot {
47
+    static let currentCachedSummaryVersion = 1
48
+
49
+    static func timelineSort(_ lhs: HealthSnapshot, _ rhs: HealthSnapshot) -> Bool {
50
+        if lhs.timestamp != rhs.timestamp {
51
+            return lhs.timestamp < rhs.timestamp
52
+        }
53
+        if lhs.localSequenceNumber != rhs.localSequenceNumber {
54
+            return lhs.localSequenceNumber < rhs.localSequenceNumber
55
+        }
56
+        return lhs.id.uuidString < rhs.id.uuidString
57
+    }
58
+
59
+    func previousInTimeline(_ snapshots: [HealthSnapshot]) -> HealthSnapshot? {
60
+        if let previousSnapshotID,
61
+           let previous = snapshots.first(where: { $0.id == previousSnapshotID }) {
62
+            return previous
63
+        }
64
+
65
+        let orderedSnapshots = snapshots.sorted(by: Self.timelineSort)
66
+        guard let index = orderedSnapshots.firstIndex(where: { $0.id == id }),
67
+              index > 0 else {
68
+            return nil
69
+        }
70
+        return orderedSnapshots[index - 1]
71
+    }
72
+
73
+    func nextInTimeline(_ snapshots: [HealthSnapshot]) -> HealthSnapshot? {
74
+        let orderedSnapshots = snapshots.sorted(by: Self.timelineSort)
75
+        guard let index = orderedSnapshots.firstIndex(where: { $0.id == id }),
76
+              index < orderedSnapshots.count - 1 else {
77
+            return nil
78
+        }
79
+        return orderedSnapshots[index + 1]
80
+    }
81
+
41 82
     var snapshotQuality: SnapshotQuality {
42 83
         get { SnapshotQuality(rawValue: snapshotQualityRaw) ?? .complete }
43 84
         set { snapshotQualityRaw = newValue.rawValue }
@@ -55,4 +96,8 @@ extension HealthSnapshot {
55 96
     var contentRepresentativeSnapshotID: UUID {
56 97
         contentEquivalentSnapshotID ?? id
57 98
     }
99
+
100
+    var hasCurrentCachedSummary: Bool {
101
+        cachedSummaryVersion >= Self.currentCachedSummaryVersion
102
+    }
58 103
 }
+33 -0
HealthProbe/Models/SnapshotDelta.swift
@@ -9,6 +9,11 @@ import SwiftData
9 9
     var computedAt: Date = Date.now
10 10
     var checksumBefore: String = ""
11 11
     var checksumAfter: String = ""
12
+    var listSummaryVersion: Int = 0
13
+    var absoluteRecordChangeCount: Int = 0
14
+    var changedMetricCount: Int = 0
15
+    var appearedMetricCount: Int = 0
16
+    var disappearedMetricCount: Int = 0
12 17
     var isCloudKitImported: Bool = false
13 18
     @Relationship(deleteRule: .cascade, inverse: \TypeDelta.delta)
14 19
     var typeDeltas: [TypeDelta]? = []
@@ -20,3 +25,31 @@ import SwiftData
20 25
         self.deviceID = deviceID
21 26
     }
22 27
 }
28
+
29
+struct SnapshotDeltaListSummary {
30
+    let absoluteRecordChangeCount: Int
31
+    let changedMetricCount: Int
32
+    let appearedMetricCount: Int
33
+    let disappearedMetricCount: Int
34
+
35
+    var affectedMetricCount: Int {
36
+        changedMetricCount + appearedMetricCount + disappearedMetricCount
37
+    }
38
+
39
+    var hasChanges: Bool {
40
+        absoluteRecordChangeCount > 0 || affectedMetricCount > 0
41
+    }
42
+}
43
+
44
+extension SnapshotDelta {
45
+    static let currentListSummaryVersion = 1
46
+
47
+    var listSummary: SnapshotDeltaListSummary {
48
+        SnapshotDeltaListSummary(
49
+            absoluteRecordChangeCount: absoluteRecordChangeCount,
50
+            changedMetricCount: changedMetricCount,
51
+            appearedMetricCount: appearedMetricCount,
52
+            disappearedMetricCount: disappearedMetricCount
53
+        )
54
+    }
55
+}
+383 -48
HealthProbe/Models/TypeCountDetailCache.swift
@@ -41,6 +41,10 @@ enum TypeCountDetailCacheArchive {
41 41
 }
42 42
 
43 43
 enum TypeCountDetailCacheBuilder {
44
+    private static let bucketTargetArchiveBytes = 8 * 1_024 * 1_024
45
+    private static let minBucketCount = 4
46
+    private static let maxBucketCount = 32
47
+
44 48
     static func build(
45 49
         current: TypeCount,
46 50
         previous: TypeCount?,
@@ -48,88 +52,419 @@ enum TypeCountDetailCacheBuilder {
48 52
     ) -> TypeCountDetailCache? {
49 53
         let metadata = buildMetadata(current: current, previous: previous)
50 54
         MemoryLog.log("typeCountDetailCache.build.begin", metadata: metadata)
51
-        guard !MemoryLog.isFootprintAtOrAbove(MemoryLog.detailCacheBuildFootprintLimit) else {
52
-            MemoryLog.log("typeCountDetailCache.build.skippedMemoryPressure", metadata: metadata.merging([
55
+
56
+        if current.count > 0, current.recordArchiveData == nil { return nil }
57
+        if let previous, previous.count > 0, previous.recordArchiveData == nil { return nil }
58
+
59
+        var accumulator = DetailCacheAccumulator()
60
+
61
+        if MemoryLog.isFootprintAtOrAbove(MemoryLog.detailCacheBuildFootprintLimit) {
62
+            MemoryLog.log("typeCountDetailCache.build.fastPathSkippedMemoryPressure", metadata: metadata.merging([
53 63
                 "limit": MemoryLog.format(MemoryLog.detailCacheBuildFootprintLimit)
54 64
             ]) { _, new in new })
55
-            return nil
65
+            guard buildBucketedDiffData(
66
+                currentArchive: current.recordArchiveData,
67
+                previousArchive: previous?.recordArchiveData,
68
+                metadata: metadata,
69
+                accumulator: &accumulator
70
+            ) else {
71
+                return nil
72
+            }
73
+        } else if !buildFastDiffData(
74
+            currentArchive: current.recordArchiveData,
75
+            previousArchive: previous?.recordArchiveData,
76
+            metadata: metadata,
77
+            accumulator: &accumulator
78
+        ) {
79
+            MemoryLog.log("typeCountDetailCache.build.fastPathFailedFallingBack", metadata: metadata)
80
+            accumulator = DetailCacheAccumulator()
81
+            guard buildBucketedDiffData(
82
+                currentArchive: current.recordArchiveData,
83
+                previousArchive: previous?.recordArchiveData,
84
+                metadata: metadata,
85
+                accumulator: &accumulator
86
+            ) else {
87
+                return nil
88
+            }
56 89
         }
57 90
 
58
-        guard let currentFingerprints = HealthRecordArchive.fingerprintSet(from: current.recordArchiveData) else {
59
-            MemoryLog.log("typeCountDetailCache.build.currentFingerprintFailed", metadata: metadata)
60
-            return nil
61
-        }
62
-        MemoryLog.log("typeCountDetailCache.build.currentFingerprintsReady", metadata: metadata.merging([
63
-            "currentFingerprintCount": "\(currentFingerprints.count)"
91
+        let cache = TypeCountDetailCache(
92
+            baselineSnapshotID: baselineSnapshotID,
93
+            addedCount: accumulator.addedCount,
94
+            disappearedCount: accumulator.disappearedCount,
95
+            addedPreviewRecords: accumulator.addedPreview.records,
96
+            disappearedPreviewRecords: accumulator.disappearedPreview.records,
97
+            dailyChangeBins: accumulator.dailyBins,
98
+            earliestRecordDate: accumulator.earliestRecordDate,
99
+            latestRecordDate: accumulator.latestRecordDate
100
+        )
101
+        MemoryLog.log("typeCountDetailCache.build.finished", metadata: metadata.merging([
102
+            "added": "\(cache.addedCount)",
103
+            "disappeared": "\(cache.disappearedCount)",
104
+            "dailyBins": "\(cache.dailyChangeBins.count)"
64 105
         ]) { _, new in new })
65
-        guard !MemoryLog.isFootprintAtOrAbove(MemoryLog.detailCacheBuildFootprintLimit) else {
66
-            MemoryLog.log("typeCountDetailCache.build.skippedAfterCurrentFingerprints", metadata: metadata.merging([
67
-                "limit": MemoryLog.format(MemoryLog.detailCacheBuildFootprintLimit)
106
+        return cache
107
+    }
108
+
109
+    private static func buildFastDiffData(
110
+        currentArchive: Data?,
111
+        previousArchive: Data?,
112
+        metadata: [String: String],
113
+        accumulator: inout DetailCacheAccumulator
114
+    ) -> Bool {
115
+        switch (currentArchive, previousArchive) {
116
+        case let (currentArchive?, previousArchive?):
117
+            if currentArchive.count <= previousArchive.count {
118
+                return scanUsingCurrentFingerprintSet(
119
+                    currentArchive: currentArchive,
120
+                    previousArchive: previousArchive,
121
+                    metadata: metadata,
122
+                    accumulator: &accumulator
123
+                )
124
+            }
125
+
126
+            return scanUsingPreviousFingerprintSet(
127
+                currentArchive: currentArchive,
128
+                previousArchive: previousArchive,
129
+                metadata: metadata,
130
+                accumulator: &accumulator
131
+            )
132
+
133
+        case let (currentArchive?, nil):
134
+            guard HealthRecordArchive.forEachRecord(in: currentArchive, { record in
135
+                accumulator.add(record, as: .added)
136
+            }) else {
137
+                MemoryLog.log("typeCountDetailCache.build.currentScanFailed", metadata: metadata)
138
+                return false
139
+            }
140
+            MemoryLog.log("typeCountDetailCache.build.currentScanFinished", metadata: metadata)
141
+            return true
142
+
143
+        case let (nil, previousArchive?):
144
+            guard HealthRecordArchive.forEachRecord(in: previousArchive, { record in
145
+                accumulator.add(record, as: .disappeared)
146
+            }) else {
147
+                MemoryLog.log("typeCountDetailCache.build.previousScanFailed", metadata: metadata)
148
+                return false
149
+            }
150
+            MemoryLog.log("typeCountDetailCache.build.previousScanFinished", metadata: metadata)
151
+            return true
152
+
153
+        case (nil, nil):
154
+            return true
155
+        }
156
+    }
157
+
158
+    private static func buildBucketedDiffData(
159
+        currentArchive: Data?,
160
+        previousArchive: Data?,
161
+        metadata: [String: String],
162
+        accumulator: inout DetailCacheAccumulator
163
+    ) -> Bool {
164
+        switch (currentArchive, previousArchive) {
165
+        case let (currentArchive?, previousArchive?):
166
+            let bucketCount = bucketCount(for: max(currentArchive.count, previousArchive.count))
167
+            MemoryLog.log("typeCountDetailCache.build.bucketedBegin", metadata: metadata.merging([
168
+                "bucketCount": "\(bucketCount)"
68 169
             ]) { _, new in new })
69
-            return nil
170
+
171
+            let didBuild: Bool
172
+            if currentArchive.count <= previousArchive.count {
173
+                didBuild = scanBucketsUsingCurrentFingerprintSet(
174
+                    currentArchive: currentArchive,
175
+                    previousArchive: previousArchive,
176
+                    bucketCount: bucketCount,
177
+                    metadata: metadata,
178
+                    accumulator: &accumulator
179
+                )
180
+            } else {
181
+                didBuild = scanBucketsUsingPreviousFingerprintSet(
182
+                    currentArchive: currentArchive,
183
+                    previousArchive: previousArchive,
184
+                    bucketCount: bucketCount,
185
+                    metadata: metadata,
186
+                    accumulator: &accumulator
187
+                )
188
+            }
189
+
190
+            if didBuild {
191
+                MemoryLog.log("typeCountDetailCache.build.bucketedEnd", metadata: metadata.merging([
192
+                    "bucketCount": "\(bucketCount)"
193
+                ]) { _, new in new })
194
+            }
195
+            return didBuild
196
+
197
+        case let (currentArchive?, nil):
198
+            guard HealthRecordArchive.forEachRecord(in: currentArchive, { record in
199
+                accumulator.add(record, as: .added)
200
+            }) else {
201
+                MemoryLog.log("typeCountDetailCache.build.currentScanFailed", metadata: metadata)
202
+                return false
203
+            }
204
+            MemoryLog.log("typeCountDetailCache.build.currentScanFinished", metadata: metadata)
205
+            return true
206
+
207
+        case let (nil, previousArchive?):
208
+            guard HealthRecordArchive.forEachRecord(in: previousArchive, { record in
209
+                accumulator.add(record, as: .disappeared)
210
+            }) else {
211
+                MemoryLog.log("typeCountDetailCache.build.previousScanFailed", metadata: metadata)
212
+                return false
213
+            }
214
+            MemoryLog.log("typeCountDetailCache.build.previousScanFinished", metadata: metadata)
215
+            return true
216
+
217
+        case (nil, nil):
218
+            return true
70 219
         }
220
+    }
71 221
 
72
-        guard let previousFingerprints = HealthRecordArchive.fingerprintSet(from: previous?.recordArchiveData) else {
222
+    private static func scanUsingPreviousFingerprintSet(
223
+        currentArchive: Data,
224
+        previousArchive: Data,
225
+        metadata: [String: String],
226
+        accumulator: inout DetailCacheAccumulator
227
+    ) -> Bool {
228
+        guard var unmatchedPreviousFingerprints = HealthRecordArchive.fingerprintSet(from: previousArchive) else {
73 229
             MemoryLog.log("typeCountDetailCache.build.previousFingerprintFailed", metadata: metadata)
74
-            return nil
230
+            return false
75 231
         }
76 232
         MemoryLog.log("typeCountDetailCache.build.previousFingerprintsReady", metadata: metadata.merging([
77
-            "previousFingerprintCount": "\(previousFingerprints.count)"
233
+            "previousFingerprintCount": "\(unmatchedPreviousFingerprints.count)",
234
+            "fingerprintStrategy": "previousOnly"
78 235
         ]) { _, new in new })
79 236
         guard !MemoryLog.isFootprintAtOrAbove(MemoryLog.detailCacheBuildFootprintLimit) else {
80 237
             MemoryLog.log("typeCountDetailCache.build.skippedAfterPreviousFingerprints", metadata: metadata.merging([
81 238
                 "limit": MemoryLog.format(MemoryLog.detailCacheBuildFootprintLimit)
82 239
             ]) { _, new in new })
83
-            return nil
240
+            return false
84 241
         }
85 242
 
86
-        if current.count > 0, current.recordArchiveData == nil { return nil }
87
-        if let previous, previous.count > 0, previous.recordArchiveData == nil { return nil }
243
+        guard HealthRecordArchive.forEachRecord(in: currentArchive, { record in
244
+            if unmatchedPreviousFingerprints.remove(record.recordFingerprint) != nil {
245
+                accumulator.add(record, as: .unchanged)
246
+            } else {
247
+                accumulator.add(record, as: .added)
248
+            }
249
+        }) else {
250
+            MemoryLog.log("typeCountDetailCache.build.currentScanFailed", metadata: metadata)
251
+            return false
252
+        }
253
+        MemoryLog.log("typeCountDetailCache.build.currentScanFinished", metadata: metadata)
88 254
 
89
-        var accumulator = DetailCacheAccumulator()
255
+        guard HealthRecordArchive.forEachRecord(in: previousArchive, { record in
256
+            if unmatchedPreviousFingerprints.contains(record.recordFingerprint) {
257
+                accumulator.add(record, as: .disappeared)
258
+            }
259
+        }) else {
260
+            MemoryLog.log("typeCountDetailCache.build.previousScanFailed", metadata: metadata)
261
+            return false
262
+        }
263
+        MemoryLog.log("typeCountDetailCache.build.previousScanFinished", metadata: metadata)
264
+        return true
265
+    }
90 266
 
91
-        if let previousArchive = previous?.recordArchiveData {
267
+    private static func scanUsingCurrentFingerprintSet(
268
+        currentArchive: Data,
269
+        previousArchive: Data,
270
+        metadata: [String: String],
271
+        accumulator: inout DetailCacheAccumulator
272
+    ) -> Bool {
273
+        guard var unmatchedCurrentFingerprints = HealthRecordArchive.fingerprintSet(from: currentArchive) else {
274
+            MemoryLog.log("typeCountDetailCache.build.currentFingerprintFailed", metadata: metadata)
275
+            return false
276
+        }
277
+        MemoryLog.log("typeCountDetailCache.build.currentFingerprintsReady", metadata: metadata.merging([
278
+            "currentFingerprintCount": "\(unmatchedCurrentFingerprints.count)",
279
+            "fingerprintStrategy": "currentOnly"
280
+        ]) { _, new in new })
281
+        guard !MemoryLog.isFootprintAtOrAbove(MemoryLog.detailCacheBuildFootprintLimit) else {
282
+            MemoryLog.log("typeCountDetailCache.build.skippedAfterCurrentFingerprints", metadata: metadata.merging([
283
+                "limit": MemoryLog.format(MemoryLog.detailCacheBuildFootprintLimit)
284
+            ]) { _, new in new })
285
+            return false
286
+        }
287
+
288
+        guard HealthRecordArchive.forEachRecord(in: previousArchive, { record in
289
+            if unmatchedCurrentFingerprints.remove(record.recordFingerprint) == nil {
290
+                accumulator.add(record, as: .disappeared)
291
+            }
292
+        }) else {
293
+            MemoryLog.log("typeCountDetailCache.build.previousScanFailed", metadata: metadata)
294
+            return false
295
+        }
296
+        MemoryLog.log("typeCountDetailCache.build.previousScanFinished", metadata: metadata)
297
+
298
+        guard HealthRecordArchive.forEachRecord(in: currentArchive, { record in
299
+            if unmatchedCurrentFingerprints.contains(record.recordFingerprint) {
300
+                accumulator.add(record, as: .added)
301
+            } else {
302
+                accumulator.add(record, as: .unchanged)
303
+            }
304
+        }) else {
305
+            MemoryLog.log("typeCountDetailCache.build.currentScanFailed", metadata: metadata)
306
+            return false
307
+        }
308
+        MemoryLog.log("typeCountDetailCache.build.currentScanFinished", metadata: metadata)
309
+        return true
310
+    }
311
+
312
+    private static func scanBucketsUsingPreviousFingerprintSet(
313
+        currentArchive: Data,
314
+        previousArchive: Data,
315
+        bucketCount: Int,
316
+        metadata: [String: String],
317
+        accumulator: inout DetailCacheAccumulator
318
+    ) -> Bool {
319
+        for bucketNumber in 0..<bucketCount {
320
+            var unmatchedPreviousFingerprints: Set<String> = []
92 321
             guard HealthRecordArchive.forEachRecord(in: previousArchive, { record in
93
-                if !currentFingerprints.contains(record.recordFingerprint) {
94
-                    accumulator.add(record, as: .disappeared)
322
+                guard fingerprintBucketIndex(for: record.recordFingerprint, bucketCount: bucketCount) == bucketNumber else {
323
+                    return
95 324
                 }
325
+                unmatchedPreviousFingerprints.insert(record.recordFingerprint)
96 326
             }) else {
97
-                MemoryLog.log("typeCountDetailCache.build.previousScanFailed", metadata: metadata)
98
-                return nil
327
+                MemoryLog.log("typeCountDetailCache.build.previousFingerprintFailed", metadata: bucketMetadata(
328
+                    metadata: metadata,
329
+                    bucketIndex: bucketNumber,
330
+                    bucketCount: bucketCount,
331
+                    strategy: "previousBucketed"
332
+                ))
333
+                return false
99 334
             }
100
-            MemoryLog.log("typeCountDetailCache.build.previousScanFinished", metadata: metadata)
101
-        }
102 335
 
103
-        if let currentArchive = current.recordArchiveData {
104 336
             guard HealthRecordArchive.forEachRecord(in: currentArchive, { record in
105
-                if previousFingerprints.contains(record.recordFingerprint) {
337
+                guard fingerprintBucketIndex(for: record.recordFingerprint, bucketCount: bucketCount) == bucketNumber else {
338
+                    return
339
+                }
340
+                if unmatchedPreviousFingerprints.remove(record.recordFingerprint) != nil {
106 341
                     accumulator.add(record, as: .unchanged)
107 342
                 } else {
108 343
                     accumulator.add(record, as: .added)
109 344
                 }
110 345
             }) else {
111
-                MemoryLog.log("typeCountDetailCache.build.currentScanFailed", metadata: metadata)
112
-                return nil
346
+                MemoryLog.log("typeCountDetailCache.build.currentScanFailed", metadata: bucketMetadata(
347
+                    metadata: metadata,
348
+                    bucketIndex: bucketNumber,
349
+                    bucketCount: bucketCount,
350
+                    strategy: "previousBucketed"
351
+                ))
352
+                return false
353
+            }
354
+
355
+            guard HealthRecordArchive.forEachRecord(in: previousArchive, { record in
356
+                guard fingerprintBucketIndex(for: record.recordFingerprint, bucketCount: bucketCount) == bucketNumber else {
357
+                    return
358
+                }
359
+                if unmatchedPreviousFingerprints.contains(record.recordFingerprint) {
360
+                    accumulator.add(record, as: .disappeared)
361
+                }
362
+            }) else {
363
+                MemoryLog.log("typeCountDetailCache.build.previousScanFailed", metadata: bucketMetadata(
364
+                    metadata: metadata,
365
+                    bucketIndex: bucketNumber,
366
+                    bucketCount: bucketCount,
367
+                    strategy: "previousBucketed"
368
+                ))
369
+                return false
113 370
             }
114
-            MemoryLog.log("typeCountDetailCache.build.currentScanFinished", metadata: metadata)
115 371
         }
116 372
 
117
-        let cache = TypeCountDetailCache(
118
-            baselineSnapshotID: baselineSnapshotID,
119
-            addedCount: accumulator.addedCount,
120
-            disappearedCount: accumulator.disappearedCount,
121
-            addedPreviewRecords: accumulator.addedPreview.records,
122
-            disappearedPreviewRecords: accumulator.disappearedPreview.records,
123
-            dailyChangeBins: accumulator.dailyBins,
124
-            earliestRecordDate: accumulator.earliestRecordDate,
125
-            latestRecordDate: accumulator.latestRecordDate
126
-        )
127
-        MemoryLog.log("typeCountDetailCache.build.finished", metadata: metadata.merging([
128
-            "added": "\(cache.addedCount)",
129
-            "disappeared": "\(cache.disappearedCount)",
130
-            "dailyBins": "\(cache.dailyChangeBins.count)"
131
-        ]) { _, new in new })
132
-        return cache
373
+        return true
374
+    }
375
+
376
+    private static func scanBucketsUsingCurrentFingerprintSet(
377
+        currentArchive: Data,
378
+        previousArchive: Data,
379
+        bucketCount: Int,
380
+        metadata: [String: String],
381
+        accumulator: inout DetailCacheAccumulator
382
+    ) -> Bool {
383
+        for bucketNumber in 0..<bucketCount {
384
+            var unmatchedCurrentFingerprints: Set<String> = []
385
+            guard HealthRecordArchive.forEachRecord(in: currentArchive, { record in
386
+                guard fingerprintBucketIndex(for: record.recordFingerprint, bucketCount: bucketCount) == bucketNumber else {
387
+                    return
388
+                }
389
+                unmatchedCurrentFingerprints.insert(record.recordFingerprint)
390
+            }) else {
391
+                MemoryLog.log("typeCountDetailCache.build.currentFingerprintFailed", metadata: bucketMetadata(
392
+                    metadata: metadata,
393
+                    bucketIndex: bucketNumber,
394
+                    bucketCount: bucketCount,
395
+                    strategy: "currentBucketed"
396
+                ))
397
+                return false
398
+            }
399
+
400
+            guard HealthRecordArchive.forEachRecord(in: previousArchive, { record in
401
+                guard fingerprintBucketIndex(for: record.recordFingerprint, bucketCount: bucketCount) == bucketNumber else {
402
+                    return
403
+                }
404
+                if unmatchedCurrentFingerprints.remove(record.recordFingerprint) == nil {
405
+                    accumulator.add(record, as: .disappeared)
406
+                }
407
+            }) else {
408
+                MemoryLog.log("typeCountDetailCache.build.previousScanFailed", metadata: bucketMetadata(
409
+                    metadata: metadata,
410
+                    bucketIndex: bucketNumber,
411
+                    bucketCount: bucketCount,
412
+                    strategy: "currentBucketed"
413
+                ))
414
+                return false
415
+            }
416
+
417
+            guard HealthRecordArchive.forEachRecord(in: currentArchive, { record in
418
+                guard fingerprintBucketIndex(for: record.recordFingerprint, bucketCount: bucketCount) == bucketNumber else {
419
+                    return
420
+                }
421
+                if unmatchedCurrentFingerprints.contains(record.recordFingerprint) {
422
+                    accumulator.add(record, as: .added)
423
+                } else {
424
+                    accumulator.add(record, as: .unchanged)
425
+                }
426
+            }) else {
427
+                MemoryLog.log("typeCountDetailCache.build.currentScanFailed", metadata: bucketMetadata(
428
+                    metadata: metadata,
429
+                    bucketIndex: bucketNumber,
430
+                    bucketCount: bucketCount,
431
+                    strategy: "currentBucketed"
432
+                ))
433
+                return false
434
+            }
435
+        }
436
+
437
+        return true
438
+    }
439
+
440
+    private static func bucketCount(for maxArchiveBytes: Int) -> Int {
441
+        let rawBucketCount = max(1, Int(ceil(Double(maxArchiveBytes) / Double(bucketTargetArchiveBytes))))
442
+        var bucketCount = 1
443
+        while bucketCount < rawBucketCount {
444
+            bucketCount <<= 1
445
+        }
446
+        return min(max(bucketCount, minBucketCount), maxBucketCount)
447
+    }
448
+
449
+    private static func fingerprintBucketIndex(for fingerprint: String, bucketCount: Int) -> Int {
450
+        var hash: UInt64 = 1_469_598_103_934_665_603
451
+        for byte in fingerprint.utf8.prefix(12) {
452
+            hash ^= UInt64(byte)
453
+            hash &*= 1_099_511_628_211
454
+        }
455
+        return Int(hash % UInt64(bucketCount))
456
+    }
457
+
458
+    private static func bucketMetadata(
459
+        metadata: [String: String],
460
+        bucketIndex: Int,
461
+        bucketCount: Int,
462
+        strategy: String
463
+    ) -> [String: String] {
464
+        metadata.merging([
465
+            "bucket": "\(bucketIndex + 1)/\(bucketCount)",
466
+            "fingerprintStrategy": strategy
467
+        ]) { _, new in new }
133 468
     }
134 469
 
135 470
     private static func buildMetadata(current: TypeCount, previous: TypeCount?) -> [String: String] {
+54 -0
HealthProbe/Services/DeltaService.swift
@@ -36,6 +36,7 @@ enum DeltaService {
36 36
         if current.isContentAlias,
37 37
            current.contentEquivalentSnapshotID == previous.contentRepresentativeSnapshotID {
38 38
             delta.typeDeltas = []
39
+            updateListSummary(for: delta, typeDeltas: [])
39 40
             context.insert(delta)
40 41
             try context.save()
41 42
             return delta
@@ -75,11 +76,33 @@ enum DeltaService {
75 76
         }
76 77
 
77 78
         delta.typeDeltas = typeDeltas
79
+        updateListSummary(for: delta, typeDeltas: typeDeltas)
78 80
         context.insert(delta)
79 81
         try context.save()
80 82
         return delta
81 83
     }
82 84
 
85
+    @discardableResult
86
+    static func rebuildMissingListSummaries(context: ModelContext, maxCount: Int) throws -> Bool {
87
+        guard maxCount > 0 else { return false }
88
+
89
+        let summaryVersion = SnapshotDelta.currentListSummaryVersion
90
+        var descriptor = FetchDescriptor<SnapshotDelta>(
91
+            predicate: #Predicate<SnapshotDelta> { $0.listSummaryVersion < summaryVersion }
92
+        )
93
+        descriptor.fetchLimit = maxCount
94
+
95
+        let deltas = try context.fetch(descriptor)
96
+        guard !deltas.isEmpty else { return false }
97
+
98
+        for delta in deltas {
99
+            updateListSummary(for: delta, typeDeltas: delta.typeDeltas ?? [])
100
+        }
101
+
102
+        try context.save()
103
+        return true
104
+    }
105
+
83 106
     // MARK: - Delta merge (for intermediate snapshot deletion)
84 107
 
85 108
     // snapshotBefore and snapshotAfter are the real surrounding snapshots (N-1 and N+1).
@@ -127,6 +150,7 @@ enum DeltaService {
127 150
             }
128 151
         }
129 152
         merged.typeDeltas = mergedTypeDeltas
153
+        updateListSummary(for: merged, typeDeltas: mergedTypeDeltas)
130 154
         return merged
131 155
     }
132 156
 
@@ -197,6 +221,36 @@ enum DeltaService {
197 221
         return td
198 222
     }
199 223
 
224
+    private static func updateListSummary(for delta: SnapshotDelta, typeDeltas: [TypeDelta]) {
225
+        var absoluteRecordChangeCount = 0
226
+        var changedMetricCount = 0
227
+        var appearedMetricCount = 0
228
+        var disappearedMetricCount = 0
229
+
230
+        for typeDelta in typeDeltas {
231
+            switch typeDelta.transition {
232
+            case .unchanged:
233
+                continue
234
+            case .changed:
235
+                changedMetricCount += 1
236
+                if typeDelta.qualityBefore == .complete,
237
+                   typeDelta.qualityAfter == .complete {
238
+                    absoluteRecordChangeCount += abs(typeDelta.countDelta)
239
+                }
240
+            case .appeared:
241
+                appearedMetricCount += 1
242
+            case .disappeared:
243
+                disappearedMetricCount += 1
244
+            }
245
+        }
246
+
247
+        delta.absoluteRecordChangeCount = absoluteRecordChangeCount
248
+        delta.changedMetricCount = changedMetricCount
249
+        delta.appearedMetricCount = appearedMetricCount
250
+        delta.disappearedMetricCount = disappearedMetricCount
251
+        delta.listSummaryVersion = SnapshotDelta.currentListSummaryVersion
252
+    }
253
+
200 254
     private static func historicalBaselinePreviousTypeCount(
201 255
         typeID: String,
202 256
         prev: TypeCount?,
+20 -0
HealthProbe/Services/HealthKitService.swift
@@ -133,6 +133,7 @@ final class HealthKitService {
133 133
         }
134 134
 
135 135
         snapshot.snapshotQuality = deriveSnapshotQuality(from: typeCounts)
136
+        updateSnapshotSummaryCache(snapshot: snapshot, typeCounts: typeCounts)
136 137
 
137 138
         configureSnapshotMetadata(
138 139
             snapshot,
@@ -171,6 +172,8 @@ final class HealthKitService {
171 172
             return snapshot
172 173
         }
173 174
 
175
+        updateSnapshotSummaryCache(snapshot: snapshot, typeCounts: typeCounts)
176
+
174 177
         configureSnapshotMetadata(
175 178
             snapshot,
176 179
             typeCounts: typeCounts,
@@ -199,6 +202,8 @@ final class HealthKitService {
199 202
             return try await savePartialSnapshot(snapshot, in: context)
200 203
         }
201 204
 
205
+        updateSnapshotSummaryCache(snapshot: snapshot, typeCounts: typeCounts)
206
+
202 207
         configureSnapshotMetadata(
203 208
             snapshot,
204 209
             typeCounts: typeCounts,
@@ -245,6 +250,21 @@ final class HealthKitService {
245 250
         try await runPostSavePipeline(snapshot: snapshot, typeCounts: typeCounts, context: context)
246 251
     }
247 252
 
253
+    private func updateSnapshotSummaryCache(
254
+        snapshot: HealthSnapshot,
255
+        typeCounts: [TypeCount]
256
+    ) {
257
+        snapshot.cachedSummaryVersion = HealthSnapshot.currentCachedSummaryVersion
258
+        snapshot.cachedTypeCount = typeCounts.count
259
+        snapshot.cachedRecordCount = typeCounts.reduce(0) { partial, typeCount in
260
+            typeCount.count > 0 ? partial + typeCount.count : partial
261
+        }
262
+
263
+        let datedTypeCounts = typeCounts.filter { !$0.isUnsupported && $0.count > 0 }
264
+        snapshot.cachedEarliestRecordDate = datedTypeCounts.compactMap(\.earliestDate).min()
265
+        snapshot.cachedLatestRecordDate = datedTypeCounts.compactMap(\.latestDate).max()
266
+    }
267
+
248 268
     private func configureSnapshotMetadata(
249 269
         _ snapshot: HealthSnapshot,
250 270
         typeCounts: [TypeCount],
+13 -13
HealthProbe/Services/ObserverService.swift
@@ -55,12 +55,12 @@ final class ObserverService {
55 55
     // MARK: - Callback handling
56 56
 
57 57
     private func handleObserverCallback(typeID: String) {
58
-        lock.lock()
59
-        let now = Date()
60
-        lastCallbackTimestamp = now
61
-        accumulatedTypeIDs.insert(typeID)
62
-        let alreadyScheduled = debounceTask != nil
63
-        lock.unlock()
58
+        let alreadyScheduled = lock.withLock {
59
+            let now = Date()
60
+            lastCallbackTimestamp = now
61
+            accumulatedTypeIDs.insert(typeID)
62
+            return debounceTask != nil
63
+        }
64 64
 
65 65
         guard !alreadyScheduled else { return }
66 66
 
@@ -74,9 +74,9 @@ final class ObserverService {
74 74
 
75 75
     @MainActor
76 76
     private func tryCreateObserverSnapshot() async {
77
-        lock.lock()
78
-        debounceTask = nil
79
-        lock.unlock()
77
+        lock.withLock {
78
+            debounceTask = nil
79
+        }
80 80
 
81 81
         guard let container = modelContainer else {
82 82
             logger.error("ObserverService: no modelContainer — cannot create snapshot")
@@ -111,10 +111,10 @@ final class ObserverService {
111 111
             logger.error("ObserverService: failed to create snapshot — \(error)")
112 112
         }
113 113
 
114
-        lock.lock()
115
-        accumulatedTypeIDs.removeAll()
116
-        lastCallbackTimestamp = nil
117
-        lock.unlock()
114
+        lock.withLock {
115
+            accumulatedTypeIDs.removeAll()
116
+            lastCallbackTimestamp = nil
117
+        }
118 118
     }
119 119
 
120 120
     // MARK: - Type classification
+20 -37
HealthProbe/Services/SnapshotLifecycleService.swift
@@ -14,9 +14,8 @@ enum SnapshotLifecycleService {
14 14
     }
15 15
 
16 16
     static func previewDeletion(of snapshot: HealthSnapshot, context: ModelContext) throws -> DeletionPreview {
17
-        let allDeltas = try fetchDeltas(context: context)
18
-        let incomingDelta = allDeltas.first { $0.toSnapshotID == snapshot.id }
19
-        let outgoingDelta = allDeltas.first { $0.fromSnapshotID == snapshot.id }
17
+        let incomingDelta = try fetchIncomingDelta(toSnapshotID: snapshot.id, context: context)
18
+        let outgoingDelta = try fetchOutgoingDelta(fromSnapshotID: snapshot.id, context: context)
20 19
 
21 20
         var willBreakChain = false
22 21
         var description = ""
@@ -62,9 +61,8 @@ enum SnapshotLifecycleService {
62 61
     }
63 62
 
64 63
     static func delete(_ snapshot: HealthSnapshot, context: ModelContext) throws {
65
-        let allDeltas = try fetchDeltas(context: context)
66
-        let incomingDelta = allDeltas.first { $0.toSnapshotID == snapshot.id }
67
-        let outgoingDelta = allDeltas.first { $0.fromSnapshotID == snapshot.id }
64
+        let incomingDelta = try fetchIncomingDelta(toSnapshotID: snapshot.id, context: context)
65
+        let outgoingDelta = try fetchOutgoingDelta(fromSnapshotID: snapshot.id, context: context)
68 66
 
69 67
         let deviceID = snapshot.deviceID
70 68
         let version = Bundle.main.appBuildVersion
@@ -92,7 +90,7 @@ enum SnapshotLifecycleService {
92 90
                 for typeCount in nextSnap.typeCounts ?? [] {
93 91
                     typeCount.contentEquivalentTypeCountID = nil
94 92
                 }
95
-                refreshDetailCaches(for: nextSnap, baseline: nil)
93
+                invalidateDetailCaches(for: nextSnap)
96 94
             }
97 95
             context.delete(outgoing)
98 96
             context.delete(snapshot)
@@ -119,7 +117,7 @@ enum SnapshotLifecycleService {
119 117
 
120 118
             nextSnap.previousSnapshotID = prevSnap.id
121 119
             _ = refreshContentEquivalence(for: nextSnap, baseline: prevSnap)
122
-            refreshDetailCaches(for: nextSnap, baseline: prevSnap)
120
+            invalidateDetailCaches(for: nextSnap)
123 121
             context.delete(d1)
124 122
             context.delete(d2)
125 123
             context.delete(snapshot)
@@ -155,9 +153,6 @@ enum SnapshotLifecycleService {
155 153
             "maxTypeCounts": "\(maxTypeCounts)"
156 154
         ])
157 155
 
158
-        let descriptor = FetchDescriptor<HealthSnapshot>(
159
-            sortBy: [SortDescriptor(\.timestamp, order: .forward)]
160
-        )
161 156
         let snapshotIDs = try context.fetch(FetchDescriptor<HealthSnapshot>()).map { $0.id }
162 157
         MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.snapshotsFetched", metadata: [
163 158
             "snapshotCount": "\(snapshotIDs.count)"
@@ -279,8 +274,18 @@ enum SnapshotLifecycleService {
279 274
 
280 275
     // MARK: - Fetch helpers
281 276
 
282
-    private static func fetchDeltas(context: ModelContext) throws -> [SnapshotDelta] {
283
-        try context.fetch(FetchDescriptor<SnapshotDelta>())
277
+    private static func fetchIncomingDelta(toSnapshotID snapshotID: UUID, context: ModelContext) throws -> SnapshotDelta? {
278
+        let descriptor = FetchDescriptor<SnapshotDelta>(
279
+            predicate: #Predicate<SnapshotDelta> { $0.toSnapshotID == snapshotID }
280
+        )
281
+        return try context.fetch(descriptor).first
282
+    }
283
+
284
+    private static func fetchOutgoingDelta(fromSnapshotID snapshotID: UUID, context: ModelContext) throws -> SnapshotDelta? {
285
+        let descriptor = FetchDescriptor<SnapshotDelta>(
286
+            predicate: #Predicate<SnapshotDelta> { $0.fromSnapshotID == snapshotID }
287
+        )
288
+        return try context.fetch(descriptor).first
284 289
     }
285 290
 
286 291
     private static func fetchSnapshot(id: UUID, context: ModelContext) throws -> HealthSnapshot? {
@@ -290,31 +295,9 @@ enum SnapshotLifecycleService {
290 295
         return try context.fetch(descriptor).first
291 296
     }
292 297
 
293
-    private static func refreshDetailCaches(for snapshot: HealthSnapshot, baseline: HealthSnapshot?) {
294
-        guard let baseline else {
295
-            for typeCount in snapshot.typeCounts ?? [] {
296
-                typeCount.setDetailCache(nil)
297
-            }
298
-            return
299
-        }
300
-
301
-        let baselineByType = Dictionary(
302
-            uniqueKeysWithValues: (baseline.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
303
-        )
304
-
298
+    private static func invalidateDetailCaches(for snapshot: HealthSnapshot) {
305 299
         for typeCount in snapshot.typeCounts ?? [] {
306
-            if typeCount.isContentAlias {
307
-                typeCount.setDetailCache(nil)
308
-                continue
309
-            }
310
-
311
-            typeCount.setDetailCache(
312
-                TypeCountDetailCacheBuilder.build(
313
-                    current: typeCount,
314
-                    previous: baselineByType[typeCount.typeIdentifier],
315
-                    baselineSnapshotID: baseline.id
316
-                )
317
-            )
300
+            typeCount.setDetailCache(nil)
318 301
         }
319 302
     }
320 303
 
+6 -6
HealthProbe/Utilities/MemoryLog.swift
@@ -10,7 +10,7 @@ struct MemorySample: Sendable {
10 10
 enum MemoryLog {
11 11
     static let detailCacheBuildFootprintLimit: UInt64 = 1_500 * 1_024 * 1_024
12 12
 
13
-    static func log(_ event: String, metadata: [String: String] = [:]) {
13
+    nonisolated static func log(_ event: String, metadata: [String: String] = [:]) {
14 14
         let sample = currentSample()
15 15
         let metadataText = metadata
16 16
             .sorted { $0.key < $1.key }
@@ -27,7 +27,7 @@ enum MemoryLog {
27 27
         print(line)
28 28
     }
29 29
 
30
-    static func startPulse(_ event: String, metadata: [String: String] = [:], intervalSeconds: UInt64 = 1) -> Task<Void, Never> {
30
+    nonisolated static func startPulse(_ event: String, metadata: [String: String] = [:], intervalSeconds: UInt64 = 1) -> Task<Void, Never> {
31 31
         Task.detached(priority: .background) {
32 32
             var previousSample = currentSample()
33 33
             var previousTime = Date()
@@ -59,23 +59,23 @@ enum MemoryLog {
59 59
         }
60 60
     }
61 61
 
62
-    static func format(_ bytes: UInt64) -> String {
62
+    nonisolated static func format(_ bytes: UInt64) -> String {
63 63
         ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .memory)
64 64
     }
65 65
 
66
-    static func isFootprintAtOrAbove(_ bytes: UInt64) -> Bool {
66
+    nonisolated static func isFootprintAtOrAbove(_ bytes: UInt64) -> Bool {
67 67
         guard let currentSample = currentSample() else { return false }
68 68
         return currentSample.physicalFootprintBytes >= bytes
69 69
     }
70 70
 
71
-    private static func signedFormat(_ bytes: Int64) -> String {
71
+    nonisolated private static func signedFormat(_ bytes: Int64) -> String {
72 72
         if bytes >= 0 {
73 73
             return "+\(format(UInt64(bytes)))"
74 74
         }
75 75
         return "-\(format(UInt64(-bytes)))"
76 76
     }
77 77
 
78
-    private static func currentSample() -> MemorySample? {
78
+    nonisolated private static func currentSample() -> MemorySample? {
79 79
         var info = task_vm_info_data_t()
80 80
         var count = mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<integer_t>.size)
81 81
         let result = withUnsafeMutablePointer(to: &info) { pointer in
+168 -0
HealthProbe/Utilities/TypeCountArchiveRepair.swift
@@ -0,0 +1,168 @@
1
+import Foundation
2
+import SwiftData
3
+
4
+struct TypeCountDetailCacheResolution: Sendable {
5
+    let cache: TypeCountDetailCache?
6
+    let diagnostic: String
7
+}
8
+
9
+extension TypeCount {
10
+    private static let detailCacheResolverVersion = "resolver-v5"
11
+
12
+    @MainActor
13
+    func ensureRecordArchiveDataIfNeeded() -> Bool {
14
+        if count <= 0 {
15
+            return true
16
+        }
17
+
18
+        if let existingArchive = recordArchiveData {
19
+            if HealthRecordArchive.isCompact(existingArchive) {
20
+                return true
21
+            }
22
+
23
+            guard let compactArchive = HealthRecordArchive.compactedIfNeeded(existingArchive) else {
24
+                return false
25
+            }
26
+            recordArchiveData = compactArchive
27
+            return true
28
+        }
29
+
30
+        let legacyRecords = records ?? []
31
+        guard !legacyRecords.isEmpty else {
32
+            return false
33
+        }
34
+
35
+        let values = legacyRecords.map { record in
36
+            HealthRecordValue(
37
+                typeIdentifier: record.typeIdentifier,
38
+                sampleUUIDHash: record.sampleUUIDHash,
39
+                recordFingerprint: record.recordFingerprint,
40
+                startDate: record.startDate,
41
+                endDate: record.endDate,
42
+                displayValue: record.displayValue
43
+            )
44
+        }
45
+        guard let archive = HealthRecordArchive.encode(values) else {
46
+            return false
47
+        }
48
+
49
+        recordArchiveData = archive
50
+        records?.removeAll()
51
+        return true
52
+    }
53
+
54
+    @MainActor
55
+    func resolveDetailCache(
56
+        previous: TypeCount?,
57
+        baselineSnapshotID: UUID?,
58
+        context: ModelContext,
59
+        source: String
60
+    ) -> TypeCountDetailCache? {
61
+        resolveDetailCacheWithDiagnostics(
62
+            previous: previous,
63
+            baselineSnapshotID: baselineSnapshotID,
64
+            context: context,
65
+            source: source
66
+        ).cache
67
+    }
68
+
69
+    @MainActor
70
+    func resolveDetailCacheWithDiagnostics(
71
+        previous: TypeCount?,
72
+        baselineSnapshotID: UUID?,
73
+        context: ModelContext,
74
+        source: String
75
+    ) -> TypeCountDetailCacheResolution {
76
+        if let cache = detailCache,
77
+           cache.matchesBaseline(baselineSnapshotID) {
78
+            return TypeCountDetailCacheResolution(
79
+                cache: cache,
80
+                diagnostic: detailCacheDiagnostic(
81
+                    previous: previous,
82
+                    baselineSnapshotID: baselineSnapshotID,
83
+                    phase: "cache-hit"
84
+                )
85
+            )
86
+        }
87
+
88
+        let currentArchiveWasMissing = count > 0 && recordArchiveData == nil
89
+        let previousArchiveWasMissing = (previous?.count ?? 0) > 0 && previous?.recordArchiveData == nil
90
+        let currentArchiveAvailable = ensureRecordArchiveDataIfNeeded()
91
+        let previousArchiveAvailable = previous?.ensureRecordArchiveDataIfNeeded() ?? true
92
+
93
+        guard currentArchiveAvailable, previousArchiveAvailable else {
94
+            let diagnostic = detailCacheDiagnostic(
95
+                previous: previous,
96
+                baselineSnapshotID: baselineSnapshotID,
97
+                phase: "missing-archive"
98
+            )
99
+            MemoryLog.log("\(source).detailCache.resolveUnavailable", metadata: detailCacheMetadata(previous: previous).merging([
100
+                "diagnostic": diagnostic
101
+            ]) { _, new in new })
102
+            return TypeCountDetailCacheResolution(cache: nil, diagnostic: diagnostic)
103
+        }
104
+
105
+        MemoryLog.log("\(source).detailCache.buildBegin", metadata: detailCacheMetadata(previous: previous))
106
+        let cache = TypeCountDetailCacheBuilder.build(
107
+            current: self,
108
+            previous: previous,
109
+            baselineSnapshotID: baselineSnapshotID
110
+        )
111
+        let diagnostic = detailCacheDiagnostic(
112
+            previous: previous,
113
+            baselineSnapshotID: baselineSnapshotID,
114
+            phase: cache == nil ? "build-nil" : "built"
115
+        )
116
+        MemoryLog.log("\(source).detailCache.buildEnd", metadata: [
117
+            "source": source,
118
+            "type": typeIdentifier,
119
+            "cacheBuilt": "\(cache != nil)",
120
+            "diagnostic": diagnostic
121
+        ])
122
+
123
+        if let cache {
124
+            setDetailCache(cache)
125
+        }
126
+
127
+        if cache != nil ||
128
+            (currentArchiveWasMissing && recordArchiveData != nil) ||
129
+            (previousArchiveWasMissing && previous?.recordArchiveData != nil) {
130
+            try? context.save()
131
+        }
132
+
133
+        return TypeCountDetailCacheResolution(cache: cache, diagnostic: diagnostic)
134
+    }
135
+
136
+    private func detailCacheMetadata(previous: TypeCount?) -> [String: String] {
137
+        [
138
+            "type": typeIdentifier,
139
+            "currentCount": "\(count)",
140
+            "previousCount": "\(previous?.count ?? 0)",
141
+            "currentArchive": recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil",
142
+            "previousArchive": previous?.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil"
143
+        ]
144
+    }
145
+
146
+    private func detailCacheDiagnostic(
147
+        previous: TypeCount?,
148
+        baselineSnapshotID: UUID?,
149
+        phase: String
150
+    ) -> String {
151
+        let baseline = baselineSnapshotID?.uuidString.prefix(6) ?? "none"
152
+        let currentArchive = archiveDebugLabel(for: self)
153
+        let previousArchive = archiveDebugLabel(for: previous)
154
+        return "\(Self.detailCacheResolverVersion) phase=\(phase) base=\(baseline) curr=\(currentArchive) prev=\(previousArchive)"
155
+    }
156
+
157
+    private func archiveDebugLabel(for typeCount: TypeCount?) -> String {
158
+        guard let typeCount else { return "none" }
159
+        if let archive = typeCount.recordArchiveData {
160
+            let formatSuffix = HealthRecordArchive.isCompact(archive) ? "-c" : "-p"
161
+            return "\(MemoryLog.format(UInt64(archive.count)))\(formatSuffix)"
162
+        }
163
+        if typeCount.count <= 0 {
164
+            return "empty"
165
+        }
166
+        return "missing"
167
+    }
168
+}
+19 -4
HealthProbe/ViewModels/DataTypeTemporalDistributionViewModel.swift
@@ -1,4 +1,5 @@
1 1
 import Foundation
2
+import SwiftData
2 3
 
3 4
 enum BinningStrategy: String, CaseIterable, Equatable {
4 5
     case day = "Zi"
@@ -114,17 +115,18 @@ final class DataTypeTemporalDistributionViewModel: Sendable {
114 115
     private var disappearedByDate: [Date: Int] = [:]
115 116
     private var unchangedByDate: [Date: Int] = [:]
116 117
 
117
-    func load(current: TypeCount?, previous: TypeCount?) async {
118
+    func load(current: TypeCount?, previous: TypeCount?, context: ModelContext) async {
118 119
         defer { isLoading = false }
120
+        error = nil
121
+        hasData = false
119 122
 
120 123
         guard let current else {
121 124
             error = "No current snapshot data"
122 125
             return
123 126
         }
124 127
 
125
-        guard let cache = current.detailCache,
126
-              cache.matchesBaseline(previous?.snapshot?.id) else {
127
-            error = "Precomputed detail data is unavailable for this snapshot. Create a new snapshot to generate temporal detail caches."
128
+        guard let cache = resolveDetailCache(current: current, previous: previous, context: context) else {
129
+            error = "Record detail data could not be computed for this snapshot pair."
128 130
             return
129 131
         }
130 132
 
@@ -141,6 +143,19 @@ final class DataTypeTemporalDistributionViewModel: Sendable {
141 143
         await rebuildBinsBackground()
142 144
     }
143 145
 
146
+    private func resolveDetailCache(
147
+        current: TypeCount,
148
+        previous: TypeCount?,
149
+        context: ModelContext
150
+    ) -> TypeCountDetailCache? {
151
+        let baselineID = previous?.snapshot?.id
152
+        guard let cache = current.detailCache,
153
+              cache.matchesBaseline(baselineID) else {
154
+            return nil
155
+        }
156
+        return cache
157
+    }
158
+
144 159
     private func indexDailyBins(_ bins: [TypeCountDailyChangeBin]) {
145 160
         addedByDate = Dictionary(uniqueKeysWithValues: bins.map { ($0.dayStart, $0.added) })
146 161
         disappearedByDate = Dictionary(uniqueKeysWithValues: bins.map { ($0.dayStart, $0.disappeared) })
+25 -2
HealthProbe/ViewModels/SnapshotsViewModel.swift
@@ -26,11 +26,34 @@ final class SnapshotsViewModel {
26 26
     var comparisonMode: ComparisonMode = .previous
27 27
     var selectedBaseline: HealthSnapshot?
28 28
 
29
+    func baselines(for snapshots: [HealthSnapshot]) -> [UUID: HealthSnapshot] {
30
+        let orderedDescending = snapshots.sorted { $0.timestamp > $1.timestamp }
31
+
32
+        return snapshots.reduce(into: [UUID: HealthSnapshot]()) { partial, snapshot in
33
+            partial[snapshot.id] = baseline(
34
+                for: snapshot,
35
+                in: snapshots,
36
+                orderedDescending: orderedDescending
37
+            )
38
+        }
39
+    }
40
+
29 41
     func baseline(for snapshot: HealthSnapshot, in snapshots: [HealthSnapshot]) -> HealthSnapshot? {
30
-        let sorted = snapshots.sorted { $0.timestamp > $1.timestamp }
42
+        baseline(
43
+            for: snapshot,
44
+            in: snapshots,
45
+            orderedDescending: snapshots.sorted { $0.timestamp > $1.timestamp }
46
+        )
47
+    }
48
+
49
+    private func baseline(
50
+        for snapshot: HealthSnapshot,
51
+        in snapshots: [HealthSnapshot],
52
+        orderedDescending: [HealthSnapshot]
53
+    ) -> HealthSnapshot? {
31 54
         switch comparisonMode {
32 55
         case .previous:
33
-            return sorted.first { $0.timestamp < snapshot.timestamp }
56
+            return orderedDescending.first { $0.timestamp < snapshot.timestamp }
34 57
         case .selected:
35 58
             return selectedBaseline
36 59
         case .relativeTime(let interval):
+7 -10
HealthProbe/Views/DataTypes/DataTypeTemporalDistributionView.swift
@@ -6,6 +6,7 @@ struct DataTypeTemporalDistributionView: View {
6 6
     let previous: TypeCount?
7 7
     let displayName: String
8 8
 
9
+    @Environment(\.modelContext) private var modelContext
9 10
     @State private var viewModel = DataTypeTemporalDistributionViewModel()
10 11
     @State private var isRecomputing = false
11 12
     @State private var isZoomed: Bool = false
@@ -30,21 +31,17 @@ struct DataTypeTemporalDistributionView: View {
30 31
     }
31 32
 
32 33
     private var timelineSnapshots: [HealthSnapshot] {
33
-        allSnapshots.filter { current?.snapshot?.deviceID ?? "" == $0.deviceID }
34
+        allSnapshots
35
+            .filter { current?.snapshot?.deviceID ?? "" == $0.deviceID }
36
+            .sorted(by: HealthSnapshot.timelineSort)
34 37
     }
35 38
 
36 39
     private var previousSnapshot: HealthSnapshot? {
37
-        guard let current = currentSnapshot,
38
-              let idx = timelineSnapshots.firstIndex(where: { $0.id == current.id }),
39
-              idx > 0 else { return nil }
40
-        return timelineSnapshots[idx - 1]
40
+        currentSnapshot?.previousInTimeline(timelineSnapshots)
41 41
     }
42 42
 
43 43
     private var nextSnapshot: HealthSnapshot? {
44
-        guard let current = currentSnapshot,
45
-              let idx = timelineSnapshots.firstIndex(where: { $0.id == current.id }),
46
-              idx < timelineSnapshots.count - 1 else { return nil }
47
-        return timelineSnapshots[idx + 1]
44
+        currentSnapshot?.nextInTimeline(timelineSnapshots)
48 45
     }
49 46
 
50 47
     var body: some View {
@@ -83,7 +80,7 @@ struct DataTypeTemporalDistributionView: View {
83 80
             }
84 81
         }
85 82
         .task {
86
-            await viewModel.load(current: current, previous: previous)
83
+            await viewModel.load(current: current, previous: previous, context: modelContext)
87 84
         }
88 85
     }
89 86
 
+32 -13
HealthProbe/Views/DataTypes/RecordChangeEvolutionChart.swift
@@ -1,4 +1,5 @@
1 1
 import SwiftUI
2
+import SwiftData
2 3
 
3 4
 struct RecordChangeEvolutionChart: View {
4 5
     let snapshots: [HealthSnapshot]
@@ -6,8 +7,10 @@ struct RecordChangeEvolutionChart: View {
6 7
     let typeIdentifier: String
7 8
     let displayName: String
8 9
 
10
+    @Query private var allDeltas: [SnapshotDelta]
11
+
9 12
     private var sortedSnapshots: [HealthSnapshot] {
10
-        snapshots.sorted { $0.timestamp < $1.timestamp }
13
+        snapshots.sorted(by: HealthSnapshot.timelineSort)
11 14
     }
12 15
 
13 16
     private var currentIndex: Int? {
@@ -21,6 +24,10 @@ struct RecordChangeEvolutionChart: View {
21 24
         return Array(sortedSnapshots[start..<end])
22 25
     }
23 26
 
27
+    private var diffTaskID: String {
28
+        ([typeIdentifier, currentSnapshotID.uuidString] + contextSnapshots.map { $0.id.uuidString }).joined(separator: "|")
29
+    }
30
+
24 31
     private var maxCount: Int {
25 32
         let counts = contextSnapshots.compactMap { snapshot in
26 33
             snapshot.typeCounts?.first { $0.typeIdentifier == typeIdentifier }?.count
@@ -30,9 +37,8 @@ struct RecordChangeEvolutionChart: View {
30 37
 
31 38
     private var maxNegativeCount: Int {
32 39
         var max = 0
33
-        for i in 0..<contextSnapshots.count {
34
-            let snapshot = contextSnapshots[i]
35
-            let previousSnapshot = i > 0 ? contextSnapshots[i - 1] : nil
40
+        for snapshot in contextSnapshots {
41
+            let previousSnapshot = snapshot.previousInTimeline(sortedSnapshots)
36 42
             let diff = recordDiff(current: snapshot, previous: previousSnapshot)
37 43
             max = Swift.max(max, diff.disappeared)
38 44
         }
@@ -41,19 +47,31 @@ struct RecordChangeEvolutionChart: View {
41 47
 
42 48
     private func recordDiff(current: HealthSnapshot, previous: HealthSnapshot?) -> (added: Int, disappeared: Int) {
43 49
         guard let previous = previous else { return (0, 0) }
44
-        guard let currentType = current.typeCounts?
45
-            .first(where: { $0.typeIdentifier == typeIdentifier }) else {
50
+
51
+        guard let delta = allDeltas.first(where: {
52
+            $0.fromSnapshotID == previous.id &&
53
+            $0.toSnapshotID == current.id
54
+        }),
55
+        let typeDelta = delta.typeDeltas?.first(where: { $0.typeIdentifier == typeIdentifier }) else {
46 56
             return (0, 0)
47 57
         }
48 58
 
49
-        if let previousType = previous.typeCounts?.first(where: { $0.typeIdentifier == typeIdentifier }),
50
-           currentType.contentEquivalentTypeCountID == previousType.contentRepresentativeTypeCountID {
59
+        switch typeDelta.transition {
60
+        case .unchanged:
51 61
             return (0, 0)
62
+        case .changed:
63
+            if typeDelta.countDelta > 0 {
64
+                return (typeDelta.countDelta, 0)
65
+            }
66
+            if typeDelta.countDelta < 0 {
67
+                return (0, abs(typeDelta.countDelta))
68
+            }
69
+            return (0, 0)
70
+        case .appeared:
71
+            return (max(typeDelta.countDelta, 1), 0)
72
+        case .disappeared:
73
+            return (0, max(abs(typeDelta.countDelta), 1))
52 74
         }
53
-
54
-        guard let cache = currentType.detailCache,
55
-              cache.matchesBaseline(previous.id) else { return (0, 0) }
56
-        return (cache.addedCount, cache.disappearedCount)
57 75
     }
58 76
 
59 77
     var body: some View {
@@ -155,7 +173,7 @@ struct RecordChangeEvolutionChart: View {
155 173
         let typeCount = snapshot.typeCounts?.first { $0.typeIdentifier == typeIdentifier }
156 174
         let count = typeCount?.count ?? 0
157 175
         let isCurrent = snapshot.id == currentSnapshotID
158
-        let previousSnapshot = index > 0 ? contextSnapshots[index - 1] : nil
176
+        let previousSnapshot = snapshot.previousInTimeline(sortedSnapshots)
159 177
         let diff = recordDiff(current: snapshot, previous: previousSnapshot)
160 178
         let unchanged = count - diff.added
161 179
 
@@ -204,6 +222,7 @@ struct RecordChangeEvolutionChart: View {
204 222
         }
205 223
         .frame(maxWidth: .infinity)
206 224
     }
225
+
207 226
 }
208 227
 
209 228
 #Preview {
+156 -39
HealthProbe/Views/Snapshots/DataTypeSnapshotDetailView.swift
@@ -8,12 +8,14 @@ struct DataTypeSnapshotDetailView: View {
8 8
 
9 9
     @Environment(\.modelContext) private var modelContext
10 10
     @Query(sort: \HealthSnapshot.timestamp) private var allSnapshots: [HealthSnapshot]
11
+    @Query private var allDeltas: [SnapshotDelta]
11 12
 
12 13
     @State private var displayedSnapshot: HealthSnapshot?
13 14
     @State private var diffState: RecordDiffState = .idle
14 15
     @State private var showAddedRecords = false
15 16
     @State private var showDisappearedRecords = false
16 17
     @State private var showTemporalDistribution = false
18
+    @State private var detailCacheDiagnostic: String?
17 19
 
18 20
     private var currentSnapshot: HealthSnapshot {
19 21
         displayedSnapshot ?? snapshot
@@ -26,12 +28,11 @@ struct DataTypeSnapshotDetailView: View {
26 28
             }
27 29
             return candidate.deviceID == currentSnapshot.deviceID
28 30
         }
31
+        .sorted(by: HealthSnapshot.timelineSort)
29 32
     }
30 33
 
31 34
     private var previousSnapshot: HealthSnapshot? {
32
-        guard let currentSnapshotIndex = timelineSnapshots.firstIndex(where: { $0.id == currentSnapshot.id }),
33
-              currentSnapshotIndex > 0 else { return nil }
34
-        return timelineSnapshots[currentSnapshotIndex - 1]
35
+        currentSnapshot.previousInTimeline(timelineSnapshots)
35 36
     }
36 37
 
37 38
     private var currentTypeCount: TypeCount? {
@@ -48,6 +49,24 @@ struct DataTypeSnapshotDetailView: View {
48 49
         return currentTypeCount.contentEquivalentTypeCountID == previousTypeCount.contentRepresentativeTypeCountID
49 50
     }
50 51
 
52
+    private var hasTemporalDistributionCache: Bool {
53
+        guard let currentTypeCount,
54
+              let previousSnapshot else { return false }
55
+        return currentTypeCount.detailCache?.matchesBaseline(previousSnapshot.id) == true
56
+    }
57
+
58
+    private var currentDelta: SnapshotDelta? {
59
+        guard let previousSnapshot else { return nil }
60
+        return allDeltas.first {
61
+            $0.toSnapshotID == currentSnapshot.id &&
62
+            $0.fromSnapshotID == previousSnapshot.id
63
+        }
64
+    }
65
+
66
+    private var currentTypeDelta: TypeDelta? {
67
+        currentDelta?.typeDeltas?.first { $0.typeIdentifier == typeIdentifier }
68
+    }
69
+
51 70
     private var diffTaskID: String {
52 71
         [
53 72
             currentSnapshot.id.uuidString,
@@ -71,6 +90,25 @@ struct DataTypeSnapshotDetailView: View {
71 90
         countText(for: previousTypeCount)
72 91
     }
73 92
 
93
+    private var quickCurrentCountValue: Int {
94
+        max(currentTypeCount?.count ?? 0, 0)
95
+    }
96
+
97
+    private var quickPreviousCountValue: Int {
98
+        max(previousTypeCount?.count ?? 0, 0)
99
+    }
100
+
101
+    private var quickAddedDisappeared: (added: Int, disappeared: Int, exact: Bool) {
102
+        if let previousSnapshot,
103
+           let cache = currentTypeCount?.detailCache,
104
+           cache.matchesBaseline(previousSnapshot.id) {
105
+            return (cache.addedCount, cache.disappearedCount, true)
106
+        }
107
+
108
+        let net = (currentTypeDelta?.countDelta ?? (quickCurrentCountValue - quickPreviousCountValue))
109
+        return (max(net, 0), max(-net, 0), false)
110
+    }
111
+
74 112
     var body: some View {
75 113
         ScrollView {
76 114
             VStack(spacing: 16) {
@@ -194,10 +232,10 @@ struct DataTypeSnapshotDetailView: View {
194 232
         }
195 233
     }
196 234
 
197
-    @ViewBuilder
198 235
     private var recordChangeComparisonSection: some View {
199
-        if previousSnapshot != nil {
200
-            switch diffState {
236
+        Group {
237
+            if previousSnapshot != nil {
238
+                switch diffState {
201 239
             case .loaded(let diff):
202 240
                 RecordChangeComparisonCard(
203 241
                     displayName: displayName,
@@ -237,13 +275,55 @@ struct DataTypeSnapshotDetailView: View {
237 275
                     )
238 276
                 }
239 277
 
278
+            case .idle:
279
+                VStack(alignment: .leading, spacing: 10) {
280
+                    let quick = quickAddedDisappeared
281
+
282
+                    VStack(alignment: .leading, spacing: 8) {
283
+                        Text("Quick Counts")
284
+                            .font(.subheadline.weight(.semibold))
285
+
286
+                        HStack {
287
+                            quickStat(label: "Current", value: "\(quickCurrentCountValue)")
288
+                            quickStat(label: "Previous", value: "\(quickPreviousCountValue)")
289
+                        }
290
+
291
+                        HStack {
292
+                            quickStat(label: "New", value: "\(quick.added)", color: .healthyGreen)
293
+                            quickStat(label: "Disappeared", value: "\(quick.disappeared)", color: .criticalRed)
294
+                        }
295
+
296
+                        if !quick.exact {
297
+                            Text("New/Disappeared are net values from snapshot delta. Exact split needs deep record analysis.")
298
+                                .font(.caption2)
299
+                                .foregroundStyle(.secondary)
300
+                        }
301
+                    }
302
+                    .padding(12)
303
+                    .background(Color(.systemBackground).opacity(0.35), in: RoundedRectangle(cornerRadius: 8))
304
+                }
305
+                .padding(12)
306
+                .frame(maxWidth: .infinity, alignment: .leading)
307
+                .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
308
+
240 309
             case .unavailable:
241
-                Label(
242
-                    "Record detail cache is unavailable for this snapshot pair.",
243
-                    systemImage: "exclamationmark.triangle.fill"
244
-                )
245
-                .font(.subheadline)
246
-                .foregroundStyle(Color.warningAmber)
310
+                VStack(alignment: .leading, spacing: 8) {
311
+                    Label(
312
+                        "Record detail cache is unavailable for this snapshot pair.",
313
+                        systemImage: "exclamationmark.triangle.fill"
314
+                    )
315
+                    .font(.subheadline)
316
+                    .foregroundStyle(Color.warningAmber)
317
+
318
+                    #if DEBUG
319
+                    if let detailCacheDiagnostic {
320
+                        Text(detailCacheDiagnostic)
321
+                            .font(.caption2.monospaced())
322
+                            .foregroundStyle(.secondary)
323
+                            .textSelection(.enabled)
324
+                    }
325
+                    #endif
326
+                }
247 327
                 .padding(12)
248 328
                 .frame(maxWidth: .infinity, alignment: .leading)
249 329
                 .background(Color.warningAmber.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
@@ -256,7 +336,7 @@ struct DataTypeSnapshotDetailView: View {
256 336
                     .frame(maxWidth: .infinity, alignment: .leading)
257 337
                     .background(Color.warningAmber.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
258 338
 
259
-            case .idle, .loading:
339
+            case .loading:
260 340
                 HStack(spacing: 8) {
261 341
                     ProgressView()
262 342
                     Text("Analyzing record changes...")
@@ -266,6 +346,7 @@ struct DataTypeSnapshotDetailView: View {
266 346
                 .frame(maxWidth: .infinity, alignment: .leading)
267 347
                 .padding(12)
268 348
                 .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
349
+                }
269 350
             }
270 351
         }
271 352
     }
@@ -285,6 +366,19 @@ struct DataTypeSnapshotDetailView: View {
285 366
     @ViewBuilder
286 367
     private var temporalDistributionSection: some View {
287 368
         if previousSnapshot != nil, currentTypeCount != nil, !isCurrentTypeContentAliasToPrevious {
369
+            if !hasTemporalDistributionCache {
370
+                VStack(alignment: .leading, spacing: 8) {
371
+                    Label(
372
+                        "Temporal distribution is available only when precomputed cache exists for this snapshot pair.",
373
+                        systemImage: "info.circle"
374
+                    )
375
+                    .font(.caption)
376
+                    .foregroundStyle(.secondary)
377
+                }
378
+                .padding(12)
379
+                .frame(maxWidth: .infinity, alignment: .leading)
380
+                .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
381
+            } else {
288 382
             Button {
289 383
                 showTemporalDistribution = true
290 384
             } label: {
@@ -313,6 +407,7 @@ struct DataTypeSnapshotDetailView: View {
313 407
                 .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
314 408
             }
315 409
             .buttonStyle(.plain)
410
+            }
316 411
         }
317 412
     }
318 413
 
@@ -332,14 +427,45 @@ struct DataTypeSnapshotDetailView: View {
332 427
         .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12))
333 428
     }
334 429
 
430
+    private func typeDeltaSummaryText(_ typeDelta: TypeDelta) -> String {
431
+        switch typeDelta.transition {
432
+        case .unchanged:
433
+            return "No metric-level change recorded."
434
+        case .changed:
435
+            if typeDelta.countDelta == 0 {
436
+                return "Content changed while count stayed the same."
437
+            }
438
+            let prefix = typeDelta.countDelta > 0 ? "+" : ""
439
+            return "Count delta: \(prefix)\(typeDelta.countDelta)."
440
+        case .appeared:
441
+            return "Metric appeared in this snapshot."
442
+        case .disappeared:
443
+            return "Metric disappeared in this snapshot."
444
+        }
445
+    }
446
+
447
+    private func quickStat(label: String, value: String, color: Color = .primary) -> some View {
448
+        VStack(alignment: .leading, spacing: 2) {
449
+            Text(label)
450
+                .font(.caption2)
451
+                .foregroundStyle(.secondary)
452
+            Text(value)
453
+                .font(.subheadline.weight(.semibold).monospacedDigit())
454
+                .foregroundStyle(color)
455
+        }
456
+        .frame(maxWidth: .infinity, alignment: .leading)
457
+    }
458
+
335 459
     @MainActor
336 460
     private func loadRecordDiff() async {
337 461
         guard previousSnapshot != nil else {
462
+            detailCacheDiagnostic = nil
338 463
             diffState = .loaded(.empty)
339 464
             return
340 465
         }
341 466
 
342 467
         if isCurrentTypeContentAliasToPrevious {
468
+            detailCacheDiagnostic = nil
343 469
             diffState = .loaded(.empty)
344 470
             return
345 471
         }
@@ -348,11 +474,14 @@ struct DataTypeSnapshotDetailView: View {
348 474
         let previousCount = previousTypeCount?.count ?? 0
349 475
 
350 476
         guard currentCount >= 0, previousCount >= 0 else {
477
+            detailCacheDiagnostic = "counts-invalid current=\(currentCount) previous=\(previousCount)"
351 478
             diffState = .unavailable
352 479
             return
353 480
         }
354 481
 
355
-        guard let cache = currentDetailCache() else {
482
+        let resolution = currentDetailCacheResolution()
483
+        detailCacheDiagnostic = resolution?.diagnostic
484
+        guard let cache = resolution?.cache else {
356 485
             diffState = .unavailable
357 486
             return
358 487
         }
@@ -361,14 +490,20 @@ struct DataTypeSnapshotDetailView: View {
361 490
     }
362 491
 
363 492
     @MainActor
364
-    private func currentDetailCache() -> TypeCountDetailCache? {
493
+    private func currentDetailCacheResolution() -> TypeCountDetailCacheResolution? {
365 494
         if isCurrentTypeContentAliasToPrevious {
366
-            return nil
495
+            return TypeCountDetailCacheResolution(
496
+                cache: nil,
497
+                diagnostic: "alias-to-previous"
498
+            )
367 499
         }
368 500
 
369 501
         if let cache = currentTypeCount?.detailCache,
370 502
            cache.matchesBaseline(previousSnapshot?.id) {
371
-            return cache
503
+            return TypeCountDetailCacheResolution(
504
+                cache: cache,
505
+                diagnostic: "resolver-v4 phase=cache-hit-view"
506
+            )
372 507
         }
373 508
 
374 509
         guard let currentTypeCount,
@@ -376,30 +511,12 @@ struct DataTypeSnapshotDetailView: View {
376 511
             return nil
377 512
         }
378 513
 
379
-        MemoryLog.log("dataTypeDetail.detailCache.buildBegin", metadata: [
380
-            "source": "detailView",
381
-            "type": currentTypeCount.typeIdentifier,
382
-            "currentCount": "\(currentTypeCount.count)",
383
-            "previousCount": "\(previousTypeCount?.count ?? 0)",
384
-            "currentArchive": currentTypeCount.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil",
385
-            "previousArchive": previousTypeCount?.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil",
386
-            "isAlias": "\(currentTypeCount.isContentAlias)"
387
-        ])
388
-        let cache = TypeCountDetailCacheBuilder.build(
389
-            current: currentTypeCount,
514
+        return currentTypeCount.resolveDetailCacheWithDiagnostics(
390 515
             previous: previousTypeCount,
391
-            baselineSnapshotID: previousSnapshot.id
516
+            baselineSnapshotID: previousSnapshot.id,
517
+            context: modelContext,
518
+            source: "dataTypeDetail"
392 519
         )
393
-        MemoryLog.log("dataTypeDetail.detailCache.buildEnd", metadata: [
394
-            "source": "detailView",
395
-            "type": currentTypeCount.typeIdentifier,
396
-            "cacheBuilt": "\(cache != nil)"
397
-        ])
398
-        currentTypeCount.setDetailCache(cache)
399
-        if cache != nil {
400
-            try? modelContext.save()
401
-        }
402
-        return cache
403 520
     }
404 521
 }
405 522
 
+105 -164
HealthProbe/Views/Snapshots/SnapshotDetailView.swift
@@ -7,49 +7,26 @@ struct SnapshotDetailView: View {
7 7
     let snapshot: HealthSnapshot
8 8
     let baseline: HealthSnapshot?
9 9
     let profile: DeviceProfile?
10
-    private let contextRadius = 3
11 10
 
12 11
     @Query(sort: \HealthSnapshot.timestamp) private var allSnapshots: [HealthSnapshot]
13
-
14
-    private let diffService = SnapshotDiffService.shared
15
-
16
-    @State private var xAxisMode: EvolutionXAxisMode = .snapshots
12
+    @Query private var allDeltas: [SnapshotDelta]
17 13
     @State private var displayedSnapshot: HealthSnapshot?
18 14
 
19 15
     private var currentSnapshot: HealthSnapshot {
20 16
         displayedSnapshot ?? snapshot
21 17
     }
22 18
 
23
-    private var sortedTypeCounts: [TypeCount] {
24
-        (currentSnapshot.typeCounts ?? []).sorted {
25
-            $0.displayName.localizedCompare($1.displayName) == .orderedAscending
26
-        }
19
+    private var currentDelta: SnapshotDelta? {
20
+        allDeltas.first { $0.toSnapshotID == currentSnapshot.id }
27 21
     }
28 22
 
29
-    private var baselineTypeMap: [String: TypeCount] {
30
-        Dictionary(
31
-            uniqueKeysWithValues: (baseline?.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
32
-        )
23
+    private var currentDeltaSummary: SnapshotDeltaListSummary? {
24
+        currentDelta?.listSummary
33 25
     }
34 26
 
35
-    private var totalCount: Int {
36
-        sortedTypeCounts.reduce(0) { partial, typeCount in
37
-            typeCount.count > 0 ? partial + typeCount.count : partial
38
-        }
39
-    }
40
-
41
-    private var snapshotEarliestRecordDate: Date? {
42
-        sortedTypeCounts
43
-            .filter { !$0.isUnsupported && $0.count > 0 }
44
-            .compactMap(\.earliestDate)
45
-            .min()
46
-    }
47
-
48
-    private var snapshotNewestRecordDate: Date? {
49
-        sortedTypeCounts
50
-            .filter { !$0.isUnsupported && $0.count > 0 }
51
-            .compactMap(\.latestDate)
52
-            .max()
27
+    private var allTypeDeltas: [TypeDelta] {
28
+        (currentDelta?.typeDeltas ?? [])
29
+            .sorted { $0.displayName.localizedCompare($1.displayName) == .orderedAscending }
53 30
     }
54 31
 
55 32
     private var deviceDisplayName: String {
@@ -66,77 +43,6 @@ struct SnapshotDetailView: View {
66 43
         }
67 44
     }
68 45
 
69
-    private var timelineContextSnapshots: [HealthSnapshot] {
70
-        guard let currentIndex = timelineSnapshots.firstIndex(where: { $0.id == currentSnapshot.id }) else {
71
-            return [currentSnapshot]
72
-        }
73
-
74
-        let desiredCount = contextRadius * 2 + 1
75
-        var start = max(0, currentIndex - contextRadius)
76
-        var end = min(timelineSnapshots.count - 1, currentIndex + contextRadius)
77
-
78
-        let currentCount = end - start + 1
79
-        if currentCount < desiredCount {
80
-            let missing = desiredCount - currentCount
81
-
82
-            let extraBefore = min(start, missing)
83
-            start -= extraBefore
84
-
85
-            let remaining = missing - extraBefore
86
-            let availableAfter = timelineSnapshots.count - 1 - end
87
-            let extraAfter = min(availableAfter, remaining)
88
-            end += extraAfter
89
-
90
-            if extraAfter < remaining {
91
-                let finalRemaining = remaining - extraAfter
92
-                let availableBefore = start
93
-                let finalExtraBefore = min(availableBefore, finalRemaining)
94
-                start -= finalExtraBefore
95
-            }
96
-        }
97
-
98
-        return Array(timelineSnapshots[start...end])
99
-    }
100
-
101
-    private var isTimelineContextTrimmed: Bool {
102
-        timelineContextSnapshots.count < timelineSnapshots.count
103
-    }
104
-
105
-    private var timelineSnapshotNumbers: [UUID: Int] {
106
-        Dictionary(
107
-            uniqueKeysWithValues: timelineSnapshots.enumerated().map { index, snapshot in
108
-                (snapshot.id, index + 1)
109
-            }
110
-        )
111
-    }
112
-
113
-    private var evolutionSeries: [TypeEvolutionSeries] {
114
-        sortedTypeCounts.compactMap { typeCount in
115
-            let points = timelineContextSnapshots.compactMap { candidate -> TypeEvolutionPoint? in
116
-                guard let candidateTypeCount = candidate.typeCounts?.first(where: {
117
-                    $0.typeIdentifier == typeCount.typeIdentifier
118
-                }),
119
-                      candidateTypeCount.count >= 0
120
-                else {
121
-                    return nil
122
-                }
123
-
124
-                return TypeEvolutionPoint(
125
-                    snapshotID: candidate.id,
126
-                    timestamp: candidate.timestamp,
127
-                    count: candidateTypeCount.count
128
-                )
129
-            }
130
-
131
-            guard !points.isEmpty else { return nil }
132
-            return TypeEvolutionSeries(
133
-                typeIdentifier: typeCount.typeIdentifier,
134
-                displayName: typeCount.displayName,
135
-                points: points
136
-            )
137
-        }
138
-    }
139
-
140 46
     @State private var showShareSheet = false
141 47
     @State private var pdfExportURL: URL?
142 48
     @State private var isExporting = false
@@ -255,20 +161,27 @@ struct SnapshotDetailView: View {
255 161
 
256 162
                     // Data Range
257 163
                     SnapshotDataRangeIndicator(
258
-                        oldestRecordDate: snapshotEarliestRecordDate,
259
-                        newestRecordDate: snapshotNewestRecordDate,
164
+                        oldestRecordDate: currentSnapshot.cachedEarliestRecordDate,
165
+                        newestRecordDate: currentSnapshot.cachedLatestRecordDate,
260 166
                         quality: currentSnapshot.snapshotQuality
261 167
                     )
262 168
 
263 169
                     // Summary Stats (compact)
264 170
                     VStack(spacing: 12) {
265
-                        HStack(spacing: 16) {
266
-                            statCompact(label: "Types", value: "\(sortedTypeCounts.count)")
267
-                            Divider()
268
-                            statCompact(label: "Records", value: "\(totalCount)")
171
+                        if currentSnapshot.hasCurrentCachedSummary {
172
+                            HStack(spacing: 16) {
173
+                                statCompact(label: "Types", value: "\(currentSnapshot.cachedTypeCount)")
174
+                                Divider()
175
+                                statCompact(label: "Records", value: "\(currentSnapshot.cachedRecordCount)")
176
+                            }
177
+                            .font(.caption)
178
+                            .foregroundStyle(.secondary)
179
+                        } else {
180
+                            Text("Snapshot summary unavailable")
181
+                                .font(.caption)
182
+                                .foregroundStyle(.secondary)
183
+                                .frame(maxWidth: .infinity, alignment: .center)
269 184
                         }
270
-                        .font(.caption)
271
-                        .foregroundStyle(.secondary)
272 185
                     }
273 186
                     .padding(12)
274 187
                     .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12))
@@ -316,9 +229,10 @@ struct SnapshotDetailView: View {
316 229
 
317 230
     @ViewBuilder
318 231
     private func comparisonSection(baseline: HealthSnapshot) -> some View {
319
-        let delta = diffService.totalAbsoluteChange(current: currentSnapshot, baseline: baseline)
232
+        let delta = currentDeltaSummary?.absoluteRecordChangeCount ?? 0
320 233
         let deltaPercent = computeDeltaPercent(delta: delta, baseline: baseline)
321
-        let isSignificant = delta > 0 || (deltaPercent > 10)
234
+        let affectedMetricCount = currentDeltaSummary?.affectedMetricCount ?? 0
235
+        let isSignificant = delta > 0 || affectedMetricCount > 0 || (deltaPercent > 10)
322 236
 
323 237
         DisclosureGroup {
324 238
             VStack(alignment: .leading, spacing: 12) {
@@ -332,6 +246,18 @@ struct SnapshotDetailView: View {
332 246
                     Text(days == 0 ? "Same day" : "\(days) days")
333 247
                         .foregroundStyle(.secondary)
334 248
                 }
249
+                if let summary = currentDeltaSummary {
250
+                    Divider()
251
+                    DetailRow(label: "Changed Metrics") {
252
+                        Text("\(summary.affectedMetricCount)")
253
+                            .foregroundStyle(.secondary)
254
+                    }
255
+                    Divider()
256
+                    DetailRow(label: "Record Changes") {
257
+                        Text("\(summary.absoluteRecordChangeCount)")
258
+                            .foregroundStyle(.secondary)
259
+                    }
260
+                }
335 261
             }
336 262
             .padding(.top, 8)
337 263
         } label: {
@@ -380,9 +306,7 @@ struct SnapshotDetailView: View {
380 306
     }
381 307
 
382 308
     private func computeDeltaPercent(delta: Int, baseline: HealthSnapshot) -> Double {
383
-        let baselineTotal = (baseline.typeCounts ?? [])
384
-            .filter { $0.count > 0 }
385
-            .reduce(0) { $0 + $1.count }
309
+        let baselineTotal = baseline.hasCurrentCachedSummary ? baseline.cachedRecordCount : 0
386 310
         return baselineTotal > 0 ? Double(delta) / Double(baselineTotal) * 100 : 0
387 311
     }
388 312
 
@@ -399,66 +323,83 @@ struct SnapshotDetailView: View {
399 323
 
400 324
     private var evolutionSection: some View {
401 325
         Section("Data Types") {
402
-            HStack {
403
-                Spacer()
404
-                Picker("X-Axis", selection: $xAxisMode) {
405
-                    ForEach(EvolutionXAxisMode.allCases.reversed()) { mode in
406
-                        Text(mode.title).tag(mode)
407
-                    }
408
-                }
409
-                .pickerStyle(.segmented)
410
-                Spacer()
411
-            }
412
-
413
-            if evolutionSeries.isEmpty {
414
-                if sortedTypeCounts.isEmpty {
415
-                    Text("No tracked data types in this snapshot.")
416
-                        .foregroundStyle(.secondary)
417
-                } else {
418
-                    ForEach(sortedTypeCounts) { typeCount in
419
-                        NavigationLink {
420
-                            DataTypeSnapshotDetailView(
421
-                                snapshot: currentSnapshot,
422
-                                typeIdentifier: typeCount.typeIdentifier,
423
-                                displayName: typeCount.displayName
424
-                            )
425
-                        } label: {
426
-                            SnapshotTypeCountRow(
427
-                                typeCount: typeCount,
428
-                                baselineTypeCount: baselineTypeMap[typeCount.typeIdentifier]
429
-                            )
430
-                        }
431
-                    }
432
-                }
326
+            if baseline == nil {
327
+                Text("This snapshot starts the chain, so no baseline comparison is available.")
328
+                    .foregroundStyle(.secondary)
329
+            } else if currentDelta == nil {
330
+                Text("Cached metric summary unavailable for this snapshot.")
331
+                    .foregroundStyle(.secondary)
332
+            } else if allTypeDeltas.isEmpty {
333
+                Text("No data types are available for this snapshot.")
334
+                    .foregroundStyle(.secondary)
433 335
             } else {
434
-                ForEach(evolutionSeries) { series in
336
+                ForEach(allTypeDeltas) { typeDelta in
435 337
                     NavigationLink {
436 338
                         DataTypeSnapshotDetailView(
437 339
                             snapshot: currentSnapshot,
438
-                            typeIdentifier: series.typeIdentifier,
439
-                            displayName: series.displayName
340
+                            typeIdentifier: typeDelta.typeIdentifier,
341
+                            displayName: typeDelta.displayName
440 342
                         )
441 343
                     } label: {
442
-                        TypeEvolutionChart(
443
-                            series: series,
444
-                            contextSnapshots: timelineContextSnapshots,
445
-                            xAxisMode: xAxisMode,
446
-                            selectedSnapshotID: currentSnapshot.id,
447
-                            selectedTimestamp: currentSnapshot.timestamp,
448
-                            snapshotNumbers: timelineSnapshotNumbers,
449
-                            baselineTypeCount: baselineTypeMap[series.typeIdentifier]
450
-                        )
344
+                        SnapshotTypeDeltaRow(typeDelta: typeDelta)
451 345
                     }
452 346
                 }
347
+            }
348
+        }
349
+    }
350
+}
453 351
 
454
-                if isTimelineContextTrimmed {
455
-                    Text("Charts show only the local window: 3 snapshots before and 3 after the current one.")
456
-                        .font(.caption)
457
-                        .foregroundStyle(.secondary)
458
-                }
352
+private struct SnapshotTypeDeltaRow: View {
353
+    let typeDelta: TypeDelta
354
+
355
+    private var deltaLabel: String {
356
+        switch typeDelta.transition {
357
+        case .changed:
358
+            if typeDelta.countDelta == 0 {
359
+                return "Content changed"
459 360
             }
361
+            let prefix = typeDelta.countDelta > 0 ? "+" : ""
362
+            return "\(prefix)\(typeDelta.countDelta) records"
363
+        case .appeared:
364
+            return "Appeared"
365
+        case .disappeared:
366
+            return "Disappeared"
367
+        case .unchanged:
368
+            return "No changes"
369
+        }
370
+    }
371
+
372
+    private var deltaColor: Color {
373
+        switch typeDelta.transition {
374
+        case .disappeared:
375
+            return .criticalRed
376
+        case .changed, .appeared:
377
+            return .warningAmber
378
+        case .unchanged:
379
+            return .secondary
460 380
         }
461 381
     }
382
+
383
+    var body: some View {
384
+        HStack(spacing: 12) {
385
+            VStack(alignment: .leading, spacing: 3) {
386
+                Text(typeDelta.displayName)
387
+                    .font(.subheadline)
388
+                Text(typeDelta.typeIdentifier)
389
+                    .font(.caption2)
390
+                    .foregroundStyle(.secondary)
391
+                    .lineLimit(1)
392
+                    .truncationMode(.middle)
393
+            }
394
+
395
+            Spacer()
396
+
397
+            Text(deltaLabel)
398
+                .font(.caption.weight(.semibold))
399
+                .foregroundStyle(deltaColor)
400
+        }
401
+        .accessibilityElement(children: .combine)
402
+    }
462 403
 }
463 404
 
464 405
 private enum EvolutionXAxisMode: String, CaseIterable, Identifiable {
@@ -992,5 +933,5 @@ private struct ShareSheet: UIViewControllerRepresentable {
992 933
             profile: DeviceProfile(deviceID: "preview-device")
993 934
         )
994 935
     }
995
-    .modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self, DeviceProfile.self], inMemory: true)
936
+    .modelContainer(for: [HealthSnapshot.self, SnapshotDelta.self, TypeDelta.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self, DeviceProfile.self], inMemory: true)
996 937
 }
+102 -26
HealthProbe/Views/Snapshots/SnapshotsView.swift
@@ -6,6 +6,7 @@ struct SnapshotsView: View {
6 6
     @Environment(\.modelContext) private var modelContext
7 7
     @Environment(AppSettings.self) private var appSettings
8 8
     @Query(sort: \HealthSnapshot.timestamp, order: .reverse) private var allSnapshots: [HealthSnapshot]
9
+    @Query private var allDeltas: [SnapshotDelta]
9 10
     @Query private var deviceProfiles: [DeviceProfile]
10 11
     @State private var viewModel = SnapshotsViewModel()
11 12
 
@@ -21,6 +22,22 @@ struct SnapshotsView: View {
21 22
         return allSnapshots.filter { selected.contains($0.deviceID) }
22 23
     }
23 24
 
25
+    private var snapshotItems: [SnapshotListItem] {
26
+        let baselines = viewModel.baselines(for: displayedSnapshots)
27
+        let deltaSummaryBySnapshotID = allDeltas.reduce(into: [UUID: SnapshotDeltaListSummary]()) { partial, delta in
28
+            partial[delta.toSnapshotID] = delta.listSummary
29
+        }
30
+
31
+        return displayedSnapshots.map { snapshot in
32
+            SnapshotListItem(
33
+                snapshot: snapshot,
34
+                baseline: baselines[snapshot.id] ?? nil,
35
+                deltaSummary: deltaSummaryBySnapshotID[snapshot.id],
36
+                showsDeltaSummary: viewModel.comparisonMode == .previous
37
+            )
38
+        }
39
+    }
40
+
24 41
     private var knownDevices: [DeviceEntry] {
25 42
         let currentID = AppSettings.currentDeviceID
26 43
         var ids = Set(allSnapshots.map { $0.deviceID })
@@ -57,6 +74,9 @@ struct SnapshotsView: View {
57 74
             }
58 75
             .navigationTitle("Snapshots")
59 76
             .toolbar { toolbarContent }
77
+            .task(id: allDeltas.count) {
78
+                repairDeltaListSummariesIfNeeded()
79
+            }
60 80
             .onChange(of: appSettings.selectedDeviceIDs) {
61 81
                 if let baseline = viewModel.selectedBaseline,
62 82
                    !displayedSnapshots.contains(where: { $0.id == baseline.id }) {
@@ -70,29 +90,31 @@ struct SnapshotsView: View {
70 90
     // MARK: - List
71 91
 
72 92
     private var snapshotList: some View {
73
-        List(displayedSnapshots) { snapshot in
93
+        List(snapshotItems) { item in
74 94
             NavigationLink {
75 95
                 SnapshotDetailView(
76
-                    snapshot: snapshot,
77
-                    baseline: viewModel.baseline(for: snapshot, in: displayedSnapshots),
78
-                    profile: profileMap[snapshot.deviceID]
96
+                    snapshot: item.snapshot,
97
+                    baseline: item.baseline,
98
+                    profile: profileMap[item.snapshot.deviceID]
79 99
                 )
80 100
             } label: {
81 101
                 SnapshotRow(
82
-                    snapshot: snapshot,
83
-                    baseline: viewModel.baseline(for: snapshot, in: displayedSnapshots),
84
-                    isSelectedBaseline: viewModel.selectedBaseline?.id == snapshot.id,
85
-                    profile: profileMap[snapshot.deviceID]
102
+                    snapshot: item.snapshot,
103
+                    baseline: item.baseline,
104
+                    deltaSummary: item.deltaSummary,
105
+                    showsDeltaSummary: item.showsDeltaSummary,
106
+                    isSelectedBaseline: viewModel.selectedBaseline?.id == item.snapshot.id,
107
+                    profile: profileMap[item.snapshot.deviceID]
86 108
                 )
87 109
             }
88 110
             .swipeActions(edge: .leading) {
89 111
                 Button {
90
-                    viewModel.toggleBaseline(snapshot)
112
+                    viewModel.toggleBaseline(item.snapshot)
91 113
                     viewModel.comparisonMode = .selected
92 114
                 } label: {
93 115
                     Label(
94
-                        viewModel.selectedBaseline?.id == snapshot.id ? "Unset Baseline" : "Set as Baseline",
95
-                        systemImage: viewModel.selectedBaseline?.id == snapshot.id ? "pin.slash" : "pin"
116
+                        viewModel.selectedBaseline?.id == item.snapshot.id ? "Unset Baseline" : "Set as Baseline",
117
+                        systemImage: viewModel.selectedBaseline?.id == item.snapshot.id ? "pin.slash" : "pin"
96 118
                     )
97 119
                 }
98 120
                 .tint(.indigo)
@@ -100,7 +122,7 @@ struct SnapshotsView: View {
100 122
             .swipeActions(edge: .trailing) {
101 123
                 Button(role: .destructive) {
102 124
                     do {
103
-                        try SnapshotLifecycleService.delete(snapshot, context: modelContext)
125
+                        try SnapshotLifecycleService.delete(item.snapshot, context: modelContext)
104 126
                     } catch {
105 127
                         // Failure is surfaced via the navigation stack; no silent discard
106 128
                     }
@@ -161,6 +183,23 @@ struct SnapshotsView: View {
161 183
         .tint(isMulti ? .orange : .accentColor)
162 184
         .accessibilityLabel("Select devices – \(selected.count) selected")
163 185
     }
186
+
187
+    private func repairDeltaListSummariesIfNeeded() {
188
+        do {
189
+            _ = try DeltaService.rebuildMissingListSummaries(context: modelContext, maxCount: 64)
190
+        } catch {
191
+            // Keep the list responsive even if summary repair fails.
192
+        }
193
+    }
194
+}
195
+
196
+private struct SnapshotListItem: Identifiable {
197
+    let snapshot: HealthSnapshot
198
+    let baseline: HealthSnapshot?
199
+    let deltaSummary: SnapshotDeltaListSummary?
200
+    let showsDeltaSummary: Bool
201
+
202
+    var id: UUID { snapshot.id }
164 203
 }
165 204
 
166 205
 // MARK: - Row
@@ -168,10 +207,11 @@ struct SnapshotsView: View {
168 207
 private struct SnapshotRow: View {
169 208
     let snapshot: HealthSnapshot
170 209
     let baseline: HealthSnapshot?
210
+    let deltaSummary: SnapshotDeltaListSummary?
211
+    let showsDeltaSummary: Bool
171 212
     let isSelectedBaseline: Bool
172 213
     let profile: DeviceProfile?
173 214
 
174
-    private let diffService = SnapshotDiffService.shared
175 215
     private static let dateFormatter: DateFormatter = {
176 216
         let f = DateFormatter()
177 217
         f.dateStyle = .medium
@@ -188,12 +228,44 @@ private struct SnapshotRow: View {
188 228
         DeviceColor(rawValue: profile?.colorTag ?? "")?.color ?? .neutralGray
189 229
     }
190 230
 
191
-    private var metricCount: Int {
192
-        snapshot.typeCounts?.count ?? 0
231
+    private var metricCountLabel: String? {
232
+        guard snapshot.hasCurrentCachedSummary else { return nil }
233
+        return snapshot.cachedTypeCount == 1 ? "1 metric" : "\(snapshot.cachedTypeCount) metrics"
234
+    }
235
+
236
+    private var deltaSummaryText: String? {
237
+        guard let deltaSummary else { return nil }
238
+
239
+        var parts: [String] = []
240
+
241
+        if deltaSummary.absoluteRecordChangeCount > 0 {
242
+            parts.append("\(deltaSummary.absoluteRecordChangeCount) record change\(deltaSummary.absoluteRecordChangeCount == 1 ? "" : "s")")
243
+        }
244
+
245
+        if deltaSummary.changedMetricCount > 0 {
246
+            parts.append("\(deltaSummary.changedMetricCount) metric change\(deltaSummary.changedMetricCount == 1 ? "" : "s")")
247
+        }
248
+
249
+        if deltaSummary.appearedMetricCount > 0 {
250
+            parts.append("\(deltaSummary.appearedMetricCount) metric appeared")
251
+        }
252
+
253
+        if deltaSummary.disappearedMetricCount > 0 {
254
+            parts.append("\(deltaSummary.disappearedMetricCount) metric disappeared")
255
+        }
256
+
257
+        if parts.isEmpty {
258
+            return "No changes"
259
+        }
260
+
261
+        return parts.joined(separator: " • ")
193 262
     }
194 263
 
195
-    private var metricCountLabel: String {
196
-        metricCount == 1 ? "1 metric" : "\(metricCount) metrics"
264
+    private var deltaSummaryColor: Color {
265
+        guard let deltaSummary else { return .secondary }
266
+        if deltaSummary.disappearedMetricCount > 0 { return Color.criticalRed }
267
+        if deltaSummary.hasChanges { return Color.warningAmber }
268
+        return Color.healthyGreen
197 269
     }
198 270
 
199 271
     private var hasOSVersionChange: Bool {
@@ -230,9 +302,11 @@ private struct SnapshotRow: View {
230 302
                 Text(deviceDisplayName)
231 303
                     .font(.caption)
232 304
                     .foregroundStyle(.secondary)
233
-                Label(metricCountLabel, systemImage: "list.bullet.rectangle")
234
-                    .font(.caption)
235
-                    .foregroundStyle(.secondary)
305
+                if let metricCountLabel {
306
+                    Label(metricCountLabel, systemImage: "list.bullet.rectangle")
307
+                        .font(.caption)
308
+                        .foregroundStyle(.secondary)
309
+                }
236 310
                 if hasOSVersionChange {
237 311
                     Label("OS \(snapshot.osVersion)", systemImage: "gearshape.fill")
238 312
                         .font(.caption)
@@ -250,14 +324,16 @@ private struct SnapshotRow: View {
250 324
                     .foregroundStyle(Color.warningAmber)
251 325
             }
252 326
 
253
-            if let baseline {
254
-                let delta = diffService.totalAbsoluteChange(current: snapshot, baseline: baseline)
327
+            if showsDeltaSummary,
328
+               let deltaSummaryText {
255 329
                 HStack(spacing: 4) {
256
-                    Image(systemName: "arrow.triangle.2.circlepath")
257
-                    Text(delta == 0 ? "No changes" : "\(delta) record changes")
330
+                    Image(systemName: deltaSummary == nil || deltaSummary?.hasChanges == false
331
+                          ? "checkmark.circle"
332
+                          : "arrow.triangle.2.circlepath")
333
+                    Text(deltaSummaryText)
258 334
                 }
259 335
                 .font(.caption)
260
-                .foregroundStyle(delta == 0 ? Color.healthyGreen : Color.warningAmber)
336
+                .foregroundStyle(deltaSummaryColor)
261 337
             }
262 338
         }
263 339
         .padding(.vertical, 2)
@@ -294,6 +370,6 @@ private struct SnapshotRow: View {
294 370
 
295 371
 #Preview {
296 372
     SnapshotsView()
297
-        .modelContainer(for: [HealthSnapshot.self, TypeCount.self, HealthRecord.self, TypeDistributionBin.self, DeviceProfile.self], inMemory: true)
373
+    .modelContainer(for: [HealthSnapshot.self, SnapshotDelta.self, TypeDelta.self, TypeCount.self, HealthRecord.self, TypeDistributionBin.self, DeviceProfile.self], inMemory: true)
298 374
         .environment(AppSettings())
299 375
 }