Showing 8 changed files with 763 additions and 137 deletions
+9 -0
AGENTS.md
@@ -122,6 +122,15 @@ final class TypeDistributionBin {
122 122
 // Import uses a global anchored query per data type so follow-up snapshots fetch only
123 123
 // HealthKit deltas instead of scanning calendar blocks with fixed per-query latency.
124 124
 
125
+// Interface updated 2026-05-14 — see AGENTS.md
126
+// TypeCount.recordArchiveData is stored with SwiftData external storage.
127
+// The archive remains a complete per-record forensic snapshot, but large blobs should
128
+// not be kept inline with the TypeCount row because high-volume HealthKit stores can
129
+// exceed device memory during import or detail inspection.
130
+// Initial high-volume imports stream records directly into a compact binary archive
131
+// instead of building a full in-memory dictionary/array; plist archives remain readable
132
+// for backwards compatibility.
133
+
125 134
 // Models/DetectedAnomaly.swift
126 135
 enum AnomalyType: String, Codable {
127 136
     case historicalInsertion = "historical_insertion"
+175 -5
HealthProbe/Models/HealthRecord.swift
@@ -12,17 +12,187 @@ struct HealthRecordValue: Codable, Hashable, Identifiable, Sendable {
12 12
 }
13 13
 
14 14
 enum HealthRecordArchive {
15
+    private static let compactMagic = Data([0x48, 0x50, 0x52, 0x41, 0x32]) // HPRA2
16
+
15 17
     static func encode(_ values: [HealthRecordValue]) -> Data? {
16
-        let encoder = PropertyListEncoder()
17
-        encoder.outputFormat = .binary
18
-        return try? encoder.encode(values)
18
+        guard let typeIdentifier = values.first?.typeIdentifier else {
19
+            return encodeCompact(typeIdentifier: "", values: [])
20
+        }
21
+        return encodeCompact(typeIdentifier: typeIdentifier, values: values)
19 22
     }
20 23
 
21 24
     static func decode(_ data: Data) -> [HealthRecordValue]? {
22
-        try? PropertyListDecoder().decode([HealthRecordValue].self, from: data)
25
+        if let decoded = decodeCompact(data) {
26
+            return decoded
27
+        }
28
+        return try? PropertyListDecoder().decode([HealthRecordValue].self, from: data)
23 29
     }
24
-}
25 30
 
31
+    static func makeCompactWriter(typeIdentifier: String, estimatedRecordCount: Int = 0) -> CompactWriter {
32
+        CompactWriter(typeIdentifier: typeIdentifier, estimatedRecordCount: estimatedRecordCount)
33
+    }
34
+
35
+    private static func encodeCompact(typeIdentifier: String, values: [HealthRecordValue]) -> Data? {
36
+        var writer = makeCompactWriter(typeIdentifier: typeIdentifier, estimatedRecordCount: values.count)
37
+        for value in values {
38
+            writer.append(value)
39
+        }
40
+        return writer.finalize()
41
+    }
42
+
43
+    struct CompactWriter {
44
+        private var data = Data()
45
+        private var countOffset = 0
46
+        private var count: UInt64 = 0
47
+
48
+        init(typeIdentifier: String, estimatedRecordCount: Int = 0) {
49
+            data.reserveCapacity(max(256, estimatedRecordCount * 160))
50
+            data.append(compactMagic)
51
+            appendString(typeIdentifier)
52
+            countOffset = data.count
53
+            appendUInt64(0)
54
+        }
55
+
56
+        mutating func append(_ value: HealthRecordValue) {
57
+            appendString(value.sampleUUIDHash)
58
+            appendString(value.recordFingerprint)
59
+            appendDouble(value.startDate.timeIntervalSinceReferenceDate)
60
+            appendDouble(value.endDate.timeIntervalSinceReferenceDate)
61
+            appendOptionalString(value.displayValue)
62
+            count += 1
63
+        }
64
+
65
+        mutating func finalize() -> Data {
66
+            var littleEndianCount = count.littleEndian
67
+            data.replaceSubrange(
68
+                countOffset..<countOffset + MemoryLayout<UInt64>.size,
69
+                with: withUnsafeBytes(of: &littleEndianCount) { Array($0) }
70
+            )
71
+            return data
72
+        }
73
+
74
+        private mutating func appendString(_ value: String) {
75
+            let bytes = Array(value.utf8)
76
+            appendUInt32(UInt32(bytes.count))
77
+            data.append(contentsOf: bytes)
78
+        }
79
+
80
+        private mutating func appendOptionalString(_ value: String?) {
81
+            guard let value else {
82
+                appendInt32(-1)
83
+                return
84
+            }
85
+            let bytes = Array(value.utf8)
86
+            appendInt32(Int32(bytes.count))
87
+            data.append(contentsOf: bytes)
88
+        }
89
+
90
+        private mutating func appendUInt32(_ value: UInt32) {
91
+            var littleEndian = value.littleEndian
92
+            data.append(contentsOf: withUnsafeBytes(of: &littleEndian) { Array($0) })
93
+        }
94
+
95
+        private mutating func appendInt32(_ value: Int32) {
96
+            var littleEndian = value.littleEndian
97
+            data.append(contentsOf: withUnsafeBytes(of: &littleEndian) { Array($0) })
98
+        }
99
+
100
+        private mutating func appendUInt64(_ value: UInt64) {
101
+            var littleEndian = value.littleEndian
102
+            data.append(contentsOf: withUnsafeBytes(of: &littleEndian) { Array($0) })
103
+        }
104
+
105
+        private mutating func appendDouble(_ value: Double) {
106
+            appendUInt64(value.bitPattern)
107
+        }
108
+    }
109
+
110
+    private static func decodeCompact(_ data: Data) -> [HealthRecordValue]? {
111
+        guard data.starts(with: compactMagic) else { return nil }
112
+        var reader = CompactReader(data: data, offset: compactMagic.count)
113
+        guard let typeIdentifier = reader.readString(),
114
+              let count = reader.readUInt64() else {
115
+            return nil
116
+        }
117
+
118
+        var values: [HealthRecordValue] = []
119
+        values.reserveCapacity(Int(min(count, UInt64(Int.max))))
120
+        for _ in 0..<count {
121
+            guard let sampleUUIDHash = reader.readString(),
122
+                  let recordFingerprint = reader.readString(),
123
+                  let startInterval = reader.readDouble(),
124
+                  let endInterval = reader.readDouble(),
125
+                  let displayValue = reader.readOptionalString() else {
126
+                return nil
127
+            }
128
+            values.append(
129
+                HealthRecordValue(
130
+                    typeIdentifier: typeIdentifier,
131
+                    sampleUUIDHash: sampleUUIDHash,
132
+                    recordFingerprint: recordFingerprint,
133
+                    startDate: Date(timeIntervalSinceReferenceDate: startInterval),
134
+                    endDate: Date(timeIntervalSinceReferenceDate: endInterval),
135
+                    displayValue: displayValue
136
+                )
137
+            )
138
+        }
139
+        return values
140
+    }
141
+
142
+    private struct CompactReader {
143
+        let data: Data
144
+        var offset: Int
145
+
146
+        mutating func readString() -> String? {
147
+            guard let length = readUInt32(),
148
+                  let bytes = readBytes(count: Int(length)) else {
149
+                return nil
150
+            }
151
+            return String(data: Data(bytes), encoding: .utf8)
152
+        }
153
+
154
+        mutating func readOptionalString() -> String?? {
155
+            guard let length = readInt32() else { return nil }
156
+            if length < 0 {
157
+                return .some(nil)
158
+            }
159
+            guard let bytes = readBytes(count: Int(length)) else { return nil }
160
+            return .some(String(data: Data(bytes), encoding: .utf8))
161
+        }
162
+
163
+        mutating func readUInt32() -> UInt32? {
164
+            readFixedWidthInteger()
165
+        }
166
+
167
+        mutating func readInt32() -> Int32? {
168
+            readFixedWidthInteger()
169
+        }
170
+
171
+        mutating func readUInt64() -> UInt64? {
172
+            readFixedWidthInteger()
173
+        }
174
+
175
+        mutating func readDouble() -> Double? {
176
+            guard let bitPattern: UInt64 = readFixedWidthInteger() else { return nil }
177
+            return Double(bitPattern: bitPattern)
178
+        }
179
+
180
+        private mutating func readBytes(count: Int) -> [UInt8]? {
181
+            guard count >= 0, offset + count <= data.count else { return nil }
182
+            let bytes = Array(data[offset..<offset + count])
183
+            offset += count
184
+            return bytes
185
+        }
186
+
187
+        private mutating func readFixedWidthInteger<T: FixedWidthInteger>() -> T? {
188
+            let size = MemoryLayout<T>.size
189
+            guard let bytes = readBytes(count: size) else { return nil }
190
+            return bytes.enumerated().reduce(T.zero) { result, item in
191
+                result | (T(item.element) << T(item.offset * 8))
192
+            }
193
+        }
194
+    }
195
+}
26 196
 // Interface updated 2026-05-12 — see AGENTS.md
27 197
 @Model final class HealthRecord {
28 198
     var id: UUID = UUID()
+2 -2
HealthProbe/Models/TypeCount.swift
@@ -1,7 +1,7 @@
1 1
 import Foundation
2 2
 import SwiftData
3 3
 
4
-// Interface updated 2026-05-12 — see AGENTS.md
4
+// Interface updated 2026-05-14 — see AGENTS.md
5 5
 @Model final class TypeCount {
6 6
     var id: UUID = UUID()
7 7
     var typeIdentifier: String = ""
@@ -12,7 +12,7 @@ import SwiftData
12 12
     var latestDate: Date?
13 13
     var qualityRaw: String = SnapshotQuality.complete.rawValue
14 14
     var isUnsupported: Bool = false
15
-    var recordArchiveData: Data?
15
+    @Attribute(.externalStorage) var recordArchiveData: Data?
16 16
     var snapshot: HealthSnapshot?
17 17
     @Relationship(deleteRule: .cascade, inverse: \YearlyCount.typeCount)
18 18
     var yearlyCounts: [YearlyCount]? = []
+26 -0
HealthProbe/Services/HashService.swift
@@ -2,6 +2,24 @@ import CryptoKit
2 2
 import Foundation
3 3
 
4 4
 enum HashService {
5
+    struct TypeHashBuilder {
6
+        private var hasher = SHA256()
7
+
8
+        init(typeIdentifier: String) {
9
+            hasher.update(data: Data(typeIdentifier.utf8))
10
+        }
11
+
12
+        mutating func append(recordFingerprint: String) {
13
+            hasher.update(data: Data("|".utf8))
14
+            hasher.update(data: Data(recordFingerprint.utf8))
15
+        }
16
+
17
+        mutating func finalize() -> String {
18
+            let digest = hasher.finalize()
19
+            return digest.map { String(format: "%02x", $0) }.joined()
20
+        }
21
+    }
22
+
5 23
     private static let iso8601Formatter: ISO8601DateFormatter = {
6 24
         let f = ISO8601DateFormatter()
7 25
         f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
@@ -36,6 +54,14 @@ enum HashService {
36 54
         return digest.map { String(format: "%02x", $0) }.joined()
37 55
     }
38 56
 
57
+    static func typeHashInRecordedOrder(typeIdentifier: String, recordFingerprints: [String]) -> String {
58
+        var builder = TypeHashBuilder(typeIdentifier: typeIdentifier)
59
+        for fingerprint in recordFingerprints {
60
+            builder.append(recordFingerprint: fingerprint)
61
+        }
62
+        return builder.finalize()
63
+    }
64
+
39 65
     static func sampleFingerprint(
40 66
         typeIdentifier: String,
41 67
         sampleUUID: String,
+478 -113
HealthProbe/Services/HealthKitService.swift
@@ -96,21 +96,17 @@ final class HealthKitService {
96 96
         snapshot.retryOfSnapshotID = retryOfSnapshotID
97 97
         snapshot.yearlyCountTimezoneIdentifier = TimeZone.current.identifier
98 98
         let previousSnapshot = findPreviousSnapshot(deviceID: snapshot.deviceID, excluding: snapshot.id, context: context)
99
-        // Fetch raw HealthKit data off the main actor, then assemble SwiftData models here
100
-        // on the main actor to prevent data races on managed object context.
101
-        let fetchResults = await fetchAllTypeCounts(
99
+        // Fetch raw HealthKit data one type at a time, then assemble each SwiftData model
100
+        // immediately so large per-type archives are not duplicated in an intermediate result array.
101
+        let typeCounts = await fetchAllTypeCounts(
102 102
             for: active,
103 103
             context: context,
104
+            snapshot: snapshot,
104 105
             previousSnapshot: previousSnapshot,
105 106
             adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled,
106 107
             timeoutMultiplier: timeoutMultiplier,
107 108
             progress: progress
108 109
         )
109
-        let typeCounts: [TypeCount] = fetchResults.map { result in
110
-            let tc = result.makeTypeCount()
111
-            tc.snapshot = snapshot
112
-            return tc
113
-        }
114 110
         snapshot.typeCounts = typeCounts
115 111
 
116 112
         applyStickyUnavailableState(
@@ -384,18 +380,19 @@ final class HealthKitService {
384 380
 
385 381
     // MARK: - Per-type fetch pipeline
386 382
 
387
-    // Returns raw Sendable fetch results — no SwiftData model creation here.
388
-    // All TypeCount/YearlyCount objects must be created on the main actor in createSnapshot.
389 383
     // Fetches sequentially to prevent race conditions and resource exhaustion.
384
+    @MainActor
390 385
     private func fetchAllTypeCounts(
391 386
         for active: [MonitoredType],
392 387
         context: ModelContext,
388
+        snapshot: HealthSnapshot,
393 389
         previousSnapshot: HealthSnapshot?,
394 390
         adaptiveTimeoutsEnabled: Bool,
395 391
         timeoutMultiplier: Double,
396 392
         progress: SnapshotFetchProgress? = nil
397
-    ) async -> [TypeCountFetchResult] {
398
-        var results: [TypeCountFetchResult] = []
393
+    ) async -> [TypeCount] {
394
+        var typeCounts: [TypeCount] = []
395
+        typeCounts.reserveCapacity(active.count)
399 396
 
400 397
         for monitoredType in active {
401 398
             let profile = timeoutProfile(for: monitoredType, context: context)
@@ -414,9 +411,11 @@ final class HealthKitService {
414 411
             updateTimeoutProfile(profile, with: result, monitoredType: monitoredType)
415 412
             result.applyTimeoutProfile(profile)
416 413
             progress?.updateTimeoutProfile(from: profile, for: monitoredType.id)
417
-            results.append(result)
414
+            let typeCount = result.makeTypeCount()
415
+            typeCount.snapshot = snapshot
416
+            typeCounts.append(typeCount)
418 417
         }
419
-        return results
418
+        return typeCounts
420 419
     }
421 420
 
422 421
     private func fetchTypeCountData(
@@ -574,16 +573,22 @@ final class HealthKitService {
574 573
             )
575 574
         }
576 575
 
577
-        let contentHash = HashService.typeHash(
576
+        let contentHash = distribution.contentHash ?? HashService.typeHash(
578 577
             typeIdentifier: monitoredType.id,
579 578
             recordFingerprints: distribution.records.map(\.recordFingerprint)
580 579
         )
581 580
 
582 581
         // YearlyCount uses Calendar.current; year attribution is local-time based.
583
-        var yearMap: [Int: Int] = [:]
584
-        for record in distribution.records {
585
-            let year = Calendar.current.component(.year, from: record.startDate)
586
-            yearMap[year, default: 0] += 1
582
+        let yearMap: [Int: Int]
583
+        if let cachedYearlyCounts = distribution.yearlyCounts {
584
+            yearMap = cachedYearlyCounts
585
+        } else {
586
+            var computedYearMap: [Int: Int] = [:]
587
+            for record in distribution.records {
588
+                let year = Calendar.current.component(.year, from: record.startDate)
589
+                computedYearMap[year, default: 0] += 1
590
+            }
591
+            yearMap = computedYearMap
587 592
         }
588 593
         let yearlyCounts = yearMap.map { year, yearCount in
589 594
             TypeCountFetchResult.YearlyCountData(
@@ -598,18 +603,7 @@ final class HealthKitService {
598 603
             detail: "Preparing record archive",
599 604
             recordCount: distribution.totalCount
600 605
         )
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
-        )
606
+        let recordArchiveData = distribution.recordArchiveData ?? HealthRecordArchive.encode(distribution.records)
613 607
 
614 608
         return TypeCountFetchResult(
615 609
             typeIdentifier: monitoredType.id,
@@ -649,29 +643,27 @@ final class HealthKitService {
649 643
         progress: SnapshotFetchProgress?
650 644
     ) async throws -> SampleDistribution {
651 645
         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
663
-                    )
664
-                )
665
-            }
666
-        )
646
+        let startedFromAnchor = anchor != nil
647
+        let estimatedPageCount = startedFromAnchor
648
+            ? Self.estimatedPageCount(for: previousDistribution.count)
649
+            : nil
650
+        let progressStarted = Date()
667 651
         var pageNumber = 0
652
+        var processedEventCount = 0
653
+        var firstDeltaPage: SampleDistributionPage?
668 654
 
669
-        while true {
670
-            pageNumber += 1
655
+        if anchor != nil {
656
+            pageNumber = 1
671 657
             progress?.updateBlockProgress(
672 658
                 typeIdentifier,
673
-                detail: anchor == nil ? "Import page \(pageNumber)" : "Delta page \(pageNumber)",
674
-                recordCount: recordMap.count
659
+                detail: Self.pageProgressDetail(
660
+                    operation: "Delta",
661
+                    pageNumber: pageNumber,
662
+                    estimatedPageCount: estimatedPageCount
663
+                ),
664
+                recordCount: previousDistribution.count,
665
+                elapsedSeconds: 0,
666
+                samplesPerSecond: 0
675 667
             )
676 668
 
677 669
             let page = try await withTimeout(seconds: DistributionCaptureConfiguration.pageTimeoutSeconds) {
@@ -683,36 +675,129 @@ final class HealthKitService {
683 675
             }
684 676
             anchor = page.anchor
685 677
 
686
-            for deletedObject in page.deletedObjects {
687
-                recordMap.removeValue(forKey: HashService.sampleUUIDHash(deletedObject.uuid.uuidString))
678
+            if page.samples.isEmpty, page.deletedObjects.isEmpty,
679
+               let unchanged = previousDistribution.unchangedDistribution(
680
+                    updatedAnchor: anchor,
681
+                    typeIdentifier: typeIdentifier,
682
+                    earliestDate: earliestDate,
683
+                    latestDate: latestDate
684
+               ) {
685
+                progress?.updateBlockProgress(
686
+                    typeIdentifier,
687
+                    detail: "No HealthKit delta",
688
+                    recordCount: unchanged.totalCount,
689
+                    elapsedSeconds: Date().timeIntervalSince(progressStarted),
690
+                    samplesPerSecond: 0
691
+                )
692
+                return unchanged
688 693
             }
689 694
 
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)
695
+            firstDeltaPage = page
696
+        }
697
+
698
+        if !startedFromAnchor {
699
+            return try await fetchInitialDistributionStreaming(
700
+                for: sampleType,
701
+                typeIdentifier: typeIdentifier,
702
+                earliestDate: earliestDate,
703
+                latestDate: latestDate,
704
+                progressStarted: progressStarted,
705
+                progress: progress
706
+            )
707
+        }
708
+
709
+        var recordMap = startedFromAnchor ? try previousDistribution.makeRecordMap() : [:]
710
+        var shouldFetchNextPage = true
711
+
712
+        if let firstDeltaPage {
713
+            applyDistributionPage(firstDeltaPage, sampleType: sampleType, to: &recordMap)
714
+            processedEventCount += pageEventCount(firstDeltaPage)
715
+            shouldFetchNextPage = firstDeltaPage.samples.count + firstDeltaPage.deletedObjects.count >= DistributionCaptureConfiguration.queryPageLimit
716
+            progress?.updateBlockProgress(
717
+                typeIdentifier,
718
+                detail: Self.pageProgressDetail(
719
+                    operation: "Delta",
720
+                    pageNumber: pageNumber,
721
+                    estimatedPageCount: estimatedPageCount
722
+                ),
723
+                recordCount: recordMap.count,
724
+                elapsedSeconds: Date().timeIntervalSince(progressStarted),
725
+                samplesPerSecond: Self.samplesPerSecond(
726
+                    processedCount: processedEventCount,
727
+                    elapsedSeconds: Date().timeIntervalSince(progressStarted)
703 728
                 )
704
-            }
729
+            )
730
+        }
705 731
 
706
-            if page.samples.count + page.deletedObjects.count < DistributionCaptureConfiguration.queryPageLimit {
707
-                break
732
+        while shouldFetchNextPage {
733
+            pageNumber += 1
734
+            progress?.updateBlockProgress(
735
+                typeIdentifier,
736
+                detail: Self.pageProgressDetail(
737
+                    operation: anchor == nil ? "Import" : "Delta",
738
+                    pageNumber: pageNumber,
739
+                    estimatedPageCount: anchor == nil ? nil : estimatedPageCount
740
+                ),
741
+                recordCount: recordMap.count,
742
+                elapsedSeconds: Date().timeIntervalSince(progressStarted),
743
+                samplesPerSecond: Self.samplesPerSecond(
744
+                    processedCount: processedEventCount,
745
+                    elapsedSeconds: Date().timeIntervalSince(progressStarted)
746
+                )
747
+            )
748
+
749
+            let page = try await withTimeout(seconds: DistributionCaptureConfiguration.pageTimeoutSeconds) {
750
+                try await self.fetchDistributionPage(
751
+                    for: sampleType,
752
+                    predicate: nil,
753
+                    anchor: anchor
754
+                )
708 755
             }
756
+            anchor = page.anchor
757
+
758
+            applyDistributionPage(page, sampleType: sampleType, to: &recordMap)
759
+            processedEventCount += pageEventCount(page)
760
+            shouldFetchNextPage = page.samples.count + page.deletedObjects.count >= DistributionCaptureConfiguration.queryPageLimit
761
+            progress?.updateBlockProgress(
762
+                typeIdentifier,
763
+                detail: Self.pageProgressDetail(
764
+                    operation: anchor == nil ? "Import" : "Delta",
765
+                    pageNumber: pageNumber,
766
+                    estimatedPageCount: anchor == nil ? nil : estimatedPageCount
767
+                ),
768
+                recordCount: recordMap.count,
769
+                elapsedSeconds: Date().timeIntervalSince(progressStarted),
770
+                samplesPerSecond: Self.samplesPerSecond(
771
+                    processedCount: processedEventCount,
772
+                    elapsedSeconds: Date().timeIntervalSince(progressStarted)
773
+                )
774
+            )
709 775
         }
710 776
 
711
-        let sortedRecords = Array(recordMap.values).sorted {
712
-            if $0.startDate != $1.startDate {
713
-                return $0.startDate < $1.startDate
777
+        let sortedKeys = recordMap.keys.sorted {
778
+            guard let left = recordMap[$0],
779
+                  let right = recordMap[$1] else {
780
+                return $0 < $1
714 781
             }
715
-            return $0.recordFingerprint < $1.recordFingerprint
782
+            if left.startDate != right.startDate {
783
+                return left.startDate < right.startDate
784
+            }
785
+            return left.recordFingerprint < right.recordFingerprint
786
+        }
787
+        var sortedRecords: [HealthRecordValue] = []
788
+        sortedRecords.reserveCapacity(sortedKeys.count)
789
+        for sampleUUIDHash in sortedKeys {
790
+            guard let record = recordMap[sampleUUIDHash] else { continue }
791
+            sortedRecords.append(
792
+                HealthRecordValue(
793
+                    typeIdentifier: typeIdentifier,
794
+                    sampleUUIDHash: sampleUUIDHash,
795
+                    recordFingerprint: record.recordFingerprint,
796
+                    startDate: record.startDate,
797
+                    endDate: record.endDate,
798
+                    displayValue: record.displayValue
799
+                )
800
+            )
716 801
         }
717 802
         let contentHash = HashService.typeHash(
718 803
             typeIdentifier: typeIdentifier,
@@ -726,7 +811,14 @@ final class HealthKitService {
726 811
         )
727 812
 
728 813
         guard !sortedRecords.isEmpty || anchor != nil else {
729
-            return SampleDistribution(totalCount: 0, bins: [], records: [])
814
+            return SampleDistribution(
815
+                totalCount: 0,
816
+                bins: [],
817
+                records: [],
818
+                contentHash: nil,
819
+                yearlyCounts: nil,
820
+                recordArchiveData: nil
821
+            )
730 822
         }
731 823
 
732 824
         let binStart = earliestDate ?? sortedRecords.first?.startDate ?? previousDistribution.earliestRecordDate ?? Date()
@@ -744,10 +836,205 @@ final class HealthKitService {
744 836
                     anchorData: anchor.flatMap(Self.archiveAnchor(_:))
745 837
                 )
746 838
             ],
747
-            records: sortedRecords
839
+            records: sortedRecords,
840
+            contentHash: contentHash,
841
+            yearlyCounts: nil,
842
+            recordArchiveData: nil
843
+        )
844
+    }
845
+
846
+    private func fetchInitialDistributionStreaming(
847
+        for sampleType: HKSampleType,
848
+        typeIdentifier: String,
849
+        earliestDate: Date?,
850
+        latestDate: Date?,
851
+        progressStarted: Date,
852
+        progress: SnapshotFetchProgress?
853
+    ) async throws -> SampleDistribution {
854
+        var anchor: HKQueryAnchor?
855
+        var pageNumber = 0
856
+        var recordCount = 0
857
+        var processedEventCount = 0
858
+        var firstRecordDate: Date?
859
+        var latestRecordDate: Date?
860
+        var yearMap: [Int: Int] = [:]
861
+        var archiveWriter = HealthRecordArchive.makeCompactWriter(typeIdentifier: typeIdentifier)
862
+        var hashBuilder = HashService.TypeHashBuilder(typeIdentifier: typeIdentifier)
863
+        var shouldFetchNextPage = true
864
+
865
+        while shouldFetchNextPage {
866
+            pageNumber += 1
867
+            let elapsedBeforePage = Date().timeIntervalSince(progressStarted)
868
+            progress?.updateBlockProgress(
869
+                typeIdentifier,
870
+                detail: Self.pageProgressDetail(
871
+                    operation: "Import",
872
+                    pageNumber: pageNumber,
873
+                    estimatedPageCount: nil
874
+                ),
875
+                recordCount: recordCount,
876
+                elapsedSeconds: elapsedBeforePage,
877
+                samplesPerSecond: Self.samplesPerSecond(
878
+                    processedCount: processedEventCount,
879
+                    elapsedSeconds: elapsedBeforePage
880
+                )
881
+            )
882
+
883
+            let page = try await withTimeout(seconds: DistributionCaptureConfiguration.pageTimeoutSeconds) {
884
+                try await self.fetchDistributionPage(
885
+                    for: sampleType,
886
+                    predicate: nil,
887
+                    anchor: anchor
888
+                )
889
+            }
890
+            anchor = page.anchor
891
+
892
+            for sample in page.samples {
893
+                let value = Self.recordValue(for: sample, sampleType: sampleType, typeIdentifier: typeIdentifier)
894
+                archiveWriter.append(value)
895
+                hashBuilder.append(recordFingerprint: value.recordFingerprint)
896
+                yearMap[Calendar.current.component(.year, from: value.startDate), default: 0] += 1
897
+                firstRecordDate = min(firstRecordDate ?? value.startDate, value.startDate)
898
+                latestRecordDate = max(latestRecordDate ?? value.endDate, value.endDate)
899
+                recordCount += 1
900
+            }
901
+
902
+            processedEventCount += pageEventCount(page)
903
+            shouldFetchNextPage = page.samples.count + page.deletedObjects.count >= DistributionCaptureConfiguration.queryPageLimit
904
+            let elapsedAfterPage = Date().timeIntervalSince(progressStarted)
905
+            progress?.updateBlockProgress(
906
+                typeIdentifier,
907
+                detail: Self.pageProgressDetail(
908
+                    operation: "Import",
909
+                    pageNumber: pageNumber,
910
+                    estimatedPageCount: nil
911
+                ),
912
+                recordCount: recordCount,
913
+                elapsedSeconds: elapsedAfterPage,
914
+                samplesPerSecond: Self.samplesPerSecond(
915
+                    processedCount: processedEventCount,
916
+                    elapsedSeconds: elapsedAfterPage
917
+                )
918
+            )
919
+        }
920
+
921
+        let contentHash = hashBuilder.finalize()
922
+        progress?.updateBlockProgress(
923
+            typeIdentifier,
924
+            detail: pageNumber == 1 ? "Imported 1 page" : "Imported \(pageNumber) pages",
925
+            recordCount: recordCount,
926
+            elapsedSeconds: Date().timeIntervalSince(progressStarted),
927
+            samplesPerSecond: Self.samplesPerSecond(
928
+                processedCount: processedEventCount,
929
+                elapsedSeconds: Date().timeIntervalSince(progressStarted)
930
+            )
931
+        )
932
+
933
+        guard recordCount > 0 || anchor != nil else {
934
+            return SampleDistribution(
935
+                totalCount: 0,
936
+                bins: [],
937
+                records: [],
938
+                contentHash: nil,
939
+                yearlyCounts: nil,
940
+                recordArchiveData: nil
941
+            )
942
+        }
943
+
944
+        let binStart = earliestDate ?? firstRecordDate ?? Date()
945
+        let rawBinEnd = latestDate ?? latestRecordDate ?? binStart
946
+        let binEnd = rawBinEnd > binStart ? rawBinEnd : binStart.addingTimeInterval(1)
947
+
948
+        return SampleDistribution(
949
+            totalCount: recordCount,
950
+            bins: [
951
+                SampleDistribution.Bin(
952
+                    start: binStart,
953
+                    end: binEnd,
954
+                    count: recordCount,
955
+                    contentHash: contentHash,
956
+                    anchorData: anchor.flatMap(Self.archiveAnchor(_:))
957
+                )
958
+            ],
959
+            records: [],
960
+            contentHash: contentHash,
961
+            yearlyCounts: yearMap,
962
+            recordArchiveData: archiveWriter.finalize()
963
+        )
964
+    }
965
+
966
+    private func applyDistributionPage(
967
+        _ page: SampleDistributionPage,
968
+        sampleType: HKSampleType,
969
+        to recordMap: inout [String: SampleRecordPayload]
970
+    ) {
971
+        for deletedObject in page.deletedObjects {
972
+            recordMap.removeValue(forKey: HashService.sampleUUIDHash(deletedObject.uuid.uuidString))
973
+        }
974
+
975
+        for sample in page.samples {
976
+            let value = Self.recordValue(for: sample, sampleType: sampleType, typeIdentifier: sampleType.identifier)
977
+            let uuidHash = value.sampleUUIDHash
978
+            recordMap[uuidHash] = SampleRecordPayload(
979
+                recordFingerprint: value.recordFingerprint,
980
+                startDate: value.startDate,
981
+                endDate: value.endDate,
982
+                displayValue: value.displayValue
983
+            )
984
+        }
985
+    }
986
+
987
+    private static func recordValue(
988
+        for sample: HKSample,
989
+        sampleType: HKSampleType,
990
+        typeIdentifier: String
991
+    ) -> HealthRecordValue {
992
+        HealthRecordValue(
993
+            typeIdentifier: typeIdentifier,
994
+            sampleUUIDHash: HashService.sampleUUIDHash(sample.uuid.uuidString),
995
+            recordFingerprint: HashService.sampleFingerprint(
996
+                typeIdentifier: sampleType.identifier,
997
+                sampleUUID: sample.uuid.uuidString,
998
+                startDate: sample.startDate,
999
+                endDate: sample.endDate
1000
+            ),
1001
+            startDate: sample.startDate,
1002
+            endDate: sample.endDate,
1003
+            displayValue: nil
748 1004
         )
749 1005
     }
750 1006
 
1007
+    private func pageEventCount(_ page: SampleDistributionPage) -> Int {
1008
+        page.samples.count + page.deletedObjects.count
1009
+    }
1010
+
1011
+    private static func estimatedPageCount(for recordCount: Int) -> Int {
1012
+        let count = max(recordCount, 0)
1013
+        let pageLimit = DistributionCaptureConfiguration.queryPageLimit
1014
+        return max(1, (count + pageLimit - 1) / pageLimit)
1015
+    }
1016
+
1017
+    private static func samplesPerSecond(processedCount: Int, elapsedSeconds: TimeInterval) -> Double {
1018
+        guard elapsedSeconds > 0, processedCount > 0 else { return 0 }
1019
+        return Double(processedCount) / elapsedSeconds
1020
+    }
1021
+
1022
+    private static func pageProgressDetail(
1023
+        operation: String,
1024
+        pageNumber: Int,
1025
+        estimatedPageCount: Int?
1026
+    ) -> String {
1027
+        guard let estimatedPageCount else {
1028
+            return "\(operation) page \(pageNumber) (total unknown)"
1029
+        }
1030
+
1031
+        if pageNumber <= estimatedPageCount {
1032
+            return "\(operation) page \(pageNumber) of ~\(estimatedPageCount)"
1033
+        }
1034
+
1035
+        return "\(operation) page \(pageNumber) of ~\(estimatedPageCount)+"
1036
+    }
1037
+
751 1038
     private func fetchDistributionPage(
752 1039
         for sampleType: HKSampleType,
753 1040
         predicate: NSPredicate?,
@@ -941,9 +1228,10 @@ final class HealthKitService {
941 1228
 
942 1229
     private func timeoutProfile(for monitoredType: MonitoredType, context: ModelContext) -> MetricTimeoutProfile {
943 1230
         let identifier = monitoredType.id
944
-        let descriptor = FetchDescriptor<MetricTimeoutProfile>(
1231
+        var descriptor = FetchDescriptor<MetricTimeoutProfile>(
945 1232
             predicate: #Predicate<MetricTimeoutProfile> { $0.metricIdentifier == identifier }
946 1233
         )
1234
+        descriptor.fetchLimit = 1
947 1235
         if let existing = try? context.fetch(descriptor).first {
948 1236
             existing.displayName = monitoredType.displayName
949 1237
             return existing
@@ -1140,17 +1428,19 @@ final class HealthKitService {
1140 1428
     // MARK: - Chain helpers
1141 1429
 
1142 1430
     private func findPreviousSnapshot(deviceID: String, excluding id: UUID, context: ModelContext) -> HealthSnapshot? {
1143
-        let descriptor = FetchDescriptor<HealthSnapshot>(
1431
+        var descriptor = FetchDescriptor<HealthSnapshot>(
1144 1432
             predicate: #Predicate<HealthSnapshot> { $0.deviceID == deviceID && $0.id != id },
1145 1433
             sortBy: [SortDescriptor(\.localSequenceNumber, order: .reverse)]
1146 1434
         )
1435
+        descriptor.fetchLimit = 1
1147 1436
         return try? context.fetch(descriptor).first
1148 1437
     }
1149 1438
 
1150 1439
     private func fetchSnapshot(id: UUID, context: ModelContext) -> HealthSnapshot? {
1151
-        let descriptor = FetchDescriptor<HealthSnapshot>(
1440
+        var descriptor = FetchDescriptor<HealthSnapshot>(
1152 1441
             predicate: #Predicate<HealthSnapshot> { $0.id == id }
1153 1442
         )
1443
+        descriptor.fetchLimit = 1
1154 1444
         return try? context.fetch(descriptor).first
1155 1445
     }
1156 1446
 
@@ -1182,7 +1472,8 @@ final class HealthKitService {
1182 1472
     }
1183 1473
 
1184 1474
     private func isStoreEmpty(context: ModelContext) -> Bool {
1185
-        let descriptor = FetchDescriptor<HealthSnapshot>()
1475
+        var descriptor = FetchDescriptor<HealthSnapshot>()
1476
+        descriptor.fetchLimit = 1
1186 1477
         return (try? context.fetch(descriptor).isEmpty) ?? true
1187 1478
     }
1188 1479
 
@@ -1272,16 +1563,12 @@ private struct SampleDistribution: Sendable {
1272 1563
         let contentHash: String
1273 1564
         let anchorData: Data?
1274 1565
     }
1275
-    struct Record: Sendable {
1276
-        let sampleUUIDHash: String
1277
-        let recordFingerprint: String
1278
-        let startDate: Date
1279
-        let endDate: Date
1280
-        let displayValue: String?
1281
-    }
1282 1566
     let totalCount: Int
1283 1567
     let bins: [Bin]
1284
-    let records: [Record]
1568
+    let records: [HealthRecordValue]
1569
+    let contentHash: String?
1570
+    let yearlyCounts: [Int: Int]?
1571
+    let recordArchiveData: Data?
1285 1572
 }
1286 1573
 
1287 1574
 private struct SampleDistributionPage: Sendable {
@@ -1290,6 +1577,27 @@ private struct SampleDistributionPage: Sendable {
1290 1577
     let anchor: HKQueryAnchor?
1291 1578
 }
1292 1579
 
1580
+private struct SampleRecordPayload: Sendable {
1581
+    let recordFingerprint: String
1582
+    let startDate: Date
1583
+    let endDate: Date
1584
+    let displayValue: String?
1585
+}
1586
+
1587
+private enum HealthRecordArchiveReadError: LocalizedError {
1588
+    case missingArchive(typeIdentifier: String, count: Int)
1589
+    case decodeFailed(typeIdentifier: String)
1590
+
1591
+    var errorDescription: String? {
1592
+        switch self {
1593
+        case .missingArchive(let typeIdentifier, let count):
1594
+            return "Missing record archive for \(typeIdentifier) with \(count) records."
1595
+        case .decodeFailed(let typeIdentifier):
1596
+            return "Could not decode previous record archive for \(typeIdentifier)."
1597
+        }
1598
+    }
1599
+}
1600
+
1293 1601
 private final class HealthKitQueryContinuationBox<Value: Sendable>: @unchecked Sendable {
1294 1602
     private let lock = NSLock()
1295 1603
     nonisolated(unsafe) private var continuation: CheckedContinuation<Value, Error>?
@@ -1367,14 +1675,6 @@ private final class HealthKitQueryContinuationBox<Value: Sendable>: @unchecked S
1367 1675
 }
1368 1676
 
1369 1677
 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 1678
     struct Bin: Sendable {
1379 1679
         let bucketStart: Date
1380 1680
         let bucketEnd: Date
@@ -1386,7 +1686,13 @@ private struct PreviousDistributionState: Sendable {
1386 1686
         }
1387 1687
     }
1388 1688
 
1389
-    let records: [Record]
1689
+    let typeIdentifier: String
1690
+    let count: Int
1691
+    let contentHash: String
1692
+    let earliestRecordDate: Date?
1693
+    let latestRecordDate: Date?
1694
+    let recordArchiveData: Data?
1695
+    let yearlyCounts: [Int: Int]
1390 1696
     let bins: [Bin]
1391 1697
 
1392 1698
     var globalAnchor: HKQueryAnchor? {
@@ -1394,24 +1700,16 @@ private struct PreviousDistributionState: Sendable {
1394 1700
         return bins[0].anchor
1395 1701
     }
1396 1702
 
1397
-    var earliestRecordDate: Date? {
1398
-        records.map(\.startDate).min()
1399
-    }
1400
-
1401
-    var latestRecordDate: Date? {
1402
-        records.map(\.endDate).max()
1403
-    }
1404
-
1405 1703
     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
-        }
1704
+        self.typeIdentifier = typeCount?.typeIdentifier ?? ""
1705
+        self.count = typeCount?.count ?? 0
1706
+        self.contentHash = typeCount?.contentHash ?? ""
1707
+        self.earliestRecordDate = typeCount?.earliestDate
1708
+        self.latestRecordDate = typeCount?.latestDate
1709
+        self.recordArchiveData = typeCount?.recordArchiveData
1710
+        self.yearlyCounts = Dictionary(
1711
+            uniqueKeysWithValues: (typeCount?.yearlyCounts ?? []).map { ($0.year, $0.count) }
1712
+        )
1415 1713
         self.bins = (typeCount?.distributionBins ?? []).map {
1416 1714
             Bin(
1417 1715
                 bucketStart: $0.bucketStart,
@@ -1420,6 +1718,73 @@ private struct PreviousDistributionState: Sendable {
1420 1718
             )
1421 1719
         }
1422 1720
     }
1721
+
1722
+    func unchangedDistribution(
1723
+        updatedAnchor: HKQueryAnchor?,
1724
+        typeIdentifier fallbackTypeIdentifier: String,
1725
+        earliestDate: Date?,
1726
+        latestDate: Date?
1727
+    ) -> SampleDistribution? {
1728
+        guard count >= 0,
1729
+              count == 0 || recordArchiveData != nil,
1730
+              count == 0 || !contentHash.isEmpty else {
1731
+            return nil
1732
+        }
1733
+
1734
+        let binStart = earliestDate ?? earliestRecordDate ?? Date()
1735
+        let rawBinEnd = latestDate ?? latestRecordDate ?? binStart
1736
+        let binEnd = rawBinEnd > binStart ? rawBinEnd : binStart.addingTimeInterval(1)
1737
+        let effectiveTypeIdentifier = typeIdentifier.isEmpty ? fallbackTypeIdentifier : typeIdentifier
1738
+        let effectiveContentHash = contentHash.isEmpty
1739
+            ? HashService.typeHash(typeIdentifier: effectiveTypeIdentifier, recordFingerprints: [])
1740
+            : contentHash
1741
+
1742
+        return SampleDistribution(
1743
+            totalCount: count,
1744
+            bins: [
1745
+                SampleDistribution.Bin(
1746
+                    start: binStart,
1747
+                    end: binEnd,
1748
+                    count: count,
1749
+                    contentHash: effectiveContentHash,
1750
+                    anchorData: updatedAnchor.flatMap(Self.archiveAnchor(_:))
1751
+                )
1752
+            ],
1753
+            records: [],
1754
+            contentHash: effectiveContentHash,
1755
+            yearlyCounts: yearlyCounts,
1756
+            recordArchiveData: recordArchiveData
1757
+        )
1758
+    }
1759
+
1760
+    func makeRecordMap() throws -> [String: SampleRecordPayload] {
1761
+        guard let recordArchiveData else {
1762
+            if count > 0 {
1763
+                throw HealthRecordArchiveReadError.missingArchive(typeIdentifier: typeIdentifier, count: count)
1764
+            }
1765
+            return [:]
1766
+        }
1767
+
1768
+        guard let records = HealthRecordArchive.decode(recordArchiveData) else {
1769
+            throw HealthRecordArchiveReadError.decodeFailed(typeIdentifier: typeIdentifier)
1770
+        }
1771
+
1772
+        var recordMap: [String: SampleRecordPayload] = [:]
1773
+        recordMap.reserveCapacity(records.count)
1774
+        for record in records {
1775
+            recordMap[record.sampleUUIDHash] = SampleRecordPayload(
1776
+                recordFingerprint: record.recordFingerprint,
1777
+                startDate: record.startDate,
1778
+                endDate: record.endDate,
1779
+                displayValue: record.displayValue
1780
+            )
1781
+        }
1782
+        return recordMap
1783
+    }
1784
+
1785
+    private nonisolated static func archiveAnchor(_ anchor: HKQueryAnchor) -> Data? {
1786
+        try? NSKeyedArchiver.archivedData(withRootObject: anchor, requiringSecureCoding: true)
1787
+    }
1423 1788
 }
1424 1789
 
1425 1790
 private struct TypeCountFetchResult: Sendable {
@@ -1575,7 +1940,7 @@ private extension SnapshotFetchProgress {
1575 1940
 }
1576 1941
 
1577 1942
 private enum DistributionCaptureConfiguration {
1578
-    static let queryPageLimit = 20_000
1943
+    static let queryPageLimit = 25_000
1579 1944
     static let pageTimeoutSeconds: TimeInterval = 60
1580 1945
     static let fullImportTimeoutSeconds: TimeInterval = HealthKitService.fullHistoryImportTimeoutSeconds
1581 1946
 }
+15 -1
HealthProbe/Utilities/SnapshotFetchProgress.swift
@@ -100,6 +100,8 @@ final class SnapshotFetchProgress {
100 100
         var timeoutCount: Int = 0
101 101
         var successCount: Int = 0
102 102
         var blockProgress: String = ""
103
+        var blockElapsedSeconds: TimeInterval = 0
104
+        var blockSamplesPerSecond: Double = 0
103 105
     }
104 106
 
105 107
     let totalTypeCount: Int
@@ -225,12 +227,24 @@ final class SnapshotFetchProgress {
225 227
         types[index].successCount = successCount
226 228
     }
227 229
 
228
-    func updateBlockProgress(_ id: String, detail: String, recordCount: Int? = nil) {
230
+    func updateBlockProgress(
231
+        _ id: String,
232
+        detail: String,
233
+        recordCount: Int? = nil,
234
+        elapsedSeconds: TimeInterval? = nil,
235
+        samplesPerSecond: Double? = nil
236
+    ) {
229 237
         let index = visibleTypeIndex(for: id)
230 238
         types[index].blockProgress = detail
231 239
         if let recordCount {
232 240
             types[index].recordCount = recordCount
233 241
         }
242
+        if let elapsedSeconds {
243
+            types[index].blockElapsedSeconds = elapsedSeconds
244
+        }
245
+        if let samplesPerSecond {
246
+            types[index].blockSamplesPerSecond = samplesPerSecond
247
+        }
234 248
     }
235 249
 
236 250
     func markUnavailable(_ id: String) {
+11 -4
HealthProbe/ViewModels/DashboardViewModel.swift
@@ -185,8 +185,12 @@ final class DashboardViewModel {
185 185
                 return
186 186
             }
187 187
 
188
-            let allSnapshots = try context.fetch(FetchDescriptor<HealthSnapshot>())
189
-            let exists = allSnapshots.contains { $0.id == snapshot.id }
188
+            let snapshotID = snapshot.id
189
+            var descriptor = FetchDescriptor<HealthSnapshot>(
190
+                predicate: #Predicate<HealthSnapshot> { $0.id == snapshotID }
191
+            )
192
+            descriptor.fetchLimit = 1
193
+            let exists = try !context.fetch(descriptor).isEmpty
190 194
 
191 195
             if !exists {
192 196
                 throw SnapshotCreationError.snapshotNotSaved
@@ -326,8 +330,11 @@ final class DashboardViewModel {
326 330
         ambiguousDisappearedMetrics = []
327 331
         if let snapshotID = completedSnapshotID {
328 332
             do {
329
-                let allSnapshots = try context.fetch(FetchDescriptor<HealthSnapshot>())
330
-                if let snapshot = allSnapshots.first(where: { $0.id == snapshotID }) {
333
+                var descriptor = FetchDescriptor<HealthSnapshot>(
334
+                    predicate: #Predicate<HealthSnapshot> { $0.id == snapshotID }
335
+                )
336
+                descriptor.fetchLimit = 1
337
+                if let snapshot = try context.fetch(descriptor).first {
331 338
                     context.delete(snapshot)
332 339
                 }
333 340
             } catch { }
+47 -12
HealthProbe/Views/Dashboard/DashboardView.swift
@@ -407,6 +407,12 @@ struct DashboardView: View {
407 407
             lines.append("  timeoutCount: \(type.timeoutCount)")
408 408
             lines.append("  successCount: \(type.successCount)")
409 409
             lines.append("  totalElapsed: \(formatDuration(type.totalElapsedSeconds))")
410
+            if type.blockElapsedSeconds > 0 {
411
+                lines.append("  fetchProgressElapsed: \(formatDuration(type.blockElapsedSeconds))")
412
+            }
413
+            if type.blockSamplesPerSecond > 0 {
414
+                lines.append("  fetchProgressRate: \(formatRate(type.blockSamplesPerSecond)) samples/s")
415
+            }
410 416
             if mode == .full || type.isUnsupported {
411 417
                 lines.append("  unsupported: \(type.isUnsupported ? "true" : "false")")
412 418
             }
@@ -805,22 +811,12 @@ struct DashboardView: View {
805 811
                                     Text(type.blockProgress)
806 812
                                         .font(.caption2)
807 813
                                         .foregroundStyle(.secondary)
808
-                                        .lineLimit(1)
814
+                                        .lineLimit(2)
809 815
                                 }
810 816
                             }
811 817
                             .frame(maxWidth: .infinity, alignment: .leading)
812 818
 
813
-                            if type.recordCount > 0 {
814
-                                Text("\(type.recordCount) records")
815
-                                    .font(.caption2)
816
-                                    .foregroundStyle(.secondary)
817
-                                    .lineLimit(1)
818
-                            } else if case .failed(let reason) = type.status, reason == "Not authorized" {
819
-                                Text("Unavailable")
820
-                                    .font(.caption2)
821
-                                    .foregroundStyle(Color.warningAmber)
822
-                                    .lineLimit(1)
823
-                            }
819
+                            fetchMetricStatsColumn(type)
824 820
                         }
825 821
                         .padding(.horizontal, 10)
826 822
                         .padding(.vertical, 9)
@@ -910,6 +906,45 @@ struct DashboardView: View {
910 906
         .frame(maxWidth: .infinity)
911 907
     }
912 908
 
909
+    @ViewBuilder
910
+    private func fetchMetricStatsColumn(_ type: SnapshotFetchProgress.TypeProgress) -> some View {
911
+        if type.recordCount > 0 || type.blockElapsedSeconds > 0 || type.blockSamplesPerSecond > 0 {
912
+            VStack(alignment: .trailing, spacing: 2) {
913
+                if type.recordCount > 0 {
914
+                    Text("\(type.recordCount) records")
915
+                }
916
+                if type.blockElapsedSeconds > 0 {
917
+                    Text(formatDuration(type.blockElapsedSeconds))
918
+                }
919
+                if type.blockSamplesPerSecond > 0 {
920
+                    Text("\(formatRate(type.blockSamplesPerSecond)) rec/s")
921
+                }
922
+            }
923
+            .font(.caption2)
924
+            .foregroundStyle(.secondary)
925
+            .lineLimit(1)
926
+            .frame(minWidth: 82, alignment: .trailing)
927
+        } else if case .failed(let reason) = type.status, reason == "Not authorized" {
928
+            Text("Unavailable")
929
+                .font(.caption2)
930
+                .foregroundStyle(Color.warningAmber)
931
+                .lineLimit(1)
932
+        }
933
+    }
934
+
935
+    private func formatRate(_ value: Double) -> String {
936
+        if value >= 1_000 {
937
+            return value.formatted(.number.precision(.fractionLength(0)).grouping(.automatic))
938
+        }
939
+        if value >= 100 {
940
+            return value.formatted(.number.precision(.fractionLength(0)))
941
+        }
942
+        if value >= 10 {
943
+            return value.formatted(.number.precision(.fractionLength(1)))
944
+        }
945
+        return value.formatted(.number.precision(.fractionLength(2)))
946
+    }
947
+
913 948
     // MARK: - Progress Sheet
914 949
 
915 950
     private var progressSheet: some View {