Showing 5 changed files with 252 additions and 96 deletions
+3 -1
HealthProbe/Doc/04-project/Import-Optimization-Log.md
@@ -599,6 +599,7 @@ rows exist".
599 599
 | 2026-06-05 | `e7d45a2` | Count observation events from the event table first. | Confirmed on a full-profile repeated capture with `buildFingerprint: 1.0(1)-1780646299-92064`: wall clock `15.6s`, `127/127` complete, `CaptureModes: unchangedDelta=114, delta=13`, and `DeltaEvents: 99`. `SummedFinalizeEventCountElapsed` dropped from the post-`bf5a861` overnight baseline `2.5s` to `0.0s`; total finalize dropped to `1.7s`, while daily aggregate rebuild stayed `0.0s`. Remaining cost is processing: `9.1s` total, led by Heart Rate `4.6s` for `18` events, Active Energy `1.8s` for `11` events, and Basal Energy `1.5s` for `5` events. |
600 600
 | 2026-06-05 | `cfd9de8` | Split processing timing diagnostics. | Confirmed on a full-profile repeated capture with `buildFingerprint: 1.0(1)-1780683224-92064`: wall clock `14.7s`, `127/127` complete, `CaptureModes: unchangedDelta=120, delta=7`, and `DeltaEvents: 11`. `SummedProcessingElapsed` was `8.5s` and `SummedProcessingRecordArchiveRebuildElapsed` was also `8.5s`; delta apply, initial record processing, record archive finalization, and processing other all rounded to `0.0s`. Per-type rebuild cost dominated changed high-volume metrics: Heart Rate `4.4s`, Active Energy `1.7s`, Basal Energy `1.5s`, Steps `0.4s`, and Walking + Running Distance `0.4s`. Conclusion: the repeated-capture bottleneck is no longer SQLite finalization; it is the legacy compact `recordArchiveData` rebuild for changed types. |
601 601
 | 2026-06-05 | `0db2f5e` | Skip legacy compact archive rebuild for SQLite-backed deltas. | Confirmed on two full-profile repeated captures. First report, `buildFingerprint: 1.0(1)-1780689289-92064`, completed in `6.2s` with `127/127` complete, `CaptureModes: unchangedDelta=118, delta=9`, `DeltaEvents: 116`, `SummedProcessingElapsed: 0.0s`, and `SummedProcessingRecordArchiveRebuildElapsed: 0.0s`; Heart Rate and Active Energy each completed in `0.2s` with no rebuild. Second report, `buildFingerprint: 1.0(1)-1780689890-92064`, completed in `5.8s` with `CaptureModes: unchangedDelta=121, delta=6`, `DeltaEvents: 7`, fetch `2.0s`, insert `0.1s`, finalize `1.7s`, residual `1.1s`, and processing/rebuild still `0.0s`. Conclusion: the legacy compact archive rebuild was the repeated-capture bottleneck and is now removed from the normal SQLite-backed delta path; the repeated full-profile floor is now roughly `6s` on this device/database. |
602
+| 2026-06-06 | pending | Expose saved diagnostic reports in Settings. | Diagnostic reports were already persisted under Application Support at snapshot completion, but they were not discoverable from the app after dismissing the result sheet. Settings now lists the latest saved reports, opens them with the same chunked lazy diagnostics viewer, supports copy, and allows per-report deletion. Expected signal: future import analysis can recover the exact report text after the fact instead of depending on screenshots or manual copy at completion time. |
602 603
 
603 604
 ## Current Diagnosis
604 605
 
@@ -812,7 +813,8 @@ The likely bottleneck is per-row SQLite work:
812 813
 - A previous Heart Rate import appeared to stall for long periods around roughly 900k records, but later progress resumed; avoid classifying this as a hard timeout without report evidence.
813 814
 - Diagnostic reports must remain available after each import. Copying from the
814 815
   Diagnostics sheet is useful, but future comparisons should not depend on the
815
-  operator remembering to copy the visible report.
816
+  operator remembering to copy the visible report. Saved reports are now exposed
817
+  in Settings so the exact text can be recovered later.
816 818
 - After a completed import, the app may remain unresponsive or crash in legacy
817 819
   post-import cache work. A 2026-06-03 console log showed Heart Rate and Active
818 820
   Energy `TypeCount.detailCacheData` precompute immediately before a Core Data
