Showing 3 changed files with 92 additions and 17 deletions
+20 -0
HealthProbe/Doc/04-project/Import-Optimization-Log.md
@@ -122,6 +122,26 @@ Additional action:
122 122
   a lock. The timeout path now cancels the HealthKit operation and resumes the
123 123
   caller immediately instead of waiting for a stuck child task to unwind.
124 124
 
125
+### 2026-06-07 Progress UI Timing Uses Snapshot T0
126
+
127
+Source: user screenshot showing `Zinc` displayed as `1m 3s` after a longer
128
+real wait, while `Workouts` displayed `0.0s` at the start of its page import.
129
+
130
+Decision:
131
+- the progress sheet is user-facing, so displayed per-metric time should mean
132
+  "elapsed since the snapshot operation started", not internal fetch time,
133
+  insert time, page time, or time since the current phase started;
134
+- detailed fetch / processing / insert / finalize timings remain in the
135
+  diagnostic report for engineering analysis.
136
+
137
+Action taken:
138
+- `SnapshotFetchProgress` now owns a snapshot-start timestamp / timer;
139
+- active metric rows use a live timeline view so elapsed time keeps advancing
140
+  even when HealthKit is stuck and no progress callback fires;
141
+- completed / failed rows keep the operation elapsed time captured at completion;
142
+- displayed record rates are derived from record count divided by operation
143
+  elapsed time, avoiding optimistic phase-local throughput.
144
+
125 145
 ### 2026-06-07 Overnight Initial Imports On Both Large Devices
126 146
 
127 147
 Source: two user-provided overnight first-import reports from large devices,
+28 -5
HealthProbe/Utilities/SnapshotFetchProgress.swift
@@ -170,6 +170,7 @@ final class SnapshotFetchProgress {
170 170
     }
171 171
 
172 172
     let totalTypeCount: Int
173
+    let startedAt: Date
173 174
     var types: [TypeProgress] = []
174 175
     var perTypeTimeoutSeconds: TimeInterval = 0
175 176
     var maxConcurrentTypeFetches: Int = 0
