Showing 19 changed files with 2210 additions and 578 deletions
+0 -10
HealthProbe/HealthProbe.entitlements
@@ -2,17 +2,7 @@
2 2
 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3 3
 <plist version="1.0">
4 4
 <dict>
5
-	<key>aps-environment</key>
6
-	<string>development</string>
7 5
 	<key>com.apple.developer.healthkit</key>
8 6
 	<true/>
9
-	<key>com.apple.developer.icloud-container-identifiers</key>
10
-	<array>
11
-		<string>iCloud.ro.xdev.HealthProbe</string>
12
-	</array>
13
-	<key>com.apple.developer.icloud-services</key>
14
-	<array>
15
-		<string>CloudKit</string>
16
-	</array>
17 7
 </dict>
18 8
 </plist>
+12 -8
HealthProbe/HealthProbeApp.swift
@@ -22,16 +22,20 @@ struct HealthProbeApp: App {
22 22
         .modelContainer(sharedModelContainer)
23 23
     }
24 24
 
25
-    // Completely wipes all SwiftData stores and recreates fresh directory structure.
26
-    // This prevents schema corruption from legacy data by starting completely clean.
25
+    // Wipes all SwiftData stores and CoreData-CloudKit caches so that schema-mismatched
26
+    // records are not re-imported from CloudKit after the store is deleted.
27 27
     private static func destroyAllStoresAndRecreate() {
28
+        let fm = FileManager.default
28 29
         let appSupportURL = URL.applicationSupportDirectory
29 30
 
30
-        // Remove the entire Application Support directory
31
-        try? FileManager.default.removeItem(at: appSupportURL)
31
+        // Remove the entire Application Support directory (store files, WAL, SHM)
32
+        try? fm.removeItem(at: appSupportURL)
33
+        try? fm.createDirectory(at: appSupportURL, withIntermediateDirectories: true)
32 34
 
33
-        // Recreate it fresh
34
-        try? FileManager.default.createDirectory(at: appSupportURL, withIntermediateDirectories: true)
35
+        // Also wipe CoreData-CloudKit transaction log metadata so stale remote records
36
+        // are not re-applied to the fresh store on the next launch.
37
+        let ckMetadataURL = appSupportURL.appending(path: "com.apple.coredata.cloudkit", directoryHint: .isDirectory)
38
+        try? fm.removeItem(at: ckMetadataURL)
35 39
     }
36 40
 
37 41
     // Two separate ModelConfiguration instances:
@@ -46,7 +50,7 @@ struct HealthProbeApp: App {
46 50
         let fullSchema = Schema([
47 51
             HealthSnapshot.self, TypeCount.self, YearlyCount.self,
48 52
             SnapshotDelta.self, TypeDelta.self, AnomalyRecord.self,
49
-            OperationLog.self, DeviceProfile.self,
53
+            OperationLog.self, DeviceProfile.self, MetricTimeoutProfile.self,
50 54
         ])
51 55
 
52 56
         #if DEBUG
@@ -63,7 +67,7 @@ struct HealthProbeApp: App {
63 67
             HealthSnapshot.self, TypeCount.self, YearlyCount.self,
64 68
             SnapshotDelta.self, TypeDelta.self, AnomalyRecord.self,
65 69
         ])
66
-        let localModels = Schema([OperationLog.self, DeviceProfile.self])
70
+        let localModels = Schema([OperationLog.self, DeviceProfile.self, MetricTimeoutProfile.self])
67 71
 
68 72
         let cloudConfig = ModelConfiguration(
69 73
             "cloud",
+0 -4
HealthProbe/Info.plist
@@ -4,9 +4,5 @@
4 4
 <dict>
5 5
 	<key>NSHealthShareUsageDescription</key>
6 6
 	<string>HealthProbe reads record counts from your Health data to audit integrity and detect anomalies such as silent deletions or unexpected insertions. No health values are read, stored, or shared outside this device.</string>
7
-	<key>UIBackgroundModes</key>
8
-	<array>
9
-		<string>remote-notification</string>
10
-	</array>
11 7
 </dict>
12 8
 </plist>
+7 -1
HealthProbe/Models/HealthSnapshot.swift
@@ -11,9 +11,10 @@ import SwiftData
11 11
     var previousSnapshotID: UUID?
12 12
     var isChainStart: Bool = false
13 13
     var recoveredDeviceID: Bool = false
14
-    var snapshotQuality: SnapshotQuality = SnapshotQuality.complete
14
+    var snapshotQualityRaw: String = SnapshotQuality.complete.rawValue
15 15
     var anomalyFlagsJSON: String = "[]"
16 16
     var triggerReason: String = "manual"
17
+    var retryOfSnapshotID: UUID?
17 18
     var isPostRestore: Bool = false
18 19
     var isPostRestoreInferred: Bool = false
19 20
     var isPostRestoreSuppressedDeltaID: UUID?
@@ -36,6 +37,11 @@ import SwiftData
36 37
 }
37 38
 
38 39
 extension HealthSnapshot {
40
+    var snapshotQuality: SnapshotQuality {
41
+        get { SnapshotQuality(rawValue: snapshotQualityRaw) ?? .complete }
42
+        set { snapshotQualityRaw = newValue.rawValue }
43
+    }
44
+
39 45
     var anomalyFlags: [String] {
40 46
         get { (try? JSONDecoder().decode([String].self, from: Data(anomalyFlagsJSON.utf8))) ?? [] }
41 47
         set { anomalyFlagsJSON = (try? String(data: JSONEncoder().encode(newValue), encoding: .utf8)) ?? "[]" }
+140 -0
HealthProbe/Models/MetricTimeoutProfile.swift
@@ -0,0 +1,140 @@
1
+import Foundation
2
+import SwiftData
3
+
4
+@Model final class MetricTimeoutProfile {
5
+    static let defaultInitialTimeout: TimeInterval = 15
6
+    static let maximumTimeout: TimeInterval = 120
7
+    private static let sampleLimit = 30
8
+    private static let p95MinimumSampleCount = 5
9
+
10
+    var id: UUID = UUID()
11
+    var metricIdentifier: String = ""
12
+    var displayName: String = ""
13
+    var lastSuccessfulElapsed: TimeInterval = 0
14
+    var averageSuccessfulElapsed: TimeInterval = 0
15
+    var p95SuccessfulElapsed: TimeInterval = 0
16
+    var lastTimeoutElapsed: TimeInterval = 0
17
+    var timeoutCount: Int = 0
18
+    var successCount: Int = 0
19
+    var lastUpdatedAt: Date = Date.distantPast
20
+    var currentTimeout: TimeInterval = MetricTimeoutProfile.defaultInitialTimeout
21
+    var isManuallyOverridden: Bool = false
22
+    var manualTimeoutValue: TimeInterval = MetricTimeoutProfile.defaultInitialTimeout
23
+    var successfulElapsedSamplesJSON: String = "[]"
24
+
25
+    init(metricIdentifier: String, displayName: String) {
26
+        self.id = UUID()
27
+        self.metricIdentifier = metricIdentifier
28
+        self.displayName = displayName
29
+        self.currentTimeout = Self.defaultInitialTimeout
30
+        self.manualTimeoutValue = Self.defaultInitialTimeout
31
+    }
32
+}
33
+
34
+extension MetricTimeoutProfile {
35
+    var effectiveTimeout: TimeInterval {
36
+        clamp(isManuallyOverridden ? manualTimeoutValue : currentTimeout)
37
+    }
38
+
39
+    var timeoutMode: String {
40
+        if isManuallyOverridden { return "manual" }
41
+        return successCount == 0 && timeoutCount == 0 ? "default" : "adaptive"
42
+    }
43
+
44
+    var hasP95: Bool {
45
+        successSamples.count >= Self.p95MinimumSampleCount
46
+    }
47
+
48
+    var p95SuccessfulElapsedIfAvailable: TimeInterval? {
49
+        hasP95 ? p95SuccessfulElapsed : nil
50
+    }
51
+
52
+    var suggestedRetryTimeout: TimeInterval {
53
+        clamp(max(effectiveTimeout * 2, lastSuccessfulElapsed * 2, Self.defaultInitialTimeout))
54
+    }
55
+
56
+    func recordSuccess(elapsed: TimeInterval, displayName: String) {
57
+        self.displayName = displayName
58
+        lastSuccessfulElapsed = elapsed
59
+        successCount += 1
60
+        lastUpdatedAt = Date()
61
+
62
+        var samples = successSamples
63
+        samples.append(elapsed)
64
+        if samples.count > Self.sampleLimit {
65
+            samples.removeFirst(samples.count - Self.sampleLimit)
66
+        }
67
+        successSamples = samples
68
+
69
+        averageSuccessfulElapsed = samples.reduce(0, +) / Double(samples.count)
70
+        p95SuccessfulElapsed = Self.percentile95(samples)
71
+        currentTimeout = automaticTimeout(from: samples)
72
+    }
73
+
74
+    func recordTimeout(elapsed: TimeInterval, displayName: String) {
75
+        self.displayName = displayName
76
+        lastTimeoutElapsed = elapsed
77
+        timeoutCount += 1
78
+        lastUpdatedAt = Date()
79
+
80
+        guard !isManuallyOverridden else { return }
81
+        let progressive = max(currentTimeout * 1.5, elapsed * 2, Self.defaultInitialTimeout)
82
+        currentTimeout = clamp(progressive)
83
+    }
84
+
85
+    func resetLearning(displayName: String? = nil) {
86
+        if let displayName { self.displayName = displayName }
87
+        lastSuccessfulElapsed = 0
88
+        averageSuccessfulElapsed = 0
89
+        p95SuccessfulElapsed = 0
90
+        lastTimeoutElapsed = 0
91
+        timeoutCount = 0
92
+        successCount = 0
93
+        lastUpdatedAt = Date()
94
+        currentTimeout = Self.defaultInitialTimeout
95
+        isManuallyOverridden = false
96
+        manualTimeoutValue = Self.defaultInitialTimeout
97
+        successfulElapsedSamplesJSON = "[]"
98
+    }
99
+
100
+    func setManualTimeout(_ value: TimeInterval) {
101
+        manualTimeoutValue = clamp(value)
102
+        isManuallyOverridden = true
103
+        lastUpdatedAt = Date()
104
+    }
105
+
106
+    func returnToAutomatic() {
107
+        isManuallyOverridden = false
108
+        lastUpdatedAt = Date()
109
+    }
110
+
111
+    private var successSamples: [TimeInterval] {
112
+        get {
113
+            guard let data = successfulElapsedSamplesJSON.data(using: .utf8),
114
+                  let values = try? JSONDecoder().decode([TimeInterval].self, from: data) else {
115
+                return []
116
+            }
117
+            return values
118
+        }
119
+        set {
120
+            successfulElapsedSamplesJSON = (try? String(data: JSONEncoder().encode(newValue), encoding: .utf8)) ?? "[]"
121
+        }
122
+    }
123
+
124
+    private func automaticTimeout(from samples: [TimeInterval]) -> TimeInterval {
125
+        let basis = samples.count >= Self.p95MinimumSampleCount ? Self.percentile95(samples) : samples.max() ?? 0
126
+        return clamp(max(Self.defaultInitialTimeout, basis * 2, currentTimeout * 0.9))
127
+    }
128
+
129
+    private func clamp(_ value: TimeInterval) -> TimeInterval {
130
+        min(max(value, Self.defaultInitialTimeout), Self.maximumTimeout)
131
+    }
132
+
133
+    private static func percentile95(_ samples: [TimeInterval]) -> TimeInterval {
134
+        guard !samples.isEmpty else { return 0 }
135
+        let sorted = samples.sorted()
136
+        let rawIndex = Int(ceil(Double(sorted.count) * 0.95)) - 1
137
+        let index = min(max(rawIndex, 0), sorted.count - 1)
138
+        return sorted[index]
139
+    }
140
+}
+10 -3
HealthProbe/Models/TypeCount.swift
@@ -6,10 +6,10 @@ import SwiftData
6 6
     var typeIdentifier: String = ""
7 7
     var displayName: String = ""
8 8
     var count: Int = 0
9
-    var hash: String = ""
9
+    var contentHash: String = ""
10 10
     var earliestDate: Date?
11 11
     var latestDate: Date?
12
-    var quality: SnapshotQuality = SnapshotQuality.complete
12
+    var qualityRaw: String = SnapshotQuality.complete.rawValue
13 13
     var isUnsupported: Bool = false
14 14
     var snapshot: HealthSnapshot?
15 15
     @Relationship(deleteRule: .cascade, inverse: \YearlyCount.typeCount)
@@ -20,6 +20,13 @@ import SwiftData
20 20
         self.typeIdentifier = typeIdentifier
21 21
         self.displayName = displayName
22 22
         self.count = count
23
-        self.quality = quality
23
+        self.qualityRaw = quality.rawValue
24
+    }
25
+}
26
+
27
+extension TypeCount {
28
+    var quality: SnapshotQuality {
29
+        get { SnapshotQuality(rawValue: qualityRaw) ?? .complete }
30
+        set { qualityRaw = newValue.rawValue }
24 31
     }
25 32
 }