+97 -0
HealthProbe/Utilities/DiagnosticReportStore.swift
@@ -0,0 +1,97 @@
1
+import Foundation
2
+
3
+struct DiagnosticReportFile: Identifiable, Equatable {
4
+    let url: URL
5
+    let createdAt: Date
6
+    let sizeBytes: Int64
7
+
8
+    var id: String { url.path }
9
+    var filename: String { url.lastPathComponent }
10
+
11
+    var displayTitle: String {
12
+        filename
13
+            .replacingOccurrences(of: ".txt", with: "")
14
+            .replacingOccurrences(of: "_", with: " ")
15
+    }
16
+
17
+    var displaySubtitle: String {
18
+        let size = ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file)
19
+        return "\(DiagnosticReportStore.displayFormatter.string(from: createdAt)) - \(size)"
20
+    }
21
+}
22
+
23
+enum DiagnosticReportStore {
24
+    static let directory = URL.applicationSupportDirectory
25
+        .appending(path: "HealthProbeDiagnostics", directoryHint: .isDirectory)
26
+
27
+    static let displayFormatter: DateFormatter = {
28
+        let formatter = DateFormatter()
29
+        formatter.dateStyle = .medium
30
+        formatter.timeStyle = .short
31
+        return formatter
32
+    }()
33
+
34
+    static func persist(text: String, snapshotID: UUID, operationID: UUID?) throws {
35
+        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
36
+
37
+        let timestamp = Self.filenameTimestamp(Date())
38
+        let operationPart = operationID.map { "_operation-\($0.uuidString)" } ?? ""
39
+        let filename = "\(timestamp)_snapshot-\(snapshotID.uuidString)\(operationPart).txt"
40
+        let fileURL = directory.appending(path: filename, directoryHint: .notDirectory)
41
+        try text.write(to: fileURL, atomically: true, encoding: .utf8)
42
+    }
43
+
44
+    static func list(limit: Int? = nil) throws -> [DiagnosticReportFile] {
45
+        guard FileManager.default.fileExists(atPath: directory.path) else {
46
+            return []
47
+        }
48
+
49
+        let urls = try FileManager.default.contentsOfDirectory(
50
+            at: directory,
51
+            includingPropertiesForKeys: [.creationDateKey, .contentModificationDateKey, .fileSizeKey],
52
+            options: [.skipsHiddenFiles]
53
+        )
54
+
55
+        let reports = urls
56
+            .filter { $0.pathExtension == "txt" }
57
+            .compactMap(reportFile)
58
+            .sorted { lhs, rhs in
59
+                if lhs.createdAt == rhs.createdAt {
60
+                    return lhs.filename > rhs.filename
61
+                }
62
+                return lhs.createdAt > rhs.createdAt
63
+            }
64
+
65
+        if let limit {
66
+            return Array(reports.prefix(limit))
67
+        }
68
+        return reports
69
+    }
70
+
71
+    static func read(_ report: DiagnosticReportFile) throws -> String {
72
+        try String(contentsOf: report.url, encoding: .utf8)
73
+    }
74
+
75
+    static func delete(_ report: DiagnosticReportFile) throws {
76
+        try FileManager.default.removeItem(at: report.url)
77
+    }
78
+
79
+    private static func reportFile(_ url: URL) -> DiagnosticReportFile? {
80
+        guard let values = try? url.resourceValues(forKeys: [.creationDateKey, .contentModificationDateKey, .fileSizeKey]) else {
81
+            return nil
82
+        }
83
+        return DiagnosticReportFile(
84
+            url: url,
85
+            createdAt: values.creationDate ?? values.contentModificationDate ?? Date.distantPast,
86
+            sizeBytes: Int64(values.fileSize ?? 0)
87
+        )
88
+    }
89
+
90
+    private static func filenameTimestamp(_ date: Date) -> String {
91
+        let formatter = DateFormatter()
92
+        formatter.locale = Locale(identifier: "en_US_POSIX")
93
+        formatter.timeZone = TimeZone(secondsFromGMT: 0)
94
+        formatter.dateFormat = "yyyyMMdd_HHmmss"
95
+        return formatter.string(from: date)
96
+    }
97
+}
+74 -0
HealthProbe/Views/Components/DiagnosticReportSheet.swift
@@ -0,0 +1,74 @@
1
+import SwiftUI
2
+
3
+struct DiagnosticReportSheet: View {
4
+    @Environment(\.dismiss) private var dismiss
5
+    let reportText: String
6
+    @State private var didCopy = false
7
+
8
+    var body: some View {
9
+        NavigationStack {
10
+            ScrollView {
11
+                if reportText.isEmpty {
12
+                    ContentUnavailableView("No diagnostics available", systemImage: "doc.text.magnifyingglass")
13
+                        .padding(24)
14
+                } else {
15
+                    LazyVStack(alignment: .leading, spacing: 0) {
16
+                        ForEach(Array(Self.textChunks(reportText).enumerated()), id: \.offset) { _, chunk in
17
+                            Text(chunk)
18
+                                .font(.system(.caption, design: .monospaced))
19
+                                .foregroundStyle(.primary)
20
+                                .textSelection(.enabled)
21
+                                .frame(maxWidth: .infinity, alignment: .leading)
22
+                        }
23
+                    }
24
+                    .padding(16)
25
+                }
26
+            }
27
+            .background(Color(.systemGroupedBackground))
28
+            .navigationTitle("Diagnostics")
29
+            .navigationBarTitleDisplayMode(.inline)
30
+            .toolbar {
31
+                ToolbarItem(placement: .cancellationAction) {
32
+                    Button("Done") {
33
+                        dismiss()
34
+                    }
35
+                }
36
+                ToolbarItem(placement: .primaryAction) {
37
+                    Button {
38
+                        copyDiagnostics()
39
+                    } label: {
40
+                        Label(didCopy ? "Copied" : "Copy", systemImage: didCopy ? "checkmark" : "doc.on.doc")
41
+                    }
42
+                    .accessibilityLabel(didCopy ? "Diagnostics copied" : "Copy diagnostics")
43
+                }
44
+            }
45
+        }
46
+        .presentationDetents([.large])
47
+        .presentationDragIndicator(.visible)
48
+    }
49
+
50
+    private func copyDiagnostics() {
51
+        UIPasteboard.general.string = reportText
52
+        withAnimation(.snappy) {
53
+            didCopy = true
54
+        }
55
+        DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
56
+            withAnimation(.snappy) {
57
+                didCopy = false
58
+            }
59
+        }
60
+    }
61
+
62
+    private static func textChunks(_ text: String, chunkSize: Int = 4_000) -> [String] {
63
+        guard !text.isEmpty else { return [] }
64
+        var chunks: [String] = []
65
+        chunks.reserveCapacity(max(1, text.count / chunkSize))
66
+        var start = text.startIndex
67
+        while start < text.endIndex {
68
+            let end = text.index(start, offsetBy: chunkSize, limitedBy: text.endIndex) ?? text.endIndex
69
+            chunks.append(String(text[start..<end]))
70
+            start = end
71
+        }
72
+        return chunks
73
+    }
74
+}
+0 -95
HealthProbe/Views/Dashboard/DashboardView.swift
@@ -1340,28 +1340,6 @@ private struct DiagnosticReport: Identifiable {
1340 1340
     let text: String
1341 1341
 }
