Showing 16 changed files with 1536 additions and 130 deletions
+16 -2
AGENTS.md
@@ -105,8 +105,22 @@ final class TypeDistributionBin {
105 105
 }
106 106
 
107 107
 // Models/TypeCount.swift
108
-// TypeCount owns zero or more daily TypeDistributionBin records.
109
-// These bins store sample counts by time bucket, not raw health values.
108
+// TypeCount owns zero or more TypeDistributionBin records.
109
+// These bins store sample counts and import anchors, not raw health values.
110
+
111
+// Interface updated 2026-05-12 — see AGENTS.md
112
+// Models/HealthRecord.swift
113
+// HealthRecord stores one anonymized HealthKit record fingerprint plus its start/end dates.
114
+// It intentionally does not store raw health values, device identifiers, or source metadata.
115
+// UI may compare HealthRecord fingerprints between adjacent snapshots to expose losses
116
+// that are masked by newly-added records with the same total count.
117
+// High-volume snapshots store these records in TypeCount.recordArchiveData instead of
118
+// creating one SwiftData model per record, avoiding main-thread stalls after import.
119
+
120
+// Interface updated 2026-05-13 — see AGENTS.md
121
+// TypeDistributionBin also stores content hashes and HealthKit query anchors.
122
+// Import uses a global anchored query per data type so follow-up snapshots fetch only
123
+// HealthKit deltas instead of scanning calendar blocks with fixed per-query latency.
110 124
 
111 125
 // Models/DetectedAnomaly.swift
112 126
 enum AnomalyType: String, Codable {
+1 -1
HealthProbe/ContentView.swift
@@ -22,6 +22,6 @@ struct ContentView: View {
22 22
 
23 23
 #Preview {
24 24
     ContentView()
25
-        .modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self, DeviceProfile.self], inMemory: true)
25
+        .modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self, DeviceProfile.self], inMemory: true)
26 26
         .environment(AppSettings())
27 27
 }
