@@ -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, |
@@ -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 |
|
@@ -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)) |