1342 1342
 
1343
-private enum DiagnosticReportStore {
1344
-    static func persist(text: String, snapshotID: UUID, operationID: UUID?) throws {
1345
-        let directory = URL.applicationSupportDirectory
1346
-            .appending(path: "HealthProbeDiagnostics", directoryHint: .isDirectory)
1347
-        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
1348
-
1349
-        let timestamp = Self.filenameTimestamp(Date())
1350
-        let operationPart = operationID.map { "_operation-\($0.uuidString)" } ?? ""
1351
-        let filename = "\(timestamp)_snapshot-\(snapshotID.uuidString)\(operationPart).txt"
1352
-        let fileURL = directory.appending(path: filename, directoryHint: .notDirectory)
1353
-        try text.write(to: fileURL, atomically: true, encoding: .utf8)
1354
-    }
1355
-
1356
-    private static func filenameTimestamp(_ date: Date) -> String {
1357
-        let formatter = DateFormatter()
1358
-        formatter.locale = Locale(identifier: "en_US_POSIX")
1359
-        formatter.timeZone = TimeZone(secondsFromGMT: 0)
1360
-        formatter.dateFormat = "yyyyMMdd_HHmmss"
1361
-        return formatter.string(from: date)
1362
-    }
1363
-}
1364
-
1365 1343
 // Avoids LabeledContent's TableRowContent ambiguity in List/Section contexts.