@@ -179,6 +180,7 @@ final class SnapshotFetchProgress {
179 180
     var snapshotChecksum: String = ""
180 181
     var monitoredTypeSetHash: String = ""
181 182
     var monitoredRegistryVersion: Int?
183
+    private let operationTimer = MonotonicTimer()
182 184
     private let displayNamesByID: [String: String]
183 185
 
184 186
     var visibleTypes: [TypeProgress] { types }
@@ -220,9 +222,18 @@ final class SnapshotFetchProgress {
220 222
 
221 223
     init(monitoredTypes: [(id: String, displayName: String)]) {
222 224
         self.totalTypeCount = monitoredTypes.count
225
+        self.startedAt = Date()
223 226
         self.displayNamesByID = Dictionary(uniqueKeysWithValues: monitoredTypes.map { ($0.id, $0.displayName) })
224 227
     }
225 228
 
229
+    var operationElapsedSeconds: TimeInterval {
230
+        operationTimer.elapsedSeconds
231
+    }
232
+
233
+    func elapsedSeconds(at date: Date) -> TimeInterval {
234
+        max(0, date.timeIntervalSince(startedAt))
235
+    }
236
+
226 237
     func updateConfiguration(
227 238
         perTypeTimeoutSeconds: TimeInterval,
228 239
         maxConcurrentTypeFetches: Int,
@@ -256,6 +267,12 @@ final class SnapshotFetchProgress {
256 267
         case .fetching:
257 268
             break
258 269
         }
270
+        switch status {
271
+        case .fetching, .complete, .failed:
272
+            types[index].blockElapsedSeconds = operationElapsedSeconds
273
+        case .pending:
274
+            break
275
+        }
259 276
         if let recordCount {
260 277
             types[index].recordCount = recordCount
261 278
         }
@@ -303,6 +320,10 @@ final class SnapshotFetchProgress {
303 320
         types[index].timingBreakdown = timingBreakdown
304 321
         types[index].captureMode = captureMode
305 322
         types[index].deltaEventCount = deltaEventCount
323
+        types[index].blockElapsedSeconds = operationElapsedSeconds
324
+        if recordCount > 0, operationElapsedSeconds > 0 {
325
+            types[index].blockSamplesPerSecond = Double(recordCount) / operationElapsedSeconds
326
+        }
306 327
     }
307 328
 
308 329
     func updateTimeoutProfile(
@@ -335,11 +356,13 @@ final class SnapshotFetchProgress {
335 356
         if let recordCount {
336 357
             types[index].recordCount = recordCount
337 358
         }
338
-        if let elapsedSeconds {
339
-            types[index].blockElapsedSeconds = elapsedSeconds
340
-        }
341
-        if let samplesPerSecond {
342
-            types[index].blockSamplesPerSecond = samplesPerSecond
359
+        let elapsed = operationElapsedSeconds
360
+        types[index].blockElapsedSeconds = elapsed
361
+        let effectiveRecordCount = recordCount ?? types[index].recordCount
362
+        if effectiveRecordCount > 0, elapsed > 0 {
363
+            types[index].blockSamplesPerSecond = Double(effectiveRecordCount) / elapsed
364
+        } else if samplesPerSecond != nil || elapsedSeconds != nil {
365
+            types[index].blockSamplesPerSecond = 0
343 366
         }
344 367
     }
345 368
 
+44 -12
HealthProbe/Views/Dashboard/DashboardView.swift
@@ -898,7 +898,7 @@ struct DashboardView: View {
898 898
                             }
899 899
                             .frame(maxWidth: .infinity, alignment: .leading)
900 900
 
901
-                            fetchMetricStatsColumn(type)
901
+                            fetchMetricStatsColumn(type, progress: progress)
902 902
                         }
903 903
                         .padding(.horizontal, 10)
904 904
                         .padding(.vertical, 9)
@@ -989,31 +989,63 @@ struct DashboardView: View {
989 989
     }
990 990
 
991 991
     @ViewBuilder
992
-    private func fetchMetricStatsColumn(_ type: SnapshotFetchProgress.TypeProgress) -> some View {
993
-        if type.recordCount > 0 || type.blockElapsedSeconds > 0 || type.blockSamplesPerSecond > 0 {
992
+    private func fetchMetricStatsColumn(
993
+        _ type: SnapshotFetchProgress.TypeProgress,
994
+        progress: SnapshotFetchProgress
995
+    ) -> some View {
996
+        if case .fetching = type.status {
997
+            TimelineView(.periodic(from: progress.startedAt, by: 1)) { context in
998
+                let elapsed = max(type.blockElapsedSeconds, progress.elapsedSeconds(at: context.date))
999
+                let rate = displayedRate(for: type, elapsedSeconds: elapsed)
1000
+                fetchMetricStatsContent(type, elapsedSeconds: elapsed, samplesPerSecond: rate)
1001
+            }
1002
+        } else if type.recordCount > 0 || type.blockElapsedSeconds > 0 || type.blockSamplesPerSecond > 0 {
1003
+            fetchMetricStatsContent(
1004
+                type,
1005
+                elapsedSeconds: type.blockElapsedSeconds,
1006
+                samplesPerSecond: displayedRate(for: type, elapsedSeconds: type.blockElapsedSeconds)
1007
+            )
1008
+        } else if case .failed(let reason) = type.status, reason == "Not authorized" {
1009
+            Text("Unavailable")
1010
+                .font(.caption2)
1011
+                .foregroundStyle(Color.warningAmber)
1012
+                .lineLimit(1)
1013
+        }
1014
+    }
1015
+
1016
+    @ViewBuilder
1017
+    private func fetchMetricStatsContent(
1018
+        _ type: SnapshotFetchProgress.TypeProgress,
1019
+        elapsedSeconds: TimeInterval,
1020
+        samplesPerSecond: Double
1021
+    ) -> some View {
1022
+        if type.recordCount > 0 || elapsedSeconds > 0 || samplesPerSecond > 0 {
994 1023
             VStack(alignment: .trailing, spacing: 2) {
995 1024
                 if type.recordCount > 0 {
996 1025
                     Text("\(type.recordCount) records")
997 1026
                 }
998
-                if type.blockElapsedSeconds > 0 {
999
-                    Text(formatDuration(type.blockElapsedSeconds))
1027
+                if elapsedSeconds > 0 {
1028
+                    Text(formatDuration(elapsedSeconds))
1000 1029
                 }
1001
-                if type.blockSamplesPerSecond > 0 {
1002
-                    Text("\(formatRate(type.blockSamplesPerSecond)) rec/s")
1030
+                if samplesPerSecond > 0 {
1031
+                    Text("\(formatRate(samplesPerSecond)) rec/s")
1003 1032
                 }
1004 1033
             }
1005 1034
             .font(.caption2)
1006 1035
             .foregroundStyle(.secondary)
1007 1036
             .lineLimit(1)
1008 1037
             .frame(minWidth: 82, alignment: .trailing)
1009
-        } else if case .failed(let reason) = type.status, reason == "Not authorized" {
1010
-            Text("Unavailable")
1011
-                .font(.caption2)
1012
-                .foregroundStyle(Color.warningAmber)
1013
-                .lineLimit(1)
1014 1038
         }
1015 1039
     }
1016 1040
 
1041
+    private func displayedRate(
1042
+        for type: SnapshotFetchProgress.TypeProgress,
1043
+        elapsedSeconds: TimeInterval
1044
+    ) -> Double {
1045
+        guard type.recordCount > 0, elapsedSeconds > 0 else { return 0 }
1046
+        return Double(type.recordCount) / elapsedSeconds
1047
+    }
1048
+
1017 1049
     private func formatRate(_ value: Double) -> String {
1018 1050
         if value >= 1_000 {
1019 1051
             return value.formatted(.number.precision(.fractionLength(0)).grouping(.automatic))