Showing 11 changed files with 515 additions and 126 deletions
+9 -0
AGENTS.md
@@ -131,6 +131,15 @@ final class TypeDistributionBin {
131 131
 // instead of building a full in-memory dictionary/array; plist archives remain readable
132 132
 // for backwards compatibility.
133 133
 
134
+// Interface updated 2026-05-17 — see AGENTS.md
135
+// Models/TypeCount.detailCacheData stores precomputed detail data for the current
136
+// TypeCount compared with the immediately previous snapshot on the same device.
137
+// The cache contains aggregate added/disappeared counts, capped preview records for
138
+// UI drill-down, and daily change bins for temporal charts. It must be computed when
139
+// snapshots are saved and refreshed for neighboring snapshots when snapshot deletion
140
+// changes chain links. Existing stores are backfilled incrementally with a strict
141
+// per-launch TypeCount cap to avoid decoding many large archives in one run.
142
+
134 143
 // Models/DetectedAnomaly.swift
135 144
 enum AnomalyType: String, Codable {
136 145
     case historicalInsertion = "historical_insertion"
+31 -0
HealthProbe/ContentView.swift
@@ -2,6 +2,11 @@ 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
+
5 10
     var body: some View {
6 11
         TabView {
7 12
             Tab("Dashboard", systemImage: "waveform.path.ecg") {
@@ -17,6 +22,32 @@ struct ContentView: View {
17 22
                 SettingsView()
18 23
             }
19 24
         }
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
+
40
+        do {
41
+            let isComplete = try SnapshotLifecycleService.rebuildMissingDetailCaches(
42
+                context: modelContext,
43
+                maxTypeCounts: 1
44
+            )
45
+            if isComplete {
46
+                typeDetailCacheBackfillVersion = AppSettings.currentTypeDetailCacheBackfillVersion
47
+            }
48
+        } catch {
49
+            assertionFailure("Failed to rebuild type detail caches: \(error)")
50
+        }
20 51
     }
21 52
 }
22 53
 
+56 -7
HealthProbe/Models/HealthRecord.swift
@@ -28,6 +28,30 @@ enum HealthRecordArchive {
28 28
         return try? PropertyListDecoder().decode([HealthRecordValue].self, from: data)
29 29
     }
30 30
 
31
+    static func forEachRecord(in data: Data, _ body: (HealthRecordValue) -> Void) -> Bool {
32
+        if data.starts(with: compactMagic) {
33
+            return forEachCompactRecord(in: data, body)
34
+        }
35
+
36
+        guard let decoded = try? PropertyListDecoder().decode([HealthRecordValue].self, from: data) else {
37
+            return false
38
+        }
39
+        for value in decoded {
40
+            body(value)
41
+        }
42
+        return true
43
+    }
44
+
45
+    static func fingerprintSet(from data: Data?) -> Set<String>? {
46
+        guard let data else { return [] }
47
+
48
+        var fingerprints = Set<String>()
49
+        let didRead = forEachRecord(in: data) { value in
50
+            fingerprints.insert(value.recordFingerprint)
51
+        }
52
+        return didRead ? fingerprints : nil
53
+    }
54
+
31 55
     static func makeCompactWriter(typeIdentifier: String, estimatedRecordCount: Int = 0) -> CompactWriter {
32 56
         CompactWriter(typeIdentifier: typeIdentifier, estimatedRecordCount: estimatedRecordCount)
33 57
     }
@@ -108,24 +132,49 @@ enum HealthRecordArchive {
108 132
     }
109 133
 
110 134
     private static func decodeCompact(_ data: Data) -> [HealthRecordValue]? {
111
-        guard data.starts(with: compactMagic) else { return nil }
135
+        var values: [HealthRecordValue] = []
136
+        var expectedCount = 0
137
+        guard forEachCompactRecord(in: data, onHeader: { count in
138
+            expectedCount = count
139
+            values.reserveCapacity(count)
140
+        }, body: { value in
141
+            values.append(value)
142
+        }) else {
143
+            return nil
144
+        }
145
+        guard values.count == expectedCount else { return nil }
146
+        return values
147
+    }
148
+
149
+    private static func forEachCompactRecord(
150
+        in data: Data,
151
+        _ body: (HealthRecordValue) -> Void
152
+    ) -> Bool {
153
+        forEachCompactRecord(in: data, onHeader: { _ in }, body: body)
154
+    }
155
+
156
+    private static func forEachCompactRecord(
157
+        in data: Data,
158
+        onHeader: (Int) -> Void,
159
+        body: (HealthRecordValue) -> Void
160
+    ) -> Bool {
161
+        guard data.starts(with: compactMagic) else { return false }
112 162
         var reader = CompactReader(data: data, offset: compactMagic.count)
113 163
         guard let typeIdentifier = reader.readString(),
114 164
               let count = reader.readUInt64() else {
115
-            return nil
165
+            return false
116 166
         }
117 167
 
118
-        var values: [HealthRecordValue] = []
119
-        values.reserveCapacity(Int(min(count, UInt64(Int.max))))
168
+        onHeader(Int(min(count, UInt64(Int.max))))
120 169
         for _ in 0..<count {
121 170
             guard let sampleUUIDHash = reader.readString(),
122 171
                   let recordFingerprint = reader.readString(),
123 172
                   let startInterval = reader.readDouble(),
124 173
                   let endInterval = reader.readDouble(),
125 174
                   let displayValue = reader.readOptionalString() else {
126
-                return nil
175
+                return false
127 176
             }
128
-            values.append(
177
+            body(
129 178
                 HealthRecordValue(
130 179
                     typeIdentifier: typeIdentifier,
131 180
                     sampleUUIDHash: sampleUUIDHash,
@@ -136,7 +185,7 @@ enum HealthRecordArchive {
136 185
                 )
137 186
             )
138 187
         }
139
-        return values
188
+        return true
140 189
     }
141 190
 
142 191
     private struct CompactReader {
+11 -1
HealthProbe/Models/TypeCount.swift
@@ -1,7 +1,7 @@
1 1
 import Foundation
2 2
 import SwiftData
3 3
 
4
-// Interface updated 2026-05-14 — see AGENTS.md
4
+// Interface updated 2026-05-17 — see AGENTS.md
5 5
 @Model final class TypeCount {
6 6
     var id: UUID = UUID()
7 7
     var typeIdentifier: String = ""
@@ -13,6 +13,7 @@ import SwiftData
13 13
     var qualityRaw: String = SnapshotQuality.complete.rawValue
14 14
     var isUnsupported: Bool = false
15 15
     @Attribute(.externalStorage) var recordArchiveData: Data?
16
+    @Attribute(.externalStorage) var detailCacheData: Data?
16 17
     var snapshot: HealthSnapshot?
17 18
     @Relationship(deleteRule: .cascade, inverse: \YearlyCount.typeCount)
18 19
     var yearlyCounts: [YearlyCount]? = []
@@ -49,4 +50,13 @@ extension TypeCount {
49 50
         recordArchiveData = HealthRecordArchive.encode(values)
50 51
         records?.removeAll()
51 52
     }
53
+
54
+    @MainActor var detailCache: TypeCountDetailCache? {
55
+        guard let detailCacheData else { return nil }
56
+        return TypeCountDetailCacheArchive.decode(detailCacheData)
57
+    }
58
+
59
+    @MainActor func setDetailCache(_ cache: TypeCountDetailCache?) {
60
+        detailCacheData = cache.flatMap(TypeCountDetailCacheArchive.encode)
61
+    }
52 62
 }
+192 -0
HealthProbe/Models/TypeCountDetailCache.swift
@@ -0,0 +1,192 @@
1
+import Foundation
2
+
3
+// Interface updated 2026-05-17 — see AGENTS.md
4
+struct TypeCountDailyChangeBin: Codable, Equatable, Sendable {
5
+    let dayStart: Date
6
+    let added: Int
7
+    let disappeared: Int
8
+    let unchanged: Int
9
+}
10
+
11
+// Interface updated 2026-05-17 — see AGENTS.md
12
+struct TypeCountDetailCache: Codable, Equatable, Sendable {
13
+    static let previewLimit = 1_000
14
+
15
+    let baselineSnapshotID: UUID?
16
+    let addedCount: Int
17
+    let disappearedCount: Int
18
+    let addedPreviewRecords: [HealthRecordValue]
19
+    let disappearedPreviewRecords: [HealthRecordValue]
20
+    let dailyChangeBins: [TypeCountDailyChangeBin]
21
+    let earliestRecordDate: Date?
22
+    let latestRecordDate: Date?
23
+
24
+    var isPreviewLimited: Bool {
25
+        addedCount > Self.previewLimit || disappearedCount > Self.previewLimit
26
+    }
27
+
28
+    func matchesBaseline(_ snapshotID: UUID?) -> Bool {
29
+        baselineSnapshotID == snapshotID
30
+    }
31
+}
32
+
33
+enum TypeCountDetailCacheArchive {
34
+    static func encode(_ cache: TypeCountDetailCache) -> Data? {
35
+        try? PropertyListEncoder().encode(cache)
36
+    }
37
+
38
+    static func decode(_ data: Data) -> TypeCountDetailCache? {
39
+        try? PropertyListDecoder().decode(TypeCountDetailCache.self, from: data)
40
+    }
41
+}
42
+
43
+enum TypeCountDetailCacheBuilder {
44
+    static func build(
45
+        current: TypeCount,
46
+        previous: TypeCount?,
47
+        baselineSnapshotID: UUID?
48
+    ) -> TypeCountDetailCache? {
49
+        guard let currentFingerprints = HealthRecordArchive.fingerprintSet(from: current.recordArchiveData),
50
+              let previousFingerprints = HealthRecordArchive.fingerprintSet(from: previous?.recordArchiveData) else {
51
+            return nil
52
+        }
53
+
54
+        if current.count > 0, current.recordArchiveData == nil { return nil }
55
+        if let previous, previous.count > 0, previous.recordArchiveData == nil { return nil }
56
+
57
+        var accumulator = DetailCacheAccumulator()
58
+
59
+        if let previousArchive = previous?.recordArchiveData {
60
+            guard HealthRecordArchive.forEachRecord(in: previousArchive, { record in
61
+                if !currentFingerprints.contains(record.recordFingerprint) {
62
+                    accumulator.add(record, as: .disappeared)
63
+                }
64
+            }) else {
65
+                return nil
66
+            }
67
+        }
68
+
69
+        if let currentArchive = current.recordArchiveData {
70
+            guard HealthRecordArchive.forEachRecord(in: currentArchive, { record in
71
+                if previousFingerprints.contains(record.recordFingerprint) {
72
+                    accumulator.add(record, as: .unchanged)
73
+                } else {
74
+                    accumulator.add(record, as: .added)
75
+                }
76
+            }) else {
77
+                return nil
78
+            }
79
+        }
80
+
81
+        return TypeCountDetailCache(
82
+            baselineSnapshotID: baselineSnapshotID,
83
+            addedCount: accumulator.addedCount,
84
+            disappearedCount: accumulator.disappearedCount,
85
+            addedPreviewRecords: accumulator.addedPreview.records,
86
+            disappearedPreviewRecords: accumulator.disappearedPreview.records,
87
+            dailyChangeBins: accumulator.dailyBins,
88
+            earliestRecordDate: accumulator.earliestRecordDate,
89
+            latestRecordDate: accumulator.latestRecordDate
90
+        )
91
+    }
92
+}
93
+
94
+private enum TypeCountDetailCacheRecordKind {
95
+    case added
96
+    case disappeared
97
+    case unchanged
98
+}
99
+
100
+private struct DetailCacheAccumulator {
101
+    private let calendar = Calendar.current
102
+    private var bins: [Date: (added: Int, disappeared: Int, unchanged: Int)] = [:]
103
+
104
+    var addedCount = 0
105
+    var disappearedCount = 0
106
+    var addedPreview = NewestRecordBuffer(limit: TypeCountDetailCache.previewLimit)
107
+    var disappearedPreview = NewestRecordBuffer(limit: TypeCountDetailCache.previewLimit)
108
+    var earliestRecordDate: Date?
109
+    var latestRecordDate: Date?
110
+
111
+    var dailyBins: [TypeCountDailyChangeBin] {
112
+        bins
113
+            .map { dayStart, counts in
114
+                TypeCountDailyChangeBin(
115
+                    dayStart: dayStart,
116
+                    added: counts.added,
117
+                    disappeared: counts.disappeared,
118
+                    unchanged: counts.unchanged
119
+                )
120
+            }
121
+            .sorted { $0.dayStart < $1.dayStart }
122
+    }
123
+
124
+    mutating func add(_ record: HealthRecordValue, as kind: TypeCountDetailCacheRecordKind) {
125
+        updateDateRange(with: record.startDate)
126
+
127
+        let dayStart = calendar.startOfDay(for: record.startDate)
128
+        var bin = bins[dayStart] ?? (added: 0, disappeared: 0, unchanged: 0)
129
+
130
+        switch kind {
131
+        case .added:
132
+            addedCount += 1
133
+            addedPreview.append(record)
134
+            bin.added += 1
135
+        case .disappeared:
136
+            disappearedCount += 1
137
+            disappearedPreview.append(record)
138
+            bin.disappeared += 1
139
+        case .unchanged:
140
+            bin.unchanged += 1
141
+        }
142
+
143
+        bins[dayStart] = bin
144
+    }
145
+
146
+    private mutating func updateDateRange(with date: Date) {
147
+        if earliestRecordDate.map({ date < $0 }) ?? true {
148
+            earliestRecordDate = date
149
+        }
150
+        if latestRecordDate.map({ date > $0 }) ?? true {
151
+            latestRecordDate = date
152
+        }
153
+    }
154
+}
155
+
156
+private struct NewestRecordBuffer {
157
+    let limit: Int
158
+    private var storage: [HealthRecordValue] = []
159
+    private var oldestPreviewIndex: Int?
160
+
161
+    init(limit: Int) {
162
+        self.limit = limit
163
+    }
164
+
165
+    var records: [HealthRecordValue] {
166
+        storage.sorted { $0.startDate > $1.startDate }
167
+    }
168
+
169
+    mutating func append(_ record: HealthRecordValue) {
170
+        guard limit > 0 else { return }
171
+
172
+        if storage.count < limit {
173
+            storage.append(record)
174
+            refreshOldestPreviewIndex()
175
+            return
176
+        }
177
+
178
+        guard let oldestPreviewIndex,
179
+              record.startDate > storage[oldestPreviewIndex].startDate else {
180
+            return
181
+        }
182
+
183
+        storage[oldestPreviewIndex] = record
184
+        refreshOldestPreviewIndex()
185
+    }
186
+
187
+    private mutating func refreshOldestPreviewIndex() {
188
+        oldestPreviewIndex = storage.indices.min { lhs, rhs in
189
+            storage[lhs].startDate < storage[rhs].startDate
190
+        }
191
+    }
192
+}
+43 -0
HealthProbe/Services/HealthKitService.swift
@@ -140,6 +140,11 @@ final class HealthKitService {
140 140
             intendedTypeIDs: active.map { $0.id },
141 141
             context: context
142 142
         )
143
+        precomputeTypeCountDetailCaches(
144
+            snapshot: snapshot,
145
+            typeCounts: typeCounts,
146
+            context: context
147
+        )
143 148
 
144 149
         if snapshot.snapshotQuality == .complete,
145 150
            reviewAmbiguousCompleteDisappearedTypes,
@@ -167,6 +172,11 @@ final class HealthKitService {
167 172
             intendedTypeIDs: typeCounts.map(\.typeIdentifier),
168 173
             context: context
169 174
         )
175
+        precomputeTypeCountDetailCaches(
176
+            snapshot: snapshot,
177
+            typeCounts: typeCounts,
178
+            context: context
179
+        )
170 180
         try await persistSnapshot(snapshot, typeCounts: typeCounts, context: context)
171 181
         return snapshot
172 182
     }
@@ -185,6 +195,11 @@ final class HealthKitService {
185 195
             intendedTypeIDs: typeCounts.map(\.typeIdentifier),
186 196
             context: context
187 197
         )
198
+        precomputeTypeCountDetailCaches(
199
+            snapshot: snapshot,
200
+            typeCounts: typeCounts,
201
+            context: context
202
+        )
188 203
         try await persistSnapshot(snapshot, typeCounts: typeCounts, context: context)
189 204
         return snapshot
190 205
     }
@@ -294,6 +309,34 @@ final class HealthKitService {
294 309
         }
295 310
     }
296 311
 
312
+    private func precomputeTypeCountDetailCaches(
313
+        snapshot: HealthSnapshot,
314
+        typeCounts: [TypeCount],
315
+        context: ModelContext
316
+    ) {
317
+        guard let previousID = snapshot.previousSnapshotID,
318
+              let previous = fetchSnapshot(id: previousID, context: context) else {
319
+            for typeCount in typeCounts {
320
+                typeCount.setDetailCache(nil)
321
+            }
322
+            return
323
+        }
324
+
325
+        let previousByType = Dictionary(
326
+            uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
327
+        )
328
+
329
+        for typeCount in typeCounts {
330
+            typeCount.setDetailCache(
331
+                TypeCountDetailCacheBuilder.build(
332
+                    current: typeCount,
333
+                    previous: previousByType[typeCount.typeIdentifier],
334
+                    baselineSnapshotID: previous.id
335
+                )
336
+            )
337
+        }
338
+    }
339
+
297 340
     private func hasAmbiguousCompleteDisappearance(
298 341
         snapshot: HealthSnapshot,
299 342
         typeCounts: [TypeCount],
+99 -0
HealthProbe/Services/SnapshotLifecycleService.swift
@@ -88,6 +88,7 @@ enum SnapshotLifecycleService {
88 88
             if let nextSnap = try fetchSnapshot(id: outgoing.toSnapshotID, context: context) {
89 89
                 nextSnap.previousSnapshotID = nil
90 90
                 nextSnap.isChainStart = true
91
+                refreshDetailCaches(for: nextSnap, baseline: nil)
91 92
             }
92 93
             context.delete(outgoing)
93 94
             context.delete(snapshot)
@@ -113,6 +114,7 @@ enum SnapshotLifecycleService {
113 114
             for td in merged.typeDeltas ?? [] { context.insert(td) }
114 115
 
115 116
             nextSnap.previousSnapshotID = prevSnap.id
117
+            refreshDetailCaches(for: nextSnap, baseline: prevSnap)
116 118
             context.delete(d1)
117 119
             context.delete(d2)
118 120
             context.delete(snapshot)
@@ -139,6 +141,59 @@ enum SnapshotLifecycleService {
139 141
         }
140 142
     }
141 143
 
144
+    static func rebuildMissingDetailCaches(
145
+        context: ModelContext,
146
+        maxTypeCounts: Int
147
+    ) throws -> Bool {
148
+        guard maxTypeCounts > 0 else { return false }
149
+
150
+        let descriptor = FetchDescriptor<HealthSnapshot>(
151
+            sortBy: [SortDescriptor(\.timestamp, order: .forward)]
152
+        )
153
+        let snapshots = try context.fetch(descriptor)
154
+        var rebuiltCount = 0
155
+
156
+        for snapshot in snapshots {
157
+            guard let baselineID = snapshot.previousSnapshotID,
158
+                  let baseline = try fetchSnapshot(id: baselineID, context: context) else {
159
+                continue
160
+            }
161
+
162
+            let baselineByType = Dictionary(
163
+                uniqueKeysWithValues: (baseline.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
164
+            )
165
+
166
+            for typeCount in snapshot.typeCounts ?? [] {
167
+                guard shouldBackfillDetailCache(
168
+                    typeCount: typeCount,
169
+                    baseline: baselineByType[typeCount.typeIdentifier],
170
+                    baselineID: baseline.id
171
+                ) else {
172
+                    continue
173
+                }
174
+
175
+                typeCount.setDetailCache(
176
+                    TypeCountDetailCacheBuilder.build(
177
+                        current: typeCount,
178
+                        previous: baselineByType[typeCount.typeIdentifier],
179
+                        baselineSnapshotID: baseline.id
180
+                    )
181
+                )
182
+                rebuiltCount += 1
183
+
184
+                if rebuiltCount >= maxTypeCounts {
185
+                    try context.save()
186
+                    return false
187
+                }
188
+            }
189
+        }
190
+
191
+        if rebuiltCount > 0 {
192
+            try context.save()
193
+        }
194
+        return true
195
+    }
196
+
142 197
     // MARK: - Fetch helpers
143 198
 
144 199
     private static func fetchDeltas(context: ModelContext) throws -> [SnapshotDelta] {
@@ -152,6 +207,50 @@ enum SnapshotLifecycleService {
152 207
         return try context.fetch(descriptor).first
153 208
     }
154 209
 
210
+    private static func refreshDetailCaches(for snapshot: HealthSnapshot, baseline: HealthSnapshot?) {
211
+        guard let baseline else {
212
+            for typeCount in snapshot.typeCounts ?? [] {
213
+                typeCount.setDetailCache(nil)
214
+            }
215
+            return
216
+        }
217
+
218
+        let baselineByType = Dictionary(
219
+            uniqueKeysWithValues: (baseline.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
220
+        )
221
+
222
+        for typeCount in snapshot.typeCounts ?? [] {
223
+            typeCount.setDetailCache(
224
+                TypeCountDetailCacheBuilder.build(
225
+                    current: typeCount,
226
+                    previous: baselineByType[typeCount.typeIdentifier],
227
+                    baselineSnapshotID: baseline.id
228
+                )
229
+            )
230
+        }
231
+    }
232
+
233
+    @MainActor private static func shouldBackfillDetailCache(
234
+        typeCount: TypeCount,
235
+        baseline: TypeCount?,
236
+        baselineID: UUID
237
+    ) -> Bool {
238
+        if typeCount.detailCache?.matchesBaseline(baselineID) == true {
239
+            return false
240
+        }
241
+
242
+        guard canBuildDetailCache(typeCount),
243
+              baseline.map(canBuildDetailCache(_:)) ?? true else {
244
+            return false
245
+        }
246
+
247
+        return true
248
+    }
249
+
250
+    @MainActor private static func canBuildDetailCache(_ typeCount: TypeCount) -> Bool {
251
+        typeCount.count <= 0 || typeCount.recordArchiveData != nil
252
+    }
253
+
155 254
     private static func buildSummary(snapshot: HealthSnapshot, incoming: SnapshotDelta?, outgoing: SnapshotDelta?) -> String {
156 255
         let position: String
157 256
         if incoming == nil && outgoing == nil { position = "standalone" }
+2 -0
HealthProbe/Utilities/AppSettings.swift
@@ -6,6 +6,8 @@ final class AppSettings {
6 6
     private static let selectedTypeIDsKey   = "hp_selectedTypeIDs"
7 7
     private static let selectedDeviceIDsKey = "hp_selectedDeviceIDs"
8 8
     static let adaptiveTimeoutsEnabledKey = "hp_adaptiveTimeoutsEnabled"
9
+    static let typeDetailCacheBackfillVersionKey = "hp_typeDetailCacheBackfillVersion"
10
+    static let currentTypeDetailCacheBackfillVersion = 1
9 11
 
10 12
     var selectedTypeIDs: Set<String> {
11 13
         didSet { persistTypes() }
+16 -38
HealthProbe/ViewModels/DataTypeTemporalDistributionViewModel.swift
@@ -6,13 +6,13 @@ enum BinningStrategy: String, CaseIterable, Equatable {
6 6
     case month = "Lună"
7 7
     case year = "An"
8 8
 
9
-    var calendar: Calendar {
9
+    nonisolated var calendar: Calendar {
10 10
         var cal = Calendar.current
11 11
         cal.timeZone = TimeZone(abbreviation: "UTC") ?? TimeZone.current
12 12
         return cal
13 13
     }
14 14
 
15
-    func bins(from startDate: Date, to endDate: Date) -> [Date] {
15
+    nonisolated func bins(from startDate: Date, to endDate: Date) -> [Date] {
16 16
         var result: [Date] = []
17 17
         var current = startDate
18 18
 
@@ -42,7 +42,7 @@ enum BinningStrategy: String, CaseIterable, Equatable {
42 42
         return result
43 43
     }
44 44
 
45
-    func label(for date: Date) -> String {
45
+    nonisolated func label(for date: Date) -> String {
46 46
         switch self {
47 47
         case .day:
48 48
             return date.formatted(.dateTime.hour().minute())
@@ -55,11 +55,11 @@ enum BinningStrategy: String, CaseIterable, Equatable {
55 55
         }
56 56
     }
57 57
 
58
-    func contains(_ date: Date, in binStart: Date, binEnd: Date) -> Bool {
58
+    nonisolated func contains(_ date: Date, in binStart: Date, binEnd: Date) -> Bool {
59 59
         date >= binStart && date < binEnd
60 60
     }
61 61
 
62
-    func dateRange(containing date: Date) -> (start: Date, end: Date) {
62
+    nonisolated func dateRange(containing date: Date) -> (start: Date, end: Date) {
63 63
         let cal = calendar
64 64
 
65 65
         switch self {
@@ -110,10 +110,6 @@ final class DataTypeTemporalDistributionViewModel: Sendable {
110 110
         }
111 111
     }
112 112
 
113
-    private var addedRecords: [HealthRecordValue] = []
114
-    private var disappearedRecords: [HealthRecordValue] = []
115
-    private var unchangedRecords: [HealthRecordValue] = []
116
-
117 113
     private var addedByDate: [Date: Int] = [:]
118 114
     private var disappearedByDate: [Date: Int] = [:]
119 115
     private var unchangedByDate: [Date: Int] = [:]
@@ -126,23 +122,14 @@ final class DataTypeTemporalDistributionViewModel: Sendable {
126 122
             return
127 123
         }
128 124
 
129
-        let currentRecords = current.recordValues
130
-        let previousRecords = previous?.recordValues ?? []
131
-
132
-        let currentFingerprints = Set(currentRecords.map(\.recordFingerprint))
133
-        let previousFingerprints = Set(previousRecords.map(\.recordFingerprint))
134
-
135
-        addedRecords = currentRecords.filter { !previousFingerprints.contains($0.recordFingerprint) }
136
-        disappearedRecords = previousRecords.filter { !currentFingerprints.contains($0.recordFingerprint) }
137
-        unchangedRecords = currentRecords.filter { currentFingerprints.contains($0.recordFingerprint) }
138
-
139
-        guard !currentRecords.isEmpty else {
140
-            error = "No records to display"
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."
141 128
             return
142 129
         }
143 130
 
144
-        let dates = currentRecords.map(\.startDate)
145
-        guard let minDate = dates.min(), let maxDate = dates.max() else {
131
+        guard let minDate = cache.earliestRecordDate,
132
+              let maxDate = cache.latestRecordDate else {
146 133
             error = "Cannot determine date range"
147 134
             return
148 135
         }
@@ -150,23 +137,14 @@ final class DataTypeTemporalDistributionViewModel: Sendable {
150 137
         dateRange = (minDate, maxDate)
151 138
         displayedDateRange = (minDate, maxDate)
152 139
 
153
-        await indexRecordsByDate()
140
+        indexDailyBins(cache.dailyChangeBins)
154 141
         await rebuildBinsBackground()
155 142
     }
156 143
 
157
-    private func indexRecordsByDate() async {
158
-        let calendar = Calendar.current
159
-
160
-        func dayKey(_ date: Date) -> Date {
161
-            calendar.startOfDay(for: date)
162
-        }
163
-
164
-        addedByDate = Dictionary(grouping: addedRecords, by: { dayKey($0.startDate) })
165
-            .mapValues { $0.count }
166
-        disappearedByDate = Dictionary(grouping: disappearedRecords, by: { dayKey($0.startDate) })
167
-            .mapValues { $0.count }
168
-        unchangedByDate = Dictionary(grouping: unchangedRecords, by: { dayKey($0.startDate) })
169
-            .mapValues { $0.count }
144
+    private func indexDailyBins(_ bins: [TypeCountDailyChangeBin]) {
145
+        addedByDate = Dictionary(uniqueKeysWithValues: bins.map { ($0.dayStart, $0.added) })
146
+        disappearedByDate = Dictionary(uniqueKeysWithValues: bins.map { ($0.dayStart, $0.disappeared) })
147
+        unchangedByDate = Dictionary(uniqueKeysWithValues: bins.map { ($0.dayStart, $0.unchanged) })
170 148
     }
171 149
 
172 150
     private func adjustDisplayRangeForStrategy() {
@@ -254,7 +232,7 @@ struct TemporalBin: Identifiable, Sendable {
254 232
 }
255 233
 
256 234
 extension Array {
257
-    fileprivate subscript(safe index: Index) -> Element? {
235
+    nonisolated fileprivate subscript(safe index: Index) -> Element? {
258 236
         indices.contains(index) ? self[index] : nil
259 237
     }
260 238
 }
+6 -26
HealthProbe/Views/DataTypes/RecordChangeEvolutionChart.swift
@@ -41,33 +41,13 @@ struct RecordChangeEvolutionChart: View {
41 41
 
42 42
     private func recordDiff(current: HealthSnapshot, previous: HealthSnapshot?) -> (added: Int, disappeared: Int) {
43 43
         guard let previous = previous else { return (0, 0) }
44
-
45
-        let currentArchive = current.typeCounts?.first { $0.typeIdentifier == typeIdentifier }?.recordArchiveData
46
-        let previousArchive = previous.typeCounts?.first { $0.typeIdentifier == typeIdentifier }?.recordArchiveData
47
-
48
-        let currentRecords: [HealthRecordValue]
49
-        if let currentArchive {
50
-            guard let decoded = HealthRecordArchive.decode(currentArchive) else { return (0, 0) }
51
-            currentRecords = decoded
52
-        } else {
53
-            currentRecords = []
44
+        guard let cache = current.typeCounts?
45
+            .first(where: { $0.typeIdentifier == typeIdentifier })?
46
+            .detailCache,
47
+              cache.matchesBaseline(previous.id) else {
48
+            return (0, 0)
54 49
         }
55
-
56
-        let previousRecords: [HealthRecordValue]
57
-        if let previousArchive {
58
-            guard let decoded = HealthRecordArchive.decode(previousArchive) else { return (0, 0) }
59
-            previousRecords = decoded
60
-        } else {
61
-            previousRecords = []
62
-        }
63
-
64
-        let previousFingerprints = Set(previousRecords.map(\.recordFingerprint))
65
-        let currentFingerprints = Set(currentRecords.map(\.recordFingerprint))
66
-
67
-        let added = currentRecords.filter { !previousFingerprints.contains($0.recordFingerprint) }.count
68
-        let disappeared = previousRecords.filter { !currentFingerprints.contains($0.recordFingerprint) }.count
69
-
70
-        return (added, disappeared)
50
+        return (cache.addedCount, cache.disappearedCount)
71 51
     }
72 52
 
73 53
     var body: some View {
+50 -54
HealthProbe/Views/Snapshots/DataTypeSnapshotDetailView.swift
@@ -6,6 +6,7 @@ struct DataTypeSnapshotDetailView: View {
6 6
     let typeIdentifier: String
7 7
     let displayName: String
8 8
 
9
+    @Environment(\.modelContext) private var modelContext
9 10
     @Query(sort: \HealthSnapshot.timestamp) private var allSnapshots: [HealthSnapshot]
10 11
 
11 12
     @State private var displayedSnapshot: HealthSnapshot?
@@ -232,7 +233,7 @@ struct DataTypeSnapshotDetailView: View {
232 233
 
233 234
             case .unavailable:
234 235
                 Label(
235
-                    "Legacy record format. Recreate database to inspect details.",
236
+                    "Record detail cache is unavailable for this snapshot pair.",
236 237
                     systemImage: "exclamationmark.triangle.fill"
237 238
                 )
238 239
                 .font(.subheadline)
@@ -334,25 +335,42 @@ struct DataTypeSnapshotDetailView: View {
334 335
 
335 336
         let currentCount = currentTypeCount?.count ?? 0
336 337
         let previousCount = previousTypeCount?.count ?? 0
337
-        let currentArchive = currentTypeCount?.recordArchiveData
338
-        let previousArchive = previousTypeCount?.recordArchiveData
339 338
 
340
-        let currentNeedsArchive = currentCount > 0
341
-        let previousNeedsArchive = previousCount > 0
342
-        guard (!currentNeedsArchive || currentArchive != nil),
343
-              (!previousNeedsArchive || previousArchive != nil) else {
339
+        guard currentCount >= 0, previousCount >= 0 else {
344 340
             diffState = .unavailable
345 341
             return
346 342
         }
347 343
 
348
-        diffState = .loading
349
-        let result = await Task.detached(priority: .userInitiated) {
350
-            DataTypeRecordDiff.compute(
351
-                currentArchive: currentArchive,
352
-                previousArchive: previousArchive
353
-            )
354
-        }.value
355
-        diffState = result
344
+        guard let cache = currentDetailCache() else {
345
+            diffState = .unavailable
346
+            return
347
+        }
348
+
349
+        diffState = .loaded(DataTypeRecordDiff(cache: cache))
350
+    }
351
+
352
+    @MainActor
353
+    private func currentDetailCache() -> TypeCountDetailCache? {
354
+        if let cache = currentTypeCount?.detailCache,
355
+           cache.matchesBaseline(previousSnapshot?.id) {
356
+            return cache
357
+        }
358
+
359
+        guard let currentTypeCount,
360
+              let previousSnapshot else {
361
+            return nil
362
+        }
363
+
364
+        let cache = TypeCountDetailCacheBuilder.build(
365
+            current: currentTypeCount,
366
+            previous: previousTypeCount,
367
+            baselineSnapshotID: previousSnapshot.id
368
+        )
369
+        currentTypeCount.setDetailCache(cache)
370
+        if cache != nil {
371
+            try? modelContext.save()
372
+        }
373
+        return cache
356 374
     }
357 375
 }
358 376
 
@@ -429,49 +447,27 @@ private struct DataTypeRecordDiff: Equatable, Sendable {
429 447
     let addedRecords: [HealthRecordValue]
430 448
     let disappearedRecords: [HealthRecordValue]
431 449
 
432
-    var isPreviewLimited: Bool {
433
-        addedCount > Self.previewLimit || disappearedCount > Self.previewLimit
450
+    init(
451
+        addedCount: Int,
452
+        disappearedCount: Int,
453
+        addedRecords: [HealthRecordValue],
454
+        disappearedRecords: [HealthRecordValue]
455
+    ) {
456
+        self.addedCount = addedCount
457
+        self.disappearedCount = disappearedCount
458
+        self.addedRecords = addedRecords
459
+        self.disappearedRecords = disappearedRecords
434 460
     }
435 461
 
436
-    static func compute(currentArchive: Data?, previousArchive: Data?) -> RecordDiffState {
437
-        let currentRecords: [HealthRecordValue]
438
-        if let currentArchive {
439
-            guard let decoded = HealthRecordArchive.decode(currentArchive) else {
440
-                return .failed("Could not decode current record archive.")
441
-            }
442
-            currentRecords = decoded
443
-        } else {
444
-            currentRecords = []
445
-        }
446
-
447
-        let previousRecords: [HealthRecordValue]
448
-        if let previousArchive {
449
-            guard let decoded = HealthRecordArchive.decode(previousArchive) else {
450
-                return .failed("Could not decode previous record archive.")
451
-            }
452
-            previousRecords = decoded
453
-        } else {
454
-            previousRecords = []
455
-        }
456
-
457
-        let previousFingerprints = Set(previousRecords.map(\.recordFingerprint))
458
-        let currentFingerprints = Set(currentRecords.map(\.recordFingerprint))
459
-
460
-        let added = currentRecords.filter { !previousFingerprints.contains($0.recordFingerprint) }
461
-        let disappeared = previousRecords.filter { !currentFingerprints.contains($0.recordFingerprint) }
462
-
463
-        return .loaded(
464
-            DataTypeRecordDiff(
465
-                addedCount: added.count,
466
-                disappearedCount: disappeared.count,
467
-                addedRecords: newestRecords(from: added),
468
-                disappearedRecords: newestRecords(from: disappeared)
469
-            )
470
-        )
462
+    init(cache: TypeCountDetailCache) {
463
+        self.addedCount = cache.addedCount
464
+        self.disappearedCount = cache.disappearedCount
465
+        self.addedRecords = cache.addedPreviewRecords
466
+        self.disappearedRecords = cache.disappearedPreviewRecords
471 467
     }
472 468
 
473
-    private static func newestRecords(from records: [HealthRecordValue]) -> [HealthRecordValue] {
474
-        Array(records.sorted { $0.startDate > $1.startDate }.prefix(previewLimit))
469
+    var isPreviewLimited: Bool {
470
+        addedCount > Self.previewLimit || disappearedCount > Self.previewLimit
475 471
     }
476 472
 }
477 473