1366 1344
 private struct InfoRow<Content: View>: View {
1367 1345
     let label: String
@@ -1426,79 +1404,6 @@ private struct ReportRow: View {
1426 1404
     }
1427 1405
 }
1428 1406
 
1429
-private struct DiagnosticReportSheet: View {
1430
-    @Environment(\.dismiss) private var dismiss
1431
-    let reportText: String
1432
-    @State private var didCopy = false
1433
-
1434
-    var body: some View {
1435
-        NavigationStack {
1436
-            ScrollView {
1437
-                if reportText.isEmpty {
1438
-                    ContentUnavailableView("No diagnostics available", systemImage: "doc.text.magnifyingglass")
1439
-                        .padding(24)
1440
-                } else {
1441
-                    LazyVStack(alignment: .leading, spacing: 0) {
1442
-                        ForEach(Array(Self.textChunks(reportText).enumerated()), id: \.offset) { _, chunk in
1443
-                            Text(chunk)
1444
-                                .font(.system(.caption, design: .monospaced))
1445
-                                .foregroundStyle(.primary)
1446
-                                .textSelection(.enabled)
1447
-                                .frame(maxWidth: .infinity, alignment: .leading)
1448
-                        }
1449
-                    }
1450
-                    .padding(16)
1451
-                }
1452
-            }
1453
-            .background(Color(.systemGroupedBackground))
1454
-            .navigationTitle("Diagnostics")
1455
-            .navigationBarTitleDisplayMode(.inline)
1456
-            .toolbar {
1457
-                ToolbarItem(placement: .cancellationAction) {
1458
-                    Button("Done") {
1459
-                        dismiss()
1460
-                    }
1461
-                }
1462
-                ToolbarItem(placement: .primaryAction) {
1463
-                    Button {
1464
-                        copyDiagnostics()
1465
-                    } label: {
1466
-                        Label(didCopy ? "Copied" : "Copy", systemImage: didCopy ? "checkmark" : "doc.on.doc")
1467
-                    }
1468
-                    .accessibilityLabel(didCopy ? "Diagnostics copied" : "Copy diagnostics")
1469
-                }
1470
-            }
1471
-        }
1472
-        .presentationDetents([.large])
1473
-        .presentationDragIndicator(.visible)
1474
-    }
1475
-
1476
-    private func copyDiagnostics() {
1477
-        UIPasteboard.general.string = reportText
1478
-        withAnimation(.snappy) {
1479
-            didCopy = true
1480
-        }
1481
-        DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
1482
-            withAnimation(.snappy) {
1483
-                didCopy = false
1484
-            }
1485
-        }
1486
-    }
1487
-
1488
-    private static func textChunks(_ text: String, chunkSize: Int = 4_000) -> [String] {
1489
-        guard !text.isEmpty else { return [] }
1490
-        var chunks: [String] = []
1491
-        chunks.reserveCapacity(max(1, text.count / chunkSize))
1492
-        var start = text.startIndex
1493
-        while start < text.endIndex {
1494
-            let end = text.index(start, offsetBy: chunkSize, limitedBy: text.endIndex) ?? text.endIndex
1495
-            chunks.append(String(text[start..<end]))
1496
-            start = end
1497
-        }
1498
-        return chunks
1499
-    }
1500
-}
1501
-
1502 1407
 extension Bundle {
1503 1408
     var appVersion: String {
1504 1409
         guard let version = infoDictionary?["CFBundleShortVersionString"] as? String else {
+78 -0
HealthProbe/Views/Settings/SettingsView.swift
@@ -10,6 +10,8 @@ struct SettingsView: View {
10 10
     @State private var timeoutProfiles: [LocalMetricTimeoutProfile] = []
11 11
     @State private var currentDeviceProfile: LocalDeviceProfile?
12 12
     @State private var resetScheduled = PrototypeStoreResetPolicy.isResetScheduled(appSupportURL: .applicationSupportDirectory)
13
+    @State private var diagnosticReports: [DiagnosticReportFile] = []
14
+    @State private var diagnosticReportText: DiagnosticReportText?
13 15
 
14 16
     private var currentDeviceID: String {
15 17
         AppSettings.currentDeviceID
@@ -24,6 +26,7 @@ struct SettingsView: View {
24 26
                 timeoutCalibrationSection
25 27
                 typeSelectionSections
26 28
                 dataSection
29
+                diagnosticReportsSection
27 30
                 aboutSection
28 31
             }
29 32
             .navigationTitle("Settings")
@@ -31,6 +34,10 @@ struct SettingsView: View {
31 34
                 loadCurrentDeviceProfile()
32 35
                 loadTimeoutProfiles()
33 36
                 loadArchiveCacheStatus()
37
+                loadDiagnosticReports()
38
+            }
39
+            .sheet(item: $diagnosticReportText) { report in
40
+                DiagnosticReportSheet(reportText: report.text)
34 41
             }
35 42
             .confirmationDialog(
36 43
                 "Delete Rebuildable UI Cache",
@@ -247,6 +254,44 @@ struct SettingsView: View {
247 254
         }
248 255
     }
249 256
 
257
+    private var diagnosticReportsSection: some View {
258
+        Section("Diagnostic Reports") {
259
+            if diagnosticReports.isEmpty {
260
+                Text("No saved reports yet")
261
+                    .foregroundStyle(.secondary)
262
+            } else {
263
+                ForEach(diagnosticReports) { report in
264
+                    Button {
265
+                        openDiagnosticReport(report)
266
+                    } label: {
267
+                        VStack(alignment: .leading, spacing: 3) {
268
+                            Text(report.displayTitle)
269
+                                .font(.subheadline.weight(.semibold))
270
+                                .foregroundStyle(.primary)
271
+                                .lineLimit(2)
272
+                            Text(report.displaySubtitle)
273
+                                .font(.caption)
274
+                                .foregroundStyle(.secondary)
275
+                        }
276
+                    }
277
+                    .swipeActions {
278
+                        Button(role: .destructive) {
279
+                            deleteDiagnosticReport(report)
280
+                        } label: {
281
+                            Label("Delete", systemImage: "trash")
282
+                        }
283
+                    }
284
+                }
285
+            }
286
+
287
+            Button {
288
+                loadDiagnosticReports()
289
+            } label: {
290
+                Label("Refresh Reports", systemImage: "arrow.clockwise")
291
+            }
292
+        }
293
+    }
294
+
250 295
     private var aboutSection: some View {
251 296
         Section("About") {
252 297
             InfoRow(label: "Version") {
@@ -325,6 +370,34 @@ struct SettingsView: View {
325 370
         }
326 371
     }
327 372
 
373
+    private func loadDiagnosticReports() {
374
+        do {
375
+            diagnosticReports = try DiagnosticReportStore.list(limit: 20)
376
+        } catch {
377
+            dataMaintenanceMessage = "Diagnostic report list failed: \(error.localizedDescription)"
378
+            diagnosticReports = []
379
+        }
380
+    }
381
+
382
+    private func openDiagnosticReport(_ report: DiagnosticReportFile) {
383
+        do {
384
+            diagnosticReportText = DiagnosticReportText(text: try DiagnosticReportStore.read(report))
385
+        } catch {
386
+            dataMaintenanceMessage = "Diagnostic report open failed: \(error.localizedDescription)"
387
+            loadDiagnosticReports()
388
+        }
389
+    }
390
+
391
+    private func deleteDiagnosticReport(_ report: DiagnosticReportFile) {
392
+        do {
393
+            try DiagnosticReportStore.delete(report)
394
+            loadDiagnosticReports()
395
+        } catch {
396
+            dataMaintenanceMessage = "Diagnostic report delete failed: \(error.localizedDescription)"
397
+            loadDiagnosticReports()
398
+        }
399
+    }
400
+
328 401
     private func rebuildArchiveCache() {
329 402
         do {
330 403
             let summary = try CoreDataArchiveCacheStore().rebuild(fromArchiveAt: SQLiteHealthArchiveStore.defaultDatabaseURL)
@@ -356,6 +429,11 @@ struct SettingsView: View {
356 429
 
357 430
 // MARK: - Subviews
358 431
 
432
+private struct DiagnosticReportText: Identifiable {
433
+    let id = UUID()
434
+    let text: String
435
+}
436
+
359 437
 private struct TypeToggleRow: View {
360 438
     let type: MonitoredType
361 439
     let appSettings: AppSettings