+7 -3
HealthProbe/HealthProbeApp.swift
@@ -29,16 +29,17 @@ struct HealthProbeApp: App {
29 29
     //    cosmetic data (name, color tag) that should not cross devices.
30 30
     private static func createModelContainer() throws -> ModelContainer {
31 31
         let fullSchema = Schema([
32
-            HealthSnapshot.self, TypeCount.self, YearlyCount.self,
32
+            HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self,
33 33
             SnapshotDelta.self, TypeDelta.self, AnomalyRecord.self,
34 34
             OperationLog.self, DeviceProfile.self, MetricTimeoutProfile.self,
35 35
         ])
36 36
 
37 37
         let appSupportURL = URL.applicationSupportDirectory
38 38
         try FileManager.default.createDirectory(at: appSupportURL, withIntermediateDirectories: true)
39
+        let cloudStoreURL = appSupportURL.appending(path: "HealthProbeRecords.store")
39 40
 
40 41
         let cloudKitModels = Schema([
41
-            HealthSnapshot.self, TypeCount.self, YearlyCount.self,
42
+            HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self,
42 43
             SnapshotDelta.self, TypeDelta.self, AnomalyRecord.self,
43 44
         ])
44 45
         let localModels = Schema([OperationLog.self, DeviceProfile.self, MetricTimeoutProfile.self])
@@ -46,7 +47,7 @@ struct HealthProbeApp: App {
46 47
         let cloudConfig = ModelConfiguration(
47 48
             "cloud",
48 49
             schema: cloudKitModels,
49
-            url: appSupportURL.appending(path: "HealthProbeCloud.store"),
50
+            url: cloudStoreURL,
50 51
             cloudKitDatabase: .none
51 52
         )
52 53
         let localConfig = ModelConfiguration(
@@ -60,6 +61,9 @@ struct HealthProbeApp: App {
60 61
             return try ModelContainer(for: fullSchema, configurations: [cloudConfig, localConfig])
61 62
         } catch {
62 63
             let candidates: [URL] = [
64
+                cloudStoreURL,
65
+                appSupportURL.appending(path: "HealthProbeRecords.store.shm"),
66
+                appSupportURL.appending(path: "HealthProbeRecords.store.wal"),
63 67
                 appSupportURL.appending(path: "HealthProbeCloud.store"),
64 68
                 appSupportURL.appending(path: "HealthProbeCloud.store.shm"),
65 69
                 appSupportURL.appending(path: "HealthProbeCloud.store.wal"),
+53 -0
HealthProbe/Models/HealthRecord.swift
@@ -0,0 +1,53 @@
1
+import Foundation
2
+import SwiftData
3
+
4
+struct HealthRecordValue: Codable, Hashable, Identifiable, Sendable {
5
+    var id: String { recordFingerprint }
6
+    let typeIdentifier: String
7
+    let sampleUUIDHash: String
8
+    let recordFingerprint: String
9
+    let startDate: Date
10
+    let endDate: Date
11
+    let displayValue: String?
12
+}
13
+
14
+enum HealthRecordArchive {
15
+    static func encode(_ values: [HealthRecordValue]) -> Data? {
16
+        let encoder = PropertyListEncoder()
17
+        encoder.outputFormat = .binary
18
+        return try? encoder.encode(values)
19
+    }
20
+
21
+    static func decode(_ data: Data) -> [HealthRecordValue]? {
22
+        try? PropertyListDecoder().decode([HealthRecordValue].self, from: data)
23
+    }
24
+}
25
+
26
+// Interface updated 2026-05-12 — see AGENTS.md
27
+@Model final class HealthRecord {
28
+    var id: UUID = UUID()
29
+    var typeIdentifier: String = ""
30
+    var sampleUUIDHash: String = ""
31
+    var recordFingerprint: String = ""
32
+    var startDate: Date = Date.distantPast
33
+    var endDate: Date = Date.distantPast
34
+    var displayValue: String?
35
+    var typeCount: TypeCount?
36
+
37
+    init(
38
+        typeIdentifier: String,
39
+        sampleUUIDHash: String,
40
+        recordFingerprint: String,
41
+        startDate: Date,
42
+        endDate: Date,
43
+        displayValue: String? = nil
44
+    ) {
45
+        self.id = UUID()
46
+        self.typeIdentifier = typeIdentifier
47
+        self.sampleUUIDHash = sampleUUIDHash
48
+        self.recordFingerprint = recordFingerprint
49
+        self.startDate = startDate
50
+        self.endDate = endDate
51
+        self.displayValue = displayValue
52
+    }
53
+}
+20 -0
HealthProbe/Models/TypeCount.swift
@@ -1,6 +1,7 @@
1 1
 import Foundation
2 2
 import SwiftData
3 3
 
4
+// Interface updated 2026-05-12 — see AGENTS.md
4 5
 @Model final class TypeCount {
5 6
     var id: UUID = UUID()
6 7
     var typeIdentifier: String = ""
@@ -11,9 +12,14 @@ import SwiftData
11 12
     var latestDate: Date?
12 13
     var qualityRaw: String = SnapshotQuality.complete.rawValue
13 14
     var isUnsupported: Bool = false
15
+    var recordArchiveData: Data?
14 16
     var snapshot: HealthSnapshot?
15 17
     @Relationship(deleteRule: .cascade, inverse: \YearlyCount.typeCount)
16 18
     var yearlyCounts: [YearlyCount]? = []
19
+    @Relationship(deleteRule: .cascade, inverse: \HealthRecord.typeCount)
20
+    var records: [HealthRecord]? = []
21
+    @Relationship(deleteRule: .cascade, inverse: \TypeDistributionBin.typeCount)
22
+    var distributionBins: [TypeDistributionBin]? = []
17 23
 
18 24
     init(typeIdentifier: String, displayName: String, count: Int, quality: SnapshotQuality = .complete) {
19 25
         self.id = UUID()
@@ -29,4 +35,18 @@ extension TypeCount {
29 35
         get { SnapshotQuality(rawValue: qualityRaw) ?? .complete }
30 36
         set { qualityRaw = newValue.rawValue }
31 37
     }
38
+
39
+    var recordValues: [HealthRecordValue] {
40
+        if let recordArchiveData,
41
+           let decoded = HealthRecordArchive.decode(recordArchiveData) {
42
+            return decoded
43
+        }
44
+
45
+        return []
46
+    }
47
+
48
+    func setRecordValues(_ values: [HealthRecordValue]) {
49
+        recordArchiveData = HealthRecordArchive.encode(values)
50
+        records?.removeAll()
51
+    }
32 52
 }
+2 -0
HealthProbe/Models/TypeDistributionBin.swift
@@ -8,6 +8,8 @@ final class TypeDistributionBin {
8 8
     var bucketStart: Date = Date.distantPast
9 9
     var bucketEnd: Date = Date.distantPast
10 10
     var count: Int = 0
11
+    var contentHash: String = ""
12
+    var anchorData: Data?
11 13
     var typeCount: TypeCount?
12 14
 
13 15
     init(bucketStart: Date, bucketEnd: Date, count: Int) {
+32 -0
HealthProbe/Services/HashService.swift
@@ -25,6 +25,38 @@ enum HashService {
25 25
         return digest.map { String(format: "%02x", $0) }.joined()
26 26
     }
27 27
 
28
+    static func typeHash(typeIdentifier: String, recordFingerprints: [String]) -> String {
29
+        var hasher = SHA256()
30
+        hasher.update(data: Data(typeIdentifier.utf8))
31
+        for fingerprint in recordFingerprints.sorted() {
32
+            hasher.update(data: Data("|".utf8))
33
+            hasher.update(data: Data(fingerprint.utf8))
34
+        }
35
+        let digest = hasher.finalize()
36
+        return digest.map { String(format: "%02x", $0) }.joined()
37
+    }
38
+
39
+    static func sampleFingerprint(
40
+        typeIdentifier: String,
41
+        sampleUUID: String,
42
+        startDate: Date,
43
+        endDate: Date
44
+    ) -> String {
45
+        let input = [
46
+            typeIdentifier,
47
+            sampleUUID,
48
+            iso8601Formatter.string(from: startDate),
49
+            iso8601Formatter.string(from: endDate)
50
+        ].joined(separator: "|")
51
+        let digest = SHA256.hash(data: Data(input.utf8))
52
+        return digest.map { String(format: "%02x", $0) }.joined()
53
+    }
54
+
55
+    static func sampleUUIDHash(_ sampleUUID: String) -> String {
56
+        let digest = SHA256.hash(data: Data(sampleUUID.utf8))
57
+        return digest.map { String(format: "%02x", $0) }.joined()
58
+    }
59
+
28 60
     // Per-snapshot: sort TypeCounts by typeIdentifier, SHA256 of concatenated type hashes.
29 61
     // Filter criterion: quality == .complete; do not use contentHash != "" as a proxy.
30 62
     // A TypeCount with quality = .failed but contentHash = "nonEmpty" must be excluded.
+576 -91
HealthProbe/Services/HealthKitService.swift
@@ -43,6 +43,7 @@ final class HealthKitService {
43 43
 
44 44
     static let defaultInitialTimeoutSeconds: TimeInterval = MetricTimeoutProfile.defaultInitialTimeout
45 45
     static let maximumTimeoutSeconds: TimeInterval = MetricTimeoutProfile.maximumTimeout
46
+    static let fullHistoryImportTimeoutSeconds: TimeInterval = 30 * 60
46 47
     // Prevents 3N simultaneous HK queries from exhausting resources at N=20 types.
47 48
     static let maxConcurrentTypeFetches = 6
48 49
 
@@ -94,11 +95,13 @@ final class HealthKitService {
94 95
         snapshot.triggerReason = triggerReason
95 96
         snapshot.retryOfSnapshotID = retryOfSnapshotID
96 97
         snapshot.yearlyCountTimezoneIdentifier = TimeZone.current.identifier
98
+        let previousSnapshot = findPreviousSnapshot(deviceID: snapshot.deviceID, excluding: snapshot.id, context: context)
97 99
         // Fetch raw HealthKit data off the main actor, then assemble SwiftData models here
98 100
         // on the main actor to prevent data races on managed object context.
99 101
         let fetchResults = await fetchAllTypeCounts(
100 102
             for: active,
101 103
             context: context,
104
+            previousSnapshot: previousSnapshot,
102 105
             adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled,
103 106
             timeoutMultiplier: timeoutMultiplier,
104 107
             progress: progress
@@ -203,6 +206,9 @@ final class HealthKitService {
203 206
             for yearlyCount in typeCount.yearlyCounts ?? [] {
204 207
                 context.insert(yearlyCount)
205 208
             }
209
+            for bin in typeCount.distributionBins ?? [] {
210
+                context.insert(bin)
211
+            }
206 212
             typeCount.snapshot = snapshot
207 213
         }
208 214
         snapshot.typeCounts = typeCounts
@@ -384,6 +390,7 @@ final class HealthKitService {
384 390
     private func fetchAllTypeCounts(
385 391
         for active: [MonitoredType],
386 392
         context: ModelContext,
393
+        previousSnapshot: HealthSnapshot?,
387 394
         adaptiveTimeoutsEnabled: Bool,
388 395
         timeoutMultiplier: Double,
389 396
         progress: SnapshotFetchProgress? = nil
@@ -401,6 +408,7 @@ final class HealthKitService {
401 408
                 for: monitoredType,
402 409
                 timeoutProfile: profile,
403 410
                 timeoutSeconds: timeout,
411
+                previousTypeCount: previousSnapshot?.typeCounts?.first { $0.typeIdentifier == monitoredType.id },
404 412
                 progress: progress
405 413
             )
406 414
             updateTimeoutProfile(profile, with: result, monitoredType: monitoredType)
@@ -415,6 +423,7 @@ final class HealthKitService {
415 423
         for monitoredType: MonitoredType,
416 424
         timeoutProfile: MetricTimeoutProfile,
417 425
         timeoutSeconds: TimeInterval,
426
+        previousTypeCount: TypeCount?,
418 427
         progress: SnapshotFetchProgress? = nil
419 428
     ) async -> TypeCountFetchResult {
420 429
         let started = Date()
@@ -435,7 +444,10 @@ final class HealthKitService {
435 444
                 isUnsupported: true,
436 445
                 authorizationStatus: "unavailable",
437 446
                 apiCalls: Self.placeholderAPICalls(status: .unsupported, message: "HealthKit type is not available on this OS or device"),
438
-                yearlyCounts: []
447
+                yearlyCounts: [],
448
+                distributionBins: [],
449
+                records: [],
450
+                recordArchiveData: nil
439 451
             )
440 452
             result.totalElapsedSeconds = Date().timeIntervalSince(started)
441 453
             result.timeoutConfiguredSeconds = timeoutSeconds
@@ -445,7 +457,13 @@ final class HealthKitService {
445 457
             return result
446 458
         }
447 459
 
448
-        var result = await fetchTypeCountDataFromHK(monitoredType: monitoredType, sampleType: sampleType, timeoutSeconds: timeoutSeconds)
460
+        var result = await fetchTypeCountDataFromHK(
461
+            monitoredType: monitoredType,
462
+            sampleType: sampleType,
463
+            timeoutSeconds: timeoutSeconds,
464
+            previousTypeCount: previousTypeCount,
465
+            progress: progress
466
+        )
449 467
         result.totalElapsedSeconds = Date().timeIntervalSince(started)
450 468
         result.timeoutConfiguredSeconds = timeoutSeconds
451 469
         result.applyTimeoutProfile(timeoutProfile)
@@ -464,45 +482,17 @@ final class HealthKitService {
464 482
         return result
465 483
     }
466 484
 
467
-    private func fetchTypeCountDataFromHK(monitoredType: MonitoredType, sampleType: HKSampleType, timeoutSeconds: TimeInterval) async -> TypeCountFetchResult {
468
-        let deadline = Date().addingTimeInterval(timeoutSeconds)
469
-        let distributionResult = await measureAPICall(
470
-            queryType: "distribution",
471
-            timeoutSeconds: max(0, deadline.timeIntervalSinceNow)
472
-        ) {
473
-            try await self.fetchDistribution(for: sampleType)
474
-        } resultDescription: { distribution in
475
-            "\(distribution.totalCount) samples"
476
-        }
477
-
478
-        guard distributionResult.apiCall.status == .complete, let distribution = distributionResult.value else {
479
-            let status = distributionResult.apiCall.status
480
-            let quality = diagnosticQuality(for: status)
481
-            return TypeCountFetchResult(
482
-                typeIdentifier: monitoredType.id,
483
-                displayName: monitoredType.displayName,
484
-                count: -1,
485
-                contentHash: "",
486
-                earliestDate: nil,
487
-                latestDate: nil,
488
-                quality: snapshotQuality(for: status),
489
-                diagnosticQuality: quality,
490
-                isUnsupported: false,
491
-                authorizationStatus: authorizationStatus(from: [distributionResult.apiCall]),
492
-                apiCalls: [
493
-                    distributionResult.apiCall,
494
-                    Self.placeholderAPICall(queryType: "earliest_sample", status: .unknown, message: "query not run"),
495
-                    Self.placeholderAPICall(queryType: "latest_sample", status: .unknown, message: "query not run")
496
-                ],
497
-                yearlyCounts: []
498
-            )
499
-        }
500
-
501
-        // Both date queries share the same 15s budget with the distribution query.
502
-        let remainingSeconds = max(0, deadline.timeIntervalSinceNow)
485
+    private func fetchTypeCountDataFromHK(
486
+        monitoredType: MonitoredType,
487
+        sampleType: HKSampleType,
488
+        timeoutSeconds: TimeInterval,
489
+        previousTypeCount: TypeCount?,
490
+        progress: SnapshotFetchProgress?
491
+    ) async -> TypeCountFetchResult {
492
+        let dateDeadline = Date().addingTimeInterval(timeoutSeconds)
503 493
         async let earliestTask = measureAPICall(
504 494
             queryType: "earliest_sample",
505
-            timeoutSeconds: remainingSeconds
495
+            timeoutSeconds: max(0, dateDeadline.timeIntervalSinceNow)
506 496
         ) {
507 497
             try await self.fetchEarliestDate(for: sampleType)
508 498
         } resultDescription: { date in
@@ -510,7 +500,7 @@ final class HealthKitService {
510 500
         }
511 501
         async let latestTask = measureAPICall(
512 502
             queryType: "latest_sample",
513
-            timeoutSeconds: remainingSeconds
503
+            timeoutSeconds: max(0, dateDeadline.timeIntervalSinceNow)
514 504
         ) {
515 505
             try await self.fetchLatestDate(for: sampleType)
516 506
         } resultDescription: { date in
@@ -518,7 +508,7 @@ final class HealthKitService {
518 508
         }
519 509
         let earliestResult = await earliestTask
520 510
         let latestResult = await latestTask
521
-        let apiCalls = [distributionResult.apiCall, earliestResult.apiCall, latestResult.apiCall]
511
+        var apiCalls = [earliestResult.apiCall, latestResult.apiCall]
522 512
 
523 513
         guard earliestResult.apiCall.status == .complete, latestResult.apiCall.status == .complete else {
524 514
             let status = firstImpairedStatus(in: apiCalls)
@@ -535,34 +525,91 @@ final class HealthKitService {
535 525
                 isUnsupported: false,
536 526
                 authorizationStatus: authorizationStatus(from: apiCalls),
537 527
                 apiCalls: apiCalls,
538
-                yearlyCounts: []
528
+                yearlyCounts: [],
529
+                distributionBins: [],
530
+                records: [],
531
+                recordArchiveData: nil
539 532
             )
540 533
         }
541 534
 
542 535
         let earliest = earliestResult.value ?? nil
543 536
         let latest = latestResult.value ?? nil
537
+        let previousDistribution = PreviousDistributionState(typeCount: previousTypeCount)
538
+        let distributionResult = await measureAPICall(
539
+            queryType: "record_import",
540
+            timeoutSeconds: DistributionCaptureConfiguration.fullImportTimeoutSeconds
541
+        ) {
542
+            try await self.fetchDistribution(
543
+                for: sampleType,
544
+                typeIdentifier: monitoredType.id,
545
+                earliestDate: earliest,
546
+                latestDate: latest,
547
+                previousDistribution: previousDistribution,
548
+                progress: progress
549
+            )
550
+        } resultDescription: { distribution in
551
+            "\(distribution.totalCount) samples in \(distribution.bins.count) anchored segment(s)"
552
+        }
553
+        apiCalls.insert(distributionResult.apiCall, at: 0)
554
+
555
+        guard distributionResult.apiCall.status == .complete, let distribution = distributionResult.value else {
556
+            let status = distributionResult.apiCall.status
557
+            let quality = diagnosticQuality(for: status)
558
+            return TypeCountFetchResult(
559
+                typeIdentifier: monitoredType.id,
560
+                displayName: monitoredType.displayName,
561
+                count: -1,
562
+                contentHash: "",
563
+                earliestDate: earliest,
564
+                latestDate: latest,
565
+                quality: snapshotQuality(for: status),
566
+                diagnosticQuality: quality,
567
+                isUnsupported: false,
568
+                authorizationStatus: authorizationStatus(from: apiCalls),
569
+                apiCalls: apiCalls,
570
+                yearlyCounts: [],
571
+                distributionBins: [],
572
+                records: [],
573
+                recordArchiveData: nil
574
+            )
575
+        }
576
+
544 577
         let contentHash = HashService.typeHash(
545 578
             typeIdentifier: monitoredType.id,
546
-            totalCount: distribution.totalCount,
547
-            earliestDate: earliest,
548
-            latestDate: latest
579
+            recordFingerprints: distribution.records.map(\.recordFingerprint)
549 580
         )
550 581
 
551 582
         // YearlyCount uses Calendar.current; year attribution is local-time based.
552
-        let isApprox = DistributionCaptureConfiguration.bucketComponent != .day
553 583
         var yearMap: [Int: Int] = [:]
554
-        for bin in distribution.bins {
555
-            let year = Calendar.current.component(.year, from: bin.start)
556
-            yearMap[year, default: 0] += bin.count
584
+        for record in distribution.records {
585
+            let year = Calendar.current.component(.year, from: record.startDate)
586
+            yearMap[year, default: 0] += 1
557 587
         }
558 588
         let yearlyCounts = yearMap.map { year, yearCount in
559 589
             TypeCountFetchResult.YearlyCountData(
560 590
                 year: year,
561 591
                 count: yearCount,
562 592
                 typeIdentifier: monitoredType.id,
563
-                isApproximate: isApprox
593
+                isApproximate: false
564 594
             )
565 595
         }
596
+        progress?.updateBlockProgress(
597
+            monitoredType.id,
598
+            detail: "Preparing record archive",
599
+            recordCount: distribution.totalCount
600
+        )
601
+        let recordArchiveData = HealthRecordArchive.encode(
602
+            distribution.records.map {
603
+                HealthRecordValue(
604
+                    typeIdentifier: monitoredType.id,
605
+                    sampleUUIDHash: $0.sampleUUIDHash,
606
+                    recordFingerprint: $0.recordFingerprint,
607
+                    startDate: $0.startDate,
608
+                    endDate: $0.endDate,
609
+                    displayValue: $0.displayValue
610
+                )
611
+            }
612
+        )
566 613
 
567 614
         return TypeCountFetchResult(
568 615
             typeIdentifier: monitoredType.id,
@@ -576,49 +623,288 @@ final class HealthKitService {
576 623
             isUnsupported: false,
577 624
             authorizationStatus: authorizationStatus(from: apiCalls),
578 625
             apiCalls: apiCalls,
579
-            yearlyCounts: yearlyCounts
626
+            yearlyCounts: yearlyCounts,
627
+            distributionBins: distribution.bins.map {
628
+                TypeCountFetchResult.DistributionBinData(
629
+                    bucketStart: $0.start,
630
+                    bucketEnd: $0.end,
631
+                    count: $0.count,
632
+                    contentHash: $0.contentHash,
633
+                    anchorData: $0.anchorData
634
+                )
635
+            },
636
+            records: [],
637
+            recordArchiveData: recordArchiveData
580 638
         )
581 639
     }
582 640
 
583 641
     // MARK: - HealthKit queries
584 642
 
585
-    private func fetchDistribution(for sampleType: HKSampleType) async throws -> SampleDistribution {
586
-        try await withCheckedThrowingContinuation { continuation in
587
-            let query = HKSampleQuery(
588
-                sampleType: sampleType,
589
-                predicate: nil,
590
-                limit: HKObjectQueryNoLimit,
591
-                sortDescriptors: nil
592
-            ) { _, samples, error in
593
-                if let error {
594
-                    continuation.resume(throwing: error)
595
-                    return
596
-                }
597
-                let samples = samples ?? []
598
-                let calendar = Calendar.current
599
-                var countsByDay: [Date: Int] = [:]
600
-                for sample in samples {
601
-                    guard let day = calendar.dateInterval(
602
-                        of: DistributionCaptureConfiguration.bucketComponent,
603
-                        for: sample.startDate
604
-                    ) else { continue }
605
-                    countsByDay[day.start, default: 0] += 1
606
-                }
607
-                let bins = countsByDay.map { dayStart, count in
608
-                    SampleDistribution.Bin(
609
-                        start: dayStart,
610
-                        end: calendar.date(
611
-                            byAdding: DistributionCaptureConfiguration.bucketComponent,
612
-                            value: DistributionCaptureConfiguration.bucketStep,
613
-                            to: dayStart
614
-                        ) ?? dayStart,
615
-                        count: count
643
+    private func fetchDistribution(
644
+        for sampleType: HKSampleType,
645
+        typeIdentifier: String,
646
+        earliestDate: Date?,
647
+        latestDate: Date?,
648
+        previousDistribution: PreviousDistributionState,
649
+        progress: SnapshotFetchProgress?
650
+    ) async throws -> SampleDistribution {
651
+        var anchor = previousDistribution.globalAnchor
652
+        let seedRecords = anchor == nil ? [] : previousDistribution.records
653
+        var recordMap = Dictionary(
654
+            uniqueKeysWithValues: seedRecords.map {
655
+                (
656
+                    $0.sampleUUIDHash,
657
+                    SampleDistribution.Record(
658
+                        sampleUUIDHash: $0.sampleUUIDHash,
659
+                        recordFingerprint: $0.recordFingerprint,
660
+                        startDate: $0.startDate,
661
+                        endDate: $0.endDate,
662
+                        displayValue: $0.displayValue
616 663
                     )
617
-                }.sorted { $0.start < $1.start }
618
-                continuation.resume(returning: SampleDistribution(totalCount: samples.count, bins: bins))
664
+                )
619 665
             }
620
-            store.execute(query)
666
+        )
667
+        var pageNumber = 0
668
+
669
+        while true {
670
+            pageNumber += 1
671
+            progress?.updateBlockProgress(
672
+                typeIdentifier,
673
+                detail: anchor == nil ? "Import page \(pageNumber)" : "Delta page \(pageNumber)",
674
+                recordCount: recordMap.count
675
+            )
676
+
677
+            let page = try await withTimeout(seconds: DistributionCaptureConfiguration.pageTimeoutSeconds) {
678
+                try await self.fetchDistributionPage(
679
+                    for: sampleType,
680
+                    predicate: nil,
681
+                    anchor: anchor
682
+                )
683
+            }
684
+            anchor = page.anchor
685
+
686
+            for deletedObject in page.deletedObjects {
687
+                recordMap.removeValue(forKey: HashService.sampleUUIDHash(deletedObject.uuid.uuidString))
688
+            }
689
+
690
+            for sample in page.samples {
691
+                let uuidHash = HashService.sampleUUIDHash(sample.uuid.uuidString)
692
+                recordMap[uuidHash] = SampleDistribution.Record(
693
+                    sampleUUIDHash: uuidHash,
694
+                    recordFingerprint: HashService.sampleFingerprint(
695
+                        typeIdentifier: sampleType.identifier,
696
+                        sampleUUID: sample.uuid.uuidString,
697
+                        startDate: sample.startDate,
698
+                        endDate: sample.endDate
699
+                    ),
700
+                    startDate: sample.startDate,
701
+                    endDate: sample.endDate,
702
+                    displayValue: Self.displayValue(for: sample)
703
+                )
704
+            }
705
+
706
+            if page.samples.count + page.deletedObjects.count < DistributionCaptureConfiguration.queryPageLimit {
707
+                break
708
+            }
709
+        }
710
+
711
+        let sortedRecords = Array(recordMap.values).sorted {
712
+            if $0.startDate != $1.startDate {
713
+                return $0.startDate < $1.startDate
714
+            }
715
+            return $0.recordFingerprint < $1.recordFingerprint
716
+        }
717
+        let contentHash = HashService.typeHash(
718
+            typeIdentifier: typeIdentifier,
719
+            recordFingerprints: sortedRecords.map(\.recordFingerprint)
720
+        )
721
+
722
+        progress?.updateBlockProgress(
723
+            typeIdentifier,
724
+            detail: pageNumber == 1 ? "Imported 1 page" : "Imported \(pageNumber) pages",
725
+            recordCount: sortedRecords.count
726
+        )
727
+
728
+        guard !sortedRecords.isEmpty || anchor != nil else {
729
+            return SampleDistribution(totalCount: 0, bins: [], records: [])
730
+        }
731
+
732
+        let binStart = earliestDate ?? sortedRecords.first?.startDate ?? previousDistribution.earliestRecordDate ?? Date()
733
+        let rawBinEnd = latestDate ?? sortedRecords.last?.endDate ?? previousDistribution.latestRecordDate ?? binStart
734
+        let binEnd = rawBinEnd > binStart ? rawBinEnd : binStart.addingTimeInterval(1)
735
+
736
+        return SampleDistribution(
737
+            totalCount: sortedRecords.count,
738
+            bins: [
739
+                SampleDistribution.Bin(
740
+                    start: binStart,
741
+                    end: binEnd,
742
+                    count: sortedRecords.count,
743
+                    contentHash: contentHash,
744
+                    anchorData: anchor.flatMap(Self.archiveAnchor(_:))
745
+                )
746
+            ],
747
+            records: sortedRecords
748
+        )
749
+    }
750
+
751
+    private func fetchDistributionPage(
752
+        for sampleType: HKSampleType,
753
+        predicate: NSPredicate?,
754
+        anchor: HKQueryAnchor?
755
+    ) async throws -> SampleDistributionPage {
756
+        let box = HealthKitQueryContinuationBox<SampleDistributionPage>()
757
+        nonisolated(unsafe) let queryPredicate = predicate
758
+        return try await withTaskCancellationHandler {
759
+            try await withCheckedThrowingContinuation { continuation in
760
+                box.setContinuation(continuation)
761
+                let query = HKAnchoredObjectQuery(
762
+                    type: sampleType,
763
+                    predicate: queryPredicate,
764
+                    anchor: anchor,
765
+                    limit: DistributionCaptureConfiguration.queryPageLimit
766
+                ) { _, samples, deletedObjects, newAnchor, error in
767
+                    if let error {
768
+                        box.resume(throwing: error)
769
+                        return
770
+                    }
771
+
772
+                    box.resume(
773
+                        returning: SampleDistributionPage(
774
+                            samples: samples ?? [],
775
+                            deletedObjects: deletedObjects ?? [],
776
+                            anchor: newAnchor
777
+                        )
778
+                    )
779
+                }
780
+                box.setQuery(query, store: store)
781
+                store.execute(query)
782
+            }
783
+        } onCancel: {
784
+            box.cancel()
785
+        }
786
+    }
787
+
788
+    private static func archiveAnchor(_ anchor: HKQueryAnchor) -> Data? {
789
+        try? NSKeyedArchiver.archivedData(withRootObject: anchor, requiringSecureCoding: true)
790
+    }
791
+
792
+    private static func unarchiveAnchor(_ data: Data?) -> HKQueryAnchor? {
793
+        guard let data else { return nil }
794
+        return try? NSKeyedUnarchiver.unarchivedObject(ofClass: HKQueryAnchor.self, from: data)
795
+    }
796
+
797
+    private static func displayValue(for sample: HKSample) -> String? {
798
+        if let quantitySample = sample as? HKQuantitySample {
799
+            return quantityDisplayValue(for: quantitySample)
800
+        }
801
+
802
+        if let categorySample = sample as? HKCategorySample {
803
+            return categoryDisplayValue(for: categorySample)
804
+        }
805
+
806
+        if let workout = sample as? HKWorkout {
807
+            return workoutDisplayValue(for: workout)
621 808
         }
809
+
810
+        return nil
811
+    }
812
+
813
+    private static func quantityDisplayValue(for sample: HKQuantitySample) -> String {
814
+        let identifier = sample.quantityType.identifier
815
+        switch identifier {
816
+        case HKQuantityTypeIdentifier.stepCount.rawValue:
817
+            return format(sample.quantity.doubleValue(for: .count()), unit: "")
818
+        case HKQuantityTypeIdentifier.distanceWalkingRunning.rawValue:
819
+            let meters = sample.quantity.doubleValue(for: .meter())
820
+            return meters >= 1_000
821
+                ? "\(format(meters / 1_000, maximumFractionDigits: 2)) km"
822
+                : "\(format(meters, maximumFractionDigits: 0)) m"
823
+        case HKQuantityTypeIdentifier.activeEnergyBurned.rawValue:
824
+            return "\(format(sample.quantity.doubleValue(for: .kilocalorie()), maximumFractionDigits: 0)) kcal"
825
+        case HKQuantityTypeIdentifier.appleExerciseTime.rawValue:
826
+            return "\(format(sample.quantity.doubleValue(for: .minute()), maximumFractionDigits: 0)) min"
827
+        case HKQuantityTypeIdentifier.heartRate.rawValue,
828
+             HKQuantityTypeIdentifier.restingHeartRate.rawValue:
829
+            return "\(format(sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: .minute())), maximumFractionDigits: 0)) bpm"
830
+        case HKQuantityTypeIdentifier.respiratoryRate.rawValue:
831
+            return "\(format(sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: .minute())), maximumFractionDigits: 1)) breaths/min"
832
+        case HKQuantityTypeIdentifier.environmentalAudioExposure.rawValue,
833
+             HKQuantityTypeIdentifier.headphoneAudioExposure.rawValue:
834
+            return "\(format(sample.quantity.doubleValue(for: .decibelAWeightedSoundPressureLevel()), maximumFractionDigits: 1)) dB"
835
+        case HKQuantityTypeIdentifier.bodyMass.rawValue:
836
+            return "\(format(sample.quantity.doubleValue(for: HKUnit.gramUnit(with: .kilo)), maximumFractionDigits: 1)) kg"
837
+        case HKQuantityTypeIdentifier.vo2Max.rawValue:
838
+            let unit = HKUnit.literUnit(with: .milli)
839
+                .unitDivided(by: HKUnit.gramUnit(with: .kilo))
840
+                .unitDivided(by: .minute())
841
+            return "\(format(sample.quantity.doubleValue(for: unit), maximumFractionDigits: 1)) mL/kg/min"
842
+        default:
843
+            return "\(format(sample.quantity.doubleValue(for: .count()), maximumFractionDigits: 2))"
844
+        }
845
+    }
846
+
847
+    private static func categoryDisplayValue(for sample: HKCategorySample) -> String {
848
+        switch sample.categoryType.identifier {
849
+        case HKCategoryTypeIdentifier.appleStandHour.rawValue:
850
+            if sample.value == HKCategoryValueAppleStandHour.stood.rawValue {
851
+                return "Stood"
852
+            }
853
+            if sample.value == HKCategoryValueAppleStandHour.idle.rawValue {
854
+                return "Idle"
855
+            }
856
+            return "Stand hour \(sample.value)"
857
+        case HKCategoryTypeIdentifier.sleepAnalysis.rawValue:
858
+            return sleepValueLabel(sample.value)
859
+        case HKCategoryTypeIdentifier.highHeartRateEvent.rawValue:
860
+            return "High heart rate event"
861
+        default:
862
+            return "Category value \(sample.value)"
863
+        }
864
+    }
865
+
866
+    private static func workoutDisplayValue(for workout: HKWorkout) -> String {
867
+        let duration = format(workout.duration / 60, maximumFractionDigits: 0)
868
+        return "\(workoutActivityName(workout.workoutActivityType)), \(duration) min"
869
+    }
870
+
871
+    private static func workoutActivityName(_ type: HKWorkoutActivityType) -> String {
872
+        switch type {
873
+        case .walking: return "Walking"
874
+        case .running: return "Running"
875
+        case .cycling: return "Cycling"
876
+        case .swimming: return "Swimming"
877
+        case .traditionalStrengthTraining: return "Strength training"
878
+        case .functionalStrengthTraining: return "Functional strength training"
879
+        case .highIntensityIntervalTraining: return "HIIT"
880
+        case .yoga: return "Yoga"
881
+        case .hiking: return "Hiking"
882
+        case .mindAndBody: return "Mind and body"
883
+        case .other: return "Workout"
884
+        default: return "Workout \(type.rawValue)"
885
+        }
886
+    }
887
+
888
+    private static func sleepValueLabel(_ value: Int) -> String {
889
+        if value == HKCategoryValueSleepAnalysis.inBed.rawValue { return "In bed" }
890
+        if value == HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue { return "Asleep" }
891
+        if value == HKCategoryValueSleepAnalysis.awake.rawValue { return "Awake" }
892
+        if value == HKCategoryValueSleepAnalysis.asleepCore.rawValue { return "Core sleep" }
893
+        if value == HKCategoryValueSleepAnalysis.asleepDeep.rawValue { return "Deep sleep" }
894
+        if value == HKCategoryValueSleepAnalysis.asleepREM.rawValue { return "REM sleep" }
895
+        return "Sleep value \(value)"
896
+    }
897
+
898
+    private static func format(_ value: Double, maximumFractionDigits: Int = 2, unit: String? = nil) -> String {
899
+        let formatter = NumberFormatter()
900
+        formatter.numberStyle = .decimal
901
+        formatter.maximumFractionDigits = maximumFractionDigits
902
+        formatter.minimumFractionDigits = 0
903
+        let formatted = formatter.string(from: NSNumber(value: value)) ?? "\(value)"
904
+        if let unit, !unit.isEmpty {
905
+            return "\(formatted) \(unit)"
906
+        }
907
+        return formatted
622 908
     }
623 909
 
624 910
     private func fetchEarliestDate(for sampleType: HKSampleType) async throws -> Date? {
@@ -810,7 +1096,7 @@ final class HealthKitService {
810 1096
 
811 1097
     private static func placeholderAPICalls(status: HealthKitAPICallResult.Status, message: String) -> [HealthKitAPICallResult] {
812 1098
         [
813
-            placeholderAPICall(queryType: "distribution", status: status, message: message),
1099
+            placeholderAPICall(queryType: "record_import", status: status, message: message),
814 1100
             placeholderAPICall(queryType: "earliest_sample", status: status, message: message),
815 1101
             placeholderAPICall(queryType: "latest_sample", status: status, message: message)
816 1102
         ]
@@ -925,9 +1211,14 @@ final class HealthKitService {
925 1211
                 try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
926 1212
                 throw CancellationError()
927 1213
             }
928
-            let result = try await group.next()!
929
-            group.cancelAll()
930
-            return result
1214
+            do {
1215
+                let result = try await group.next()!
1216
+                group.cancelAll()
1217
+                return result
1218
+            } catch {
1219
+                group.cancelAll()
1220
+                throw error
1221
+            }
931 1222
         }
932 1223
     }
933 1224
 
@@ -978,9 +1269,157 @@ private struct SampleDistribution: Sendable {
978 1269
         let start: Date
979 1270
         let end: Date
980 1271
         let count: Int
1272
+        let contentHash: String
1273
+        let anchorData: Data?
1274
+    }
1275
+    struct Record: Sendable {
1276
+        let sampleUUIDHash: String
1277
+        let recordFingerprint: String
1278
+        let startDate: Date
1279
+        let endDate: Date
1280
+        let displayValue: String?
981 1281
     }
982 1282
     let totalCount: Int
983 1283
     let bins: [Bin]
1284
+    let records: [Record]
1285
+}
1286
+
1287
+private struct SampleDistributionPage: Sendable {
1288
+    let samples: [HKSample]
1289
+    let deletedObjects: [HKDeletedObject]
1290
+    let anchor: HKQueryAnchor?
1291
+}
1292
+
1293
+private final class HealthKitQueryContinuationBox<Value: Sendable>: @unchecked Sendable {
1294
+    private let lock = NSLock()
1295
+    nonisolated(unsafe) private var continuation: CheckedContinuation<Value, Error>?
1296
+    nonisolated(unsafe) private var query: HKQuery?
1297
+    nonisolated(unsafe) private weak var store: HKHealthStore?
1298
+    nonisolated(unsafe) private var didResume = false
1299
+
1300
+    nonisolated func setContinuation(_ continuation: CheckedContinuation<Value, Error>) {
1301
+        let shouldCancel: Bool
1302
+        lock.lock()
1303
+        if didResume {
1304
+            shouldCancel = true
1305
+        } else {
1306
+            shouldCancel = false
1307
+            self.continuation = continuation
1308
+        }
1309
+        lock.unlock()
1310
+
1311
+        if shouldCancel {
1312
+            continuation.resume(throwing: CancellationError())
1313
+        }
1314
+    }
1315
+
1316
+    nonisolated func setQuery(_ query: HKQuery, store: HKHealthStore) {
1317
+        lock.lock()
1318
+        self.query = query
1319
+        self.store = store
1320
+        let shouldStop = didResume
1321
+        lock.unlock()
1322
+
1323
+        if shouldStop {
1324
+            store.stop(query)
1325
+        }
1326
+    }
1327
+
1328
+    nonisolated func resume(returning value: Value) {
1329
+        complete { $0.resume(returning: value) }
1330
+    }
1331
+
1332
+    nonisolated func resume(throwing error: Error) {
1333
+        complete { $0.resume(throwing: error) }
1334
+    }
1335
+
1336
+    nonisolated func cancel() {
1337
+        let queryToStop: HKQuery?
1338
+        let storeToStop: HKHealthStore?
1339
+        lock.lock()
1340
+        queryToStop = query
1341
+        storeToStop = store
1342
+        lock.unlock()
1343
+
1344
+        if let queryToStop, let storeToStop {
1345
+            storeToStop.stop(queryToStop)
1346
+        }
1347
+
1348
+        resume(throwing: CancellationError())
1349
+    }
1350
+
1351
+    nonisolated private func complete(_ resume: (CheckedContinuation<Value, Error>) -> Void) {
1352
+        let continuationToResume: CheckedContinuation<Value, Error>?
1353
+        lock.lock()
1354
+        if didResume {
1355
+            continuationToResume = nil
1356
+        } else {
1357
+            didResume = true
1358
+            continuationToResume = continuation
1359
+            continuation = nil
1360
+        }
1361
+        lock.unlock()
1362
+
1363
+        if let continuationToResume {
1364
+            resume(continuationToResume)
1365
+        }
1366
+    }
1367
+}
1368
+
1369
+private struct PreviousDistributionState: Sendable {
1370
+    struct Record: Sendable {
1371
+        let sampleUUIDHash: String
1372
+        let recordFingerprint: String
1373
+        let startDate: Date
1374
+        let endDate: Date
1375
+        let displayValue: String?
1376
+    }
1377
+
1378
+    struct Bin: Sendable {
1379
+        let bucketStart: Date
1380
+        let bucketEnd: Date
1381
+        let anchorData: Data?
1382
+
1383
+        var anchor: HKQueryAnchor? {
1384
+            guard let anchorData else { return nil }
1385
+            return try? NSKeyedUnarchiver.unarchivedObject(ofClass: HKQueryAnchor.self, from: anchorData)
1386
+        }
1387
+    }
1388
+
1389
+    let records: [Record]
1390
+    let bins: [Bin]
1391
+
1392
+    var globalAnchor: HKQueryAnchor? {
1393
+        guard bins.count == 1 else { return nil }
1394
+        return bins[0].anchor
1395
+    }
1396
+
1397
+    var earliestRecordDate: Date? {
1398
+        records.map(\.startDate).min()
1399
+    }
1400
+
1401
+    var latestRecordDate: Date? {
1402
+        records.map(\.endDate).max()
1403
+    }
1404
+
1405
+    init(typeCount: TypeCount?) {
1406
+        self.records = (typeCount?.recordValues ?? []).map {
1407
+            Record(
1408
+                sampleUUIDHash: $0.sampleUUIDHash,
1409
+                recordFingerprint: $0.recordFingerprint,
1410
+                startDate: $0.startDate,
1411
+                endDate: $0.endDate,
1412
+                displayValue: $0.displayValue
1413
+            )
1414
+        }
1415
+        self.bins = (typeCount?.distributionBins ?? []).map {
1416
+            Bin(
1417
+                bucketStart: $0.bucketStart,
1418
+                bucketEnd: $0.bucketEnd,
1419
+                anchorData: $0.anchorData
1420
+            )
1421
+        }
1422
+    }
984 1423
 }
985 1424
 
986 1425
 private struct TypeCountFetchResult: Sendable {
@@ -990,6 +1429,21 @@ private struct TypeCountFetchResult: Sendable {
990 1429
         let typeIdentifier: String
991 1430
         let isApproximate: Bool
992 1431
     }
1432
+    struct RecordData: Sendable {
1433
+        let typeIdentifier: String
1434
+        let sampleUUIDHash: String
1435
+        let recordFingerprint: String
1436
+        let startDate: Date
1437
+        let endDate: Date
1438
+        let displayValue: String?
1439
+    }
1440
+    struct DistributionBinData: Sendable {
1441
+        let bucketStart: Date
1442
+        let bucketEnd: Date
1443
+        let count: Int
1444
+        let contentHash: String
1445
+        let anchorData: Data?
1446
+    }
993 1447
 
994 1448
     let typeIdentifier: String
995 1449
     let displayName: String
@@ -1003,6 +1457,9 @@ private struct TypeCountFetchResult: Sendable {
1003 1457
     let authorizationStatus: String
1004 1458
     let apiCalls: [HealthKitAPICallResult]
1005 1459
     let yearlyCounts: [YearlyCountData]
1460
+    let distributionBins: [DistributionBinData]
1461
+    let records: [RecordData]
1462
+    let recordArchiveData: Data?
1006 1463
     var timeoutConfiguredSeconds: TimeInterval = 0
1007 1464
     var totalElapsedSeconds: TimeInterval = 0
1008 1465
     var timeoutMode: String = "default"
@@ -1044,6 +1501,33 @@ private struct TypeCountFetchResult: Sendable {
1044 1501
             typeCount.yearlyCounts?.append(yearlyCount)
1045 1502
         }
1046 1503
 
1504
+        for binData in distributionBins {
1505
+            let bin = TypeDistributionBin(
1506
+                bucketStart: binData.bucketStart,
1507
+                bucketEnd: binData.bucketEnd,
1508
+                count: binData.count
1509
+            )
1510
+            bin.contentHash = binData.contentHash
1511
+            bin.anchorData = binData.anchorData
1512
+            typeCount.distributionBins?.append(bin)
1513
+        }
1514
+
1515
+        if let recordArchiveData {
1516
+            typeCount.recordArchiveData = recordArchiveData
1517
+            typeCount.records?.removeAll()
1518
+        } else {
1519
+            typeCount.setRecordValues(records.map { recordData in
1520
+                HealthRecordValue(
1521
+                    typeIdentifier: recordData.typeIdentifier,
1522
+                    sampleUUIDHash: recordData.sampleUUIDHash,
1523
+                    recordFingerprint: recordData.recordFingerprint,
1524
+                    startDate: recordData.startDate,
1525
+                    endDate: recordData.endDate,
1526
+                    displayValue: recordData.displayValue
1527
+                )
1528
+            })
1529
+        }
1530
+
1047 1531
         return typeCount
1048 1532
     }
1049 1533
 }
@@ -1091,6 +1575,7 @@ private extension SnapshotFetchProgress {
1091 1575
 }
1092 1576
 
1093 1577
 private enum DistributionCaptureConfiguration {
1094
-    static let bucketComponent: Calendar.Component = .day
1095
-    static let bucketStep = 1
1578
+    static let queryPageLimit = 20_000
1579
+    static let pageTimeoutSeconds: TimeInterval = 60
1580
+    static let fullImportTimeoutSeconds: TimeInterval = HealthKitService.fullHistoryImportTimeoutSeconds
1096 1581
 }
+15 -0
HealthProbe/Utilities/SnapshotFetchProgress.swift
@@ -99,6 +99,7 @@ final class SnapshotFetchProgress {
99 99
         var suggestedRetryTimeout: TimeInterval = 0
100 100
         var timeoutCount: Int = 0
101 101
         var successCount: Int = 0
102
+        var blockProgress: String = ""
102 103
     }
103 104
 
104 105
     let totalTypeCount: Int
@@ -157,6 +158,12 @@ final class SnapshotFetchProgress {
157 158
     func updateStatus(_ id: String, status: TypeProgress.FetchStatus, recordCount: Int? = nil) {
158 159
         let index = visibleTypeIndex(for: id)
159 160
         types[index].status = status
161
+        switch status {
162
+        case .complete, .failed, .pending:
163
+            types[index].blockProgress = ""
164
+        case .fetching:
165
+            break
166
+        }
160 167
         if let recordCount {
161 168
             types[index].recordCount = recordCount
162 169
         }
@@ -218,6 +225,14 @@ final class SnapshotFetchProgress {
218 225
         types[index].successCount = successCount
219 226
     }
220 227
 
228
+    func updateBlockProgress(_ id: String, detail: String, recordCount: Int? = nil) {
229
+        let index = visibleTypeIndex(for: id)
230
+        types[index].blockProgress = detail
231
+        if let recordCount {
232
+            types[index].recordCount = recordCount
233
+        }
234
+    }
235
+
221 236
     func markUnavailable(_ id: String) {
222 237
         let index = visibleTypeIndex(for: id)
223 238
         types[index].status = .failed("Not authorized")
+4 -1
HealthProbe/ViewModels/DashboardViewModel.swift
@@ -87,7 +87,10 @@ final class DashboardViewModel {
87 87
         defer { isCreatingSnapshot = false }
88 88
 
89 89
         do {
90
-            let operationTimeout = max(120, Double(selectedTypeIDs.count) * HealthKitService.maximumTimeoutSeconds + 30)
90
+            let concurrentBatches = ceil(Double(selectedTypeIDs.count) / Double(HealthKitService.maxConcurrentTypeFetches))
91
+            let historyImportTimeout = concurrentBatches * HealthKitService.fullHistoryImportTimeoutSeconds + 30
92
+            let learnedMetricTimeout = Double(selectedTypeIDs.count) * HealthKitService.maximumTimeoutSeconds + 30
93
+            let operationTimeout = max(120, historyImportTimeout, learnedMetricTimeout)
91 94
             let snapshot = try await withTimeout(seconds: operationTimeout) {
92 95
                 try await self.healthKit.createSnapshot(
93 96
                     in: context,
+41 -7
HealthProbe/Views/Dashboard/DashboardView.swift
@@ -13,6 +13,8 @@ struct DashboardView: View {
13 13
     @State private var snapshotSheetTab: SnapshotSheetTab = .progress
14 14
     @State private var diagnosticReport: DiagnosticReport?
15 15
     @State private var expandedIssueIDs: Set<String> = []
16
+    @State private var idleTimerWasDisabledBeforeSnapshot = false
17
+    @State private var snapshotIdleTimerOverrideActive = false
16 18
 
17 19
     init() {
18 20
         let deviceID = AppSettings.currentDeviceID
@@ -65,10 +67,34 @@ struct DashboardView: View {
65 67
                 await viewModel.requestAuthorization()
66 68
             }
67 69
         }
70
+        .onChange(of: viewModel.snapshotProgress) { _, newProgress in
71
+            updateIdleTimer(for: newProgress)
72
+        }
73
+        .onDisappear {
74
+            restoreSnapshotIdleTimerIfNeeded()
75
+        }
68 76
     }
69 77
 
70 78
     // MARK: - Helpers
71 79
 
80
+    private func updateIdleTimer(for progress: SnapshotProgress) {
81
+        let shouldDisableIdleTimer = progress == .fetching || progress == .processing
82
+
83
+        if shouldDisableIdleTimer && !snapshotIdleTimerOverrideActive {
84
+            idleTimerWasDisabledBeforeSnapshot = UIApplication.shared.isIdleTimerDisabled
85
+            UIApplication.shared.isIdleTimerDisabled = true
86
+            snapshotIdleTimerOverrideActive = true
87
+        } else if !shouldDisableIdleTimer {
88
+            restoreSnapshotIdleTimerIfNeeded()
89
+        }
90
+    }
91
+
92
+    private func restoreSnapshotIdleTimerIfNeeded() {
93
+        guard snapshotIdleTimerOverrideActive else { return }
94
+        UIApplication.shared.isIdleTimerDisabled = idleTimerWasDisabledBeforeSnapshot
95
+        snapshotIdleTimerOverrideActive = false
96
+    }
97
+
72 98
     // MARK: - Report helpers
73 99
 
74 100
     private func failedTypesList(in progress: SnapshotFetchProgress) -> [SnapshotFetchProgress.TypeProgress] {
@@ -101,7 +127,7 @@ struct DashboardView: View {
101 127
 
102 128
     private func degradedTypesList(in progress: SnapshotFetchProgress) -> [SnapshotFetchProgress.TypeProgress] {
103 129
         progress.types.filter { type in
104
-            let hasAllExpectedCalls = Set(type.apiCallDetails.map(\.queryType)) == Set(["distribution", "earliest_sample", "latest_sample"])
130
+            let hasAllExpectedCalls = Set(type.apiCallDetails.map(\.queryType)) == Set(["record_import", "earliest_sample", "latest_sample"])
105 131
             let allCallsComplete = type.apiCallDetails.allSatisfy { $0.status == .complete }
106 132
             return type.quality != "complete" || !hasAllExpectedCalls || !allCallsComplete
107 133
         }
@@ -400,7 +426,7 @@ struct DashboardView: View {
400 426
             }
401 427
 
402 428
             lines.append("  apiCalls:")
403
-            for queryType in ["distribution", "earliest_sample", "latest_sample"] {
429
+            for queryType in ["record_import", "earliest_sample", "latest_sample"] {
404 430
                 if let call = type.apiCallDetails.first(where: { $0.queryType == queryType }) {
405 431
                     lines.append("    \(queryType):")
406 432
                     lines.append("      status: \(call.statusDescription)")
@@ -771,10 +797,18 @@ struct DashboardView: View {
771 797
                                     .foregroundStyle(colorForStatus(type.status))
772 798
                             }
773 799
 
774
-                            Text(type.displayName)
775
-                                .font(.caption)
776
-                                .lineLimit(1)
777
-                                .frame(maxWidth: .infinity, alignment: .leading)
800
+                            VStack(alignment: .leading, spacing: 2) {
801
+                                Text(type.displayName)
802
+                                    .font(.caption)
803
+                                    .lineLimit(1)
804
+                                if !type.blockProgress.isEmpty {
805
+                                    Text(type.blockProgress)
806
+                                        .font(.caption2)
807
+                                        .foregroundStyle(.secondary)
808
+                                        .lineLimit(1)
809
+                                }
810
+                            }
811
+                            .frame(maxWidth: .infinity, alignment: .leading)
778 812
 
779 813
                             if type.recordCount > 0 {
780 814
                                 Text("\(type.recordCount) records")
@@ -1351,6 +1385,6 @@ extension Bundle {
1351 1385
 
1352 1386
 #Preview {
1353 1387
     DashboardView()
1354
-        .modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self], inMemory: true)
1388
+        .modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self], inMemory: true)
1355 1389
         .environment(AppSettings())
1356 1390
 }
+1 -1
HealthProbe/Views/DataTypes/DataTypesView.swift
@@ -200,6 +200,6 @@ private struct TypeDiffRow: View {
200 200
 
201 201
 #Preview {
202 202
     DataTypesView()
203
-        .modelContainer(for: [HealthSnapshot.self, TypeCount.self, DeviceProfile.self], inMemory: true)
203
+        .modelContainer(for: [HealthSnapshot.self, TypeCount.self, HealthRecord.self, TypeDistributionBin.self, DeviceProfile.self], inMemory: true)
204 204
         .environment(AppSettings())
205 205
 }
+46 -3
HealthProbe/Views/Settings/SettingsView.swift
@@ -11,6 +11,8 @@ struct SettingsView: View {
11 11
     @Query(sort: \MetricTimeoutProfile.displayName) private var timeoutProfiles: [MetricTimeoutProfile]
12 12
     @AppStorage("checkFrequencyHours") private var checkFrequencyHours: Int = 6
13 13
     @State private var showDeleteConfirm = false
14
+    @State private var showRepairLegacyRecordsConfirm = false
15
+    @State private var dataMaintenanceMessage: String?
14 16
 
15 17
     private var currentDeviceID: String {
16 18
         AppSettings.currentDeviceID
@@ -45,6 +47,16 @@ struct SettingsView: View {
45 47
             } message: {
46 48
                 Text("This permanently deletes all \(snapshots.count) snapshots. This action cannot be undone.")
47 49
             }
50
+            .confirmationDialog(
51
+                "Repair Legacy Records",
52
+                isPresented: $showRepairLegacyRecordsConfirm,
53
+                titleVisibility: .visible
54
+            ) {
55
+                Button("Delete Legacy Record Rows", role: .destructive) { repairLegacyRecordRows() }
56
+                Button("Cancel", role: .cancel) { }
57
+            } message: {
58
+                Text("This removes old per-record SwiftData rows left by earlier builds. Compact record archives and snapshot counts are preserved.")
59
+            }
48 60
         }
49 61
     }
50 62
 
@@ -162,6 +174,18 @@ struct SettingsView: View {
162 174
                 Label("Delete All Audit Data", systemImage: "trash")
163 175
             }
164 176
             .disabled(snapshots.isEmpty)
177
+
178
+            Button {
179
+                showRepairLegacyRecordsConfirm = true
180
+            } label: {
181
+                Label("Repair Legacy Record Rows", systemImage: "wrench.and.screwdriver")
182
+            }
183
+
184
+            if let dataMaintenanceMessage {
185
+                Text(dataMaintenanceMessage)
186
+                    .font(.caption)
187
+                    .foregroundStyle(.secondary)
188
+            }
165 189
         }
166 190
     }
167 191
 
@@ -202,8 +226,27 @@ struct SettingsView: View {
202 226
     }
203 227
 
204 228
     private func deleteAllData() {
205
-        for snapshot in snapshots { modelContext.delete(snapshot) }
206
-        try? modelContext.save()
229
+        do {
230
+            try modelContext.delete(model: HealthRecord.self)
231
+            try modelContext.delete(model: TypeDistributionBin.self)
232
+            try modelContext.delete(model: YearlyCount.self)
233
+            try modelContext.delete(model: TypeCount.self)
234
+            try modelContext.delete(model: HealthSnapshot.self)
235
+            try modelContext.save()
236
+            dataMaintenanceMessage = "Audit data deleted."
237
+        } catch {
238
+            dataMaintenanceMessage = "Delete failed: \(error.localizedDescription)"
239
+        }
240
+    }
241
+
242
+    private func repairLegacyRecordRows() {
243
+        do {
244
+            try modelContext.delete(model: HealthRecord.self)
245
+            try modelContext.save()
246
+            dataMaintenanceMessage = "Legacy record rows deleted."
247
+        } catch {
248
+            dataMaintenanceMessage = "Repair failed: \(error.localizedDescription)"
249
+        }
207 250
     }
208 251
 }
209 252
 
@@ -333,6 +376,6 @@ private func formatDuration(_ seconds: TimeInterval) -> String {
333 376
 
334 377
 #Preview {
335 378
     SettingsView()
336
-        .modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self, DeviceProfile.self, MetricTimeoutProfile.self], inMemory: true)
379
+        .modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self, DeviceProfile.self, MetricTimeoutProfile.self], inMemory: true)
337 380
         .environment(AppSettings())
338 381
 }
+666 -0
HealthProbe/Views/Snapshots/DataTypeSnapshotDetailView.swift
@@ -0,0 +1,666 @@
1
+import SwiftUI
2
+import SwiftData
3
+
4
+struct DataTypeSnapshotDetailView: View {
5
+    let snapshot: HealthSnapshot
6
+    let typeIdentifier: String
7
+    let displayName: String
8
+
9
+    @Query(sort: \HealthSnapshot.timestamp) private var allSnapshots: [HealthSnapshot]
10
+
11
+    @State private var displayedSnapshot: HealthSnapshot?
12
+    @State private var diffState: RecordDiffState = .idle
13
+
14
+    private var currentSnapshot: HealthSnapshot {
15
+        displayedSnapshot ?? snapshot
16
+    }
17
+
18
+    private var timelineSnapshots: [HealthSnapshot] {
19
+        allSnapshots.filter { candidate in
20
+            if currentSnapshot.deviceID.isEmpty {
21
+                return candidate.deviceID.isEmpty
22
+            }
23
+            return candidate.deviceID == currentSnapshot.deviceID
24
+        }
25
+    }
26
+
27
+    private var currentSnapshotIndex: Int? {
28
+        timelineSnapshots.firstIndex { $0.id == currentSnapshot.id }
29
+    }
30
+
31
+    private var previousSnapshot: HealthSnapshot? {
32
+        guard let currentSnapshotIndex, currentSnapshotIndex > 0 else { return nil }
33
+        return timelineSnapshots[currentSnapshotIndex - 1]
34
+    }
35
+
36
+    private var nextSnapshot: HealthSnapshot? {
37
+        guard let currentSnapshotIndex, currentSnapshotIndex < timelineSnapshots.count - 1 else { return nil }
38
+        return timelineSnapshots[currentSnapshotIndex + 1]
39
+    }
40
+
41
+    private var currentTypeCount: TypeCount? {
42
+        typeCount(in: currentSnapshot)
43
+    }
44
+
45
+    private var previousTypeCount: TypeCount? {
46
+        previousSnapshot.flatMap(typeCount(in:))
47
+    }
48
+
49
+    private var diffTaskID: String {
50
+        [
51
+            currentSnapshot.id.uuidString,
52
+            previousSnapshot?.id.uuidString ?? "none",
53
+            typeIdentifier
54
+        ].joined(separator: "|")
55
+    }
56
+
57
+    private var totalDelta: Int? {
58
+        guard previousSnapshot != nil,
59
+              let currentCount = countValue(for: currentTypeCount),
60
+              let previousCount = countValue(for: previousTypeCount) else { return nil }
61
+        return currentCount - previousCount
62
+    }
63
+
64
+    private var currentCountText: String {
65
+        countText(for: currentTypeCount)
66
+    }
67
+
68
+    private var previousCountText: String {
69
+        countText(for: previousTypeCount)
70
+    }
71
+
72
+    var body: some View {
73
+        List {
74
+            summarySection
75
+            rangeSection
76
+            if previousSnapshot == nil {
77
+                noPreviousSection
78
+            } else if currentTypeCount == nil && previousTypeCount == nil {
79
+                missingCurrentSection
80
+            } else {
81
+                changeSummarySection
82
+                recordNavigationSection
83
+            }
84
+        }
85
+        .navigationTitle(displayName)
86
+        .navigationBarTitleDisplayMode(.inline)
87
+        .safeAreaInset(edge: .top, spacing: 0) {
88
+            snapshotNavigationHeader
89
+                .frame(height: 64)
90
+        }
91
+        .toolbar {
92
+            ToolbarItem(placement: .principal) {
93
+                snapshotToolbarTitle
94
+            }
95
+        }
96
+        .task(id: diffTaskID) {
97
+            await loadRecordDiff()
98
+        }
99
+    }
100
+
101
+    private func typeCount(in snapshot: HealthSnapshot) -> TypeCount? {
102
+        snapshot.typeCounts?.first { $0.typeIdentifier == typeIdentifier }
103
+    }
104
+
105
+    private func countText(for typeCount: TypeCount?) -> String {
106
+        guard let typeCount else { return "Not tracked" }
107
+        if typeCount.isUnsupported { return "Unsupported" }
108
+        if typeCount.count < 0 { return "Unavailable" }
109
+        return "\(typeCount.count)"
110
+    }
111
+
112
+    private func countValue(for typeCount: TypeCount?) -> Int? {
113
+        guard let typeCount else { return 0 }
114
+        guard !typeCount.isUnsupported, typeCount.count >= 0 else { return nil }
115
+        return typeCount.count
116
+    }
117
+
118
+    @ViewBuilder
119
+    private var snapshotToolbarTitle: some View {
120
+        if #available(iOS 26.0, *) {
121
+            Text(displayName)
122
+                .font(.headline.weight(.semibold))
123
+                .lineLimit(1)
124
+                .padding(.horizontal, 18)
125
+                .frame(height: 36)
126
+                .background(Color(.systemBackground).opacity(0.08), in: Capsule())
127
+                .glassEffect(
128
+                    .regular.tint(Color(.systemBackground).opacity(0.12)),
129
+                    in: Capsule()
130
+                )
131
+        } else {
132
+            Text(displayName)
133
+                .font(.headline.weight(.semibold))
134
+                .lineLimit(1)
135
+                .padding(.horizontal, 18)
136
+                .frame(height: 36)
137
+                .background(.ultraThinMaterial, in: Capsule())
138
+        }
139
+    }
140
+
141
+    @ViewBuilder
142
+    private var snapshotNavigationHeader: some View {
143
+        if #available(iOS 26.0, *) {
144
+            GlassEffectContainer(spacing: 10) {
145
+                snapshotNavigationHeaderContent
146
+                    .padding(.horizontal, 12)
147
+                    .frame(height: 52)
148
+                    .background(Color(.systemBackground).opacity(0.08), in: Capsule())
149
+                    .glassEffect(
150
+                        .regular.tint(Color(.systemBackground).opacity(0.14)),
151
+                        in: Capsule()
152
+                    )
153
+                    .shadow(color: .black.opacity(0.18), radius: 18, x: 0, y: 8)
154
+            }
155
+            .padding(.horizontal, 12)
156
+            .padding(.vertical, 6)
157
+        } else {
158
+            snapshotNavigationHeaderContent
159
+                .padding(.horizontal, 12)
160
+                .frame(height: 52)
161
+                .background(.ultraThinMaterial, in: Capsule())
162
+                .overlay(
163
+                    Capsule()
164
+                        .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
165
+                )
166
+                .shadow(color: .black.opacity(0.14), radius: 16, x: 0, y: 8)
167
+                .padding(.horizontal, 12)
168
+                .padding(.vertical, 6)
169
+        }
170
+    }
171
+
172
+    private var snapshotNavigationHeaderContent: some View {
173
+        HStack(spacing: 12) {
174
+            snapshotNavigationButton(
175
+                systemName: "chevron.left",
176
+                label: "Prev",
177
+                target: previousSnapshot,
178
+                accessibilityLabel: "Previous snapshot"
179
+            )
180
+
181
+            Spacer(minLength: 8)
182
+
183
+            Menu {
184
+                ForEach(timelineSnapshots) { candidate in
185
+                    Button {
186
+                        displayedSnapshot = candidate
187
+                    } label: {
188
+                        if candidate.id == currentSnapshot.id {
189
+                            Label(
190
+                                candidate.timestamp.formatted(.dateTime.year().month().day().hour().minute()),
191
+                                systemImage: "checkmark"
192
+                            )
193
+                        } else {
194
+                            Text(candidate.timestamp, format: .dateTime.year().month().day().hour().minute())
195
+                        }
196
+                    }
197
+                }
198
+            } label: {
199
+                VStack(spacing: 2) {
200
+                    Text("Snapshot")
201
+                        .font(.headline.weight(.semibold))
202
+                    Text(currentSnapshot.timestamp, format: .dateTime.month().day().hour().minute())
203
+                        .font(.caption)
204
+                        .foregroundStyle(.secondary)
205
+                }
206
+            }
207
+            .buttonStyle(.plain)
208
+            .lineLimit(1)
209
+            .accessibilityLabel("Select current snapshot")
210
+
211
+            Spacer(minLength: 8)
212
+
213
+            snapshotNavigationButton(
214
+                systemName: "chevron.right",
215
+                label: "Next",
216
+                target: nextSnapshot,
217
+                accessibilityLabel: "Next snapshot"
218
+            )
219
+        }
220
+    }
221
+
222
+    @ViewBuilder
223
+    private func snapshotNavigationButton(
224
+        systemName: String,
225
+        label: String,
226
+        target: HealthSnapshot?,
227
+        accessibilityLabel: String
228
+    ) -> some View {
229
+        if let target {
230
+            Button {
231
+                displayedSnapshot = target
232
+            } label: {
233
+                VStack(spacing: 2) {
234
+                    Image(systemName: systemName)
235
+                        .font(.system(size: 23, weight: .regular))
236
+                        .symbolRenderingMode(.hierarchical)
237
+                    Text(label)
238
+                        .font(.caption2.weight(.medium))
239
+                        .lineLimit(1)
240
+                }
241
+                .frame(width: 70, height: 50)
242
+                .contentShape(Rectangle())
243
+            }
244
+            .buttonStyle(.plain)
245
+            .foregroundStyle(Color.primary)
246
+            .accessibilityLabel(accessibilityLabel)
247
+        } else {
248
+            Color.clear
249
+                .frame(width: 70, height: 50)
250
+        }
251
+    }
252
+
253
+    private var summarySection: some View {
254
+        Section("Data Type") {
255
+            DataTypeDetailRow(label: "Name") {
256
+                Text(displayName)
257
+                    .foregroundStyle(.secondary)
258
+            }
259
+            DataTypeDetailRow(label: "Identifier") {
260
+                Text(typeIdentifier)
261
+                    .font(.caption)
262
+                    .foregroundStyle(.secondary)
263
+                    .lineLimit(1)
264
+                    .truncationMode(.middle)
265
+            }
266
+            DataTypeDetailRow(label: "Current Count") {
267
+                Text(currentCountText)
268
+                    .foregroundStyle((currentTypeCount?.count ?? 0) < 0 ? Color.criticalRed : Color.primary)
269
+                    .monospacedDigit()
270
+            }
271
+            DataTypeDetailRow(label: "Previous Count") {
272
+                Text(previousCountText)
273
+                    .foregroundStyle((previousTypeCount?.count ?? 0) < 0 ? Color.criticalRed : .secondary)
274
+                    .monospacedDigit()
275
+            }
276
+        }
277
+    }
278
+
279
+    private var rangeSection: some View {
280
+        Section("Range") {
281
+            DataTypeDateRow(label: "Earliest", date: currentTypeCount?.earliestDate)
282
+            DataTypeDateRow(label: "Latest", date: currentTypeCount?.latestDate)
283
+            if currentTypeCount?.quality != SnapshotQuality.complete {
284
+                Label("Incomplete type capture", systemImage: "exclamationmark.triangle")
285
+                    .font(.caption)
286
+                    .foregroundStyle(Color.warningAmber)
287
+            }
288
+        }
289
+    }
290
+
291
+    private var noPreviousSection: some View {
292
+        Section("Changes") {
293
+            Text("No previous snapshot is available for this device.")
294
+                .font(.subheadline)
295
+                .foregroundStyle(.secondary)
296
+        }
297
+    }
298
+
299
+    private var missingCurrentSection: some View {
300
+        Section("Changes") {
301
+            Text("This data type is not tracked in the selected snapshot.")
302
+                .font(.subheadline)
303
+                .foregroundStyle(.secondary)
304
+        }
305
+    }
306
+
307
+    private var changeSummarySection: some View {
308
+        Section("Changes") {
309
+            DataTypeDetailRow(label: "Compared To") {
310
+                if let previousSnapshot {
311
+                    Text(previousSnapshot.timestamp, format: .dateTime.year().month().day().hour().minute())
312
+                        .foregroundStyle(.secondary)
313
+                } else {
314
+                    Text("None")
315
+                        .foregroundStyle(.secondary)
316
+                }
317
+            }
318
+            DataTypeDetailRow(label: "Net Change") {
319
+                if let totalDelta {
320
+                    SeverityBadge(delta: totalDelta)
321
+                } else {
322
+                    Text("Unavailable")
323
+                        .foregroundStyle(.secondary)
324
+                }
325
+            }
326
+            recordCountSummaryRow(title: "Added Records", countText: addedRecordCountText, color: addedRecordCountColor)
327
+            recordCountSummaryRow(title: "Disappeared Records", countText: disappearedRecordCountText, color: disappearedRecordCountColor)
328
+            switch diffState {
329
+            case .idle, .loading:
330
+                Label("Preparing record comparison in the background.", systemImage: "clock")
331
+                    .font(.caption)
332
+                    .foregroundStyle(.secondary)
333
+            case .unavailable:
334
+                Label("This snapshot uses a legacy record format. Recreate the database to inspect record-level loss.", systemImage: "exclamationmark.triangle")
335
+                    .font(.caption)
336
+                    .foregroundStyle(Color.warningAmber)
337
+            case .failed(let message):
338
+                Label(message, systemImage: "exclamationmark.triangle")
339
+                    .font(.caption)
340
+                    .foregroundStyle(Color.warningAmber)
341
+            case .loaded(let diff):
342
+                if diff.isPreviewLimited {
343
+                    Text("Showing newest \(DataTypeRecordDiff.previewLimit) records in each list.")
344
+                        .font(.caption)
345
+                        .foregroundStyle(.secondary)
346
+                }
347
+            }
348
+        }
349
+    }
350
+
351
+    private func recordCountSummaryRow(title: String, countText: String, color: Color) -> some View {
352
+        DataTypeDetailRow(label: title) {
353
+            Text(countText)
354
+                .foregroundStyle(color)
355
+                .monospacedDigit()
356
+        }
357
+    }
358
+
359
+    private var recordNavigationSection: some View {
360
+        Section("Records") {
361
+            if case .loaded(let diff) = diffState {
362
+                NavigationLink {
363
+                    DataTypeRecordListView(
364
+                        title: "Added Records",
365
+                        displayName: displayName,
366
+                        records: diff.addedRecords,
367
+                        totalCount: diff.addedCount,
368
+                        tint: Color.healthyGreen
369
+                    )
370
+                } label: {
371
+                    RecordNavigationRow(
372
+                        title: "Added Records",
373
+                        count: diff.addedCount,
374
+                        tint: Color.healthyGreen
375
+                    )
376
+                }
377
+                .disabled(diff.addedCount == 0)
378
+
379
+                NavigationLink {
380
+                    DataTypeRecordListView(
381
+                        title: "Disappeared Records",
382
+                        displayName: displayName,
383
+                        records: diff.disappearedRecords,
384
+                        totalCount: diff.disappearedCount,
385
+                        tint: Color.criticalRed
386
+                    )
387
+                } label: {
388
+                    RecordNavigationRow(
389
+                        title: "Disappeared Records",
390
+                        count: diff.disappearedCount,
391
+                        tint: Color.criticalRed
392
+                    )
393
+                }
394
+                .disabled(diff.disappearedCount == 0)
395
+            } else {
396
+                HStack {
397
+                    ProgressView()
398
+                    Text("Preparing record lists")
399
+                        .foregroundStyle(.secondary)
400
+                }
401
+            }
402
+        }
403
+    }
404
+}
405
+
406
+private struct RecordNavigationRow: View {
407
+    let title: String
408
+    let count: Int
409
+    let tint: Color
410
+
411
+    var body: some View {
412
+        HStack {
413
+            Text(title)
414
+            Spacer()
415
+            Text("\(count)")
416
+                .foregroundStyle(count > 0 ? tint : .secondary)
417
+                .monospacedDigit()
418
+        }
419
+    }
420
+}
421
+
422
+private extension DataTypeSnapshotDetailView {
423
+    private var addedRecordCountText: String {
424
+        switch diffState {
425
+        case .loaded(let diff): return "\(diff.addedCount)"
426
+        case .unavailable: return "Legacy"
427
+        case .failed: return "Failed"
428
+        case .idle, .loading: return "Loading"
429
+        }
430
+    }
431
+
432
+    private var disappearedRecordCountText: String {
433
+        switch diffState {
434
+        case .loaded(let diff): return "\(diff.disappearedCount)"
435
+        case .unavailable: return "Legacy"
436
+        case .failed: return "Failed"
437
+        case .idle, .loading: return "Loading"
438
+        }
439
+    }
440
+
441
+    private var addedRecordCountColor: Color {
442
+        if case .loaded(let diff) = diffState, diff.addedCount > 0 {
443
+            return Color.healthyGreen
444
+        }
445
+        return .secondary
446
+    }
447
+
448
+    private var disappearedRecordCountColor: Color {
449
+        if case .loaded(let diff) = diffState, diff.disappearedCount > 0 {
450
+            return Color.criticalRed
451
+        }
452
+        return .secondary
453
+    }
454
+
455
+    @MainActor
456
+    private func loadRecordDiff() async {
457
+        guard previousSnapshot != nil else {
458
+            diffState = .loaded(.empty)
459
+            return
460
+        }
461
+
462
+        let currentCount = currentTypeCount?.count ?? 0
463
+        let previousCount = previousTypeCount?.count ?? 0
464
+        let currentArchive = currentTypeCount?.recordArchiveData
465
+        let previousArchive = previousTypeCount?.recordArchiveData
466
+
467
+        let currentNeedsArchive = currentCount > 0
468
+        let previousNeedsArchive = previousCount > 0
469
+        guard (!currentNeedsArchive || currentArchive != nil),
470
+              (!previousNeedsArchive || previousArchive != nil) else {
471
+            diffState = .unavailable
472
+            return
473
+        }
474
+
475
+        diffState = .loading
476
+        let result = await Task.detached(priority: .userInitiated) {
477
+            DataTypeRecordDiff.compute(
478
+                currentArchive: currentArchive,
479
+                previousArchive: previousArchive
480
+            )
481
+        }.value
482
+        diffState = result
483
+    }
484
+}
485
+
486
+private struct DataTypeRecordListView: View {
487
+    let title: String
488
+    let displayName: String
489
+    let records: [HealthRecordValue]
490
+    let totalCount: Int
491
+    let tint: Color
492
+
493
+    var body: some View {
494
+        List {
495
+            Section {
496
+                DataTypeDetailRow(label: "Data Type") {
497
+                    Text(displayName)
498
+                        .foregroundStyle(.secondary)
499
+                        .lineLimit(1)
500
+                }
501
+                DataTypeDetailRow(label: "Records") {
502
+                    Text("\(totalCount)")
503
+                        .foregroundStyle(tint)
504
+                        .monospacedDigit()
505
+                }
506
+                if totalCount > records.count {
507
+                    Text("Showing newest \(records.count) records.")
508
+                        .font(.caption)
509
+                        .foregroundStyle(.secondary)
510
+                }
511
+            }
512
+
513
+            Section(title) {
514
+                if records.isEmpty {
515
+                    Text("No records.")
516
+                        .foregroundStyle(.secondary)
517
+                } else {
518
+                    ForEach(records) { record in
519
+                        DataTypeRecordRow(record: record, tint: tint)
520
+                    }
521
+                }
522
+            }
523
+        }
524
+        .navigationTitle(title)
525
+        .navigationBarTitleDisplayMode(.inline)
526
+    }
527
+}
528
+
529
+private enum RecordDiffState: Equatable {
530
+    case idle
531
+    case loading
532
+    case unavailable
533
+    case failed(String)
534
+    case loaded(DataTypeRecordDiff)
535
+}
536
+
537
+private struct DataTypeRecordDiff: Equatable, Sendable {
538
+    static let previewLimit = 1_000
539
+    static let empty = DataTypeRecordDiff(
540
+        addedCount: 0,
541
+        disappearedCount: 0,
542
+        addedRecords: [],
543
+        disappearedRecords: []
544
+    )
545
+
546
+    let addedCount: Int
547
+    let disappearedCount: Int
548
+    let addedRecords: [HealthRecordValue]
549
+    let disappearedRecords: [HealthRecordValue]
550
+
551
+    var isPreviewLimited: Bool {
552
+        addedCount > Self.previewLimit || disappearedCount > Self.previewLimit
553
+    }
554
+
555
+    static func compute(currentArchive: Data?, previousArchive: Data?) -> RecordDiffState {
556
+        let currentRecords: [HealthRecordValue]
557
+        if let currentArchive {
558
+            guard let decoded = HealthRecordArchive.decode(currentArchive) else {
559
+                return .failed("Could not decode current record archive.")
560
+            }
561
+            currentRecords = decoded
562
+        } else {
563
+            currentRecords = []
564
+        }
565
+
566
+        let previousRecords: [HealthRecordValue]
567
+        if let previousArchive {
568
+            guard let decoded = HealthRecordArchive.decode(previousArchive) else {
569
+                return .failed("Could not decode previous record archive.")
570
+            }
571
+            previousRecords = decoded
572
+        } else {
573
+            previousRecords = []
574
+        }
575
+
576
+        let previousFingerprints = Set(previousRecords.map(\.recordFingerprint))
577
+        let currentFingerprints = Set(currentRecords.map(\.recordFingerprint))
578
+
579
+        let added = currentRecords.filter { !previousFingerprints.contains($0.recordFingerprint) }
580
+        let disappeared = previousRecords.filter { !currentFingerprints.contains($0.recordFingerprint) }
581
+
582
+        return .loaded(
583
+            DataTypeRecordDiff(
584
+                addedCount: added.count,
585
+                disappearedCount: disappeared.count,
586
+                addedRecords: newestRecords(from: added),
587
+                disappearedRecords: newestRecords(from: disappeared)
588
+            )
589
+        )
590
+    }
591
+
592
+    private static func newestRecords(from records: [HealthRecordValue]) -> [HealthRecordValue] {
593
+        Array(records.sorted { $0.startDate > $1.startDate }.prefix(previewLimit))
594
+    }
595
+}
596
+
597
+private struct DataTypeRecordRow: View {
598
+    let record: HealthRecordValue
599
+    let tint: Color
600
+
601
+    var body: some View {
602
+        HStack(spacing: 12) {
603
+            VStack(alignment: .leading, spacing: 2) {
604
+                Text(record.displayValue ?? "Value unavailable")
605
+                    .font(.subheadline)
606
+                    .foregroundStyle(record.displayValue == nil ? .secondary : .primary)
607
+                Text(record.startDate, format: .dateTime.year().month().day().hour().minute())
608
+                    .font(.caption)
609
+                    .foregroundStyle(.secondary)
610
+            }
611
+
612
+            Spacer()
613
+            Text(record.endDate, format: .dateTime.hour().minute())
614
+                .font(.caption)
615
+                .foregroundStyle(tint)
616
+        }
617
+        .padding(.vertical, 2)
618
+        .accessibilityElement(children: .combine)
619
+    }
620
+}
621
+
622
+private struct DataTypeDateRow: View {
623
+    let label: String
624
+    let date: Date?
625
+
626
+    var body: some View {
627
+        DataTypeDetailRow(label: label) {
628
+            if let date {
629
+                Text(date, format: .dateTime.year().month().day().hour().minute())
630
+                    .foregroundStyle(.secondary)
631
+            } else {
632
+                Text("Unavailable")
633
+                    .foregroundStyle(.secondary)
634
+            }
635
+        }
636
+    }
637
+}
638
+
639
+private struct DataTypeDetailRow<Content: View>: View {
640
+    let label: String
641
+    @ViewBuilder let content: () -> Content
642
+
643
+    var body: some View {
644
+        HStack {
645
+            Text(label)
646
+            Spacer()
647
+            content()
648
+        }
649
+    }
650
+}
651
+
652
+#Preview {
653
+    NavigationStack {
654
+        DataTypeSnapshotDetailView(
655
+            snapshot: HealthSnapshot(
656
+                timestamp: .now,
657
+                osVersion: "iOS 26.4",
658
+                deviceName: "Preview iPhone",
659
+                deviceID: "preview-device"
660
+            ),
661
+            typeIdentifier: "HKQuantityTypeIdentifierStepCount",
662
+            displayName: "Step Count"
663
+        )
664
+    }
665
+    .modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self], inMemory: true)
666
+}
+55 -20
HealthProbe/Views/Snapshots/SnapshotDetailView.swift
@@ -266,14 +266,33 @@ struct SnapshotDetailView: View {
266 266
 
267 267
             Spacer(minLength: 8)
268 268
 
269
-            VStack(spacing: 2) {
270
-                Text("Snapshot")
271
-                    .font(.headline.weight(.semibold))
272
-                Text(currentSnapshot.timestamp, format: .dateTime.month().day().hour().minute())
273
-                    .font(.caption)
274
-                    .foregroundStyle(.secondary)
269
+            Menu {
270
+                ForEach(timelineSnapshots) { candidate in
271
+                    Button {
272
+                        displayedSnapshot = candidate
273
+                    } label: {
274
+                        if candidate.id == currentSnapshot.id {
275
+                            Label(
276
+                                candidate.timestamp.formatted(.dateTime.year().month().day().hour().minute()),
277
+                                systemImage: "checkmark"
278
+                            )
279
+                        } else {
280
+                            Text(candidate.timestamp, format: .dateTime.year().month().day().hour().minute())
281
+                        }
282
+                    }
283
+                }
284
+            } label: {
285
+                VStack(spacing: 2) {
286
+                    Text("Snapshot")
287
+                        .font(.headline.weight(.semibold))
288
+                    Text(currentSnapshot.timestamp, format: .dateTime.month().day().hour().minute())
289
+                        .font(.caption)
290
+                        .foregroundStyle(.secondary)
291
+                }
275 292
             }
293
+            .buttonStyle(.plain)
276 294
             .lineLimit(1)
295
+            .accessibilityLabel("Select current snapshot")
277 296
 
278 297
             Spacer(minLength: 8)
279 298
 
@@ -383,23 +402,39 @@ struct SnapshotDetailView: View {
383 402
                         .foregroundStyle(.secondary)
384 403
                 } else {
385 404
                     ForEach(sortedTypeCounts) { typeCount in
386
-                        SnapshotTypeCountRow(
387
-                            typeCount: typeCount,
388
-                            baselineTypeCount: baselineTypeMap[typeCount.typeIdentifier]
389
-                        )
405
+                        NavigationLink {
406
+                            DataTypeSnapshotDetailView(
407
+                                snapshot: currentSnapshot,
408
+                                typeIdentifier: typeCount.typeIdentifier,
409
+                                displayName: typeCount.displayName
410
+                            )
411
+                        } label: {
412
+                            SnapshotTypeCountRow(
413
+                                typeCount: typeCount,
414
+                                baselineTypeCount: baselineTypeMap[typeCount.typeIdentifier]
415
+                            )
416
+                        }
390 417
                     }
391 418
                 }
392 419
             } else {
393 420
                 ForEach(evolutionSeries) { series in
394
-                    TypeEvolutionChart(
395
-                        series: series,
396
-                        contextSnapshots: timelineContextSnapshots,
397
-                        xAxisMode: xAxisMode,
398
-                        selectedSnapshotID: currentSnapshot.id,
399
-                        selectedTimestamp: currentSnapshot.timestamp,
400
-                        snapshotNumbers: timelineSnapshotNumbers,
401
-                        baselineTypeCount: baselineTypeMap[series.typeIdentifier]
402
-                    )
421
+                    NavigationLink {
422
+                        DataTypeSnapshotDetailView(
423
+                            snapshot: currentSnapshot,
424
+                            typeIdentifier: series.typeIdentifier,
425
+                            displayName: series.displayName
426
+                        )
427
+                    } label: {
428
+                        TypeEvolutionChart(
429
+                            series: series,
430
+                            contextSnapshots: timelineContextSnapshots,
431
+                            xAxisMode: xAxisMode,
432
+                            selectedSnapshotID: currentSnapshot.id,
433
+                            selectedTimestamp: currentSnapshot.timestamp,
434
+                            snapshotNumbers: timelineSnapshotNumbers,
435
+                            baselineTypeCount: baselineTypeMap[series.typeIdentifier]
436
+                        )
437
+                    }
403 438
                 }
404 439
 
405 440
                 if isTimelineContextTrimmed {
@@ -807,5 +842,5 @@ private struct ShareSheet: UIViewControllerRepresentable {
807 842
             profile: DeviceProfile(deviceID: "preview-device")
808 843
         )
809 844
     }
810
-    .modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self, DeviceProfile.self], inMemory: true)
845
+    .modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self, DeviceProfile.self], inMemory: true)
811 846
 }
+1 -1
HealthProbe/Views/Snapshots/SnapshotsView.swift
@@ -294,6 +294,6 @@ private struct SnapshotRow: View {
294 294
 
295 295
 #Preview {
296 296
     SnapshotsView()
297
-        .modelContainer(for: [HealthSnapshot.self, TypeCount.self, DeviceProfile.self], inMemory: true)
297
+        .modelContainer(for: [HealthSnapshot.self, TypeCount.self, HealthRecord.self, TypeDistributionBin.self, DeviceProfile.self], inMemory: true)
298 298
         .environment(AppSettings())
299 299
 }