+2 -2
HealthProbe/Services/DeltaService.swift
@@ -123,8 +123,8 @@ enum DeltaService {
123 123
 
124 124
         let prevCount = prev?.count ?? 0
125 125
         let currCount = curr?.count ?? 0
126
-        let prevHash  = prev?.hash ?? ""
127
-        let currHash  = curr?.hash ?? ""
126
+        let prevHash  = prev?.contentHash ?? ""
127
+        let currHash  = curr?.contentHash ?? ""
128 128
 
129 129
         if let prev, let curr {
130 130
             // Type present in both snapshots
+3 -4
HealthProbe/Services/HashService.swift
@@ -26,14 +26,13 @@ enum HashService {
26 26
     }
27 27
 
28 28
     // Per-snapshot: sort TypeCounts by typeIdentifier, SHA256 of concatenated type hashes.
29
-    // Filter criterion: quality == .complete — do NOT use hash != "" as a proxy.
30
-    // A TypeCount with quality = .failed but hash = "nonEmpty" (e.g. from a prior bug) must
31
-    // be excluded. Filtering on hash != "" would include it, producing a non-deterministic checksum.
29
+    // Filter criterion: quality == .complete; do not use contentHash != "" as a proxy.
30
+    // A TypeCount with quality = .failed but contentHash = "nonEmpty" must be excluded.
32 31
     static func snapshotChecksum(typeCounts: [TypeCount]) -> String {
33 32
         let completeHashes = typeCounts
34 33
             .filter { $0.quality == .complete }
35 34
             .sorted { $0.typeIdentifier < $1.typeIdentifier }
36
-            .map { $0.hash }
35
+            .map { $0.contentHash }
37 36
             .joined()
38 37
         let digest = SHA256.hash(data: Data(completeHashes.utf8))
39 38
         return digest.map { String(format: "%02x", $0) }.joined()
+501 -116
HealthProbe/Services/HealthKitService.swift
@@ -15,7 +15,7 @@ enum TypeCategory: String, CaseIterable {
15 15
     case body        = "Body"
16 16
 }
17 17
 
18
-struct MonitoredType: Identifiable {
18
+struct MonitoredType: Identifiable, @unchecked Sendable {
19 19
     let id: String
20 20
     let displayName: String
21 21
     let category: TypeCategory
@@ -29,19 +29,29 @@ final class HealthKitService {
29 29
 
30 30
     static let allTypes: [MonitoredType] = buildAllTypes()
31 31
 
32
-    // 15s budget covers distribution + earliestDate + latestDate combined — not 15s each.
33
-    private static let perTypeTimeoutSeconds: TimeInterval = 15
32
+    static let defaultInitialTimeoutSeconds: TimeInterval = MetricTimeoutProfile.defaultInitialTimeout
33
+    static let maximumTimeoutSeconds: TimeInterval = MetricTimeoutProfile.maximumTimeout
34 34
     // Prevents 3N simultaneous HK queries from exhausting resources at N=20 types.
35
-    private static let maxConcurrentTypeFetches = 6
35
+    static let maxConcurrentTypeFetches = 6
36 36
 
37 37
     var isAvailable: Bool { HKHealthStore.isHealthDataAvailable() }
38 38
 
39
+    var hasRequestedPermissionsBefore: Bool {
40
+        get {
41
+            UserDefaults.standard.bool(forKey: "healthkit.permissions.requested")
42
+        }
43
+        set {
44
+            UserDefaults.standard.set(newValue, forKey: "healthkit.permissions.requested")
45
+        }
46
+    }
47
+
39 48
     // MARK: - Authorization
40 49
 
41 50
     func requestAuthorization() async throws {
42 51
         guard isAvailable else { return }
43 52
         let readTypes = Set(Self.allTypes.compactMap { $0.objectType })
44 53
         try await store.requestAuthorization(toShare: [], read: readTypes)
54
+        hasRequestedPermissionsBefore = true
45 55
     }
46 56
 
47 57
     // MARK: - Snapshot creation
@@ -50,7 +60,11 @@ final class HealthKitService {
50 60
     func createSnapshot(
51 61
         in context: ModelContext,
52 62
         selectedTypeIDs: Set<String>,
53
-        onTypeCompleted: ((TypeCount) -> Void)? = nil
63
+        adaptiveTimeoutsEnabled: Bool,
64
+        triggerReason: String = "manual",
65
+        retryOfSnapshotID: UUID? = nil,
66
+        timeoutMultiplier: Double = 1,
67
+        progress: SnapshotFetchProgress? = nil
54 68
     ) async throws -> HealthSnapshot {
55 69
         let active = Self.allTypes.filter { selectedTypeIDs.contains($0.id) }
56 70
         let deviceResolution = KeychainService.resolveDeviceID(swiftDataStoreIsEmpty: isStoreEmpty(context: context))
@@ -62,11 +76,29 @@ final class HealthKitService {
62 76
             deviceID: deviceResolution.id
63 77
         )
64 78
         snapshot.recoveredDeviceID = deviceResolution.isRecovered
65
-        snapshot.triggerReason = "manual"
79
+        snapshot.triggerReason = triggerReason
80
+        snapshot.retryOfSnapshotID = retryOfSnapshotID
66 81
         snapshot.yearlyCountTimezoneIdentifier = TimeZone.current.identifier
67 82
         context.insert(snapshot)
68 83
 
69
-        let typeCounts = await fetchAllTypeCounts(for: active, snapshot: snapshot, onTypeCompleted: onTypeCompleted)
84
+        // Fetch raw HealthKit data off the main actor, then assemble SwiftData models here
85
+        // on the main actor to prevent data races on managed object context.
86
+        let fetchResults = await fetchAllTypeCounts(
87
+            for: active,
88
+            context: context,
89
+            adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled,
90
+            timeoutMultiplier: timeoutMultiplier,
91
+            progress: progress
92
+        )
93
+        let typeCounts: [TypeCount] = fetchResults.map { result in
94
+            let tc = result.makeTypeCount()
95
+            context.insert(tc)
96
+            for yearlyCount in tc.yearlyCounts ?? [] {
97
+                context.insert(yearlyCount)
98
+            }
99
+            tc.snapshot = snapshot
100
+            return tc
101
+        }
70 102
 
71 103
         // Invariant assertions before save — debug asserts + release silent correction
72 104
         for tc in typeCounts {
@@ -182,147 +214,205 @@ final class HealthKitService {
182 214
 
183 215
     // MARK: - Per-type fetch pipeline
184 216
 
217
+    // Returns raw Sendable fetch results — no SwiftData model creation here.
218
+    // All TypeCount/YearlyCount objects must be created on the main actor in createSnapshot.
219
+    // Fetches sequentially to prevent race conditions and resource exhaustion.
185 220
     private func fetchAllTypeCounts(
186 221
         for active: [MonitoredType],
187
-        snapshot: HealthSnapshot,
188
-        onTypeCompleted: ((TypeCount) -> Void)? = nil
189
-    ) async -> [TypeCount] {
190
-        var results: [TypeCount] = []
191
-
192
-        // Fetch in batches to cap concurrent HK queries
193
-        let batches = stride(from: 0, to: active.count, by: Self.maxConcurrentTypeFetches).map {
194
-            Array(active[$0..<min($0 + Self.maxConcurrentTypeFetches, active.count)])
195
-        }
196
-
197
-        for batch in batches {
198
-            await withTaskGroup(of: TypeCount.self) { group in
199
-                for monitoredType in batch {
200
-                    group.addTask { [weak self] in
201
-                        guard let self else {
202
-                            return self?.makeFailedTypeCount(monitoredType) ?? TypeCount(
203
-                                typeIdentifier: monitoredType.id,
204
-                                displayName: monitoredType.displayName,
205
-                                count: -1,
206
-                                quality: SnapshotQuality.failed
207
-                            )
208
-                        }
209
-                        return await self.fetchTypeCount(for: monitoredType)
210
-                    }
211
-                }
212
-                for await tc in group {
213
-                    tc.snapshot = snapshot
214
-                    snapshot.typeCounts?.append(tc)
215
-                    onTypeCompleted?(tc)
216
-                    results.append(tc)
217
-                }
218
-            }
222
+        context: ModelContext,
223
+        adaptiveTimeoutsEnabled: Bool,
224
+        timeoutMultiplier: Double,
225
+        progress: SnapshotFetchProgress? = nil
226
+    ) async -> [TypeCountFetchResult] {
227
+        var results: [TypeCountFetchResult] = []
228
+
229
+        for monitoredType in active {
230
+            let profile = timeoutProfile(for: monitoredType, context: context)
231
+            let timeout = timeoutForFetch(
232
+                profile: profile,
233
+                adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled,
234
+                timeoutMultiplier: timeoutMultiplier
235
+            )
236
+            var result = await fetchTypeCountData(
237
+                for: monitoredType,
238
+                timeoutProfile: profile,
239
+                timeoutSeconds: timeout,
240
+                progress: progress
241
+            )
242
+            updateTimeoutProfile(profile, with: result, monitoredType: monitoredType)
243
+            result.applyTimeoutProfile(profile)
244
+            progress?.updateTimeoutProfile(from: profile, for: monitoredType.id)
245
+            results.append(result)
219 246
         }
220 247
         return results
221 248
     }
222 249
 
223
-    private func fetchTypeCount(for monitoredType: MonitoredType) async -> TypeCount {
250
+    private func fetchTypeCountData(
251
+        for monitoredType: MonitoredType,
252
+        timeoutProfile: MetricTimeoutProfile,
253
+        timeoutSeconds: TimeInterval,
254
+        progress: SnapshotFetchProgress? = nil
255
+    ) async -> TypeCountFetchResult {
256
+        let started = Date()
257
+        progress?.updateStatus(monitoredType.id, status: .fetching)
258
+
224 259
         // Unsupported type: HKObjectType factory returned nil for this identifier
225 260
         guard let objectType = monitoredType.objectType,
226 261
               let sampleType = objectType as? HKSampleType else {
227
-            let tc = TypeCount(
262
+            var result = TypeCountFetchResult(
228 263
                 typeIdentifier: monitoredType.id,
229 264
                 displayName: monitoredType.displayName,
230 265
                 count: -1,
231
-                quality: SnapshotQuality.failed
266
+                contentHash: "",
267
+                earliestDate: nil,
268
+                latestDate: nil,
269
+                quality: SnapshotQuality.failed,
270
+                diagnosticQuality: HealthKitAPICallResult.Status.unsupported.rawValue,
271
+                isUnsupported: true,
272
+                authorizationStatus: "unavailable",
273
+                apiCalls: Self.placeholderAPICalls(status: .unsupported, message: "HealthKit type is not available on this OS or device"),
274
+                yearlyCounts: []
232 275
             )
233
-            tc.isUnsupported = true
234
-            return tc
276
+            result.totalElapsedSeconds = Date().timeIntervalSince(started)
277
+            result.timeoutConfiguredSeconds = timeoutSeconds
278
+            result.applyTimeoutProfile(timeoutProfile)
279
+            progress?.updateDetails(from: result)
280
+            progress?.updateStatus(monitoredType.id, status: .failed("Unsupported"))
281
+            return result
235 282
         }
236 283
 
237
-        // Check authorization status before querying — if denied, fail immediately
238
-        // (HealthKit queries for denied types might succeed with 0 data, appearing complete)
239
-        if store.authorizationStatus(for: sampleType) == .sharingDenied {
240
-            return TypeCount(
241
-                typeIdentifier: monitoredType.id,
242
-                displayName: monitoredType.displayName,
243
-                count: -1,
244
-                quality: SnapshotQuality.unauthorized
245
-            )
284
+        var result = await fetchTypeCountDataFromHK(monitoredType: monitoredType, sampleType: sampleType, timeoutSeconds: timeoutSeconds)
285
+        result.totalElapsedSeconds = Date().timeIntervalSince(started)
286
+        result.timeoutConfiguredSeconds = timeoutSeconds
287
+        result.applyTimeoutProfile(timeoutProfile)
288
+        progress?.updateDetails(from: result)
289
+
290
+        if result.diagnosticQuality == SnapshotQuality.complete.rawValue {
291
+            progress?.updateStatus(monitoredType.id, status: .complete, recordCount: max(result.count, 0))
292
+        } else if result.apiCalls.contains(where: { $0.status == .timeout }) {
293
+            progress?.updateStatus(monitoredType.id, status: .failed("Timeout"))
294
+        } else if result.diagnosticQuality == SnapshotQuality.unauthorized.rawValue {
295
+            progress?.updateStatus(monitoredType.id, status: .failed("Not authorized"))
296
+        } else {
297
+            progress?.updateStatus(monitoredType.id, status: .failed("Failed"))
246 298
         }
247 299
 
248
-        // 15s budget covers distribution + earliestDate + latestDate combined — not 15s each.
249
-        do {
250
-            return try await withTimeout(seconds: Self.perTypeTimeoutSeconds) {
251
-                await self.fetchTypeCountFromHK(monitoredType: monitoredType, sampleType: sampleType)
252
-            }
253
-        } catch {
254
-            let isAuthDenied = (error as? HKError)?.code == .errorAuthorizationDenied
255
-            return TypeCount(
300
+        return result
301
+    }
302
+
303
+    private func fetchTypeCountDataFromHK(monitoredType: MonitoredType, sampleType: HKSampleType, timeoutSeconds: TimeInterval) async -> TypeCountFetchResult {
304
+        let deadline = Date().addingTimeInterval(timeoutSeconds)
305
+        let distributionResult = await measureAPICall(
306
+            queryType: "distribution",
307
+            timeoutSeconds: max(0, deadline.timeIntervalSinceNow)
308
+        ) {
309
+            try await self.fetchDistribution(for: sampleType)
310
+        } resultDescription: { distribution in
311
+            "\(distribution.totalCount) samples"
312
+        }
313
+
314
+        guard distributionResult.apiCall.status == .complete, let distribution = distributionResult.value else {
315
+            let status = distributionResult.apiCall.status
316
+            let quality = diagnosticQuality(for: status)
317
+            return TypeCountFetchResult(
256 318
                 typeIdentifier: monitoredType.id,
257 319
                 displayName: monitoredType.displayName,
258 320
                 count: -1,
259
-                quality: isAuthDenied ? SnapshotQuality.unauthorized : SnapshotQuality.failed
321
+                contentHash: "",
322
+                earliestDate: nil,
323
+                latestDate: nil,
324
+                quality: snapshotQuality(for: status),
325
+                diagnosticQuality: quality,
326
+                isUnsupported: false,
327
+                authorizationStatus: authorizationStatus(from: [distributionResult.apiCall]),
328
+                apiCalls: [
329
+                    distributionResult.apiCall,
330
+                    Self.placeholderAPICall(queryType: "earliest_sample", status: .unknown, message: "query not run"),
331
+                    Self.placeholderAPICall(queryType: "latest_sample", status: .unknown, message: "query not run")
332
+                ],
333
+                yearlyCounts: []
260 334
             )
261 335
         }
262
-    }
263 336
 
264
-    private func fetchTypeCountFromHK(monitoredType: MonitoredType, sampleType: HKSampleType) async -> TypeCount {
265
-        do {
266
-            let distribution = try await fetchDistribution(for: sampleType)
267
-
268
-            // Both date queries share the same 15s budget via withTimeout in the caller.
269
-            // If either date query fails, both are set to nil (no partial date results).
270
-            async let earliestTask = fetchEarliestDate(for: sampleType)
271
-            async let latestTask   = fetchLatestDate(for: sampleType)
272
-            let (earliest, latest) = try await (earliestTask, latestTask)
273
-
274
-            let tc = TypeCount(
337
+        // Both date queries share the same 15s budget with the distribution query.
338
+        let remainingSeconds = max(0, deadline.timeIntervalSinceNow)
339
+        async let earliestTask = measureAPICall(
340
+            queryType: "earliest_sample",
341
+            timeoutSeconds: remainingSeconds
342
+        ) {
343
+            try await self.fetchEarliestDate(for: sampleType)
344
+        } resultDescription: { date in
345
+            Self.iso8601String(for: date)
346
+        }
347
+        async let latestTask = measureAPICall(
348
+            queryType: "latest_sample",
349
+            timeoutSeconds: remainingSeconds
350
+        ) {
351
+            try await self.fetchLatestDate(for: sampleType)
352
+        } resultDescription: { date in
353
+            Self.iso8601String(for: date)
354
+        }
355
+        let earliestResult = await earliestTask
356
+        let latestResult = await latestTask
357
+        let apiCalls = [distributionResult.apiCall, earliestResult.apiCall, latestResult.apiCall]
358
+
359
+        guard earliestResult.apiCall.status == .complete, latestResult.apiCall.status == .complete else {
360
+            let status = firstImpairedStatus(in: apiCalls)
361
+            let quality = diagnosticQuality(for: status)
362
+            return TypeCountFetchResult(
275 363
                 typeIdentifier: monitoredType.id,
276 364
                 displayName: monitoredType.displayName,
277
-                count: distribution.totalCount,
278
-                quality: SnapshotQuality.complete
279
-            )
280
-            tc.earliestDate = earliest
281
-            tc.latestDate = latest
282
-            tc.hash = HashService.typeHash(
283
-                typeIdentifier: monitoredType.id,
284
-                totalCount: distribution.totalCount,
285
-                earliestDate: earliest,
286
-                latestDate: latest
365
+                count: -1,
366
+                contentHash: "",
367
+                earliestDate: earliestResult.value ?? nil,
368
+                latestDate: latestResult.value ?? nil,
369
+                quality: snapshotQuality(for: status),
370
+                diagnosticQuality: quality,
371
+                isUnsupported: false,
372
+                authorizationStatus: authorizationStatus(from: apiCalls),
373
+                apiCalls: apiCalls,
374
+                yearlyCounts: []
287 375
             )
376
+        }
288 377
 
289
-            // YearlyCount — group distribution bins by year
290
-            // YearlyCount uses Calendar.current — year attribution is local-time based.
291
-            let isApprox = DistributionCaptureConfiguration.bucketComponent != .day
292
-            var yearMap: [Int: Int] = [:]
293
-            for bin in distribution.bins {
294
-                let year = Calendar.current.component(.year, from: bin.start)
295
-                yearMap[year, default: 0] += bin.count
296
-            }
297
-            for (year, yearCount) in yearMap {
298
-                let yc = YearlyCount(
299
-                    year: year,
300
-                    count: yearCount,
301
-                    typeIdentifier: monitoredType.id,
302
-                    isApproximate: isApprox
303
-                )
304
-                yc.typeCount = tc
305
-                tc.yearlyCounts?.append(yc)
306
-            }
378
+        let earliest = earliestResult.value ?? nil
379
+        let latest = latestResult.value ?? nil
380
+        let contentHash = HashService.typeHash(
381
+            typeIdentifier: monitoredType.id,
382
+            totalCount: distribution.totalCount,
383
+            earliestDate: earliest,
384
+            latestDate: latest
385
+        )
307 386
 
308
-            return tc
309
-        } catch {
310
-            let isAuthDenied = (error as? HKError)?.code == .errorAuthorizationDenied
311
-            return TypeCount(
387
+        // YearlyCount uses Calendar.current; year attribution is local-time based.
388
+        let isApprox = DistributionCaptureConfiguration.bucketComponent != .day
389
+        var yearMap: [Int: Int] = [:]
390
+        for bin in distribution.bins {
391
+            let year = Calendar.current.component(.year, from: bin.start)
392
+            yearMap[year, default: 0] += bin.count
393
+        }
394
+        let yearlyCounts = yearMap.map { year, yearCount in
395
+            TypeCountFetchResult.YearlyCountData(
396
+                year: year,
397
+                count: yearCount,
312 398
                 typeIdentifier: monitoredType.id,
313
-                displayName: monitoredType.displayName,
314
-                count: -1,
315
-                quality: isAuthDenied ? SnapshotQuality.unauthorized : SnapshotQuality.failed
399
+                isApproximate: isApprox
316 400
             )
317 401
         }
318
-    }
319 402
 
320
-    private func makeFailedTypeCount(_ monitoredType: MonitoredType) -> TypeCount {
321
-        TypeCount(
403
+        return TypeCountFetchResult(
322 404
             typeIdentifier: monitoredType.id,
323 405
             displayName: monitoredType.displayName,
324
-            count: -1,
325
-            quality: SnapshotQuality.failed
406
+            count: distribution.totalCount,
407
+            contentHash: contentHash,
408
+            earliestDate: earliest,
409
+            latestDate: latest,
410
+            quality: SnapshotQuality.complete,
411
+            diagnosticQuality: HealthKitAPICallResult.Status.complete.rawValue,
412
+            isUnsupported: false,
413
+            authorizationStatus: authorizationStatus(from: apiCalls),
414
+            apiCalls: apiCalls,
415
+            yearlyCounts: yearlyCounts
326 416
         )
327 417
     }
328 418
 
@@ -397,6 +487,194 @@ final class HealthKitService {
397 487
         }
398 488
     }
399 489
 
490
+    // MARK: - Timeout profiles
491
+
492
+    private func timeoutProfile(for monitoredType: MonitoredType, context: ModelContext) -> MetricTimeoutProfile {
493
+        let identifier = monitoredType.id
494
+        let descriptor = FetchDescriptor<MetricTimeoutProfile>(
495
+            predicate: #Predicate<MetricTimeoutProfile> { $0.metricIdentifier == identifier }
496
+        )
497
+        if let existing = try? context.fetch(descriptor).first {
498
+            existing.displayName = monitoredType.displayName
499
+            return existing
500
+        }
501
+
502
+        let profile = MetricTimeoutProfile(metricIdentifier: monitoredType.id, displayName: monitoredType.displayName)
503
+        context.insert(profile)
504
+        return profile
505
+    }
506
+
507
+    private func timeoutForFetch(
508
+        profile: MetricTimeoutProfile,
509
+        adaptiveTimeoutsEnabled: Bool,
510
+        timeoutMultiplier: Double
511
+    ) -> TimeInterval {
512
+        let base = adaptiveTimeoutsEnabled ? profile.effectiveTimeout : Self.defaultInitialTimeoutSeconds
513
+        return min(max(base * timeoutMultiplier, Self.defaultInitialTimeoutSeconds), Self.maximumTimeoutSeconds)
514
+    }
515
+
516
+    private func updateTimeoutProfile(
517
+        _ profile: MetricTimeoutProfile,
518
+        with result: TypeCountFetchResult,
519
+        monitoredType: MonitoredType
520
+    ) {
521
+        if result.quality == .complete {
522
+            profile.recordSuccess(elapsed: result.totalElapsedSeconds, displayName: monitoredType.displayName)
523
+        } else if result.apiCalls.contains(where: { $0.status == .timeout }) {
524
+            profile.recordTimeout(elapsed: result.totalElapsedSeconds, displayName: monitoredType.displayName)
525
+        } else {
526
+            profile.displayName = monitoredType.displayName
527
+        }
528
+    }
529
+
530
+    // MARK: - Query diagnostics
531
+
532
+    private struct APICallMeasurement<Value: Sendable>: Sendable {
533
+        let value: Value?
534
+        let apiCall: HealthKitAPICallResult
535
+    }
536
+
537
+    private func measureAPICall<Value: Sendable>(
538
+        queryType: String,
539
+        timeoutSeconds: TimeInterval,
540
+        operation: @escaping @Sendable () async throws -> Value,
541
+        resultDescription: @escaping @Sendable (Value) -> String
542
+    ) async -> APICallMeasurement<Value> {
543
+        let started = Date()
544
+        do {
545
+            guard timeoutSeconds > 0 else { throw CancellationError() }
546
+            let value = try await withTimeout(seconds: timeoutSeconds, operation: operation)
547
+            return APICallMeasurement(
548
+                value: value,
549
+                apiCall: HealthKitAPICallResult(
550
+                    queryType: queryType,
551
+                    status: .complete,
552
+                    elapsedSeconds: Date().timeIntervalSince(started),
553
+                    resultValue: resultDescription(value)
554
+                )
555
+            )
556
+        } catch {
557
+            let failure = Self.apiFailure(for: error)
558
+            let nsError = error as NSError
559
+            return APICallMeasurement(
560
+                value: nil,
561
+                apiCall: HealthKitAPICallResult(
562
+                    queryType: queryType,
563
+                    status: failure.status,
564
+                    elapsedSeconds: Date().timeIntervalSince(started),
565
+                    resultValue: nil,
566
+                    errorCode: failure.errorCode ?? "\(nsError.code)",
567
+                    errorDomain: failure.errorDomain ?? nsError.domain,
568
+                    errorDescription: failure.errorDescription ?? nsError.localizedDescription,
569
+                    failureKind: failure.failureKind,
570
+                    cancellationReason: failure.cancellationReason
571
+                )
572
+            )
573
+        }
574
+    }
575
+
576
+    private static func apiFailure(for error: Error) -> (
577
+        status: HealthKitAPICallResult.Status,
578
+        failureKind: String,
579
+        errorDomain: String?,
580
+        errorCode: String?,
581
+        errorDescription: String?,
582
+        cancellationReason: String?
583
+    ) {
584
+        if error is CancellationError {
585
+            return (
586
+                .timeout,
587
+                "internal cancellation",
588
+                "HealthProbe",
589
+                "perTypeTimeout",
590
+                "HealthProbe cancelled this query after the per-type timeout",
591
+                "per-type timeout reached"
592
+            )
593
+        }
594
+
595
+        let nsError = error as NSError
596
+        if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorTimedOut {
597
+            return (.timeout, "timeout", nil, nil, nil, nil)
598
+        }
599
+
600
+        if (error as? HKError)?.code == .errorAuthorizationDenied {
601
+            return (.unauthorized, "HealthKit error", nil, nil, nil, nil)
602
+        }
603
+
604
+        return (.failed, "HealthKit error", nil, nil, nil, nil)
605
+    }
606
+
607
+    private func authorizationStatus(from apiCalls: [HealthKitAPICallResult]) -> String {
608
+        if apiCalls.contains(where: { $0.status == .complete }) {
609
+            return "granted"
610
+        }
611
+        if apiCalls.contains(where: { $0.status == .unauthorized }) {
612
+            return "denied"
613
+        }
614
+        return HealthKitAPICallResult.Status.unknown.rawValue
615
+    }
616
+
617
+    private func snapshotQuality(for status: HealthKitAPICallResult.Status) -> SnapshotQuality {
618
+        status == .unauthorized ? .unauthorized : .failed
619
+    }
620
+
621
+    private func diagnosticQuality(for status: HealthKitAPICallResult.Status) -> String {
622
+        switch status {
623
+        case .complete:
624
+            return HealthKitAPICallResult.Status.complete.rawValue
625
+        case .unauthorized:
626
+            return HealthKitAPICallResult.Status.unauthorized.rawValue
627
+        case .unsupported:
628
+            return HealthKitAPICallResult.Status.unsupported.rawValue
629
+        case .timeout:
630
+            return SnapshotQuality.failed.rawValue
631
+        case .failed:
632
+            return HealthKitAPICallResult.Status.failed.rawValue
633
+        case .unknown:
634
+            return HealthKitAPICallResult.Status.unknown.rawValue
635
+        }
636
+    }
637
+
638
+    private func firstImpairedStatus(in apiCalls: [HealthKitAPICallResult]) -> HealthKitAPICallResult.Status {
639
+        let statuses = apiCalls.map(\.status)
640
+        if statuses.contains(.unauthorized) { return .unauthorized }
641
+        if statuses.contains(.timeout) { return .timeout }
642
+        if statuses.contains(.unsupported) { return .unsupported }
643
+        if statuses.contains(.failed) { return .failed }
644
+        return .unknown
645
+    }
646
+
647
+    private static func placeholderAPICalls(status: HealthKitAPICallResult.Status, message: String) -> [HealthKitAPICallResult] {
648
+        [
649
+            placeholderAPICall(queryType: "distribution", status: status, message: message),
650
+            placeholderAPICall(queryType: "earliest_sample", status: status, message: message),
651
+            placeholderAPICall(queryType: "latest_sample", status: status, message: message)
652
+        ]
653
+    }
654
+
655
+    private static func placeholderAPICall(
656
+        queryType: String,
657
+        status: HealthKitAPICallResult.Status,
658
+        message: String
659
+    ) -> HealthKitAPICallResult {
660
+        HealthKitAPICallResult(
661
+            queryType: queryType,
662
+            status: status,
663
+            elapsedSeconds: 0,
664
+            resultValue: nil,
665
+            errorCode: "none",
666
+            errorDomain: "none",
667
+            errorDescription: message,
668
+            failureKind: status == .unknown ? "not run" : status.rawValue,
669
+            cancellationReason: "none"
670
+        )
671
+    }
672
+
673
+    private static func iso8601String(for date: Date?) -> String {
674
+        guard let date else { return "none" }
675
+        return ISO8601DateFormatter().string(from: date)
676
+    }
677
+
400 678
     // MARK: - Quality aggregation
401 679
 
402 680
     func deriveSnapshotQuality(from typeCounts: [TypeCount]) -> SnapshotQuality {
@@ -497,8 +775,8 @@ final class HealthKitService {
497 775
     }
498 776
 }
499 777
 
500
-private struct SampleDistribution {
501
-    struct Bin {
778
+private struct SampleDistribution: Sendable {
779
+    struct Bin: Sendable {
502 780
         let start: Date
503 781
         let end: Date
504 782
         let count: Int
@@ -507,6 +785,113 @@ private struct SampleDistribution {
507 785
     let bins: [Bin]
508 786
 }
509 787
 
788
+private struct TypeCountFetchResult: Sendable {
789
+    struct YearlyCountData: Sendable {
790
+        let year: Int
791
+        let count: Int
792
+        let typeIdentifier: String
793
+        let isApproximate: Bool
794
+    }
795
+
796
+    let typeIdentifier: String
797
+    let displayName: String
798
+    let count: Int
799
+    let contentHash: String
800
+    let earliestDate: Date?
801
+    let latestDate: Date?
802
+    let quality: SnapshotQuality
803
+    let diagnosticQuality: String
804
+    let isUnsupported: Bool
805
+    let authorizationStatus: String
806
+    let apiCalls: [HealthKitAPICallResult]
807
+    let yearlyCounts: [YearlyCountData]
808
+    var timeoutConfiguredSeconds: TimeInterval = 0
809
+    var totalElapsedSeconds: TimeInterval = 0
810
+    var timeoutMode: String = "default"
811
+    var lastSuccessfulElapsed: TimeInterval = 0
812
+    var learnedTimeout: TimeInterval = 0
813
+    var suggestedRetryTimeout: TimeInterval = 0
814
+    var timeoutCount: Int = 0
815
+    var successCount: Int = 0
816
+
817
+    mutating func applyTimeoutProfile(_ profile: MetricTimeoutProfile) {
818
+        timeoutMode = profile.timeoutMode
819
+        lastSuccessfulElapsed = profile.lastSuccessfulElapsed
820
+        learnedTimeout = profile.effectiveTimeout
821
+        suggestedRetryTimeout = profile.suggestedRetryTimeout
822
+        timeoutCount = profile.timeoutCount
823
+        successCount = profile.successCount
824
+    }
825
+
826
+    @MainActor
827
+    func makeTypeCount() -> TypeCount {
828
+        let typeCount = TypeCount(
829
+            typeIdentifier: typeIdentifier,
830
+            displayName: displayName,
831
+            count: count,
832
+            quality: quality
833
+        )
834
+        typeCount.contentHash = contentHash
835
+        typeCount.earliestDate = earliestDate
836
+        typeCount.latestDate = latestDate
837
+        typeCount.isUnsupported = isUnsupported
838
+
839
+        for yearlyCountData in yearlyCounts {
840
+            let yearlyCount = YearlyCount(
841
+                year: yearlyCountData.year,
842
+                count: yearlyCountData.count,
843
+                typeIdentifier: yearlyCountData.typeIdentifier,
844
+                isApproximate: yearlyCountData.isApproximate
845
+            )
846
+            typeCount.yearlyCounts?.append(yearlyCount)
847
+        }
848
+
849
+        return typeCount
850
+    }
851
+}
852
+
853
+private extension SnapshotFetchProgress {
854
+    func updateDetails(from result: TypeCountFetchResult) {
855
+        updateDetails(
856
+            result.typeIdentifier,
857
+            quality: result.diagnosticQuality,
858
+            recordCount: result.count,
859
+            isUnsupported: result.isUnsupported,
860
+            authorizationStatus: result.authorizationStatus,
861
+            earliestDate: result.earliestDate,
862
+            latestDate: result.latestDate,
863
+            yearlyCounts: result.yearlyCounts.map {
864
+                SnapshotFetchProgress.YearlyCountProgress(
865
+                    year: $0.year,
866
+                    count: $0.count,
867
+                    isApproximate: $0.isApproximate
868
+                )
869
+            },
870
+            apiCallDetails: result.apiCalls,
871
+            timeoutConfiguredSeconds: result.timeoutConfiguredSeconds,
872
+            totalElapsedSeconds: result.totalElapsedSeconds,
873
+            timeoutMode: result.timeoutMode,
874
+            lastSuccessfulElapsed: result.lastSuccessfulElapsed,
875
+            learnedTimeout: result.learnedTimeout,
876
+            suggestedRetryTimeout: result.suggestedRetryTimeout,
877
+            timeoutCount: result.timeoutCount,
878
+            successCount: result.successCount
879
+        )
880
+    }
881
+
882
+    func updateTimeoutProfile(from profile: MetricTimeoutProfile, for typeID: String) {
883
+        updateTimeoutProfile(
884
+            typeID,
885
+            timeoutMode: profile.timeoutMode,
886
+            lastSuccessfulElapsed: profile.lastSuccessfulElapsed,
887
+            learnedTimeout: profile.effectiveTimeout,
888
+            suggestedRetryTimeout: profile.suggestedRetryTimeout,
889
+            timeoutCount: profile.timeoutCount,
890
+            successCount: profile.successCount
891
+        )
892
+    }
893
+}
894
+
510 895
 private enum DistributionCaptureConfiguration {
511 896
     static let bucketComponent: Calendar.Component = .day
512 897
     static let bucketStep = 1
+3 -3
HealthProbe/Services/ObserverService.swift
@@ -102,10 +102,10 @@ final class ObserverService {
102 102
         do {
103 103
             let snapshot = try await HealthKitService.shared.createSnapshot(
104 104
                 in: context,
105
-                selectedTypeIDs: selectedTypeIDs
105
+                selectedTypeIDs: selectedTypeIDs,
106
+                adaptiveTimeoutsEnabled: true,
107
+                triggerReason: "observerCallback"
106 108
             )
107
-            snapshot.triggerReason = "observerCallback"
108
-            try context.save()
109 109
             logger.info("ObserverService: observer-triggered snapshot created \(snapshot.id)")
110 110
         } catch {
111 111
             logger.error("ObserverService: failed to create snapshot — \(error)")
+2 -2
HealthProbe/Utilities/AppSettings.swift
@@ -33,8 +33,8 @@ final class AppSettings {
33 33
            let ids  = try? JSONDecoder().decode([String].self, from: data) {
34 34
             selectedDeviceIDs = Set(ids)
35 35
         } else {
36
-            let currentID = KeychainService.resolveDeviceID(swiftDataStoreIsEmpty: false).id
37
-            selectedDeviceIDs = [currentID]
36
+            let currentID = UIDevice.current.identifierForVendor?.uuidString ?? ""
37
+            selectedDeviceIDs = currentID.isEmpty ? [] : [currentID]
38 38
         }
39 39
 
40 40
         if UserDefaults.standard.object(forKey: Self.adaptiveTimeoutsEnabledKey) == nil {
+219 -0
HealthProbe/Utilities/SnapshotFetchProgress.swift
@@ -0,0 +1,219 @@
1
+import Foundation
2
+
3
+struct HealthKitAPICallResult: Sendable, Equatable {
4
+    enum Status: String, Sendable {
5
+        case complete
6
+        case failed
7
+        case timeout
8
+        case unknown
9
+        case unauthorized
10
+        case unsupported
11
+    }
12
+
13
+    let queryType: String
14
+    let status: Status
15
+    let elapsedSeconds: TimeInterval
16
+    let resultValue: String?
17
+    let errorCode: String?
18
+    let errorDomain: String?
19
+    let errorDescription: String?
20
+    let failureKind: String?
21
+    let cancellationReason: String?
22
+
23
+    init(
24
+        queryType: String,
25
+        status: Status,
26
+        elapsedSeconds: TimeInterval,
27
+        resultValue: String?,
28
+        errorCode: String? = nil,
29
+        errorDomain: String? = nil,
30
+        errorDescription: String? = nil,
31
+        failureKind: String? = nil,
32
+        cancellationReason: String? = nil
33
+    ) {
34
+        self.queryType = queryType
35
+        self.status = status
36
+        self.elapsedSeconds = elapsedSeconds
37
+        self.resultValue = resultValue
38
+        self.errorCode = errorCode
39
+        self.errorDomain = errorDomain
40
+        self.errorDescription = errorDescription
41
+        self.failureKind = failureKind
42
+        self.cancellationReason = cancellationReason
43
+    }
44
+
45
+    var statusDescription: String {
46
+        switch status {
47
+        case .complete: return "complete"
48
+        case .failed: return "failed"
49
+        case .timeout: return "timeout"
50
+        case .unknown: return "unknown"
51
+        case .unauthorized: return "unauthorized"
52
+        case .unsupported: return "unsupported"
53
+        }
54
+    }
55
+}
56
+
57
+@Observable
58
+final class SnapshotFetchProgress {
59
+    struct YearlyCountProgress: Identifiable, Sendable, Equatable {
60
+        var id: Int { year }
61
+        let year: Int
62
+        let count: Int
63
+        let isApproximate: Bool
64
+    }
65
+
66
+    struct TypeProgress: Identifiable, Sendable, Equatable {
67
+        enum FetchStatus: Sendable, Equatable {
68
+            case pending
69
+            case fetching
70
+            case complete
71
+            case failed(String)
72
+
73
+            var icon: String {
74
+                switch self {
75
+                case .pending: return "circle"
76
+                case .fetching: return "arrow.triangle.2.circlepath"
77
+                case .complete: return "checkmark.circle.fill"
78
+                case .failed: return "xmark.circle.fill"
79
+                }
80
+            }
81
+        }
82
+
83
+        let id: String
84
+        let displayName: String
85
+        var status: FetchStatus = .pending
86
+        var quality: String = SnapshotQuality.loading.rawValue
87
+        var recordCount: Int = 0
88
+        var isUnsupported: Bool = false
89
+        var authorizationStatus: String = "unknown"
90
+        var earliestDate: Date?
91
+        var latestDate: Date?
92
+        var yearlyCounts: [YearlyCountProgress] = []
93
+        var apiCallDetails: [HealthKitAPICallResult] = []
94
+        var timeoutConfiguredSeconds: TimeInterval = 0
95
+        var totalElapsedSeconds: TimeInterval = 0
96
+        var timeoutMode: String = "default"
97
+        var lastSuccessfulElapsed: TimeInterval = 0
98
+        var learnedTimeout: TimeInterval = 0
99
+        var suggestedRetryTimeout: TimeInterval = 0
100
+        var timeoutCount: Int = 0
101
+        var successCount: Int = 0
102
+    }
103
+
104
+    var types: [TypeProgress]
105
+    var perTypeTimeoutSeconds: TimeInterval = 0
106
+    var maxConcurrentTypeFetches: Int = 0
107
+    var adaptiveTimeoutsEnabled: Bool = false
108
+    var previousSnapshotID: UUID?
109
+    var isChainStart: Bool?
110
+    var snapshotChecksum: String = ""
111
+    var monitoredTypeSetHash: String = ""
112
+    var monitoredRegistryVersion: Int?
113
+
114
+    var visibleTypes: [TypeProgress] { types }
115
+    var completedCount: Int { types.filter { $0.status == .complete }.count }
116
+    var failedCount: Int {
117
+        types.filter {
118
+            if case .failed = $0.status { return true }
119
+            return false
120
+        }.count
121
+    }
122
+    var totalRecords: Int {
123
+        types.reduce(0) { $0 + max($1.recordCount, 0) }
124
+    }
125
+
126
+    init(monitoredTypes: [(id: String, displayName: String)]) {
127
+        self.types = monitoredTypes.map {
128
+            TypeProgress(id: $0.id, displayName: $0.displayName)
129
+        }
130
+    }
131
+
132
+    func updateConfiguration(
133
+        perTypeTimeoutSeconds: TimeInterval,
134
+        maxConcurrentTypeFetches: Int,
135
+        adaptiveTimeoutsEnabled: Bool
136
+    ) {
137
+        self.perTypeTimeoutSeconds = perTypeTimeoutSeconds
138
+        self.maxConcurrentTypeFetches = maxConcurrentTypeFetches
139
+        self.adaptiveTimeoutsEnabled = adaptiveTimeoutsEnabled
140
+    }
141
+
142
+    func updateChainContext(
143
+        previousSnapshotID: UUID?,
144
+        isChainStart: Bool,
145
+        snapshotChecksum: String,
146
+        monitoredTypeSetHash: String,
147
+        monitoredRegistryVersion: Int
148
+    ) {
149
+        self.previousSnapshotID = previousSnapshotID
150
+        self.isChainStart = isChainStart
151
+        self.snapshotChecksum = snapshotChecksum
152
+        self.monitoredTypeSetHash = monitoredTypeSetHash
153
+        self.monitoredRegistryVersion = monitoredRegistryVersion
154
+    }
155
+
156
+    func updateStatus(_ id: String, status: TypeProgress.FetchStatus, recordCount: Int? = nil) {
157
+        guard let index = types.firstIndex(where: { $0.id == id }) else { return }
158
+        types[index].status = status
159
+        if let recordCount {
160
+            types[index].recordCount = recordCount
161
+        }
162
+    }
163
+
164
+    func updateDetails(
165
+        _ id: String,
166
+        quality: String,
167
+        recordCount: Int,
168
+        isUnsupported: Bool,
169
+        authorizationStatus: String,
170
+        earliestDate: Date?,
171
+        latestDate: Date?,
172
+        yearlyCounts: [YearlyCountProgress],
173
+        apiCallDetails: [HealthKitAPICallResult],
174
+        timeoutConfiguredSeconds: TimeInterval,
175
+        totalElapsedSeconds: TimeInterval,
176
+        timeoutMode: String,
177
+        lastSuccessfulElapsed: TimeInterval,
178
+        learnedTimeout: TimeInterval,
179
+        suggestedRetryTimeout: TimeInterval,
180
+        timeoutCount: Int,
181
+        successCount: Int
182
+    ) {
183
+        guard let index = types.firstIndex(where: { $0.id == id }) else { return }
184
+        types[index].quality = quality
185
+        types[index].recordCount = recordCount
186
+        types[index].isUnsupported = isUnsupported
187
+        types[index].authorizationStatus = authorizationStatus
188
+        types[index].earliestDate = earliestDate
189
+        types[index].latestDate = latestDate
190
+        types[index].yearlyCounts = yearlyCounts
191
+        types[index].apiCallDetails = apiCallDetails
192
+        types[index].timeoutConfiguredSeconds = timeoutConfiguredSeconds
193
+        types[index].totalElapsedSeconds = totalElapsedSeconds
194
+        types[index].timeoutMode = timeoutMode
195
+        types[index].lastSuccessfulElapsed = lastSuccessfulElapsed
196
+        types[index].learnedTimeout = learnedTimeout
197
+        types[index].suggestedRetryTimeout = suggestedRetryTimeout
198
+        types[index].timeoutCount = timeoutCount
199
+        types[index].successCount = successCount
200
+    }
201
+
202
+    func updateTimeoutProfile(
203
+        _ id: String,
204
+        timeoutMode: String,
205
+        lastSuccessfulElapsed: TimeInterval,
206
+        learnedTimeout: TimeInterval,
207
+        suggestedRetryTimeout: TimeInterval,
208
+        timeoutCount: Int,
209
+        successCount: Int
210
+    ) {
211
+        guard let index = types.firstIndex(where: { $0.id == id }) else { return }
212
+        types[index].timeoutMode = timeoutMode
213
+        types[index].lastSuccessfulElapsed = lastSuccessfulElapsed
214
+        types[index].learnedTimeout = learnedTimeout
215
+        types[index].suggestedRetryTimeout = suggestedRetryTimeout
216
+        types[index].timeoutCount = timeoutCount
217
+        types[index].successCount = successCount
218
+    }
219
+}
+1 -1
HealthProbe/Utilities/SnapshotPDFExporter.swift
@@ -84,7 +84,7 @@ enum SnapshotPDFExporter {
84 84
     }
85 85
 
86 86
     /// Generates PDF using only CoreGraphics + CoreText. Safe to call off the main thread.
87
-    nonisolated static func generatePDF(from data: SnapshotReportData) -> Data {
87
+    static func generatePDF(from data: SnapshotReportData) -> Data {
88 88
         let pageSize = CGSize(width: 595.2, height: 841.8)
89 89
         let pdfData = NSMutableData()
90 90
         guard let consumer = CGDataConsumer(data: pdfData as CFMutableData) else { return Data() }
+234 -46
HealthProbe/ViewModels/DashboardViewModel.swift
@@ -1,29 +1,27 @@
1 1
 import Foundation
2 2
 import SwiftData
3 3
 
4
-struct TypeProgressEntry: Identifiable {
5
-    let id: String
6
-    let displayName: String
7
-    var status: Status
8
-
9
-    enum Status {
10
-        case pending
11
-        case complete(Int)  // record count
12
-        case unauthorized
13
-        case failed(String)  // error reason
14
-    }
15
-}
16
-
17 4
 @Observable
18 5
 final class DashboardViewModel {
19 6
     var isRequestingAuth = false
20 7
     var isCreatingSnapshot = false
21 8
     var authError: String?
22 9
     var snapshotError: String?
23
-    var fetchProgress: [TypeProgressEntry] = []
10
+    var snapshotProgress: SnapshotProgress = .idle
11
+    var snapshotProgressMessage: String = ""
12
+    var snapshotProgressDetail: String = ""
24 13
     var showProgressSheet = false
25
-    var fetchStartTime: Date?
26
-    var lastSnapshotQuality: SnapshotQuality? = nil
14
+    var canRetryWithPermissions = false
15
+    var permissionsAlreadyRequested = false
16
+    var fetchProgress: SnapshotFetchProgress?
17
+    var fetchDurationSeconds: TimeInterval? = nil
18
+    var fetchStartDate: Date? = nil
19
+    var operationID: UUID? = nil
20
+    var completedSnapshotID: UUID? = nil
21
+    var completedSnapshotTimestamp: Date? = nil
22
+    var completedSnapshotDeviceID: String? = nil
23
+    var completedSnapshotTriggerReason: String? = nil
24
+    var completedSnapshotRetryOfSnapshotID: UUID? = nil
27 25
 
28 26
     private let healthKit = HealthKitService.shared
29 27
     private let diffService = SnapshotDiffService.shared
@@ -39,56 +37,246 @@ final class DashboardViewModel {
39 37
         }
40 38
     }
41 39
 
42
-    func createSnapshot(context: ModelContext, selectedTypeIDs: Set<String>) async {
40
+    func createSnapshot(
41
+        context: ModelContext,
42
+        selectedTypeIDs: Set<String>,
43
+        adaptiveTimeoutsEnabled: Bool,
44
+        triggerReason: String = "manual",
45
+        retryOfSnapshotID: UUID? = nil,
46
+        timeoutMultiplier: Double = 1
47
+    ) async {
48
+        guard !selectedTypeIDs.isEmpty else {
49
+            snapshotError = "No health data types selected. Please select types to monitor in Settings."
50
+            return
51
+        }
52
+
43 53
         isCreatingSnapshot = true
44 54
         snapshotError = nil
55
+        snapshotProgress = .fetching
45 56
         showProgressSheet = true
46
-        fetchStartTime = Date()
57
+        fetchStartDate = Date()
58
+        fetchDurationSeconds = nil
59
+        operationID = UUID()
60
+        completedSnapshotID = nil
61
+        completedSnapshotTimestamp = nil
62
+        completedSnapshotDeviceID = nil
63
+        completedSnapshotTriggerReason = nil
64
+        completedSnapshotRetryOfSnapshotID = nil
65
+        snapshotProgressMessage = ""
66
+        snapshotProgressDetail = ""
67
+        canRetryWithPermissions = false
68
+        permissionsAlreadyRequested = false
47 69
 
48 70
         let monitoredTypes = HealthKitService.allTypes
49 71
             .filter { selectedTypeIDs.contains($0.id) }
50
-            .sorted { $0.displayName < $1.displayName }
51
-
52
-        fetchProgress = monitoredTypes.map { type in
53
-            TypeProgressEntry(id: type.id, displayName: type.displayName, status: .pending)
54
-        }
72
+            .map { (id: $0.id, displayName: $0.displayName) }
73
+        fetchProgress = SnapshotFetchProgress(monitoredTypes: monitoredTypes)
74
+        fetchProgress?.updateConfiguration(
75
+            perTypeTimeoutSeconds: HealthKitService.defaultInitialTimeoutSeconds,
76
+            maxConcurrentTypeFetches: HealthKitService.maxConcurrentTypeFetches,
77
+            adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled
78
+        )
55 79
 
56 80
         defer { isCreatingSnapshot = false }
57 81
 
58 82
         do {
59
-            _ = try await healthKit.createSnapshot(
60
-                in: context,
61
-                selectedTypeIDs: selectedTypeIDs,
62
-                onTypeCompleted: { [weak self] typeCount in
63
-                    Task { @MainActor in
64
-                        self?.updateProgress(for: typeCount)
83
+            let operationTimeout = max(120, Double(selectedTypeIDs.count) * HealthKitService.maximumTimeoutSeconds + 30)
84
+            let snapshot = try await withTimeout(seconds: operationTimeout) {
85
+                try await self.healthKit.createSnapshot(
86
+                    in: context,
87
+                    selectedTypeIDs: selectedTypeIDs,
88
+                    adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled,
89
+                    triggerReason: triggerReason,
90
+                    retryOfSnapshotID: retryOfSnapshotID,
91
+                    timeoutMultiplier: timeoutMultiplier,
92
+                    progress: self.fetchProgress
93
+                )
94
+            }
95
+
96
+            let allSnapshots = try context.fetch(FetchDescriptor<HealthSnapshot>())
97
+            let exists = allSnapshots.contains { $0.id == snapshot.id }
98
+
99
+            if !exists {
100
+                throw SnapshotCreationError.snapshotNotSaved
101
+            }
102
+
103
+            fetchDurationSeconds = fetchStartDate.map { Date().timeIntervalSince($0) }
104
+            completedSnapshotID = snapshot.id
105
+            completedSnapshotTimestamp = snapshot.timestamp
106
+            completedSnapshotDeviceID = snapshot.deviceID
107
+            completedSnapshotTriggerReason = snapshot.triggerReason
108
+            completedSnapshotRetryOfSnapshotID = snapshot.retryOfSnapshotID
109
+            fetchProgress?.updateChainContext(
110
+                previousSnapshotID: snapshot.previousSnapshotID,
111
+                isChainStart: snapshot.isChainStart,
112
+                snapshotChecksum: HashService.snapshotChecksum(typeCounts: snapshot.typeCounts ?? []),
113
+                monitoredTypeSetHash: snapshot.monitoredTypeSetHash,
114
+                monitoredRegistryVersion: snapshot.monitoredRegistryVersion
115
+            )
116
+
117
+            if snapshot.snapshotQuality != SnapshotQuality.complete {
118
+                let typeCounts = snapshot.typeCounts ?? []
119
+                let unauthorizedCount = typeCounts.filter { $0.quality == SnapshotQuality.unauthorized }.count
120
+                let failedCount = typeCounts.filter { $0.quality == SnapshotQuality.failed }.count
121
+
122
+                let failedTypes = typeCounts.filter { $0.quality == SnapshotQuality.failed }
123
+                let unauthorizedTypes = typeCounts.filter { $0.quality == SnapshotQuality.unauthorized }
124
+
125
+                snapshotProgress = .incomplete
126
+                snapshotProgressMessage = "Incomplete snapshot"
127
+                permissionsAlreadyRequested = healthKit.hasRequestedPermissionsBefore
128
+
129
+                if unauthorizedCount > 0 {
130
+                    snapshotProgressMessage = "Missing permissions (\(unauthorizedCount) type\(unauthorizedCount == 1 ? "" : "s"))"
131
+                    let typesList = unauthorizedTypes.map { $0.displayName }.joined(separator: ", ")
132
+
133
+                    if permissionsAlreadyRequested {
134
+                        snapshotProgressDetail = """
135
+                        Not authorized: \(typesList)
136
+
137
+                        You need to grant access to health data. Open Settings and enable HealthKit access for the types you want to monitor.
138
+                        """
139
+                        canRetryWithPermissions = false
140
+                    } else {
141
+                        snapshotProgressDetail = "Some health data types are not authorized. You can request permissions and try again."
142
+                        canRetryWithPermissions = true
65 143
                     }
144
+                } else if failedCount > 0 {
145
+                    snapshotProgressMessage = "Data load failed (\(failedCount) type\(failedCount == 1 ? "" : "s"))"
146
+                    let typesList = failedTypes.map { $0.displayName }.joined(separator: ", ")
147
+                    snapshotProgressDetail = """
148
+                    Failed to load: \(typesList)
149
+
150
+                    This may indicate a temporary issue. Try again or check HealthKit availability.
151
+                    """
152
+                    canRetryWithPermissions = false
153
+                } else {
154
+                    snapshotProgressDetail = "Snapshot created with quality: \(snapshot.snapshotQuality.rawValue). Some data may be incomplete."
155
+                    canRetryWithPermissions = false
66 156
                 }
67
-            )
68
-            lastSnapshotQuality = .complete
157
+
158
+                showProgressSheet = true
159
+                return
160
+            }
161
+
162
+            snapshotProgress = .complete
163
+        } catch is CancellationError {
164
+            snapshotError = "Snapshot creation exceeded the operation timeout. Individual metric timeouts are adaptive; retry failed metrics with an extended timeout when available."
165
+            snapshotProgress = .idle
166
+            showProgressSheet = true
167
+        } catch let error as SnapshotCreationError {
168
+            snapshotError = error.message
169
+            snapshotProgress = .idle
170
+            showProgressSheet = true
69 171
         } catch {
70
-            snapshotError = error.localizedDescription
71
-            lastSnapshotQuality = .failed
172
+            snapshotError = "Failed to create snapshot: \(error.localizedDescription)"
173
+            snapshotProgress = .idle
174
+            showProgressSheet = true
72 175
         }
73 176
     }
74 177
 
75
-    @MainActor
76
-    private func updateProgress(for typeCount: TypeCount) {
77
-        if let index = fetchProgress.firstIndex(where: { $0.id == typeCount.typeIdentifier }) {
78
-            let status: TypeProgressEntry.Status
79
-            switch typeCount.quality {
80
-            case .complete:
81
-                status = .complete(typeCount.count)
82
-            case .unauthorized:
83
-                status = .unauthorized
84
-            case .failed, .partial, .loading:
85
-                status = .failed("Failed")
86
-            }
87
-            fetchProgress[index].status = status
178
+    func retryWithPermissions(context: ModelContext, selectedTypeIDs: Set<String>, adaptiveTimeoutsEnabled: Bool) async {
179
+        isRequestingAuth = true
180
+        defer { isRequestingAuth = false }
181
+        do {
182
+            try await healthKit.requestAuthorization()
183
+            await createSnapshot(context: context, selectedTypeIDs: selectedTypeIDs, adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled)
184
+        } catch {
185
+            snapshotError = "Failed to request permissions: \(error.localizedDescription)"
186
+            snapshotProgress = .idle
187
+            showProgressSheet = false
188
+        }
189
+    }
190
+
191
+    func retryFailedMetricsWithExtendedTimeout(context: ModelContext) async {
192
+        guard let progress = fetchProgress else { return }
193
+        let retryTypeIDs = Set(progress.types.compactMap { type -> String? in
194
+            guard case .failed(let reason) = type.status else { return nil }
195
+            return reason == "Timeout" ? type.id : nil
196
+        })
197
+        guard !retryTypeIDs.isEmpty else { return }
198
+
199
+        await createSnapshot(
200
+            context: context,
201
+            selectedTypeIDs: retryTypeIDs,
202
+            adaptiveTimeoutsEnabled: true,
203
+            triggerReason: "retryFailedMetrics",
204
+            retryOfSnapshotID: completedSnapshotID,
205
+            timeoutMultiplier: 2
206
+        )
207
+    }
208
+
209
+    func acceptPartialSnapshot() {
210
+        fetchProgress = nil
211
+        showProgressSheet = false
212
+        snapshotProgress = .idle
213
+    }
214
+
215
+    func discardSnapshot(context: ModelContext) async {
216
+        if let snapshotID = completedSnapshotID {
217
+            do {
218
+                let allSnapshots = try context.fetch(FetchDescriptor<HealthSnapshot>())
219
+                if let snapshot = allSnapshots.first(where: { $0.id == snapshotID }) {
220
+                    context.delete(snapshot)
221
+                }
222
+            } catch { }
88 223
         }
224
+        completedSnapshotID = nil
225
+        fetchProgress = nil
226
+        showProgressSheet = false
227
+        snapshotProgress = .idle
89 228
     }
90 229
 
91 230
     func totalChanges(latest: HealthSnapshot, previous: HealthSnapshot) -> Int {
92 231
         diffService.totalAbsoluteChange(current: latest, baseline: previous)
93 232
     }
233
+
234
+    private func withTimeout<T>(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T {
235
+        try await withThrowingTaskGroup(of: T.self) { group in
236
+            group.addTask { try await operation() }
237
+            group.addTask {
238
+                try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
239
+                throw CancellationError()
240
+            }
241
+            let result = try await group.next()!
242
+            group.cancelAll()
243
+            return result
244
+        }
245
+    }
246
+}
247
+
248
+enum SnapshotProgress {
249
+    case idle
250
+    case fetching
251
+    case processing
252
+    case complete
253
+    case incomplete
254
+
255
+    var message: String {
256
+        switch self {
257
+        case .idle: return ""
258
+        case .fetching: return "Fetching health data..."
259
+        case .processing: return "Processing snapshot..."
260
+        case .complete: return "Snapshot created successfully!"
261
+        case .incomplete: return "Snapshot created with issues"
262
+        }
263
+    }
264
+
265
+    var isError: Bool {
266
+        switch self {
267
+        case .incomplete: return true
268
+        default: return false
269
+        }
270
+    }
271
+}
272
+
273
+enum SnapshotCreationError: Error {
274
+    case snapshotNotSaved
275
+
276
+    var message: String {
277
+        switch self {
278
+        case .snapshotNotSaved:
279
+            return "Snapshot was not saved to database. This may indicate a HealthKit permission issue or data corruption. Try requesting health access again in the Actions section."
280
+        }
281
+    }
94 282
 }
+925 -9
HealthProbe/Views/Dashboard/DashboardView.swift
@@ -8,11 +8,14 @@ struct DashboardView: View {
8 8
     @Environment(AppSettings.self) private var appSettings
9 9
     @Query(sort: \HealthSnapshot.timestamp, order: .reverse) private var snapshots: [HealthSnapshot]
10 10
     @State private var viewModel = DashboardViewModel()
11
+    @State private var didAutoRequestPermissions = false
12
+    @State private var snapshotSheetTab: SnapshotSheetTab = .progress
13
+    @State private var expandedIssueIDs: Set<String> = []
11 14
 
12 15
     init() {
13
-        let id = UIDevice.current.identifierForVendor?.uuidString ?? ""
16
+        let deviceID = KeychainService.resolveDeviceID(swiftDataStoreIsEmpty: false).id
14 17
         _snapshots = Query(
15
-            filter: #Predicate<HealthSnapshot> { $0.deviceID == id },
18
+            filter: #Predicate<HealthSnapshot> { $0.deviceID == deviceID },
16 19
             sort: \HealthSnapshot.timestamp,
17 20
             order: .reverse
18 21
         )
@@ -38,10 +41,856 @@ struct DashboardView: View {
38 41
             .navigationTitle("HealthProbe")
39 42
         }
40 43
         .sheet(isPresented: $viewModel.showProgressSheet) {
41
-            SnapshotProgressSheet(viewModel: viewModel)
44
+            progressSheet
45
+        }
46
+        .task {
47
+            if !didAutoRequestPermissions && !HealthKitService.shared.hasRequestedPermissionsBefore {
48
+                didAutoRequestPermissions = true
49
+                await viewModel.requestAuthorization()
50
+            }
51
+        }
52
+    }
53
+
54
+    // MARK: - Helpers
55
+
56
+    // MARK: - Report helpers
57
+
58
+    private func failedTypesList(in progress: SnapshotFetchProgress) -> [SnapshotFetchProgress.TypeProgress] {
59
+        progress.visibleTypes.filter {
60
+            if case .failed = $0.status { return true }
61
+            return false
62
+        }
63
+    }
64
+
65
+    private func timedOutMetricNames(_ progress: SnapshotFetchProgress?) -> [String] {
66
+        progress?.types.compactMap { type in
67
+            if case .failed(let r) = type.status, r == "Timeout" { return type.displayName }
68
+            return nil
69
+        } ?? []
70
+    }
71
+
72
+    private func hasTimedOutMetrics(_ progress: SnapshotFetchProgress?) -> Bool {
73
+        progress?.types.contains {
74
+            if case .failed(let reason) = $0.status { return reason == "Timeout" }
75
+            return false
76
+        } ?? false
77
+    }
78
+
79
+    private func hasAuthorizationFailures(_ progress: SnapshotFetchProgress?) -> Bool {
80
+        progress?.types.contains {
81
+            if case .failed(let reason) = $0.status { return reason == "Not authorized" }
82
+            return false
83
+        } ?? false
84
+    }
85
+
86
+    private func degradedTypesList(in progress: SnapshotFetchProgress) -> [SnapshotFetchProgress.TypeProgress] {
87
+        progress.types.filter { type in
88
+            let hasAllExpectedCalls = Set(type.apiCallDetails.map(\.queryType)) == Set(["distribution", "earliest_sample", "latest_sample"])
89
+            let allCallsComplete = type.apiCallDetails.allSatisfy { $0.status == .complete }
90
+            return type.quality != "complete" || !hasAllExpectedCalls || !allCallsComplete
91
+        }
92
+    }
93
+
94
+    private func formatDuration(_ seconds: TimeInterval) -> String {
95
+        if seconds < 60 { return String(format: "%.1fs", seconds) }
96
+        return "\(Int(seconds) / 60)m \(Int(seconds) % 60)s"
97
+    }
98
+
99
+    private func qualityLabel(progress: SnapshotFetchProgress) -> String {
100
+        switch viewModel.snapshotProgress {
101
+        case .complete: return "Complete"
102
+        case .incomplete:
103
+            let hasUnauthorized = failedTypesList(in: progress).contains {
104
+                if case .failed(let r) = $0.status { return r == "Not authorized" }
105
+                return false
106
+            }
107
+            return hasUnauthorized ? "Partial (unauthorized)" : "Partial"
108
+        default: return "—"
109
+        }
110
+    }
111
+
112
+    private func qualityColor(progress: SnapshotFetchProgress) -> Color {
113
+        switch viewModel.snapshotProgress {
114
+        case .complete:   return .healthyGreen
115
+        case .incomplete: return .warningAmber
116
+        default:          return .neutralGray
117
+        }
118
+    }
119
+
120
+    private func failureReason(_ type: SnapshotFetchProgress.TypeProgress) -> String {
121
+        if case .failed(let r) = type.status { return r.isEmpty ? "Failed" : r }
122
+        return "Unknown"
123
+    }
124
+
125
+    private func failureImpact(_ reason: String) -> String {
126
+        switch reason {
127
+        case "Not authorized": return "Excluded from checksum and anomaly detection"
128
+        case "Timeout":        return "Data unavailable — type skipped this run"
129
+        case "Unsupported":    return "Not supported on this device or OS version"
130
+        default:               return "Data unavailable"
131
+        }
132
+    }
133
+
134
+private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
135
+        let failed = failedTypesList(in: progress)
136
+        var items: [String] = []
137
+        let timedOut = failed.filter {
138
+            if case .failed(let r) = $0.status { return r == "Timeout" }
139
+            return false
140
+        }
141
+        if !timedOut.isEmpty {
142
+            items.append("Timeout reflects HealthProbe's configured limit, not missing Health data or permission.")
143
+        }
144
+        if viewModel.snapshotProgress == .incomplete {
145
+            items.append("Partial snapshots are excluded from anomaly detection. A complete snapshot is needed to resume integrity checks.")
146
+        }
147
+        return items
148
+    }
149
+
150
+    private func iso8601String(_ date: Date?) -> String {
151
+        guard let date else { return "none" }
152
+        return ISO8601DateFormatter().string(from: date)
153
+    }
154
+
155
+    private enum DiagnosticReportMode {
156
+        case compact
157
+        case full
158
+    }
159
+
160
+    private func operationResultValue() -> String {
161
+        switch viewModel.snapshotProgress {
162
+        case .complete:
163
+            return "complete_success"
164
+        case .incomplete:
165
+            return "partial_success"
166
+        case .idle, .fetching, .processing:
167
+            return "failed"
168
+        }
169
+    }
170
+
171
+    private func normalizedAuthorization(for type: SnapshotFetchProgress.TypeProgress) -> (status: String, source: String) {
172
+        let hasSuccessfulQuery = type.apiCallDetails.contains { $0.status == .complete }
173
+        let normalized = type.authorizationStatus.lowercased()
174
+
175
+        if hasSuccessfulQuery {
176
+            return ("granted", "inferredFromSuccessfulQuery")
177
+        }
178
+
179
+        if normalized == "denied" {
180
+            return ("denied", "inferredFromUnauthorizedQuery")
181
+        }
182
+
183
+        if normalized == "notdetermined" {
184
+            return ("notDetermined", "reported")
185
+        }
186
+
187
+        if normalized == "granted" {
188
+            return ("granted", "reported")
189
+        }
190
+
191
+        // When real HK authorization status is unavailable, report unknown+unavailable.
192
+        return ("unknown", "unavailable")
193
+    }
194
+
195
+    private func metricDateString(_ date: Date?, queryType: String, calls: [HealthKitAPICallResult]) -> String {
196
+        if let date { return iso8601String(date) }
197
+        guard let call = calls.first(where: { $0.queryType == queryType }) else { return "unknown" }
198
+        return call.status == .complete ? "none" : "unknown"
199
+    }
200
+
201
+    private func yearlyCountsString(_ counts: [SnapshotFetchProgress.YearlyCountProgress]) -> String {
202
+        guard !counts.isEmpty else { return "none" }
203
+        return counts
204
+            .sorted { $0.year < $1.year }
205
+            .map { "\($0.year)=\($0.count)\($0.isApproximate ? " approximate" : "")" }
206
+            .joined(separator: ", ")
207
+    }
208
+
209
+    private func callResultLabel(_ call: HealthKitAPICallResult) -> String {
210
+        if let result = call.resultValue, !result.isEmpty {
211
+            return result
212
+        }
213
+        return call.status == .complete ? "none" : "unknown"
214
+    }
215
+
216
+    private func shouldPrintCallErrorFields(_ call: HealthKitAPICallResult) -> Bool {
217
+        switch call.status {
218
+        case .failed, .timeout, .unknown, .unauthorized:
219
+            return true
220
+        case .complete, .unsupported:
221
+            return false
42 222
         }
43 223
     }
44 224
 
225
+    private func buildDiagnosticText(_ progress: SnapshotFetchProgress, mode: DiagnosticReportMode = .full) -> String {
226
+        let snapshotID = viewModel.completedSnapshotID.map { $0.uuidString } ?? "unknown"
227
+        let deviceID   = viewModel.completedSnapshotDeviceID ?? "unknown"
228
+        let timestamp  = viewModel.completedSnapshotTimestamp ?? Date()
229
+        let operationID = viewModel.operationID?.uuidString ?? "unknown"
230
+        let reportGeneratedAt = iso8601String(Date())
231
+        let trigger = viewModel.completedSnapshotTriggerReason ?? "manual"
232
+        let quality: String
233
+        switch viewModel.snapshotProgress {
234
+        case .complete:   quality = "complete"
235
+        case .incomplete: quality = "partial"
236
+        default:          quality = "unknown"
237
+        }
238
+        let duration = viewModel.fetchDurationSeconds.map { formatDuration($0) } ?? "unknown"
239
+        let fmt = DateFormatter()
240
+        fmt.dateFormat = "yyyy-MM-dd HH:mm:ss"
241
+        fmt.locale = Locale(identifier: "en_US_POSIX")
242
+        let tsStr = fmt.string(from: timestamp)
243
+        let degraded = degradedTypesList(in: progress)
244
+        let isPartialSnapshot = viewModel.snapshotProgress == .incomplete
245
+        let failedLines: String
246
+        if degraded.isEmpty {
247
+            failedLines = "FAILED/DEGRADED METRICS: none"
248
+        } else {
249
+            failedLines = (["FAILED/DEGRADED METRICS"] + degraded
250
+                .map { "  \($0.displayName): \($0.quality)" })
251
+                .joined(separator: "\n")
252
+        }
253
+
254
+        var lines: [String] = []
255
+        lines.append("BEGIN HEALTHPROBE REPORT")
256
+        lines.append("")
257
+        lines.append("OPERATION METADATA")
258
+        lines.append("operationType: \(trigger == "retryFailedMetrics" ? "retryFailedMetrics" : "createSnapshot")")
259
+        lines.append("operationID: \(operationID)")
260
+        lines.append("operationResult: \(operationResultValue())")
261
+        lines.append("primaryObjectType: snapshot")
262
+        lines.append("primaryObjectID: \(snapshotID)")
263
+        lines.append("reportSchemaVersion: 1")
264
+        lines.append("reportGeneratedAt: \(reportGeneratedAt)")
265
+        lines.append("")
266
+        lines.append("OPERATION SUMMARY")
267
+        lines.append("Snapshot:   \(snapshotID)")
268
+        lines.append("Device:     \(deviceID)")
269
+        lines.append("Timestamp:  \(tsStr)")
270
+        lines.append("Quality:    \(quality)")
271
+        lines.append("Duration:   \(duration)")
272
+        lines.append("Trigger:    \(trigger)")
273
+        if let retryOfSnapshotID = viewModel.completedSnapshotRetryOfSnapshotID {
274
+            lines.append("Retry of:   \(retryOfSnapshotID.uuidString)")
275
+        }
276
+        lines.append("")
277
+        lines.append("INTERPRETATION_HINTS")
278
+        lines.append("Partial snapshot: \(isPartialSnapshot ? "true" : "false")")
279
+        lines.append("Failed metrics are excluded from checksum/anomaly detection")
280
+        lines.append("Do not infer deletion from partial snapshots")
281
+        lines.append("Timeout means HealthProbe cancelled after configured timeout, not necessarily HealthKit denial")
282
+        lines.append("")
283
+        lines.append("CONFIGURATION")
284
+        lines.append("adaptiveTimeoutsEnabled: \(progress.adaptiveTimeoutsEnabled ? "true" : "false")")
285
+        lines.append("defaultInitialTimeout:   \(formatDuration(MetricTimeoutProfile.defaultInitialTimeout))")
286
+        lines.append("maximumTimeout:          \(formatDuration(MetricTimeoutProfile.maximumTimeout))")
287
+        lines.append("maxConcurrentTypeFetches: \(progress.maxConcurrentTypeFetches)")
288
+        lines.append("")
289
+        lines.append("CHAIN/INTEGRITY CONTEXT")
290
+        lines.append("previousSnapshotID:      \(progress.previousSnapshotID.map { $0.uuidString } ?? "none")")
291
+        lines.append("isChainStart:            \(progress.isChainStart.map { $0 ? "true" : "false" } ?? "unknown")")
292
+        lines.append("snapshotChecksum:        \(progress.snapshotChecksum)")
293
+        lines.append("monitoredTypeSetHash:    \(progress.monitoredTypeSetHash)")
294
+        lines.append("monitoredRegistryVersion: \(progress.monitoredRegistryVersion.map(String.init) ?? "unknown")")
295
+        lines.append("")
296
+        lines.append("STATISTICS")
297
+        lines.append("Records:    \(progress.totalRecords)")
298
+        lines.append("Types:      \(progress.types.count) processed, \(progress.completedCount) complete, \(degraded.count) degraded")
299
+        lines.append("")
300
+        lines.append(failedLines)
301
+
302
+        lines.append("")
303
+        lines.append("HEALTHKIT API RESULTS")
304
+        let orderedTypes = progress.types.sorted(by: { $0.displayName < $1.displayName })
305
+        let degradedIDs = Set(degraded.map(\.id))
306
+        let reportTypes: [SnapshotFetchProgress.TypeProgress]
307
+        switch mode {
308
+        case .compact:
309
+            reportTypes = orderedTypes.filter { degradedIDs.contains($0.id) }
310
+        case .full:
311
+            reportTypes = orderedTypes
312
+        }
313
+
314
+        for type in reportTypes {
315
+            lines.append("")
316
+            lines.append("\(type.displayName):")
317
+            lines.append("  identifier: \(type.id)")
318
+            lines.append("  quality: \(type.quality)")
319
+            lines.append("  count: \(type.recordCount)")
320
+            lines.append("  timeoutMode: \(type.timeoutMode)")
321
+            lines.append("  timeoutConfigured: \(formatDuration(type.timeoutConfiguredSeconds))")
322
+            lines.append("  lastSuccessfulElapsed: \(type.lastSuccessfulElapsed > 0 ? formatDuration(type.lastSuccessfulElapsed) : "none")")
323
+            lines.append("  learnedTimeout: \(type.learnedTimeout > 0 ? formatDuration(type.learnedTimeout) : "none")")
324
+            lines.append("  suggestedRetryTimeout: \(type.suggestedRetryTimeout > 0 ? formatDuration(type.suggestedRetryTimeout) : "none")")
325
+            lines.append("  timeoutCount: \(type.timeoutCount)")
326
+            lines.append("  successCount: \(type.successCount)")
327
+            lines.append("  totalElapsed: \(formatDuration(type.totalElapsedSeconds))")
328
+            if mode == .full || type.isUnsupported {
329
+                lines.append("  unsupported: \(type.isUnsupported ? "true" : "false")")
330
+            }
331
+            let auth = normalizedAuthorization(for: type)
332
+            lines.append("  authorizationStatus: \(auth.status)")
333
+            lines.append("  authorizationStatusSource: \(auth.source)")
334
+            lines.append("  earliestDate: \(metricDateString(type.earliestDate, queryType: "earliest_sample", calls: type.apiCallDetails))")
335
+            lines.append("  latestDate: \(metricDateString(type.latestDate, queryType: "latest_sample", calls: type.apiCallDetails))")
336
+            lines.append("  yearlyCounts: \(yearlyCountsString(type.yearlyCounts))")
337
+            if type.recordCount == 0 {
338
+                let zeroReason = type.quality == "complete" ? "complete query with zero records" : "\(type.authorizationStatus) authorization/query state"
339
+                lines.append("  zeroCountContext: \(zeroReason)")
340
+            }
341
+            if type.apiCallDetails.contains(where: { $0.status == .timeout }) {
342
+                lines.append("  interpretation: timeout policy failure, not HealthKit denial")
343
+                lines.append("  action: Retry with extended timeout")
344
+            }
345
+
346
+            lines.append("  apiCalls:")
347
+            for queryType in ["distribution", "earliest_sample", "latest_sample"] {
348
+                if let call = type.apiCallDetails.first(where: { $0.queryType == queryType }) {
349
+                    lines.append("    \(queryType):")
350
+                    lines.append("      status: \(call.statusDescription)")
351
+                    lines.append("      elapsed: \(String(format: "%.2f", call.elapsedSeconds))s")
352
+                    if mode == .full || call.status != .complete {
353
+                        lines.append("      result: \(callResultLabel(call))")
354
+                    }
355
+                    if shouldPrintCallErrorFields(call) {
356
+                        if let failureKind = call.failureKind, !failureKind.isEmpty, failureKind != "none" {
357
+                            lines.append("      failureKind: \(failureKind)")
358
+                        }
359
+                        if let cancellationReason = call.cancellationReason, !cancellationReason.isEmpty, cancellationReason != "none" {
360
+                            lines.append("      cancellationReason: \(cancellationReason)")
361
+                        }
362
+                        if let errorDomain = call.errorDomain, !errorDomain.isEmpty, errorDomain != "none" {
363
+                            lines.append("      errorDomain: \(errorDomain)")
364
+                        }
365
+                        if let errorCode = call.errorCode, !errorCode.isEmpty, errorCode != "none" {
366
+                            lines.append("      errorCode: \(errorCode)")
367
+                        }
368
+                        if let errorMessage = call.errorDescription, !errorMessage.isEmpty, errorMessage != "none" {
369
+                            lines.append("      errorMessage: \(errorMessage)")
370
+                        }
371
+                    }
372
+                } else {
373
+                    lines.append("    \(queryType):")
374
+                    lines.append("      status: unknown")
375
+                    lines.append("      elapsed: 0.00s")
376
+                    if mode == .full {
377
+                        lines.append("      result: unknown")
378
+                        lines.append("      failureKind: not_run")
379
+                    }
380
+                }
381
+            }
382
+        }
383
+
384
+        lines.append("")
385
+        lines.append("DEVICE/APP CONTEXT")
386
+        lines.append("OS: \(UIDevice.current.systemVersion)")
387
+        lines.append("App Version: \(Bundle.main.appVersion)")
388
+        lines.append("")
389
+        lines.append("END HEALTHPROBE REPORT")
390
+
391
+        return lines.joined(separator: "\n")
392
+    }
393
+
394
+    private func plainTextStatus(for status: SnapshotFetchProgress.TypeProgress.FetchStatus) -> String {
395
+        switch status {
396
+        case .pending: return "Pending"
397
+        case .fetching: return "Fetching"
398
+        case .complete: return "Complete"
399
+        case .failed(let message): return message.isEmpty ? "Failed" : "Failed (\(message))"
400
+        }
401
+    }
402
+
403
+    private func fetchProgressSummary(_ progress: SnapshotFetchProgress) -> String {
404
+        if progress.failedCount > 0 {
405
+            return "\(progress.completedCount)/\(progress.types.count) fetched - \(progress.failedCount) failed"
406
+        }
407
+        return "\(progress.completedCount)/\(progress.types.count) fetched"
408
+    }
409
+
410
+    private func colorForStatus(_ status: SnapshotFetchProgress.TypeProgress.FetchStatus) -> Color {
411
+        switch status {
412
+        case .pending: return .gray
413
+        case .fetching: return .blue
414
+        case .complete: return .healthyGreen
415
+        case .failed: return .criticalRed
416
+        }
417
+    }
418
+
419
+    private func rowBackground(for status: SnapshotFetchProgress.TypeProgress.FetchStatus) -> Color {
420
+        switch status {
421
+        case .fetching:
422
+            return .blue.opacity(0.1)
423
+        default:
424
+            return Color(.systemGray6)
425
+        }
426
+    }
427
+
428
+    private func rowBorder(for status: SnapshotFetchProgress.TypeProgress.FetchStatus) -> Color {
429
+        switch status {
430
+        case .fetching:
431
+            return .blue.opacity(0.3)
432
+        default:
433
+            return .clear
434
+        }
435
+    }
436
+
437
+    private var shouldShowFetchProgress: Bool {
438
+        switch viewModel.snapshotProgress {
439
+        case .fetching, .complete, .incomplete:
440
+            return viewModel.fetchProgress != nil
441
+        case .idle, .processing:
442
+            return false
443
+        }
444
+    }
445
+
446
+    private var shouldShowFetchReport: Bool {
447
+        switch viewModel.snapshotProgress {
448
+        case .complete, .incomplete:
449
+            return true
450
+        case .idle, .fetching, .processing:
451
+            return false
452
+        }
453
+    }
454
+
455
+    private func fetchReportView(_ progress: SnapshotFetchProgress) -> some View {
456
+        let isIncomplete = viewModel.snapshotProgress == .incomplete
457
+        let failed = isIncomplete ? failedTypesList(in: progress) : []
458
+        let noteItems  = isIncomplete ? remediationNoteItems(progress: progress) : []
459
+        return VStack(alignment: .leading, spacing: 20) {
460
+            reportSummarySection(progress)
461
+            if !failed.isEmpty { reportIssuesSection(failed) }
462
+            if !noteItems.isEmpty { reportRemediationSection(noteItems, title: "NOTES") }
463
+            reportDiagnosticBlock(progress)
464
+            if isIncomplete, hasTimedOutMetrics(progress) { reportDecisionOverviewSection() }
465
+            if isIncomplete { snapshotResultActionsSection }
466
+        }
467
+    }
468
+
469
+    private func reportSummarySection(_ progress: SnapshotFetchProgress) -> some View {
470
+        VStack(alignment: .leading, spacing: 6) {
471
+            Text("SUMMARY")
472
+                .font(.caption.weight(.semibold))
473
+                .foregroundStyle(.secondary)
474
+
475
+            let isComplete = viewModel.snapshotProgress == .complete
476
+            HStack(spacing: 10) {
477
+                Image(systemName: isComplete ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
478
+                    .font(.title3)
479
+                    .foregroundStyle(isComplete ? Color.healthyGreen : Color.warningAmber)
480
+                VStack(alignment: .leading, spacing: 2) {
481
+                    Text(isComplete ? "Snapshot created successfully" : "Partial snapshot")
482
+                        .font(.subheadline.weight(.semibold))
483
+                    Text(isComplete ? "All monitored metrics loaded" : "\(progress.failedCount) metric\(progress.failedCount == 1 ? "" : "s") failed")
484
+                        .font(.caption)
485
+                        .foregroundStyle(.secondary)
486
+                }
487
+                Spacer()
488
+            }
489
+            .padding(.horizontal, 12)
490
+            .padding(.vertical, 10)
491
+            .background(isComplete ? Color.healthyGreen.opacity(0.08) : Color.warningAmber.opacity(0.08))
492
+            .cornerRadius(8)
493
+
494
+            VStack(spacing: 0) {
495
+                ReportRow(label: "Types processed", value: "\(progress.types.count)")
496
+                Rectangle().fill(Color(.separator)).frame(height: 0.5).padding(.leading, 16)
497
+                ReportRow(label: "Successful", value: "\(progress.completedCount)", valueColor: .healthyGreen)
498
+                if progress.failedCount > 0 {
499
+                    Rectangle().fill(Color(.separator)).frame(height: 0.5).padding(.leading, 16)
500
+                    ReportRow(label: "Failed", value: "\(progress.failedCount)", valueColor: .criticalRed)
501
+                }
502
+                Rectangle().fill(Color(.separator)).frame(height: 0.5).padding(.leading, 16)
503
+                ReportRow(label: "Records fetched", value: "\(progress.totalRecords)")
504
+                Rectangle().fill(Color(.separator)).frame(height: 0.5).padding(.leading, 16)
505
+                ReportRow(
506
+                    label: "Quality",
507
+                    value: qualityLabel(progress: progress),
508
+                    valueColor: qualityColor(progress: progress)
509
+                )
510
+                if let secs = viewModel.fetchDurationSeconds {
511
+                    Rectangle().fill(Color(.separator)).frame(height: 0.5).padding(.leading, 16)
512
+                    ReportRow(label: "Duration", value: formatDuration(secs))
513
+                }
514
+                Rectangle().fill(Color(.separator)).frame(height: 0.5).padding(.leading, 16)
515
+                ReportRow(label: "Trigger", value: viewModel.completedSnapshotTriggerReason ?? "manual")
516
+            }
517
+            .background(Color(.systemGray6))
518
+            .cornerRadius(10)
519
+        }
520
+    }
521
+
522
+    private func reportIssuesSection(_ failed: [SnapshotFetchProgress.TypeProgress]) -> some View {
523
+        VStack(alignment: .leading, spacing: 6) {
524
+            Text("ISSUES")
525
+                .font(.caption.weight(.semibold))
526
+                .foregroundStyle(.secondary)
527
+
528
+            Text("\(failed.count) metric\(failed.count == 1 ? "" : "s") failed")
529
+                .font(.subheadline.weight(.semibold))
530
+                .foregroundStyle(Color.criticalRed)
531
+
532
+            VStack(spacing: 4) {
533
+                ForEach(failed) { type in
534
+                    let reason = failureReason(type)
535
+                    let isExpanded = expandedIssueIDs.contains(type.id)
536
+                    VStack(alignment: .leading, spacing: 0) {
537
+                        // Collapsed row (always visible)
538
+                        Button {
539
+                            withAnimation(.easeInOut(duration: 0.2)) {
540
+                                if isExpanded {
541
+                                    expandedIssueIDs.remove(type.id)
542
+                                } else {
543
+                                    expandedIssueIDs.insert(type.id)
544
+                                }
545
+                            }
546
+                        } label: {
547
+                            HStack(spacing: 8) {
548
+                                Image(systemName: reason == "Not authorized"
549
+                                      ? "exclamationmark.triangle.fill" : "xmark.circle.fill")
550
+                                    .foregroundStyle(reason == "Not authorized"
551
+                                                     ? Color.warningAmber : Color.criticalRed)
552
+                                    .font(.caption)
553
+                                    .frame(width: 14)
554
+                                Text(type.displayName)
555
+                                    .font(.subheadline.weight(.semibold))
556
+                                Text("— \(reason)")
557
+                                    .font(.subheadline)
558
+                                    .foregroundStyle(.secondary)
559
+                                Spacer()
560
+                                Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
561
+                                    .font(.caption2.weight(.semibold))
562
+                                    .foregroundStyle(.tertiary)
563
+                            }
564
+                            .padding(.horizontal, 12)
565
+                            .padding(.vertical, 10)
566
+                            .contentShape(Rectangle())
567
+                        }
568
+                        .buttonStyle(.plain)
569
+
570
+                        // Expanded detail (on tap)
571
+                        if isExpanded {
572
+                            Divider().padding(.horizontal, 12)
573
+                            VStack(alignment: .leading, spacing: 4) {
574
+                                Text("Reason: \(reason)").font(.caption).foregroundStyle(.secondary)
575
+                                Text("Impact: \(failureImpact(reason))").font(.caption).foregroundStyle(.secondary)
576
+                                if reason == "Timeout" {
577
+                                    Text("Configured: \(formatDuration(type.timeoutConfiguredSeconds)) \(type.timeoutMode)")
578
+                                        .font(.caption).foregroundStyle(.secondary)
579
+                                    if type.learnedTimeout > 0 {
580
+                                        Text("Observed needed: ~\(formatDuration(type.learnedTimeout))")
581
+                                            .font(.caption).foregroundStyle(.secondary)
582
+                                    }
583
+                                    Text("Suggested retry: \(type.suggestedRetryTimeout > 0 ? formatDuration(type.suggestedRetryTimeout) : formatDuration(MetricTimeoutProfile.maximumTimeout))")
584
+                                        .font(.caption).foregroundStyle(.secondary)
585
+                                }
586
+                            }
587
+                            .padding(.horizontal, 12)
588
+                            .padding(.vertical, 8)
589
+                            .frame(maxWidth: .infinity, alignment: .leading)
590
+                        }
591
+                    }
592
+                    .background(Color(.systemGray6))
593
+                    .cornerRadius(8)
594
+                }
595
+            }
596
+        }
597
+    }
598
+
599
+    private func reportDiagnosticBlock(_ progress: SnapshotFetchProgress) -> some View {
600
+        let previewText = buildDiagnosticText(progress, mode: .compact)
601
+        let fullText = buildDiagnosticText(progress, mode: .full)
602
+        return CollapsibleDiagnosticBlock(previewText: previewText, fullText: fullText)
603
+    }
604
+
605
+    private func reportDecisionOverviewSection() -> some View {
606
+        reportRemediationSection(
607
+            [
608
+                "Retry failed metric with extended timeout.",
609
+                "Accept snapshot if partial data is sufficient.",
610
+                "Discard snapshot and start a new one.",
611
+                "Disable this metric in Settings if it consistently times out."
612
+            ],
613
+            title: "WHAT YOU CAN DO"
614
+        )
615
+    }
616
+
617
+    private func reportRemediationSection(_ items: [String], title: String = "WHAT TO DO") -> some View {
618
+        let isNotes = title == "NOTES"
619
+        return VStack(alignment: .leading, spacing: 6) {
620
+            Text(title)
621
+                .font(isNotes ? .caption : .caption.weight(.semibold))
622
+                .foregroundStyle(isNotes ? Color(.tertiaryLabel) : .secondary)
623
+            VStack(alignment: .leading, spacing: isNotes ? 4 : 8) {
624
+                ForEach(Array(items.enumerated()), id: \.offset) { index, item in
625
+                    HStack(alignment: .top, spacing: 8) {
626
+                        if isNotes {
627
+                            Text("·")
628
+                                .font(.caption2)
629
+                                .foregroundStyle(Color(.tertiaryLabel))
630
+                                .frame(width: 12, alignment: .trailing)
631
+                        } else {
632
+                            Text("\(index + 1).")
633
+                                .font(.caption.weight(.semibold))
634
+                                .foregroundStyle(.secondary)
635
+                                .frame(width: 16, alignment: .trailing)
636
+                        }
637
+                        Text(item)
638
+                            .font(isNotes ? .caption2 : .caption)
639
+                            .foregroundStyle(isNotes ? Color(.secondaryLabel) : .primary)
640
+                            .fixedSize(horizontal: false, vertical: true)
641
+                    }
642
+                }
643
+            }
644
+            .padding(12)
645
+            .frame(maxWidth: .infinity, alignment: .leading)
646
+            .background(Color(.systemGray6))
647
+            .cornerRadius(8)
648
+        }
649
+    }
650
+
651
+    private func progressFeedView(_ progress: SnapshotFetchProgress) -> some View {
652
+        VStack(spacing: 12) {
653
+            HStack {
654
+                Text(fetchProgressSummary(progress))
655
+                    .font(.caption.weight(.semibold))
656
+                    .foregroundStyle(.secondary)
657
+                Spacer()
658
+                Text("\(progress.totalRecords) records")
659
+                    .font(.caption)
660
+                    .foregroundStyle(.secondary)
661
+            }
662
+
663
+            ScrollView {
664
+                VStack(spacing: 8) {
665
+                    ForEach(progress.visibleTypes) { type in
666
+                        HStack(spacing: 12) {
667
+                            if case .fetching = type.status {
668
+                                ProgressView()
669
+                                    .scaleEffect(0.8, anchor: .center)
670
+                                    .frame(width: 20)
671
+                            } else {
672
+                                Image(systemName: type.status.icon)
673
+                                    .font(.subheadline.weight(.semibold))
674
+                                    .frame(width: 20)
675
+                                    .foregroundStyle(colorForStatus(type.status))
676
+                            }
677
+
678
+                            Text(type.displayName)
679
+                                .font(.caption)
680
+                                .lineLimit(1)
681
+                                .frame(maxWidth: .infinity, alignment: .leading)
682
+
683
+                            if type.recordCount > 0 {
684
+                                Text("\(type.recordCount) records")
685
+                                    .font(.caption2)
686
+                                    .foregroundStyle(.secondary)
687
+                                    .lineLimit(1)
688
+                            }
689
+                        }
690
+                        .padding(.horizontal, 10)
691
+                        .padding(.vertical, 9)
692
+                        .background(rowBackground(for: type.status))
693
+                        .cornerRadius(6)
694
+                        .overlay {
695
+                            RoundedRectangle(cornerRadius: 6)
696
+                                .stroke(rowBorder(for: type.status), lineWidth: 1)
697
+                        }
698
+                    }
699
+                }
700
+                .frame(maxWidth: .infinity)
701
+            }
702
+        }
703
+        .frame(maxHeight: .infinity)
704
+    }
705
+
706
+    private var pendingReportView: some View {
707
+        VStack(spacing: 12) {
708
+            Image(systemName: "doc.text.magnifyingglass")
709
+                .font(.system(size: 36))
710
+                .foregroundStyle(.secondary)
711
+            Text("Report will appear when the snapshot finishes.")
712
+                .font(.caption)
713
+                .foregroundStyle(.secondary)
714
+                .multilineTextAlignment(.center)
715
+        }
716
+        .frame(maxWidth: .infinity, maxHeight: .infinity)
717
+    }
718
+
719
+    private var snapshotSheetTabPicker: some View {
720
+        HStack(spacing: 2) {
721
+            ForEach(SnapshotSheetTab.allCases) { tab in
722
+                let isSelected = snapshotSheetTab == tab
723
+                let isDisabled = tab == .report && !shouldShowFetchReport
724
+
725
+                Button {
726
+                    snapshotSheetTab = tab
727
+                } label: {
728
+                    Text(tab.rawValue)
729
+                        .font(.caption.weight(.semibold))
730
+                        .frame(maxWidth: .infinity)
731
+                        .padding(.vertical, 8)
732
+                        .foregroundStyle(isDisabled ? .tertiary : (isSelected ? .primary : .secondary))
733
+                        .background(isSelected ? Color(.systemBackground) : Color.clear)
734
+                        .cornerRadius(6)
735
+                }
736
+                .buttonStyle(.plain)
737
+                .disabled(isDisabled)
738
+                .accessibilityLabel(tab.rawValue)
739
+            }
740
+        }
741
+        .padding(2)
742
+        .background(Color(.systemGray5))
743
+        .cornerRadius(8)
744
+    }
745
+
746
+    private var sheetOperationHeader: some View {
747
+        VStack(alignment: .center, spacing: 4) {
748
+            HStack(spacing: 8) {
749
+                Image(systemName: "camera.viewfinder")
750
+                    .font(.system(size: 20, weight: .semibold))
751
+                    .foregroundStyle(Color(.secondaryLabel))
752
+                    .frame(width: 22)
753
+
754
+                HStack(spacing: 4) {
755
+                    Text("Health data snapshot").font(.headline)
756
+
757
+                    if viewModel.snapshotProgress == .complete {
758
+                        Image(systemName: "checkmark.circle.fill")
759
+                            .font(.caption)
760
+                            .foregroundStyle(Color.healthyGreen)
761
+                    } else if viewModel.snapshotProgress == .incomplete {
762
+                        Image(systemName: "exclamationmark.circle.fill")
763
+                            .font(.caption)
764
+                            .foregroundStyle(Color.warningAmber)
765
+                    }
766
+                }
767
+            }
768
+
769
+            if let progress = viewModel.fetchProgress {
770
+                Text("\(progress.types.count) metrics")
771
+                    .font(.caption)
772
+                    .foregroundStyle(.secondary)
773
+            }
774
+        }
775
+        .frame(maxWidth: .infinity)
776
+    }
777
+
778
+    // MARK: - Progress Sheet
779
+
780
+    private var progressSheet: some View {
781
+        VStack(spacing: 16) {
782
+            sheetOperationHeader
783
+                .frame(maxWidth: .infinity, alignment: .center)
784
+
785
+            if let progress = viewModel.fetchProgress, shouldShowFetchProgress {
786
+                VStack(spacing: 12) {
787
+                    snapshotSheetTabPicker
788
+
789
+                    Group {
790
+                        switch snapshotSheetTab {
791
+                        case .progress:
792
+                            progressFeedView(progress)
793
+                        case .report:
794
+                            if shouldShowFetchReport {
795
+                                ScrollView {
796
+                                    fetchReportView(progress)
797
+                                }
798
+                            } else {
799
+                                pendingReportView
800
+                            }
801
+                        }
802
+                    }
803
+                    .frame(maxHeight: .infinity)
804
+                }
805
+                .frame(maxHeight: .infinity)
806
+            }
807
+
808
+            if viewModel.snapshotProgress == .idle, let errorMsg = viewModel.snapshotError, !errorMsg.isEmpty {
809
+                VStack(spacing: 12) {
810
+                    HStack {
811
+                        Text("Error details")
812
+                            .font(.caption.weight(.semibold))
813
+                        Spacer()
814
+                        Button {
815
+                            UIPasteboard.general.string = errorMsg
816
+                        } label: {
817
+                            Image(systemName: "doc.on.doc")
818
+                                .font(.caption)
819
+                        }
820
+                        .accessibilityLabel("Copy error details to clipboard")
821
+                    }
822
+                    .foregroundStyle(.secondary)
823
+
824
+                    Text(errorMsg)
825
+                        .font(.caption)
826
+                        .textSelection(.enabled)
827
+                        .frame(maxWidth: .infinity, alignment: .leading)
828
+                        .padding(12)
829
+                        .background(Color(.systemGray6))
830
+                        .cornerRadius(8)
831
+                }
832
+            }
833
+        }
834
+        .padding(24)
835
+        .presentationDetents(sheetDetents)
836
+        .presentationDragIndicator(.visible)
837
+        .interactiveDismissDisabled(viewModel.snapshotProgress == .fetching || viewModel.isRequestingAuth)
838
+        .onAppear {
839
+            snapshotSheetTab = .progress
840
+        }
841
+        .onChange(of: viewModel.snapshotProgress) { _, newProgress in
842
+            if newProgress == .incomplete {
843
+                snapshotSheetTab = .report
844
+            } else if newProgress == .fetching {
845
+                snapshotSheetTab = .progress
846
+            }
847
+        }
848
+    }
849
+
850
+    @ViewBuilder
851
+    private var snapshotResultActionsSection: some View {
852
+        HStack(spacing: 12) {
853
+            Button {
854
+                Task {
855
+                    if viewModel.canRetryWithPermissions {
856
+                        await viewModel.retryWithPermissions(
857
+                            context: modelContext,
858
+                            selectedTypeIDs: appSettings.selectedTypeIDs,
859
+                            adaptiveTimeoutsEnabled: appSettings.adaptiveTimeoutsEnabled
860
+                        )
861
+                    } else {
862
+                        await viewModel.retryFailedMetricsWithExtendedTimeout(context: modelContext)
863
+                    }
864
+                }
865
+            } label: {
866
+                Text("Retry").frame(maxWidth: .infinity)
867
+            }
868
+            .buttonStyle(.borderedProminent)
869
+            .disabled(viewModel.isCreatingSnapshot || viewModel.isRequestingAuth)
870
+
871
+            Button {
872
+                viewModel.acceptPartialSnapshot()
873
+            } label: {
874
+                Text("Accept").frame(maxWidth: .infinity)
875
+            }
876
+            .buttonStyle(.bordered)
877
+            .accessibilityLabel("Accept partial snapshot")
878
+
879
+            Button {
880
+                Task { await viewModel.discardSnapshot(context: modelContext) }
881
+            } label: {
882
+                Text("Discard").foregroundStyle(Color.criticalRed).frame(maxWidth: .infinity)
883
+            }
884
+            .buttonStyle(.bordered)
885
+            .disabled(viewModel.isCreatingSnapshot)
886
+            .accessibilityLabel("Discard snapshot")
887
+        }
888
+    }
889
+
890
+    private var sheetDetents: Set<PresentationDetent> {
891
+        [.large]
892
+    }
893
+
45 894
     // MARK: - Sections
46 895
 
47 896
     private var statusSection: some View {
@@ -103,15 +952,12 @@ struct DashboardView: View {
103 952
                 Task {
104 953
                     await viewModel.createSnapshot(
105 954
                         context: modelContext,
106
-                        selectedTypeIDs: appSettings.selectedTypeIDs
955
+                        selectedTypeIDs: appSettings.selectedTypeIDs,
956
+                        adaptiveTimeoutsEnabled: appSettings.adaptiveTimeoutsEnabled
107 957
                     )
108 958
                 }
109 959
             } label: {
110
-                HStack {
111
-                    Label("Create Snapshot", systemImage: "camera.viewfinder")
112
-                    Spacer()
113
-                    if viewModel.isCreatingSnapshot { ProgressView() }
114
-                }
960
+                Label("Create Snapshot", systemImage: "camera.viewfinder")
115 961
             }
116 962
             .disabled(viewModel.isCreatingSnapshot)
117 963
             .accessibilityLabel("Create a new data snapshot")
@@ -119,6 +965,13 @@ struct DashboardView: View {
119 965
     }
120 966
 }
121 967
 
968
+private enum SnapshotSheetTab: String, CaseIterable, Identifiable {
969
+    case progress = "Progress"
970
+    case report = "Report"
971
+
972
+    var id: Self { self }
973
+}
974
+
122 975
 // Avoids LabeledContent's TableRowContent ambiguity in List/Section contexts.
123 976
 private struct InfoRow<Content: View>: View {
124 977
     let label: String
@@ -133,6 +986,57 @@ private struct InfoRow<Content: View>: View {
133 986
     }
134 987
 }
135 988
 
989
+private struct ReportRow: View {
990
+    let label: String
991
+    let value: String
992
+    var valueColor: Color = .primary
993
+
994
+    var body: some View {
995
+        HStack {
996
+            Text(label)
997
+                .font(.subheadline)
998
+            Spacer()
999
+            Text(value)
1000
+                .font(.subheadline.weight(.semibold))
1001
+                .foregroundStyle(valueColor)
1002
+        }
1003
+        .padding(.horizontal, 12)
1004
+        .padding(.vertical, 10)
1005
+    }
1006
+}
1007
+
1008
+private struct CollapsibleDiagnosticBlock: View {
1009
+    let previewText: String
1010
+    let fullText: String
1011
+    @State private var isExpanded = false
1012
+
1013
+    var body: some View {
1014
+        VStack(alignment: .leading, spacing: 8) {
1015
+            Button {
1016
+                withAnimation(.snappy) {
1017
+                    isExpanded.toggle()
1018
+                }
1019
+            } label: {
1020
+                HStack {
1021
+                    Label("Diagnostics", systemImage: "doc.text.magnifyingglass")
1022
+                    Spacer()
1023
+                    Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
1024
+                        .font(.caption.weight(.semibold))
1025
+                }
1026
+            }
1027
+            .buttonStyle(.plain)
1028
+
1029
+            Text(isExpanded ? fullText : previewText)
1030
+                .font(.system(.caption, design: .monospaced))
1031
+                .textSelection(.enabled)
1032
+                .frame(maxWidth: .infinity, alignment: .leading)
1033
+                .padding(10)
1034
+                .background(Color(.secondarySystemGroupedBackground))
1035
+                .clipShape(RoundedRectangle(cornerRadius: 8))
1036
+        }
1037
+    }
1038
+}
1039
+
136 1040
 private struct AnomalySummarySection: View {
137 1041
     @Query(filter: #Predicate<AnomalyRecord> { !$0.isResolved })
138 1042
     private var unresolved: [AnomalyRecord]
@@ -158,6 +1062,18 @@ private struct AnomalySummarySection: View {
158 1062
     }
159 1063
 }
160 1064
 
1065
+extension Bundle {
1066
+    var appVersion: String {
1067
+        guard let version = infoDictionary?["CFBundleShortVersionString"] as? String else {
1068
+            return "unknown"
1069
+        }
1070
+        guard let build = infoDictionary?["CFBundleVersion"] as? String else {
1071
+            return version
1072
+        }
1073
+        return "\(version)(\(build))"
1074
+    }
1075
+}
1076
+
161 1077
 #Preview {
162 1078
     DashboardView()
163 1079
         .modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self], inMemory: true)
+0 -358
HealthProbe/Views/Dashboard/SnapshotProgressSheet.swift
@@ -1,358 +0,0 @@
1
-import SwiftUI
2
-
3
-struct SnapshotProgressSheet: View {
4
-    @Bindable var viewModel: DashboardViewModel
5
-    @State private var selectedTab: Tab = .progress
6
-
7
-    enum Tab { case progress, report }
8
-
9
-    var body: some View {
10
-        NavigationStack {
11
-            VStack(spacing: 12) {
12
-                // Header
13
-                HStack {
14
-                    VStack(alignment: .leading, spacing: 2) {
15
-                        Text("Health data snapshot")
16
-                            .font(.headline)
17
-                        Text("\(viewModel.fetchProgress.count) metrics")
18
-                            .font(.caption)
19
-                            .foregroundStyle(.secondary)
20
-                    }
21
-                    Spacer()
22
-                    Image(systemName: statusIcon)
23
-                        .font(.title3)
24
-                        .foregroundStyle(statusColor)
25
-                }
26
-                .padding(.horizontal)
27
-
28
-                // Tab picker
29
-                Picker("View", selection: $selectedTab) {
30
-                    Text("Progress").tag(Tab.progress)
31
-                    Text("Report").tag(Tab.report)
32
-                }
33
-                .pickerStyle(.segmented)
34
-                .padding(.horizontal)
35
-
36
-                // Content
37
-                Group {
38
-                    switch selectedTab {
39
-                    case .progress:
40
-                        ProgressTabView(entries: viewModel.fetchProgress, isComplete: !viewModel.isCreatingSnapshot)
41
-                    case .report:
42
-                        ReportTabView(entries: viewModel.fetchProgress, duration: duration)
43
-                    }
44
-                }
45
-                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
46
-            }
47
-            .padding(.vertical, 12)
48
-        }
49
-    }
50
-
51
-    private var statusIcon: String {
52
-        let unauthorized = viewModel.fetchProgress.filter { if case .unauthorized = $0.status { return true }; return false }
53
-        let failed = viewModel.fetchProgress.filter { if case .failed = $0.status { return true }; return false }
54
-
55
-        if viewModel.isCreatingSnapshot {
56
-            return "hourglass.circle"
57
-        } else if unauthorized.isEmpty && failed.isEmpty {
58
-            return "checkmark.circle.fill"
59
-        } else {
60
-            return "exclamationmark.circle.fill"
61
-        }
62
-    }
63
-
64
-    private var statusColor: Color {
65
-        let unauthorized = viewModel.fetchProgress.filter { if case .unauthorized = $0.status { return true }; return false }
66
-        let failed = viewModel.fetchProgress.filter { if case .failed = $0.status { return true }; return false }
67
-
68
-        if viewModel.isCreatingSnapshot {
69
-            return .blue
70
-        } else if unauthorized.isEmpty && failed.isEmpty {
71
-            return Color.healthyGreen
72
-        } else if unauthorized.isEmpty {
73
-            return Color.criticalRed
74
-        } else {
75
-            return Color.warningAmber
76
-        }
77
-    }
78
-
79
-    private var duration: TimeInterval? {
80
-        guard let start = viewModel.fetchStartTime else { return nil }
81
-        return Date().timeIntervalSince(start)
82
-    }
83
-}
84
-
85
-// MARK: - Progress Tab
86
-private struct ProgressTabView: View {
87
-    let entries: [TypeProgressEntry]
88
-    let isComplete: Bool
89
-
90
-    var body: some View {
91
-        List {
92
-            ForEach(entries) { entry in
93
-                HStack(spacing: 12) {
94
-                    icon(for: entry.status)
95
-                        .frame(width: 20)
96
-
97
-                    VStack(alignment: .leading, spacing: 2) {
98
-                        Text(entry.displayName)
99
-                            .font(.subheadline)
100
-                        statusText(for: entry.status)
101
-                            .font(.caption)
102
-                            .foregroundStyle(.secondary)
103
-                    }
104
-
105
-                    Spacer()
106
-                }
107
-                .padding(.vertical, 4)
108
-            }
109
-        }
110
-        .listStyle(.plain)
111
-    }
112
-
113
-    private func icon(for status: TypeProgressEntry.Status) -> some View {
114
-        Group {
115
-            switch status {
116
-            case .pending:
117
-                Image(systemName: "circle")
118
-                    .foregroundStyle(.gray)
119
-            case .complete:
120
-                Image(systemName: "checkmark.circle.fill")
121
-                    .foregroundStyle(Color.healthyGreen)
122
-            case .unauthorized:
123
-                Image(systemName: "exclamationmark.triangle.fill")
124
-                    .foregroundStyle(Color.warningAmber)
125
-            case .failed:
126
-                Image(systemName: "xmark.circle.fill")
127
-                    .foregroundStyle(Color.criticalRed)
128
-            }
129
-        }
130
-    }
131
-
132
-    private func statusText(for status: TypeProgressEntry.Status) -> some View {
133
-        Group {
134
-            switch status {
135
-            case .pending:
136
-                Text("Pending")
137
-            case .complete(let count):
138
-                Text("\(count) records")
139
-            case .unauthorized:
140
-                Text("Not authorized")
141
-            case .failed(let reason):
142
-                Text(reason)
143
-            }
144
-        }
145
-    }
146
-}
147
-
148
-// MARK: - Report Tab
149
-private struct ReportTabView: View {
150
-    let entries: [TypeProgressEntry]
151
-    let duration: TimeInterval?
152
-
153
-    var body: some View {
154
-        ScrollView {
155
-            VStack(alignment: .leading, spacing: 16) {
156
-                // Summary
157
-                VStack(alignment: .leading, spacing: 8) {
158
-                    Text("SUMMARY")
159
-                        .font(.caption.weight(.semibold))
160
-                        .foregroundStyle(.secondary)
161
-
162
-                    let successful = entries.filter { if case .complete = $0.status { return true }; return false }
163
-                    let unauthorized = entries.filter { if case .unauthorized = $0.status { return true }; return false }
164
-                    let failed = entries.filter { if case .failed = $0.status { return true }; return false }
165
-
166
-                    HStack {
167
-                        Text("Successful: \(successful.count)")
168
-                        Spacer()
169
-                        Text("\(successful.count)")
170
-                            .fontWeight(.semibold)
171
-                            .foregroundStyle(Color.healthyGreen)
172
-                    }
173
-                    .font(.caption)
174
-
175
-                    if !unauthorized.isEmpty {
176
-                        HStack {
177
-                            Text("Not authorized: \(unauthorized.count)")
178
-                            Spacer()
179
-                            Text("\(unauthorized.count)")
180
-                                .fontWeight(.semibold)
181
-                                .foregroundStyle(Color.warningAmber)
182
-                        }
183
-                        .font(.caption)
184
-                    }
185
-
186
-                    if !failed.isEmpty {
187
-                        HStack {
188
-                            Text("Failed: \(failed.count)")
189
-                            Spacer()
190
-                            Text("\(failed.count)")
191
-                                .fontWeight(.semibold)
192
-                                .foregroundStyle(Color.criticalRed)
193
-                        }
194
-                        .font(.caption)
195
-                    }
196
-
197
-                    if let duration {
198
-                        HStack {
199
-                            Text("Duration:")
200
-                            Spacer()
201
-                            Text(formatDuration(duration))
202
-                                .fontWeight(.semibold)
203
-                                .foregroundStyle(.secondary)
204
-                        }
205
-                        .font(.caption)
206
-                    }
207
-                }
208
-                .padding(12)
209
-                .background(Color(.systemGray6))
210
-                .cornerRadius(8)
211
-
212
-                // Unauthorized metrics advice
213
-                let unauthorized = entries.filter { if case .unauthorized = $0.status { return true }; return false }
214
-                if !unauthorized.isEmpty {
215
-                    VStack(alignment: .leading, spacing: 8) {
216
-                        Label("Permission required", systemImage: "exclamationmark.circle.fill")
217
-                            .font(.subheadline.weight(.semibold))
218
-                            .foregroundStyle(Color.warningAmber)
219
-
220
-                        Text("\(unauthorized.count) metric\(unauthorized.count == 1 ? "" : "s") excluded — enable in Health app")
221
-                            .font(.caption)
222
-                            .foregroundStyle(.secondary)
223
-
224
-                        VStack(alignment: .leading, spacing: 8) {
225
-                            ForEach(unauthorized) { entry in
226
-                                VStack(alignment: .leading, spacing: 4) {
227
-                                    Text(entry.displayName)
228
-                                        .font(.caption.weight(.semibold))
229
-                                    Text("Health app → Settings → Privacy → Health → HealthProbe")
230
-                                        .font(.caption2)
231
-                                        .foregroundStyle(.secondary)
232
-                                }
233
-                                .padding(8)
234
-                                .background(Color(.systemGray6))
235
-                                .cornerRadius(6)
236
-                            }
237
-                        }
238
-                    }
239
-                }
240
-
241
-                // Failed metrics
242
-                let failed = entries.filter { if case .failed = $0.status { return true }; return false }
243
-                if !failed.isEmpty {
244
-                    VStack(alignment: .leading, spacing: 8) {
245
-                        Label("Errors", systemImage: "xmark.circle.fill")
246
-                            .font(.subheadline.weight(.semibold))
247
-                            .foregroundStyle(Color.criticalRed)
248
-
249
-                        VStack(alignment: .leading, spacing: 8) {
250
-                            ForEach(failed) { entry in
251
-                                VStack(alignment: .leading, spacing: 2) {
252
-                                    Text(entry.displayName)
253
-                                        .font(.caption.weight(.semibold))
254
-                                    if case .failed(let reason) = entry.status {
255
-                                        Text(reason)
256
-                                            .font(.caption2)
257
-                                            .foregroundStyle(.secondary)
258
-                                    }
259
-                                }
260
-                                .padding(8)
261
-                                .background(Color(.systemGray6))
262
-                                .cornerRadius(6)
263
-                            }
264
-                        }
265
-                    }
266
-                }
267
-
268
-                // All metrics list
269
-                VStack(alignment: .leading, spacing: 8) {
270
-                    Text("ALL METRICS")
271
-                        .font(.caption.weight(.semibold))
272
-                        .foregroundStyle(.secondary)
273
-
274
-                    VStack(spacing: 6) {
275
-                        ForEach(entries.sorted { $0.displayName < $1.displayName }) { entry in
276
-                            HStack(spacing: 8) {
277
-                                icon(for: entry.status)
278
-                                    .frame(width: 14)
279
-
280
-                                Text(entry.displayName)
281
-                                    .font(.caption)
282
-
283
-                                Spacer()
284
-
285
-                                statusLabel(for: entry.status)
286
-                                    .font(.caption2)
287
-                                    .foregroundStyle(.secondary)
288
-                            }
289
-                            .padding(.vertical, 6)
290
-                            .padding(.horizontal, 10)
291
-                            .background(Color(.systemGray6))
292
-                            .cornerRadius(6)
293
-                        }
294
-                    }
295
-                }
296
-            }
297
-            .padding(12)
298
-        }
299
-    }
300
-
301
-    private func icon(for status: TypeProgressEntry.Status) -> some View {
302
-        Group {
303
-            switch status {
304
-            case .complete:
305
-                Image(systemName: "checkmark.circle.fill")
306
-                    .foregroundStyle(Color.healthyGreen)
307
-            case .unauthorized:
308
-                Image(systemName: "exclamationmark.triangle.fill")
309
-                    .foregroundStyle(Color.warningAmber)
310
-            case .failed:
311
-                Image(systemName: "xmark.circle.fill")
312
-                    .foregroundStyle(Color.criticalRed)
313
-            case .pending:
314
-                Image(systemName: "circle")
315
-                    .foregroundStyle(.gray)
316
-            }
317
-        }
318
-    }
319
-
320
-    private func statusLabel(for status: TypeProgressEntry.Status) -> some View {
321
-        Group {
322
-            switch status {
323
-            case .complete(let count):
324
-                Text("\(count) records")
325
-            case .unauthorized:
326
-                Text("Not authorized")
327
-            case .failed(let reason):
328
-                Text(reason)
329
-            case .pending:
330
-                Text("Pending")
331
-            }
332
-        }
333
-    }
334
-
335
-    private func formatDuration(_ seconds: TimeInterval) -> String {
336
-        if seconds < 60 {
337
-            return String(format: "%.1fs", seconds)
338
-        } else {
339
-            let minutes = Int(seconds) / 60
340
-            let secs = Int(seconds) % 60
341
-            return String(format: "%dm %ds", minutes, secs)
342
-        }
343
-    }
344
-}
345
-
346
-#Preview {
347
-    @Previewable @State var viewModel = DashboardViewModel()
348
-
349
-    SnapshotProgressSheet(viewModel: viewModel)
350
-        .onAppear {
351
-            viewModel.fetchProgress = [
352
-                TypeProgressEntry(id: "steps", displayName: "Steps", status: .complete(1234)),
353
-                TypeProgressEntry(id: "activeEnergy", displayName: "Active Energy", status: .unauthorized),
354
-                TypeProgressEntry(id: "heartRate", displayName: "Heart Rate", status: .complete(5678)),
355
-                TypeProgressEntry(id: "sleep", displayName: "Sleep", status: .failed("Timeout")),
356
-            ]
357
-        }
358
-}
+143 -2
HealthProbe/Views/Settings/SettingsView.swift
@@ -8,6 +8,7 @@ struct SettingsView: View {
8 8
     @Environment(AppSettings.self) private var appSettings
9 9
     @Query private var snapshots: [HealthSnapshot]
10 10
     @Query private var deviceProfiles: [DeviceProfile]
11
+    @Query(sort: \MetricTimeoutProfile.displayName) private var timeoutProfiles: [MetricTimeoutProfile]
11 12
     @AppStorage("checkFrequencyHours") private var checkFrequencyHours: Int = 6
12 13
     @State private var showDeleteConfirm = false
13 14
 
@@ -24,12 +25,16 @@ struct SettingsView: View {
24 25
             List {
25 26
                 deviceSection
26 27
                 monitoringSection
28
+                timeoutCalibrationSection
27 29
                 typeSelectionSections
28 30
                 dataSection
29 31
                 aboutSection
30 32
             }
31 33
             .navigationTitle("Settings")
32
-            .onAppear { ensureCurrentDeviceProfile() }
34
+            .onAppear {
35
+                ensureCurrentDeviceProfile()
36
+                ensureTimeoutProfiles()
37
+            }
33 38
             .confirmationDialog(
34 39
                 "Delete All Audit Data",
35 40
                 isPresented: $showDeleteConfirm,
@@ -98,6 +103,36 @@ struct SettingsView: View {
98 103
         }
99 104
     }
100 105
 
106
+    private var timeoutCalibrationSection: some View {
107
+        Section("Timeout Calibration") {
108
+            Toggle("Adaptive Timeouts", isOn: Binding(
109
+                get: { appSettings.adaptiveTimeoutsEnabled },
110
+                set: { appSettings.adaptiveTimeoutsEnabled = $0 }
111
+            ))
112
+
113
+            InfoRow(label: "Default Initial Timeout") {
114
+                Text(formatDuration(MetricTimeoutProfile.defaultInitialTimeout))
115
+                    .foregroundStyle(.secondary)
116
+            }
117
+
118
+            InfoRow(label: "Maximum Timeout") {
119
+                Text(formatDuration(MetricTimeoutProfile.maximumTimeout))
120
+                    .foregroundStyle(.secondary)
121
+            }
122
+
123
+            ForEach(timeoutProfiles) { profile in
124
+                TimeoutProfileRow(profile: profile)
125
+            }
126
+
127
+            Button(role: .destructive) {
128
+                resetAllTimeoutProfiles()
129
+            } label: {
130
+                Label("Reset All Learned Timeouts", systemImage: "arrow.counterclockwise")
131
+            }
132
+            .disabled(timeoutProfiles.isEmpty)
133
+        }
134
+    }
135
+
101 136
     @ViewBuilder
102 137
     private var typeSelectionSections: some View {
103 138
         ForEach(TypeCategory.allCases, id: \.self) { category in
@@ -144,6 +179,21 @@ struct SettingsView: View {
144 179
         modelContext.insert(DeviceProfile(deviceID: currentDeviceID))
145 180
     }
146 181
 
182
+    private func ensureTimeoutProfiles() {
183
+        let existingIDs = Set(timeoutProfiles.map(\.metricIdentifier))
184
+        for type in HealthKitService.allTypes where !existingIDs.contains(type.id) {
185
+            modelContext.insert(MetricTimeoutProfile(metricIdentifier: type.id, displayName: type.displayName))
186
+        }
187
+        try? modelContext.save()
188
+    }
189
+
190
+    private func resetAllTimeoutProfiles() {
191
+        for profile in timeoutProfiles {
192
+            profile.resetLearning()
193
+        }
194
+        try? modelContext.save()
195
+    }
196
+
147 197
     private func deleteAllData() {
148 198
         for snapshot in snapshots { modelContext.delete(snapshot) }
149 199
         try? modelContext.save()
@@ -167,6 +217,91 @@ private struct TypeToggleRow: View {
167 217
     }
168 218
 }
169 219
 
220
+private struct TimeoutProfileRow: View {
221
+    @Bindable var profile: MetricTimeoutProfile
222
+
223
+    var body: some View {
224
+        VStack(alignment: .leading, spacing: 8) {
225
+            HStack(alignment: .firstTextBaseline) {
226
+                Text(profile.displayName)
227
+                    .font(.subheadline.weight(.semibold))
228
+                Spacer()
229
+                Text(formatDuration(profile.effectiveTimeout))
230
+                    .font(.subheadline.weight(.semibold))
231
+                    .foregroundStyle(.secondary)
232
+            }
233
+
234
+            HStack {
235
+                Text(profile.timeoutMode.capitalized)
236
+                Spacer()
237
+                Text("Success \(profile.successCount)")
238
+                Text("Timeouts \(profile.timeoutCount)")
239
+            }
240
+            .font(.caption)
241
+            .foregroundStyle(.secondary)
242
+
243
+            HStack {
244
+                Text("Last success")
245
+                Spacer()
246
+                Text(profile.lastSuccessfulElapsed > 0 ? formatDuration(profile.lastSuccessfulElapsed) : "none")
247
+            }
248
+            .font(.caption)
249
+            .foregroundStyle(.secondary)
250
+
251
+            HStack {
252
+                Text("Last timeout")
253
+                Spacer()
254
+                Text(profile.lastTimeoutElapsed > 0 ? formatDuration(profile.lastTimeoutElapsed) : "none")
255
+            }
256
+            .font(.caption)
257
+            .foregroundStyle(.secondary)
258
+
259
+            HStack {
260
+                Text("p95 success")
261
+                Spacer()
262
+                Text(profile.p95SuccessfulElapsedIfAvailable.map(formatDuration) ?? "collecting")
263
+            }
264
+            .font(.caption)
265
+            .foregroundStyle(.secondary)
266
+
267
+            if profile.isManuallyOverridden {
268
+                Stepper(
269
+                    "Manual \(formatDuration(profile.manualTimeoutValue))",
270
+                    value: $profile.manualTimeoutValue,
271
+                    in: MetricTimeoutProfile.defaultInitialTimeout...MetricTimeoutProfile.maximumTimeout,
272
+                    step: 5
273
+                )
274
+                .font(.caption)
275
+                .onChange(of: profile.manualTimeoutValue) { _, newValue in
276
+                    profile.setManualTimeout(newValue)
277
+                }
278
+            }
279
+
280
+            HStack {
281
+                Button("Set Manual") {
282
+                    profile.setManualTimeout(profile.effectiveTimeout)
283
+                }
284
+                .buttonStyle(.borderless)
285
+
286
+                Button("Automatic") {
287
+                    profile.returnToAutomatic()
288
+                }
289
+                .buttonStyle(.borderless)
290
+                .disabled(!profile.isManuallyOverridden)
291
+
292
+                Spacer()
293
+
294
+                Button("Reset", role: .destructive) {
295
+                    profile.resetLearning()
296
+                }
297
+                .buttonStyle(.borderless)
298
+            }
299
+            .font(.caption)
300
+        }
301
+        .padding(.vertical, 4)
302
+    }
303
+}
304
+
170 305
 private struct InfoRow<Content: View>: View {
171 306
     let label: String
172 307
     @ViewBuilder let content: () -> Content
@@ -183,8 +318,14 @@ private extension Bundle {
183 318
     }
184 319
 }
185 320
 
321
+private func formatDuration(_ seconds: TimeInterval) -> String {
322
+    if seconds <= 0 { return "none" }
323
+    if seconds < 60 { return String(format: "%.0fs", seconds) }
324
+    return "\(Int(seconds) / 60)m \(Int(seconds) % 60)s"
325
+}
326
+
186 327
 #Preview {
187 328
     SettingsView()
188
-        .modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self, DeviceProfile.self], inMemory: true)
329
+        .modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self, DeviceProfile.self, MetricTimeoutProfile.self], inMemory: true)
189 330
         .environment(AppSettings())
190 331
 }
+4 -6
HealthProbe/Views/Snapshots/SnapshotDetailView.swift
@@ -118,18 +118,16 @@ struct SnapshotDetailView: View {
118 118
             profile: profile
119 119
         )
120 120
         let timestamp = snapshot.timestamp
121
-        Task.detached(priority: .userInitiated) {
121
+        Task(priority: .userInitiated) {
122 122
             let pdfData = SnapshotPDFExporter.generatePDF(from: reportData)
123 123
             let formatter = DateFormatter()
124 124
             formatter.dateFormat = "yyyy-MM-dd-HH-mm"
125 125
             let name = "HealthProbe-Snapshot-\(formatter.string(from: timestamp)).pdf"
126 126
             let url = FileManager.default.temporaryDirectory.appendingPathComponent(name)
127 127
             try? pdfData.write(to: url)
128
-            await MainActor.run {
129
-                isExporting = false
130
-                pdfExportURL = url
131
-                showShareSheet = true
132
-            }
128
+            isExporting = false
129
+            pdfExportURL = url
130
+            showShareSheet = true
133 131
         }
134 132
     }
135 133
 
+4 -3
HealthProbe/Views/Snapshots/SnapshotsView.swift
@@ -22,9 +22,9 @@ struct SnapshotsView: View {
22 22
     }
23 23
 
24 24
     private var knownDevices: [DeviceEntry] {
25
-        let currentID = UIDevice.current.identifierForVendor?.uuidString ?? ""
25
+        let currentID = KeychainService.resolveDeviceID(swiftDataStoreIsEmpty: false).id
26 26
         var ids = Set(allSnapshots.map { $0.deviceID })
27
-        if !currentID.isEmpty { ids.insert(currentID) }
27
+        ids.insert(currentID)
28 28
         return ids.map { id in
29 29
             let profile = profileMap[id]
30 30
             let name: String
@@ -33,7 +33,8 @@ struct SnapshotsView: View {
33 33
             else { name = "Unknown Device" }
34 34
             let color = DeviceColor(rawValue: profile?.colorTag ?? "")?.color ?? .neutralGray
35 35
             return DeviceEntry(id: id, displayName: name, color: color, isCurrent: id == currentID)
36
-        }.sorted {
36
+        }
37
+        .sorted {
37 38
             if $0.isCurrent != $1.isCurrent { return $0.isCurrent }
38 39
             return $0.displayName < $1.displayName
39 40
         }