Showing 6 changed files with 30 additions and 1856 deletions
+6 -6
HealthProbe/Doc/04-project/IMPLEMENTATION_STATUS.md
@@ -25,10 +25,10 @@ There are no real deployments, only test installations. Existing prototype datab
25 25
 |------|----------------|--------------------|
26 26
 | Product docs | Updated | Keep `HealthProbe/Doc/README.md` as canonical index |
27 27
 | HealthKit capture | Capture now opens one archive observation per user-visible snapshot and attaches HealthKit pages, deleted-object evidence, and type verification to that observation id before finishing it | Continue moving UI/cache reads to archive-backed observation ids |
28
-| SQLite archive | Archive v2 schema, snapshot-level observation grouping, differential write path, v2 verification/delete bookkeeping, daily aggregate rebuilds, integrity report, v2 record reads, SQL diff/count/aggregate/provenance/consolidation-evidence APIs, large synthetic diff pagination coverage, formal timing/memory metrics, and XCTest coverage are in place; the legacy `archive_samples` mirror has been removed | Continue replacing remaining transition detail/PDF paths with archive/cache DTOs |
28
+| SQLite archive | Archive v2 schema, snapshot-level observation grouping, differential write path, v2 verification/delete bookkeeping, daily aggregate rebuilds, integrity report, v2 record reads, SQL diff/count/aggregate/provenance/consolidation-evidence APIs, large synthetic diff pagination coverage, formal timing/memory metrics, and XCTest coverage are in place; the legacy `archive_samples` mirror has been removed | Continue moving capture/Dashboard actions to archive/cache DTOs |
29 29
 | Core Data cache | Initial programmatic Core Data model, full-cache rebuild service, read DTOs for observation/type/diff/health rows, and Dashboard archive-cache status wiring are in place | Move remaining export/report paths to cache DTOs and add targeted partial invalidation |
30
-| SwiftData cache | Exists; test builds now reset legacy prototype UI/archive/cache stores once for archive v2 so old SwiftData-only snapshots are not treated as backed-up observations. Metric timeout calibration, local device profile settings, operation logging, ContentView preview, Settings data maintenance, and legacy anomaly/count-drop review have moved outside SwiftData or been removed. Remaining SwiftData imports are inventoried in [`SwiftData-Retirement-Inventory.md`](SwiftData-Retirement-Inventory.md) | Treat as disposable prototype data; replace capture/review actions and transition detail/PDF paths before removing `ModelContainer` |
31
-| UI | Prototype exists; Dashboard status reads archive/cache observation rows and shows cache health, with SwiftData retained only for capture/review actions; Snapshots and Data Types tab roots no longer import SwiftData, load Core Data cached observation rows, and open archive/cache-backed detail rows; `SnapshotArchiveDetailView` and `DataTypeArchiveDetailView` read Core Data type/diff summaries and page record drill-down through SQLite; legacy `SnapshotDetailView`/`DataTypeSnapshotDetailView` remain as transition views outside the active tab roots; record-change evolution and temporal distribution screens now receive DTO rows/cache input instead of querying SwiftData directly; export preview reads the archive export API before showing/exporting JSON; simplified detail mode replaces heavy charts with summary rows on small/accessibility layouts or when enabled in Settings; visible change labels now use neutral new/missing/change-review language, with already-existing SwiftData detail cache as transition fallback | Remove or isolate remaining SwiftData transition detail/PDF paths |
30
+| SwiftData cache | Exists; test builds now reset legacy prototype UI/archive/cache stores once for archive v2 so old SwiftData-only snapshots are not treated as backed-up observations. Metric timeout calibration, local device profile settings, operation logging, ContentView preview, Settings data maintenance, legacy detail/PDF views, and legacy anomaly/count-drop review have moved outside SwiftData or been removed. Remaining SwiftData imports are inventoried in [`SwiftData-Retirement-Inventory.md`](SwiftData-Retirement-Inventory.md) | Treat as disposable prototype data; replace capture/review actions before removing `ModelContainer` |
31
+| UI | Prototype exists; Dashboard status reads archive/cache observation rows and shows cache health, with SwiftData retained only for capture/review actions; Snapshots and Data Types tab roots no longer import SwiftData, load Core Data cached observation rows, and open archive/cache-backed detail rows; `SnapshotArchiveDetailView` and `DataTypeArchiveDetailView` read Core Data type/diff summaries and page record drill-down through SQLite; unused legacy SwiftData snapshot/type detail and PDF views have been deleted; record-change evolution and temporal distribution screens now receive DTO rows/cache input instead of querying SwiftData directly; export preview reads the archive export API before showing/exporting JSON; simplified detail mode replaces heavy charts with summary rows on small/accessibility layouts or when enabled in Settings; visible change labels now use neutral new/missing/change-review language | Move remaining Dashboard capture/review actions away from SwiftData |
32 32
 | Diff/change explanation | SQL diff summaries, paged diff records, aggregate comparisons, and consolidation-evidence labels exist; legacy anomaly/count-drop review has been removed from active flows; the old direct `HealthSnapshot.typeCounts` diff helper has been retired | Continue moving remaining SwiftData fallback detail paths to archive/cache DTOs |
33 33
 | Export | SQLite export preview, paged JSON writing, SHA256 manifest hashing, and `export_manifests` rows are in place for selected records and observation diffs | Fill remaining recovery-compatible envelope metadata, CSV export, relationship preservation, and reproducibility checks |
34 34
 | Legacy device support | Simplified detail UI mode is implemented for small/accessibility layouts and as a Settings toggle | Remove SwiftData dependency and validate lower deployment targets |
@@ -38,7 +38,7 @@ There are no real deployments, only test installations. Existing prototype datab
38 38
 
39 39
 Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.md).
40 40
 
41
-1. Remove or isolate remaining SwiftData transition detail/PDF paths.
41
+1. Move remaining Dashboard capture/review actions away from SwiftData.
42 42
 2. Add targeted cache invalidation for affected observation/type ranges.
43 43
 3. Finish remaining UI language cleanup from anomaly/status to observation/diff/export where legacy model names still leak into active flows.
44 44
 4. Complete recovery-compatible export metadata, CSV output, and reproducibility checks.
@@ -48,9 +48,9 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m
48 48
 
49 49
 - SwiftData currently blocks iOS 15-era device support.
50 50
 - Some screens still imply snapshot-count monitoring rather than Time Machine inspection.
51
-- Current UI/cache layers still depend on 17 SwiftData-backed files for launch container, capture review actions, legacy transition detail paths, model definitions, and PDF paths.
51
+- Current UI/cache layers still depend on 15 SwiftData-backed files for launch container, capture review actions, model definitions, and legacy repair services.
52 52
 - Snapshots timeline/detail rows, Data Types list/detail rows, and record drill-down are archive/cache-backed for new archive v2 observations when cache rows exist.
53
-- Legacy SwiftData-only snapshots can show diffs without archive-backed values; they are now reset for archive v2 test installs rather than migrated.
53
+- Legacy SwiftData-only snapshots are reset for archive v2 test installs rather than migrated.
54 54
 - Capture strategy and some legacy SwiftData transition paths may still decode or cache too much data for low-end devices.
55 55
 - Old prototype database compatibility is no longer required.
56 56
 - Initial SQLite archive tests cover open/init/reset/idempotency, snapshot-level observation grouping, legacy mirror removal, small observation diffs, large synthetic diff pagination, formal timing/memory metrics, materialized aggregate comparison, source/provenance breakdowns, consolidation-evidence labels, export preview, paged JSON output, and manifest row persistence.
+4 -2
HealthProbe/Doc/04-project/Refactoring-Plan.md
@@ -232,6 +232,8 @@ Checklist:
232 232
 - [x] Data Types list rows use Core Data cached counts plus SQLite `diffSummary` and no longer fall back to SwiftData `TypeCount` traversal.
233 233
 - [x] Data Types root reads Core Data cached observation rows directly and no longer imports SwiftData.
234 234
 - [x] Snapshots root reads Core Data cached observation rows directly and no longer imports SwiftData.
235
+- [x] Delete unused legacy SwiftData snapshot/type detail views and the PDF
236
+  exporter tied to those views.
235 237
 - [x] Data type detail uses Core Data/SQLite `diffSummary` when archive observation ids exist and no longer queries `SnapshotDelta`/`TypeDelta` or rebuilds legacy detail caches from the UI.
236 238
 - [x] Remove unused legacy Type Evolution timeline and direct `HealthSnapshot.typeCounts` diff helper.
237 239
 - [x] Data type new/missing drill-down pages through SQLite `diffRecords` when archive observation ids exist.
@@ -261,8 +263,8 @@ Checklist:
261 263
   moved to local Codable stores and removed from `ModelContainer`; Settings
262 264
   data maintenance now uses the rebuildable Core Data cache; legacy
263 265
   anomaly/count-drop review has been deleted; Snapshots/Data Types tab roots no
264
-  longer import SwiftData; Dashboard capture/review actions and legacy
265
-  transition detail/PDF paths remain.
266
+  longer import SwiftData; unused legacy snapshot/type detail and PDF views have
267
+  been deleted; Dashboard capture/review actions remain.
266 268
 - [ ] Remove/disable `ModelContainer` as required for target builds.
267 269
 - [x] Add prototype-store ignore/delete/reset path for test installs.
268 270
 - [ ] Verify no old-store compatibility layer remains in active flows.
+20 -30
HealthProbe/Doc/04-project/SwiftData-Retirement-Inventory.md
@@ -10,9 +10,10 @@ local settings stored outside SwiftData where needed.
10 10
 ## Current Count
11 11
 
12 12
 After moving the Snapshots and Data Types tab roots to archive/cache
13
-observations, 17 app files still have SwiftData imports because capture,
14
-Dashboard review actions, legacy detail transition paths, model definitions, and
15
-PDF/export transition paths still use prototype snapshot handles.
13
+observations and deleting the unused legacy snapshot/type detail views, 15 app
14
+files still have SwiftData imports because capture, Dashboard review actions,
15
+model definitions, and legacy repair services still use prototype snapshot
16
+handles.
16 17
 
17 18
 ## Launch Container
18 19
 
@@ -21,9 +22,8 @@ This file keeps SwiftData required at app launch:
21 22
 - `HealthProbe/HealthProbeApp.swift`
22 23
 
23 24
 Retirement path:
24
-- replace prototype snapshot model dependencies in tab roots;
25
-- remove `.modelContainer(...)` once no active view needs `@Query` or
26
-  `ModelContext`.
25
+- remove `.modelContainer(...)` once no active view or capture service needs
26
+  `@Query` or `ModelContext`.
27 27
 
28 28
 ## Legacy Model Definitions
29 29
 
@@ -55,12 +55,11 @@ These services still write/read legacy SwiftData transition models:
55 55
 - `HealthProbe/Utilities/TypeCountArchiveRepair.swift`
56 56
 
57 57
 Retirement path:
58
-- make capture persist archive observations first and expose only bridge ids
59
-  while transition UI still exists;
60
-- move operation logging out of SwiftData;
58
+- make capture persist archive observations without writing prototype
59
+  `HealthSnapshot` bridge rows;
61 60
 - delete legacy record repair once old SwiftData stores are no longer opened;
62
-- remove snapshot deletion/repair logic after archive/cache navigation replaces
63
-  prototype snapshots.
61
+- remove snapshot deletion/repair logic after capture and Dashboard actions no
62
+  longer require prototype snapshots.
64 63
 
65 64
 ## UI And View Models
66 65
 
@@ -69,14 +68,10 @@ types:
69 68
 
70 69
 - `HealthProbe/ViewModels/DashboardViewModel.swift`
71 70
 - `HealthProbe/Views/Dashboard/DashboardView.swift`
72
-- `HealthProbe/Views/Snapshots/DataTypeSnapshotDetailView.swift`
73
-- `HealthProbe/Views/Snapshots/SnapshotDetailView.swift`
74 71
 
75 72
 Retirement path:
76
-- replace detail navigation parameters from SwiftData models to observation/type
77
-  DTOs;
78
-- remove remaining snapshot/cache SwiftData rows from active flows;
79
-- keep paged record drill-down and export paths on archive APIs.
73
+- move capture/review actions away from `ModelContext`;
74
+- keep status/report rows on archive/cache APIs.
80 75
 
81 76
 ## Removed During This Pass
82 77
 
@@ -93,7 +88,7 @@ The following SwiftData dependencies were removed from active flows:
93 88
 - `HealthProbe/Models/DeviceProfile.swift` was deleted.
94 89
 - Device display name/color settings now use
95 90
   `HealthProbe/Utilities/LocalDeviceProfile.swift`, a Codable local store used
96
-  by Settings, Dashboard, Snapshots, and legacy PDF export.
91
+  by Settings, Dashboard, and archive/cache snapshot rows.
97 92
 - `HealthProbe/Models/OperationLog.swift` was deleted.
98 93
 - Snapshot deletion logging now uses
99 94
   `HealthProbe/Utilities/LocalOperationLog.swift`, a bounded Codable local log
@@ -129,16 +124,11 @@ The following SwiftData dependencies were removed from active flows:
129 124
   SwiftData or queries `HealthSnapshot`; it loads Core Data cached observation
130 125
   rows and opens `SnapshotArchiveDetailView`, an archive/cache-only detail view
131 126
   that feeds Data Type drill-down through observation ids and cached summaries.
132
-- `HealthProbe/Views/Snapshots/SnapshotDetailView.swift` no longer queries
133
-  `SnapshotDelta`/`TypeDelta` or carries the old SwiftData type-delta/chart
134
-  fallback. Snapshot detail type rows now require archive/cache summaries; the
135
-  temporary SwiftData dependency is limited to snapshot navigation, metadata,
136
-  and PDF export handles.
137
-- `HealthProbe/Views/Snapshots/DataTypeSnapshotDetailView.swift` no longer
138
-  queries `SnapshotDelta`/`TypeDelta` and no longer rebuilds legacy
139
-  `TypeCount.detailCache` rows from the UI. It reads Core Data/SQLite diff
140
-  summaries first and only displays an already-existing legacy detail cache as a
141
-  transition fallback.
127
+- The unused legacy SwiftData
128
+  `HealthProbe/Views/Snapshots/SnapshotDetailView.swift`,
129
+  `HealthProbe/Views/Snapshots/DataTypeSnapshotDetailView.swift`, and
130
+  `HealthProbe/Utilities/SnapshotPDFExporter.swift` were deleted. Active
131
+  snapshot/type drill-down now uses archive/cache DTOs.
142 132
 - The unused `HealthProbe/Views/DataTypes/TypeEvolutionTimeline.swift` legacy
143 133
   chart was deleted, and the remaining `TypeDiff`/`DiffFilter` DTOs now live in
144 134
   `HealthProbe/Models/TypeDiff.swift` instead of the removed
@@ -152,5 +142,5 @@ The following SwiftData dependencies were removed from active flows:
152 142
 ## Next Recommended Slices
153 143
 
154 144
 1. Move `DashboardView` capture review actions away from `ModelContext`.
155
-2. Delete or isolate unused SwiftData snapshot/type detail transition views once
156
-   PDF/export and any remaining preview paths have archive/cache replacements.
145
+2. Stop writing prototype `HealthSnapshot` bridge rows during capture once
146
+   Dashboard actions no longer need them.
+0 -380
HealthProbe/Utilities/SnapshotPDFExporter.swift
@@ -1,380 +0,0 @@
1
-import CoreGraphics
2
-import CoreText
3
-import Foundation
4
-
5
-// MARK: - Report data (value type, Sendable — passed to background task)
6
-
7
-struct SnapshotReportData: Sendable {
8
-    let timestamp: Date
9
-    let osVersion: String
10
-    let deviceName: String
11
-    let deviceID: String
12
-    let typeCounts: [TypeCountData]
13
-    let baseline: BaselineData?
14
-
15
-    struct TypeCountData: Sendable {
16
-        let identifier: String
17
-        let displayName: String
18
-        let count: Int
19
-    }
20
-
21
-    struct BaselineData: Sendable {
22
-        let timestamp: Date
23
-        let totalChange: Int
24
-        let changedCount: Int
25
-        let countByIdentifier: [String: Int]
26
-    }
27
-}
28
-
29
-// MARK: - Exporter
30
-
31
-enum SnapshotPDFExporter {
32
-
33
-    /// Reads SwiftData models. Must be called on the main actor.
34
-    @MainActor
35
-    static func extractReportData(
36
-        snapshot: HealthSnapshot,
37
-        baseline: HealthSnapshot?,
38
-        profile: LocalDeviceProfile?
39
-    ) -> SnapshotReportData {
40
-        let profileName: String? = {
41
-            guard let n = profile?.name, !n.isEmpty else { return nil }
42
-            return n
43
-        }()
44
-
45
-        let typeCounts = (snapshot.typeCounts ?? [])
46
-            .sorted { $0.displayName.localizedCompare($1.displayName) == .orderedAscending }
47
-            .map {
48
-                SnapshotReportData.TypeCountData(
49
-                    identifier: $0.typeIdentifier,
50
-                    displayName: $0.displayName,
51
-                    count: $0.count
52
-                )
53
-            }
54
-
55
-        let baselineData: SnapshotReportData.BaselineData?
56
-        if let baseline {
57
-            let baselineCountByIdentifier = Dictionary(
58
-                uniqueKeysWithValues: (baseline.typeCounts ?? []).map { ($0.typeIdentifier, $0.count) }
59
-            )
60
-            let comparableCurrentCounts = (snapshot.typeCounts ?? []).filter { currentType in
61
-                currentType.quality == .complete &&
62
-                (baseline.typeCounts ?? []).contains {
63
-                    $0.typeIdentifier == currentType.typeIdentifier && $0.quality == .complete
64
-                }
65
-            }
66
-            let totalChange = comparableCurrentCounts.reduce(0) { partial, currentType in
67
-                partial + abs(currentType.count - (baselineCountByIdentifier[currentType.typeIdentifier] ?? 0))
68
-            }
69
-            let changedCount = comparableCurrentCounts.filter {
70
-                $0.count != (baselineCountByIdentifier[$0.typeIdentifier] ?? 0)
71
-            }.count
72
-
73
-            baselineData = SnapshotReportData.BaselineData(
74
-                timestamp: baseline.timestamp,
75
-                totalChange: totalChange,
76
-                changedCount: changedCount,
77
-                countByIdentifier: baselineCountByIdentifier
78
-            )
79
-        } else {
80
-            baselineData = nil
81
-        }
82
-
83
-        // Device ID is truncated to first 8 chars — enough for local correlation,
84
-        // not enough to uniquely identify the device in a shared report.
85
-        let rawID = snapshot.deviceID
86
-        let displayID = rawID.isEmpty ? "—" : String(rawID.prefix(8)) + "…"
87
-
88
-        return SnapshotReportData(
89
-            timestamp: snapshot.timestamp,
90
-            osVersion: snapshot.osVersion.isEmpty ? "—" : snapshot.osVersion,
91
-            deviceName: profileName ?? "This Device",
92
-            deviceID: displayID,
93
-            typeCounts: typeCounts,
94
-            baseline: baselineData
95
-        )
96
-    }
97
-
98
-    /// Generates PDF using only CoreGraphics + CoreText. Safe to call off the main thread.
99
-    static func generatePDF(from data: SnapshotReportData) -> Data {
100
-        let pageSize = CGSize(width: 595.2, height: 841.8)
101
-        let pdfData = NSMutableData()
102
-        guard let consumer = CGDataConsumer(data: pdfData as CFMutableData) else { return Data() }
103
-        var mediaBox = CGRect(origin: .zero, size: pageSize)
104
-        guard let ctx = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else { return Data() }
105
-
106
-        let pen = Pen(ctx: ctx, pageSize: pageSize, margin: 48)
107
-        pen.beginPage()
108
-
109
-        drawPageHeader(pen, timestamp: data.timestamp)
110
-        drawSummarySection(pen, data: data)
111
-        drawDeviceSection(pen, data: data)
112
-        if let baseline = data.baseline {
113
-            drawComparisonSection(pen, baseline: baseline)
114
-        }
115
-        drawDataTypesSection(pen, data: data)
116
-        pen.endPage()
117
-
118
-        ctx.closePDF()
119
-        return pdfData as Data
120
-    }
121
-
122
-    // MARK: - Sections
123
-
124
-    private static func drawPageHeader(_ pen: Pen, timestamp: Date) {
125
-        text("HealthProbe", x: pen.margin, y: pen.y, font: pf(10), color: .secondary, pen: pen)
126
-        pen.advance(16)
127
-        text("Snapshot Report", x: pen.margin, y: pen.y, font: pf(22, .bold), color: .primary, pen: pen)
128
-        pen.advance(30)
129
-        text("Generated \(formatted(Date()))", x: pen.margin, y: pen.y, font: pf(9), color: .secondary, pen: pen)
130
-        pen.advance(14)
131
-        rule(pen)
132
-        pen.advance(16)
133
-    }
134
-
135
-    private static func drawSummarySection(_ pen: Pen, data: SnapshotReportData) {
136
-        let total = data.typeCounts.filter { $0.count > 0 }.reduce(0) { $0 + $1.count }
137
-        sectionTitle(pen, "Summary")
138
-        keyValue(pen, "Captured",      value: formatted(data.timestamp))
139
-        keyValue(pen, "Tracked Types", value: "\(data.typeCounts.count)")
140
-        keyValue(pen, "Total Records", value: "\(total)")
141
-        pen.advance(12)
142
-    }
143
-
144
-    private static func drawDeviceSection(_ pen: Pen, data: SnapshotReportData) {
145
-        sectionTitle(pen, "Device")
146
-        keyValue(pen, "Name",      value: data.deviceName)
147
-        keyValue(pen, "OS",        value: data.osVersion)
148
-        keyValue(pen, "Device ID", value: data.deviceID)
149
-        pen.advance(12)
150
-    }
151
-
152
-    private static func drawComparisonSection(_ pen: Pen, baseline: SnapshotReportData.BaselineData) {
153
-        sectionTitle(pen, "Comparison vs. Baseline")
154
-        keyValue(pen, "Baseline Date",  value: formatted(baseline.timestamp))
155
-        keyValue(pen, "Total Changes",  value: baseline.totalChange == 0 ? "None" : "\(baseline.totalChange) records")
156
-        keyValue(pen, "Changed Types",  value: "\(baseline.changedCount)")
157
-        pen.advance(12)
158
-    }
159
-
160
-    private static func drawDataTypesSection(_ pen: Pen, data: SnapshotReportData) {
161
-        guard !data.typeCounts.isEmpty else { return }
162
-        let hasBaseline = data.baseline != nil
163
-        sectionTitle(pen, "Data Types (\(data.typeCounts.count))")
164
-        tableHeader(pen, hasBaseline: hasBaseline)
165
-        for tc in data.typeCounts {
166
-            pen.checkBreak(height: 16)
167
-            let delta = data.baseline?.countByIdentifier[tc.identifier].map { tc.count - $0 }
168
-            tableRow(pen, tc: tc, delta: delta, hasBaseline: hasBaseline)
169
-        }
170
-    }
171
-
172
-    // MARK: - Drawing primitives
173
-
174
-    private static func sectionTitle(_ pen: Pen, _ title: String) {
175
-        pen.checkBreak(height: 40)
176
-        text(title, x: pen.margin, y: pen.y, font: pf(12, .semibold), color: .primary, pen: pen)
177
-        pen.advance(18)
178
-    }
179
-
180
-    private static func keyValue(_ pen: Pen, _ label: String, value: String) {
181
-        pen.checkBreak(height: 18)
182
-        let f = pf(10)
183
-        text(label, x: pen.margin, y: pen.y, font: f, color: .secondary, pen: pen)
184
-        text(value, x: pen.margin + pen.contentWidth - tw(value, font: f), y: pen.y, font: f, color: .primary, pen: pen)
185
-        pen.advance(16)
186
-    }
187
-
188
-    private static func tableHeader(_ pen: Pen, hasBaseline: Bool) {
189
-        let f = pf(8, .semibold)
190
-        let countRight = pen.margin + pen.contentWidth - (hasBaseline ? 100 : 0)
191
-        let deltaRight = pen.margin + pen.contentWidth
192
-
193
-        text("TYPE",  x: pen.margin,                  y: pen.y, font: f, color: .tertiary, pen: pen)
194
-        text("COUNT", x: countRight - tw("COUNT", font: f), y: pen.y, font: f, color: .tertiary, pen: pen)
195
-        if hasBaseline {
196
-            text("DELTA", x: deltaRight - tw("DELTA", font: f), y: pen.y, font: f, color: .tertiary, pen: pen)
197
-        }
198
-        pen.advance(12)
199
-        rule(pen, alpha: 0.35, width: 0.25)
200
-        pen.advance(4)
201
-    }
202
-
203
-    private static func tableRow(
204
-        _ pen: Pen,
205
-        tc: SnapshotReportData.TypeCountData,
206
-        delta: Int?,
207
-        hasBaseline: Bool
208
-    ) {
209
-        let nf = pf(9)
210
-        let mf = mf(9)
211
-        let countRight = pen.margin + pen.contentWidth - (hasBaseline ? 100 : 0)
212
-        let deltaRight = pen.margin + pen.contentWidth
213
-        let maxNameW   = countRight - pen.margin - 12
214
-
215
-        var name = tc.displayName
216
-        if tw(name, font: nf) > maxNameW { name = String(name.prefix(45)) + "…" }
217
-        text(name, x: pen.margin, y: pen.y, font: nf, color: .primary, pen: pen)
218
-
219
-        let cs = tc.count < 0 ? "err" : "\(tc.count)"
220
-        text(cs, x: countRight - tw(cs, font: mf), y: pen.y, font: mf, color: .primary, pen: pen)
221
-
222
-        if hasBaseline, let delta {
223
-            let ds  = delta == 0 ? "—" : (delta > 0 ? "+\(delta)" : "\(delta)")
224
-            let col: CGColor = delta > 0 ? .orange : delta < 0 ? .red : .tertiary
225
-            text(ds, x: deltaRight - tw(ds, font: mf), y: pen.y, font: mf, color: col, pen: pen)
226
-        }
227
-        pen.advance(15)
228
-    }
229
-
230
-    private static func rule(_ pen: Pen, alpha: CGFloat = 1, width: CGFloat = 0.5) {
231
-        let cgY = pen.pageSize.height - pen.y
232
-        pen.ctx.saveGState()
233
-        pen.ctx.setStrokeColor(CGColor(gray: 0.75, alpha: alpha))
234
-        pen.ctx.setLineWidth(width)
235
-        pen.ctx.move(to: CGPoint(x: pen.margin, y: cgY))
236
-        pen.ctx.addLine(to: CGPoint(x: pen.margin + pen.contentWidth, y: cgY))
237
-        pen.ctx.strokePath()
238
-        pen.ctx.restoreGState()
239
-        pen.advance(6)
240
-    }
241
-
242
-    // MARK: - CoreText
243
-
244
-    private static func text(
245
-        _ string: String,
246
-        x: CGFloat,
247
-        y: CGFloat,
248
-        font: CTFont,
249
-        color: CGColor,
250
-        pen: Pen
251
-    ) {
252
-        let attrStr = NSAttributedString(string: string, attributes: [
253
-            kCTFontAttributeName as NSAttributedString.Key: font,
254
-            kCTForegroundColorAttributeName as NSAttributedString.Key: color
255
-        ])
256
-        let line = CTLineCreateWithAttributedString(attrStr)
257
-        let cgY = pen.pageSize.height - y - CTFontGetAscent(font)
258
-        pen.ctx.textMatrix = .identity
259
-        pen.ctx.textPosition = CGPoint(x: x, y: cgY)
260
-        CTLineDraw(line, pen.ctx)
261
-    }
262
-
263
-    private static func tw(_ string: String, font: CTFont) -> CGFloat {
264
-        let attrStr = NSAttributedString(string: string, attributes: [
265
-            kCTFontAttributeName as NSAttributedString.Key: font
266
-        ])
267
-        return CGFloat(CTLineGetTypographicBounds(CTLineCreateWithAttributedString(attrStr), nil, nil, nil))
268
-    }
269
-
270
-    // MARK: - Font helpers (CTFont, no UIKit)
271
-
272
-    private enum Weight { case regular, semibold, bold }
273
-
274
-    private static func pf(_ size: CGFloat, _ weight: Weight = .regular) -> CTFont {
275
-        let name: CFString
276
-        switch weight {
277
-        case .regular:  name = "HelveticaNeue" as CFString
278
-        case .semibold: name = "HelveticaNeue-Medium" as CFString
279
-        case .bold:     name = "HelveticaNeue-Bold" as CFString
280
-        }
281
-        return CTFontCreateWithName(name, size, nil)
282
-    }
283
-
284
-    private static func mf(_ size: CGFloat) -> CTFont {
285
-        CTFontCreateWithName("Menlo-Regular" as CFString, size, nil)
286
-    }
287
-
288
-    private static func formatted(_ date: Date) -> String {
289
-        let f = DateFormatter()
290
-        f.dateStyle = .medium
291
-        f.timeStyle = .short
292
-        return f.string(from: date)
293
-    }
294
-}
295
-
296
-// MARK: - CGColor shortcuts
297
-
298
-private extension CGColor {
299
-    static let primary   = CGColor(gray: 0.05, alpha: 1)
300
-    static let secondary = CGColor(gray: 0.40, alpha: 1)
301
-    static let tertiary  = CGColor(gray: 0.60, alpha: 1)
302
-    static let orange    = CGColor(srgbRed: 1.00, green: 0.58, blue: 0.00, alpha: 1)
303
-    static let red       = CGColor(srgbRed: 1.00, green: 0.23, blue: 0.19, alpha: 1)
304
-}
305
-
306
-// MARK: - Pen (page state, CoreGraphics only)
307
-
308
-private final class Pen {
309
-    let ctx: CGContext
310
-    let pageSize: CGSize
311
-    let margin: CGFloat
312
-    private(set) var y: CGFloat
313
-    private(set) var pageNumber: Int = 0
314
-
315
-    var contentWidth: CGFloat  { pageSize.width  - margin * 2 }
316
-    var bottomBoundary: CGFloat { pageSize.height - margin - 28 }
317
-
318
-    init(ctx: CGContext, pageSize: CGSize, margin: CGFloat) {
319
-        self.ctx      = ctx
320
-        self.pageSize = pageSize
321
-        self.margin   = margin
322
-        self.y        = margin
323
-    }
324
-
325
-    func beginPage() {
326
-        ctx.beginPDFPage(nil)
327
-        y = margin
328
-        pageNumber += 1
329
-    }
330
-
331
-    func endPage() {
332
-        drawFooter()
333
-        ctx.endPDFPage()
334
-    }
335
-
336
-    func advance(_ delta: CGFloat) { y += delta }
337
-
338
-    func checkBreak(height: CGFloat) {
339
-        guard y + height > bottomBoundary else { return }
340
-        endPage()
341
-        beginPage()
342
-    }
343
-
344
-    private func drawFooter() {
345
-        let footerFont  = CTFontCreateWithName("HelveticaNeue" as CFString, 8, nil)
346
-        let footerColor = CGColor(gray: 0.6, alpha: 1)
347
-        let ascent      = CTFontGetAscent(footerFont)
348
-        let sepCGY      = margin                    // separator y in CGContext (from bottom)
349
-        let textCGY     = margin - 10 - ascent      // text y in CGContext
350
-
351
-        // Separator
352
-        ctx.saveGState()
353
-        ctx.setStrokeColor(CGColor(gray: 0.75, alpha: 0.4))
354
-        ctx.setLineWidth(0.5)
355
-        ctx.move(to: CGPoint(x: margin, y: sepCGY))
356
-        ctx.addLine(to: CGPoint(x: pageSize.width - margin, y: sepCGY))
357
-        ctx.strokePath()
358
-        ctx.restoreGState()
359
-
360
-        func footerLine(_ str: String, x: CGFloat) {
361
-            let a = NSAttributedString(string: str, attributes: [
362
-                kCTFontAttributeName as NSAttributedString.Key: footerFont,
363
-                kCTForegroundColorAttributeName as NSAttributedString.Key: footerColor
364
-            ])
365
-            let line = CTLineCreateWithAttributedString(a)
366
-            ctx.textMatrix = .identity
367
-            ctx.textPosition = CGPoint(x: x, y: textCGY)
368
-            CTLineDraw(line, ctx)
369
-        }
370
-
371
-        footerLine("HealthProbe — Snapshot Report", x: margin)
372
-
373
-        let pageStr   = "Page \(pageNumber)"
374
-        let pageAttr  = NSAttributedString(string: pageStr, attributes: [
375
-            kCTFontAttributeName as NSAttributedString.Key: footerFont
376
-        ])
377
-        let pageWidth = CGFloat(CTLineGetTypographicBounds(CTLineCreateWithAttributedString(pageAttr), nil, nil, nil))
378
-        footerLine(pageStr, x: pageSize.width - margin - pageWidth)
379
-    }
380
-}
+0 -733
HealthProbe/Views/Snapshots/DataTypeSnapshotDetailView.swift
@@ -1,733 +0,0 @@
1
-import SwiftUI
2
-import SwiftData
3
-
4
-struct DataTypeSnapshotDetailView: View {
5
-    let snapshot: HealthSnapshot
6
-    let typeIdentifier: String
7
-    let displayName: String
8
-
9
-    @Environment(AppSettings.self) private var appSettings
10
-    @Environment(\.dynamicTypeSize) private var dynamicTypeSize
11
-    @Environment(\.horizontalSizeClass) private var horizontalSizeClass
12
-    @Query(sort: \HealthSnapshot.timestamp) private var allSnapshots: [HealthSnapshot]
13
-
14
-    @State private var displayedSnapshot: HealthSnapshot?
15
-    @State private var diffState: RecordDiffState = .idle
16
-    @State private var showAddedRecords = false
17
-    @State private var showDisappearedRecords = false
18
-    @State private var showTemporalDistribution = false
19
-    @State private var detailCacheDiagnostic: String?
20
-    @State private var currentCachedTypeSummary: CachedArchiveTypeSummary?
21
-    @State private var previousCachedTypeSummary: CachedArchiveTypeSummary?
22
-    @State private var contentWidth: CGFloat = 744
23
-
24
-    private var currentSnapshot: HealthSnapshot {
25
-        displayedSnapshot ?? snapshot
26
-    }
27
-
28
-    private var timelineSnapshots: [HealthSnapshot] {
29
-        allSnapshots.filter { candidate in
30
-            if currentSnapshot.deviceID.isEmpty {
31
-                return candidate.deviceID.isEmpty
32
-            }
33
-            return candidate.deviceID == currentSnapshot.deviceID
34
-        }
35
-        .sorted(by: HealthSnapshot.timelineSort)
36
-    }
37
-
38
-    private var previousSnapshot: HealthSnapshot? {
39
-        currentSnapshot.previousInTimeline(timelineSnapshots)
40
-    }
41
-
42
-    private var currentTypeCount: TypeCount? {
43
-        typeCount(in: currentSnapshot)
44
-    }
45
-
46
-    private var previousTypeCount: TypeCount? {
47
-        previousSnapshot.flatMap(typeCount(in:))
48
-    }
49
-
50
-    private var isCurrentTypeContentAliasToPrevious: Bool {
51
-        guard let currentTypeCount,
52
-              let previousTypeCount else { return false }
53
-        return currentTypeCount.contentEquivalentTypeCountID == previousTypeCount.contentRepresentativeTypeCountID
54
-    }
55
-
56
-    private var hasTemporalDistributionCache: Bool {
57
-        temporalDistributionInput != nil
58
-    }
59
-
60
-    private var temporalDistributionInput: TemporalDistributionInput? {
61
-        guard let currentTypeCount,
62
-              let previousSnapshot,
63
-              let cache = currentTypeCount.detailCache,
64
-              cache.matchesBaseline(previousSnapshot.id) else { return nil }
65
-
66
-        return TemporalDistributionInput(
67
-            displayName: currentTypeCount.displayName,
68
-            currentCount: currentTypeCount.count,
69
-            previousCount: previousTypeCount?.count ?? 0,
70
-            detailCache: cache
71
-        )
72
-    }
73
-
74
-    private var diffTaskID: String {
75
-        [
76
-            currentSnapshot.id.uuidString,
77
-            previousSnapshot?.id.uuidString ?? "none",
78
-            typeIdentifier
79
-        ].joined(separator: "|")
80
-    }
81
-
82
-    private var totalDelta: Int? {
83
-        guard previousSnapshot != nil,
84
-              let currentCount = countValue(for: currentTypeCount),
85
-              let previousCount = countValue(for: previousTypeCount) else { return nil }
86
-        return currentCount - previousCount
87
-    }
88
-
89
-    private var currentCountText: String {
90
-        countText(for: currentTypeCount)
91
-    }
92
-
93
-    private var previousCountText: String {
94
-        countText(for: previousTypeCount)
95
-    }
96
-
97
-    private var quickCurrentCountValue: Int {
98
-        max(currentCachedTypeSummary?.visibleRecordCount ?? currentTypeCount?.count ?? 0, 0)
99
-    }
100
-
101
-    private var quickPreviousCountValue: Int {
102
-        max(previousCachedTypeSummary?.visibleRecordCount ?? previousTypeCount?.count ?? 0, 0)
103
-    }
104
-
105
-    private var quickAddedDisappeared: (added: Int, disappeared: Int, exact: Bool) {
106
-        if let previousSnapshot,
107
-           let cache = currentTypeCount?.detailCache,
108
-           cache.matchesBaseline(previousSnapshot.id) {
109
-            return (cache.addedCount, cache.disappearedCount, true)
110
-        }
111
-
112
-        let net = quickCurrentCountValue - quickPreviousCountValue
113
-        return (max(net, 0), max(-net, 0), false)
114
-    }
115
-
116
-    private var recordEvolutionSnapshots: [RecordChangeEvolutionSnapshot] {
117
-        timelineSnapshots.map { snapshot in
118
-            let count = max(typeCount(in: snapshot)?.count ?? 0, 0)
119
-            let fallback = recordEvolutionCachedFallback(for: snapshot)
120
-            return RecordChangeEvolutionSnapshot(
121
-                id: snapshot.id,
122
-                timestamp: snapshot.timestamp,
123
-                localSequenceNumber: snapshot.localSequenceNumber,
124
-                previousSnapshotID: snapshot.previousSnapshotID,
125
-                archiveObservationID: snapshot.archiveObservationID,
126
-                count: count,
127
-                fallbackAdded: fallback.added,
128
-                fallbackDisappeared: fallback.disappeared,
129
-                fallbackIsExact: fallback.exact
130
-            )
131
-        }
132
-    }
133
-
134
-    private var simplifiedRecordCounts: (added: Int, disappeared: Int, exact: Bool) {
135
-        if case .loaded(let diff) = diffState {
136
-            return (diff.addedCount, diff.disappearedCount, true)
137
-        }
138
-        return quickAddedDisappeared
139
-    }
140
-
141
-    private var currentArchiveObservationID: Int64? {
142
-        currentSnapshot.archiveObservationID
143
-    }
144
-
145
-    private var previousArchiveObservationID: Int64? {
146
-        previousSnapshot?.archiveObservationID
147
-    }
148
-
149
-    private var isTypeTrackedInCurrentContext: Bool {
150
-        currentTypeCount != nil ||
151
-        previousTypeCount != nil ||
152
-        currentCachedTypeSummary != nil ||
153
-        previousCachedTypeSummary != nil ||
154
-        currentArchiveObservationID != nil
155
-    }
156
-
157
-    private var usesSimplifiedDetailUI: Bool {
158
-        LegacyUIMode.isEnabled(
159
-            forceEnabled: appSettings.simplifiedUIModeEnabled,
160
-            horizontalSizeClass: horizontalSizeClass,
161
-            dynamicTypeSize: dynamicTypeSize,
162
-            screenWidth: contentWidth
163
-        )
164
-    }
165
-
166
-    var body: some View {
167
-        ScrollView {
168
-            VStack(spacing: 16) {
169
-                if previousSnapshot == nil {
170
-                    emptyStateContent("No baseline available for this device.", icon: "clock.badge.questionmark")
171
-                } else if !isTypeTrackedInCurrentContext {
172
-                    emptyStateContent("Data type not tracked in selected snapshots.", icon: "eye.slash")
173
-                } else {
174
-                    dataRangeSection
175
-                    recordChangeComparisonSection
176
-                    if usesSimplifiedDetailUI {
177
-                        simplifiedDetailSection
178
-                    } else {
179
-                        recordChangeEvolutionSection
180
-                        temporalDistributionSection
181
-                    }
182
-                }
183
-            }
184
-            .padding(16)
185
-        }
186
-        .navigationTitle(displayName)
187
-        .navigationBarTitleDisplayMode(.inline)
188
-        .safeAreaInset(edge: .top, spacing: 0) {
189
-            SnapshotNavigationHeader(
190
-                snapshots: timelineSnapshots,
191
-                currentSnapshot: currentSnapshot,
192
-                onSnapshotSelected: { displayedSnapshot = $0 }
193
-            )
194
-                .frame(height: 64)
195
-        }
196
-        .toolbar {
197
-            ToolbarItem(placement: .principal) {
198
-                snapshotToolbarTitle
199
-            }
200
-        }
201
-        .background(contentWidthReader)
202
-        .task(id: diffTaskID) {
203
-            await loadArchiveTypeSummaries()
204
-            await loadRecordDiff()
205
-        }
206
-        .navigationDestination(isPresented: $showTemporalDistribution) {
207
-            DataTypeTemporalDistributionView(
208
-                input: temporalDistributionInput,
209
-                displayName: displayName
210
-            )
211
-        }
212
-    }
213
-
214
-    private var contentWidthReader: some View {
215
-        GeometryReader { proxy in
216
-            Color.clear
217
-                .onAppear {
218
-                    contentWidth = proxy.size.width
219
-                }
220
-                .onChange(of: proxy.size.width) { _, newWidth in
221
-                    contentWidth = newWidth
222
-                }
223
-        }
224
-    }
225
-
226
-    private func typeCount(in snapshot: HealthSnapshot) -> TypeCount? {
227
-        snapshot.typeCounts?.first { $0.typeIdentifier == typeIdentifier }
228
-    }
229
-
230
-    private func countText(for typeCount: TypeCount?) -> String {
231
-        guard let typeCount else { return "Not tracked" }
232
-        if typeCount.isUnsupported { return "Unsupported" }
233
-        if typeCount.count < 0 { return "Unavailable" }
234
-        return "\(typeCount.count)"
235
-    }
236
-
237
-    private func countValue(for typeCount: TypeCount?) -> Int? {
238
-        guard let typeCount else { return 0 }
239
-        guard !typeCount.isUnsupported, typeCount.count >= 0 else { return nil }
240
-        return typeCount.count
241
-    }
242
-
243
-    @ViewBuilder
244
-    private var snapshotToolbarTitle: some View {
245
-        if #available(iOS 26.0, *) {
246
-            Text(displayName)
247
-                .font(.headline.weight(.semibold))
248
-                .lineLimit(1)
249
-                .padding(.horizontal, 18)
250
-                .frame(height: 36)
251
-                .background(Color(.systemBackground).opacity(0.08), in: Capsule())
252
-                .glassEffect(
253
-                    .regular.tint(Color(.systemBackground).opacity(0.12)),
254
-                    in: Capsule()
255
-                )
256
-        } else {
257
-            Text(displayName)
258
-                .font(.headline.weight(.semibold))
259
-                .lineLimit(1)
260
-                .padding(.horizontal, 18)
261
-                .frame(height: 36)
262
-                .background(.ultraThinMaterial, in: Capsule())
263
-        }
264
-    }
265
-
266
-    @ViewBuilder
267
-    private var dataRangeSection: some View {
268
-        if currentTypeCount != nil || currentCachedTypeSummary != nil {
269
-            DataTypeRangeIndicator(
270
-                earliestDate: currentCachedTypeSummary?.earliestStartDate ?? currentTypeCount?.earliestDate,
271
-                latestDate: currentCachedTypeSummary?.latestEndDate ?? currentTypeCount?.latestDate,
272
-                quality: currentTypeCount?.quality ?? .complete
273
-            )
274
-        }
275
-    }
276
-
277
-    private var recordChangeComparisonSection: some View {
278
-        Group {
279
-            if previousSnapshot != nil {
280
-                switch diffState {
281
-            case .loaded(let diff):
282
-                RecordChangeComparisonCard(
283
-                    displayName: displayName,
284
-                    currentCount: quickCurrentCountValue,
285
-                    previousCount: previousCachedTypeSummary?.visibleRecordCount ?? previousTypeCount?.count,
286
-                    addedCount: diff.addedCount,
287
-                    disappearedCount: diff.disappearedCount,
288
-                    isCurrentValid: quickCurrentCountValue >= 0,
289
-                    isPreviousTracked: previousTypeCount != nil || previousCachedTypeSummary != nil,
290
-                    onAddedTap: {
291
-                        if diff.addedCount > 0 {
292
-                            showAddedRecords = true
293
-                        }
294
-                    },
295
-                    onDisappearedTap: {
296
-                        if diff.disappearedCount > 0 {
297
-                            showDisappearedRecords = true
298
-                        }
299
-                    }
300
-                )
301
-                .navigationDestination(isPresented: $showAddedRecords) {
302
-                    if let previous = previousSnapshot {
303
-                        DataTypeRecordListView(
304
-                            title: "New Records",
305
-                            displayName: displayName,
306
-                            totalCount: diff.addedCount,
307
-                            mode: addedRecordListMode(previous: previous),
308
-                            previewRecords: diff.addedRecords,
309
-                            tint: Color.healthyGreen
310
-                        )
311
-                    }
312
-                }
313
-                .navigationDestination(isPresented: $showDisappearedRecords) {
314
-                    DataTypeRecordListView(
315
-                        title: "Missing Records",
316
-                        displayName: displayName,
317
-                        totalCount: diff.disappearedCount,
318
-                        mode: disappearedRecordListMode(),
319
-                        previewRecords: diff.disappearedRecords,
320
-                        tint: Color.criticalRed
321
-                    )
322
-                }
323
-
324
-            case .idle:
325
-                VStack(alignment: .leading, spacing: 10) {
326
-                    let quick = quickAddedDisappeared
327
-
328
-                    VStack(alignment: .leading, spacing: 8) {
329
-                        Text("Quick Counts")
330
-                            .font(.subheadline.weight(.semibold))
331
-
332
-                        HStack {
333
-                            quickStat(label: "Current", value: "\(quickCurrentCountValue)")
334
-                            quickStat(label: "Previous", value: "\(quickPreviousCountValue)")
335
-                        }
336
-
337
-                        HStack {
338
-                            quickStat(label: "New", value: "\(quick.added)", color: .healthyGreen)
339
-                            quickStat(label: "Missing", value: "\(quick.disappeared)", color: .criticalRed)
340
-                        }
341
-
342
-                        if !quick.exact {
343
-                            Text("New/Missing are net values from observation delta. Exact split needs deep record analysis.")
344
-                                .font(.caption2)
345
-                                .foregroundStyle(.secondary)
346
-                        }
347
-                    }
348
-                    .padding(12)
349
-                    .background(Color(.systemBackground).opacity(0.35), in: RoundedRectangle(cornerRadius: 8))
350
-                }
351
-                .padding(12)
352
-                .frame(maxWidth: .infinity, alignment: .leading)
353
-                .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
354
-
355
-            case .unavailable:
356
-                VStack(alignment: .leading, spacing: 8) {
357
-                    Label(
358
-                        "Record detail cache is unavailable for this snapshot pair.",
359
-                        systemImage: "exclamationmark.triangle.fill"
360
-                    )
361
-                    .font(.subheadline)
362
-                    .foregroundStyle(Color.warningAmber)
363
-
364
-                    #if DEBUG
365
-                    if let detailCacheDiagnostic {
366
-                        Text(detailCacheDiagnostic)
367
-                            .font(.caption2.monospaced())
368
-                            .foregroundStyle(.secondary)
369
-                            .textSelection(.enabled)
370
-                    }
371
-                    #endif
372
-                }
373
-                .padding(12)
374
-                .frame(maxWidth: .infinity, alignment: .leading)
375
-                .background(Color.warningAmber.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
376
-
377
-            case .failed(let message):
378
-                Label(message, systemImage: "exclamationmark.triangle.fill")
379
-                    .font(.subheadline)
380
-                    .foregroundStyle(Color.warningAmber)
381
-                    .padding(12)
382
-                    .frame(maxWidth: .infinity, alignment: .leading)
383
-                    .background(Color.warningAmber.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
384
-
385
-            case .loading:
386
-                HStack(spacing: 8) {
387
-                    ProgressView()
388
-                    Text("Analyzing record changes...")
389
-                        .font(.subheadline)
390
-                        .foregroundStyle(.secondary)
391
-                }
392
-                .frame(maxWidth: .infinity, alignment: .leading)
393
-                .padding(12)
394
-                .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
395
-                }
396
-            }
397
-        }
398
-    }
399
-
400
-    @ViewBuilder
401
-    private var recordChangeEvolutionSection: some View {
402
-        if previousSnapshot != nil, currentTypeCount != nil || currentCachedTypeSummary != nil {
403
-            RecordChangeEvolutionChart(
404
-                snapshots: recordEvolutionSnapshots,
405
-                currentSnapshotID: currentSnapshot.id,
406
-                typeIdentifier: typeIdentifier,
407
-                displayName: displayName
408
-            )
409
-        }
410
-    }
411
-
412
-    private var simplifiedDetailSection: some View {
413
-        VStack(alignment: .leading, spacing: 12) {
414
-            Text("Summary")
415
-                .font(.headline.weight(.semibold))
416
-
417
-            HStack(spacing: 12) {
418
-                quickStat(label: "Current", value: "\(quickCurrentCountValue)")
419
-                quickStat(label: "Previous", value: "\(quickPreviousCountValue)")
420
-            }
421
-
422
-            let quick = simplifiedRecordCounts
423
-            HStack(spacing: 12) {
424
-                quickStat(label: "New", value: "\(quick.added)", color: .healthyGreen)
425
-                quickStat(label: "Missing", value: "\(quick.disappeared)", color: .criticalRed)
426
-            }
427
-
428
-            if !quick.exact {
429
-                Text("Exact split needs record analysis.")
430
-                    .font(.caption2)
431
-                    .foregroundStyle(.secondary)
432
-            }
433
-        }
434
-        .padding(12)
435
-        .frame(maxWidth: .infinity, alignment: .leading)
436
-        .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
437
-        .accessibilityElement(children: .combine)
438
-        .accessibilityLabel("Summary. Current \(quickCurrentCountValue). Previous \(quickPreviousCountValue). New \(simplifiedRecordCounts.added). Missing \(simplifiedRecordCounts.disappeared).")
439
-    }
440
-
441
-    @ViewBuilder
442
-    private var temporalDistributionSection: some View {
443
-        if previousSnapshot != nil, currentTypeCount != nil, !isCurrentTypeContentAliasToPrevious {
444
-            if !hasTemporalDistributionCache {
445
-                VStack(alignment: .leading, spacing: 8) {
446
-                    Label(
447
-                        "Temporal distribution is available only when precomputed cache exists for this snapshot pair.",
448
-                        systemImage: "info.circle"
449
-                    )
450
-                    .font(.caption)
451
-                    .foregroundStyle(.secondary)
452
-                }
453
-                .padding(12)
454
-                .frame(maxWidth: .infinity, alignment: .leading)
455
-                .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
456
-            } else {
457
-            Button {
458
-                showTemporalDistribution = true
459
-            } label: {
460
-                HStack(spacing: 10) {
461
-                    Image(systemName: "chart.bar.xaxis")
462
-                        .font(.system(size: 16, weight: .semibold))
463
-                        .foregroundStyle(.secondary)
464
-
465
-                    VStack(alignment: .leading, spacing: 2) {
466
-                        Text("Temporal Distribution")
467
-                            .font(.headline.weight(.semibold))
468
-                            .foregroundStyle(.primary)
469
-                        Text("New / missing by time bucket")
470
-                            .font(.caption)
471
-                            .foregroundStyle(.secondary)
472
-                    }
473
-
474
-                    Spacer()
475
-
476
-                    Image(systemName: "chevron.right")
477
-                        .font(.system(size: 12, weight: .semibold))
478
-                        .foregroundStyle(.secondary)
479
-                }
480
-                .padding(12)
481
-                .frame(maxWidth: .infinity, alignment: .leading)
482
-                .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
483
-            }
484
-            .buttonStyle(.plain)
485
-            }
486
-        }
487
-    }
488
-
489
-    private func emptyStateContent(_ message: String, icon: String) -> some View {
490
-        VStack(spacing: 12) {
491
-            Image(systemName: icon)
492
-                .font(.system(size: 32, weight: .semibold))
493
-                .foregroundStyle(.secondary)
494
-
495
-            Text(message)
496
-                .font(.subheadline)
497
-                .foregroundStyle(.secondary)
498
-                .multilineTextAlignment(.center)
499
-        }
500
-        .padding(24)
501
-        .frame(maxWidth: .infinity)
502
-        .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12))
503
-    }
504
-
505
-    private func quickStat(label: String, value: String, color: Color = .primary) -> some View {
506
-        VStack(alignment: .leading, spacing: 2) {
507
-            Text(label)
508
-                .font(.caption2)
509
-                .foregroundStyle(.secondary)
510
-            Text(value)
511
-                .font(.subheadline.weight(.semibold).monospacedDigit())
512
-                .foregroundStyle(color)
513
-        }
514
-        .frame(maxWidth: .infinity, alignment: .leading)
515
-    }
516
-
517
-    private func recordEvolutionCachedFallback(for snapshot: HealthSnapshot) -> (added: Int, disappeared: Int, exact: Bool) {
518
-        guard let previous = snapshot.previousInTimeline(timelineSnapshots) else {
519
-            return (0, 0, false)
520
-        }
521
-
522
-        if let cache = typeCount(in: snapshot)?.detailCache,
523
-           cache.matchesBaseline(previous.id) {
524
-            return (cache.addedCount, cache.disappearedCount, true)
525
-        }
526
-
527
-        return (0, 0, false)
528
-    }
529
-
530
-    private func addedRecordListMode(previous: HealthSnapshot) -> RecordListMode {
531
-        if let fromObservationID = previous.archiveObservationID,
532
-           let toObservationID = currentArchiveObservationID {
533
-            return .addedDiff(
534
-                typeIdentifier: typeIdentifier,
535
-                afterDate: previous.timestamp,
536
-                beforeDate: currentSnapshot.timestamp,
537
-                fromObservationID: fromObservationID,
538
-                toObservationID: toObservationID
539
-            )
540
-        }
541
-
542
-        return .added(
543
-            typeIdentifier: typeIdentifier,
544
-            afterDate: previous.timestamp,
545
-            beforeDate: currentSnapshot.timestamp
546
-        )
547
-    }
548
-
549
-    private func disappearedRecordListMode() -> RecordListMode {
550
-        if let fromObservationID = previousArchiveObservationID,
551
-           let toObservationID = currentArchiveObservationID {
552
-            return .disappearedDiff(
553
-                typeIdentifier: typeIdentifier,
554
-                fromObservationID: fromObservationID,
555
-                toObservationID: toObservationID
556
-            )
557
-        }
558
-
559
-        return .disappeared(typeIdentifier: typeIdentifier)
560
-    }
561
-
562
-    @MainActor
563
-    private func loadRecordDiff() async {
564
-        guard previousSnapshot != nil else {
565
-            detailCacheDiagnostic = nil
566
-            diffState = .loaded(.empty)
567
-            return
568
-        }
569
-
570
-        if isCurrentTypeContentAliasToPrevious {
571
-            detailCacheDiagnostic = nil
572
-            diffState = .loaded(.empty)
573
-            return
574
-        }
575
-
576
-        let currentCount = currentCachedTypeSummary?.visibleRecordCount ?? currentTypeCount?.count ?? 0
577
-        let previousCount = previousCachedTypeSummary?.visibleRecordCount ?? previousTypeCount?.count ?? 0
578
-
579
-        guard currentCount >= 0, previousCount >= 0 else {
580
-            detailCacheDiagnostic = "counts-invalid current=\(currentCount) previous=\(previousCount)"
581
-            diffState = .unavailable
582
-            return
583
-        }
584
-
585
-        if let previousArchiveObservationID,
586
-           let currentArchiveObservationID {
587
-            do {
588
-                let cache = try CoreDataArchiveCacheStore()
589
-                if let cached = try cache.diffSummary(
590
-                    fromObservationID: previousArchiveObservationID,
591
-                    toObservationID: currentArchiveObservationID,
592
-                    sampleTypeIdentifier: typeIdentifier
593
-                ) {
594
-                    detailCacheDiagnostic = "resolver-v6 phase=core-data-diff-cache"
595
-                    diffState = .loaded(DataTypeRecordDiff(cached: cached))
596
-                    return
597
-                }
598
-            } catch {
599
-                detailCacheDiagnostic = "core-data-diff-cache-failed \(error.localizedDescription)"
600
-            }
601
-
602
-            do {
603
-                let summary = try await SQLiteHealthArchiveStore.shared.diffSummary(HealthArchiveDiffRequest(
604
-                    fromObservationID: previousArchiveObservationID,
605
-                    toObservationID: currentArchiveObservationID,
606
-                    sampleTypeIdentifier: typeIdentifier
607
-                ))
608
-                detailCacheDiagnostic = "resolver-v5 phase=archive-diff"
609
-                diffState = .loaded(DataTypeRecordDiff(summary: summary))
610
-                return
611
-            } catch {
612
-                detailCacheDiagnostic = "archive-diff-failed \(error.localizedDescription)"
613
-            }
614
-        }
615
-
616
-        if let cache = currentTypeCount?.detailCache,
617
-           cache.matchesBaseline(previousSnapshot?.id) {
618
-            detailCacheDiagnostic = "resolver-v7 phase=legacy-detail-cache-read-only"
619
-            diffState = .loaded(DataTypeRecordDiff(cache: cache))
620
-            return
621
-        }
622
-
623
-        diffState = .unavailable
624
-    }
625
-
626
-    @MainActor
627
-    private func loadArchiveTypeSummaries() async {
628
-        guard currentArchiveObservationID != nil || previousArchiveObservationID != nil else {
629
-            currentCachedTypeSummary = nil
630
-            previousCachedTypeSummary = nil
631
-            return
632
-        }
633
-
634
-        do {
635
-            let cache = try CoreDataArchiveCacheStore()
636
-            if let currentArchiveObservationID {
637
-                currentCachedTypeSummary = try cache.typeSummaries(observationID: currentArchiveObservationID)
638
-                    .first { $0.sampleTypeIdentifier == typeIdentifier }
639
-            } else {
640
-                currentCachedTypeSummary = nil
641
-            }
642
-
643
-            if let previousArchiveObservationID {
644
-                previousCachedTypeSummary = try cache.typeSummaries(observationID: previousArchiveObservationID)
645
-                    .first { $0.sampleTypeIdentifier == typeIdentifier }
646
-            } else {
647
-                previousCachedTypeSummary = nil
648
-            }
649
-        } catch {
650
-            currentCachedTypeSummary = nil
651
-            previousCachedTypeSummary = nil
652
-            detailCacheDiagnostic = "core-data-type-cache-failed \(error.localizedDescription)"
653
-        }
654
-    }
655
-
656
-}
657
-
658
-private enum RecordDiffState: Equatable {
659
-    case idle
660
-    case loading
661
-    case unavailable
662
-    case failed(String)
663
-    case loaded(DataTypeRecordDiff)
664
-}
665
-
666
-private struct DataTypeRecordDiff: Equatable, Sendable {
667
-    static let previewLimit = 1_000
668
-    static let empty = DataTypeRecordDiff(
669
-        addedCount: 0,
670
-        disappearedCount: 0,
671
-        addedRecords: [],
672
-        disappearedRecords: []
673
-    )
674
-
675
-    let addedCount: Int
676
-    let disappearedCount: Int
677
-    let addedRecords: [HealthRecordValue]
678
-    let disappearedRecords: [HealthRecordValue]
679
-
680
-    init(
681
-        addedCount: Int,
682
-        disappearedCount: Int,
683
-        addedRecords: [HealthRecordValue],
684
-        disappearedRecords: [HealthRecordValue]
685
-    ) {
686
-        self.addedCount = addedCount
687
-        self.disappearedCount = disappearedCount
688
-        self.addedRecords = addedRecords
689
-        self.disappearedRecords = disappearedRecords
690
-    }
691
-
692
-    init(cache: TypeCountDetailCache) {
693
-        self.addedCount = cache.addedCount
694
-        self.disappearedCount = cache.disappearedCount
695
-        self.addedRecords = cache.addedPreviewRecords
696
-        self.disappearedRecords = cache.disappearedPreviewRecords
697
-    }
698
-
699
-    init(summary: HealthArchiveDiffSummary) {
700
-        self.addedCount = summary.appearedCount
701
-        self.disappearedCount = summary.disappearedCount
702
-        self.addedRecords = []
703
-        self.disappearedRecords = []
704
-    }
705
-
706
-    init(cached: CachedArchiveDiffSummary) {
707
-        self.addedCount = cached.appearedCount
708
-        self.disappearedCount = cached.disappearedCount
709
-        self.addedRecords = []
710
-        self.disappearedRecords = []
711
-    }
712
-
713
-    var isPreviewLimited: Bool {
714
-        addedCount > Self.previewLimit || disappearedCount > Self.previewLimit
715
-    }
716
-}
717
-
718
-#Preview {
719
-    NavigationStack {
720
-        DataTypeSnapshotDetailView(
721
-            snapshot: HealthSnapshot(
722
-                timestamp: .now,
723
-                osVersion: "iOS 26.4",
724
-                deviceName: "Preview iPhone",
725
-                deviceID: "preview-device"
726
-            ),
727
-            typeIdentifier: "HKQuantityTypeIdentifierStepCount",
728
-            displayName: "Step Count"
729
-        )
730
-    }
731
-    .modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self], inMemory: true)
732
-    .environment(AppSettings())
733
-}
+0 -705
HealthProbe/Views/Snapshots/SnapshotDetailView.swift
@@ -1,705 +0,0 @@
1
-import SwiftUI
2
-import SwiftData
3
-import UIKit
4
-
5
-struct SnapshotDetailView: View {
6
-    let snapshot: HealthSnapshot
7
-    let baseline: HealthSnapshot?
8
-    let profile: LocalDeviceProfile?
9
-
10
-    @Query(sort: \HealthSnapshot.timestamp) private var allSnapshots: [HealthSnapshot]
11
-    @State private var displayedSnapshot: HealthSnapshot?
12
-    @State private var archiveTypeRows: [SnapshotArchiveTypeRow]?
13
-    @State private var archiveTypeError: String?
14
-
15
-    private var currentSnapshot: HealthSnapshot {
16
-        displayedSnapshot ?? snapshot
17
-    }
18
-
19
-    private var archiveReloadID: String {
20
-        [
21
-            currentSnapshot.id.uuidString,
22
-            String(currentSnapshot.archiveObservationID ?? -1),
23
-            String(baseline?.archiveObservationID ?? -1)
24
-        ].joined(separator: "|")
25
-    }
26
-
27
-    private var summaryTypeCount: Int? {
28
-        if let archiveTypeRows {
29
-            return archiveTypeRows.count
30
-        }
31
-        guard currentSnapshot.hasCurrentCachedSummary else { return nil }
32
-        return currentSnapshot.cachedTypeCount
33
-    }
34
-
35
-    private var summaryRecordCount: Int? {
36
-        if let archiveTypeRows {
37
-            return archiveTypeRows.reduce(0) { $0 + $1.currentCount }
38
-        }
39
-        guard currentSnapshot.hasCurrentCachedSummary else { return nil }
40
-        return currentSnapshot.cachedRecordCount
41
-    }
42
-
43
-    private var summaryEarliestRecordDate: Date? {
44
-        archiveTypeRows?.compactMap(\.earliestStartDate).min() ?? currentSnapshot.cachedEarliestRecordDate
45
-    }
46
-
47
-    private var summaryLatestRecordDate: Date? {
48
-        archiveTypeRows?.compactMap(\.latestEndDate).max() ?? currentSnapshot.cachedLatestRecordDate
49
-    }
50
-
51
-    private var archiveRecordChangeCount: Int? {
52
-        archiveTypeRows?.reduce(0) { $0 + $1.recordChangeCount }
53
-    }
54
-
55
-    private var archiveAffectedMetricCount: Int? {
56
-        archiveTypeRows?.filter(\.hasChanges).count
57
-    }
58
-
59
-    private var deviceDisplayName: String {
60
-        if let name = profile?.name, !name.isEmpty { return name }
61
-        return currentSnapshot.deviceName.isEmpty ? "Unknown device" : currentSnapshot.deviceName
62
-    }
63
-
64
-    private var timelineSnapshots: [HealthSnapshot] {
65
-        allSnapshots.filter { candidate in
66
-            if currentSnapshot.deviceID.isEmpty {
67
-                return candidate.deviceID.isEmpty
68
-            }
69
-            return candidate.deviceID == currentSnapshot.deviceID
70
-        }
71
-    }
72
-
73
-    @State private var showShareSheet = false
74
-    @State private var pdfExportURL: URL?
75
-    @State private var isExporting = false
76
-    @State private var showMetadataSheet = false
77
-
78
-    var body: some View {
79
-        List {
80
-            evolutionSection
81
-        }
82
-        .navigationTitle("Snapshot")
83
-        .navigationBarTitleDisplayMode(.inline)
84
-        .safeAreaInset(edge: .top, spacing: 0) {
85
-            SnapshotNavigationHeader(
86
-                snapshots: timelineSnapshots,
87
-                currentSnapshot: currentSnapshot,
88
-                onSnapshotSelected: { displayedSnapshot = $0 }
89
-            )
90
-                .frame(height: 64)
91
-        }
92
-        .toolbar {
93
-            ToolbarItem(placement: .principal) {
94
-                snapshotToolbarTitle
95
-            }
96
-            ToolbarItem(placement: .navigationBarTrailing) {
97
-                HStack(spacing: 12) {
98
-                    Button {
99
-                        showMetadataSheet = true
100
-                    } label: {
101
-                        Image(systemName: "info.circle")
102
-                    }
103
-                    .accessibilityLabel("View snapshot details")
104
-
105
-                    if isExporting {
106
-                        ProgressView()
107
-                            .accessibilityLabel("Generating PDF")
108
-                    } else {
109
-                        Button {
110
-                            exportAsPDF()
111
-                        } label: {
112
-                            Image(systemName: "square.and.arrow.up")
113
-                        }
114
-                        .accessibilityLabel("Export snapshot as PDF")
115
-                    }
116
-                }
117
-            }
118
-        }
119
-        .sheet(isPresented: $showMetadataSheet) {
120
-            metadataSheetContent
121
-        }
122
-        .sheet(isPresented: $showShareSheet) {
123
-            if let url = pdfExportURL {
124
-                ShareSheet(items: [url])
125
-                    .ignoresSafeArea()
126
-            }
127
-        }
128
-        .task(id: archiveReloadID) {
129
-            await loadArchiveTypeRows()
130
-        }
131
-    }
132
-
133
-    private func exportAsPDF() {
134
-        isExporting = true
135
-        let reportData = SnapshotPDFExporter.extractReportData(
136
-            snapshot: currentSnapshot,
137
-            baseline: baseline,
138
-            profile: profile
139
-        )
140
-        let timestamp = currentSnapshot.timestamp
141
-        Task(priority: .userInitiated) {
142
-            let pdfData = SnapshotPDFExporter.generatePDF(from: reportData)
143
-            let formatter = DateFormatter()
144
-            formatter.dateFormat = "yyyy-MM-dd-HH-mm"
145
-            let name = "HealthProbe-Snapshot-\(formatter.string(from: timestamp)).pdf"
146
-            let url = FileManager.default.temporaryDirectory.appendingPathComponent(name)
147
-            try? pdfData.write(to: url)
148
-            isExporting = false
149
-            pdfExportURL = url
150
-            showShareSheet = true
151
-        }
152
-    }
153
-
154
-    @MainActor
155
-    private func loadArchiveTypeRows() async {
156
-        guard let currentObservationID = currentSnapshot.archiveObservationID else {
157
-            archiveTypeRows = nil
158
-            archiveTypeError = nil
159
-            return
160
-        }
161
-
162
-        do {
163
-            let cache = try CoreDataArchiveCacheStore()
164
-            let currentSummaries = try cache.typeSummaries(observationID: currentObservationID)
165
-            let baselineObservationID = baseline?.archiveObservationID
166
-            let baselineSummaries = try baselineObservationID.map {
167
-                try cache.typeSummaries(observationID: $0)
168
-            } ?? []
169
-
170
-            let currentByType = Dictionary(uniqueKeysWithValues: currentSummaries.map { ($0.sampleTypeIdentifier, $0) })
171
-            let baselineByType = Dictionary(uniqueKeysWithValues: baselineSummaries.map { ($0.sampleTypeIdentifier, $0) })
172
-            let allTypeIdentifiers = Set(currentByType.keys).union(baselineByType.keys)
173
-
174
-            var rows: [SnapshotArchiveTypeRow] = []
175
-            rows.reserveCapacity(allTypeIdentifiers.count)
176
-
177
-            for typeIdentifier in allTypeIdentifiers {
178
-                let current = currentByType[typeIdentifier]
179
-                let baselineSummary = baselineByType[typeIdentifier]
180
-                let diff: HealthArchiveDiffSummary
181
-                if let baselineObservationID {
182
-                    diff = try await SQLiteHealthArchiveStore.shared.diffSummary(HealthArchiveDiffRequest(
183
-                        fromObservationID: baselineObservationID,
184
-                        toObservationID: currentObservationID,
185
-                        sampleTypeIdentifier: typeIdentifier
186
-                    ))
187
-                } else {
188
-                    diff = HealthArchiveDiffSummary(
189
-                        fromObservationID: currentObservationID,
190
-                        toObservationID: currentObservationID,
191
-                        sampleTypeIdentifier: typeIdentifier,
192
-                        appearedCount: 0,
193
-                        disappearedCount: 0,
194
-                        representationChangedCount: 0
195
-                    )
196
-                }
197
-
198
-                rows.append(SnapshotArchiveTypeRow(
199
-                    typeIdentifier: typeIdentifier,
200
-                    displayName: current?.displayName ?? baselineSummary?.displayName ?? typeIdentifier,
201
-                    currentCount: current?.visibleRecordCount ?? 0,
202
-                    previousCount: baselineSummary?.visibleRecordCount,
203
-                    appearedCount: diff.appearedCount,
204
-                    disappearedCount: diff.disappearedCount,
205
-                    representationChangedCount: diff.representationChangedCount,
206
-                    earliestStartDate: current?.earliestStartDate,
207
-                    latestEndDate: current?.latestEndDate
208
-                ))
209
-            }
210
-
211
-            archiveTypeRows = rows.sorted {
212
-                $0.displayName.localizedCompare($1.displayName) == .orderedAscending
213
-            }
214
-            archiveTypeError = nil
215
-        } catch {
216
-            archiveTypeRows = nil
217
-            archiveTypeError = error.localizedDescription
218
-        }
219
-    }
220
-
221
-    @ViewBuilder
222
-    private var snapshotToolbarTitle: some View {
223
-        if #available(iOS 26.0, *) {
224
-            Text("Snapshot")
225
-                .font(.headline.weight(.semibold))
226
-                .padding(.horizontal, 18)
227
-                .frame(height: 36)
228
-                .background(Color(.systemBackground).opacity(0.08), in: Capsule())
229
-                .glassEffect(
230
-                    .regular.tint(Color(.systemBackground).opacity(0.12)),
231
-                    in: Capsule()
232
-                )
233
-        } else {
234
-            Text(currentSnapshot.timestamp, format: .dateTime.month().day().hour().minute())
235
-                .font(.headline.weight(.semibold))
236
-                .padding(.horizontal, 18)
237
-                .frame(height: 36)
238
-                .background(.ultraThinMaterial, in: Capsule())
239
-        }
240
-    }
241
-
242
-    @ViewBuilder
243
-    private var metadataSheetContent: some View {
244
-        NavigationStack {
245
-            ScrollView {
246
-                VStack(alignment: .leading, spacing: 12) {
247
-                    // Title with Date
248
-                    VStack(spacing: 4) {
249
-                        Text("Snapshot")
250
-                            .font(.headline.weight(.semibold))
251
-                        Text(currentSnapshot.timestamp, format: .dateTime.month().day().hour().minute())
252
-                            .font(.subheadline)
253
-                            .foregroundStyle(.secondary)
254
-                    }
255
-                    .frame(maxWidth: .infinity, alignment: .center)
256
-                    .padding(12)
257
-                    .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12))
258
-
259
-                    // Data Range
260
-                    SnapshotDataRangeIndicator(
261
-                        oldestRecordDate: summaryEarliestRecordDate,
262
-                        newestRecordDate: summaryLatestRecordDate,
263
-                        quality: currentSnapshot.snapshotQuality
264
-                    )
265
-
266
-                    // Summary Stats (compact)
267
-                    VStack(spacing: 12) {
268
-                        if let summaryTypeCount,
269
-                           let summaryRecordCount {
270
-                            HStack(spacing: 16) {
271
-                                statCompact(label: "Types", value: "\(summaryTypeCount)")
272
-                                Divider()
273
-                                statCompact(label: "Records", value: "\(summaryRecordCount)")
274
-                            }
275
-                            .font(.caption)
276
-                            .foregroundStyle(.secondary)
277
-                        } else {
278
-                            Text("Snapshot summary unavailable")
279
-                                .font(.caption)
280
-                                .foregroundStyle(.secondary)
281
-                                .frame(maxWidth: .infinity, alignment: .center)
282
-                        }
283
-                    }
284
-                    .padding(12)
285
-                    .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12))
286
-
287
-                    // Device (collapsible)
288
-                    DisclosureGroup {
289
-                        VStack(alignment: .leading, spacing: 12) {
290
-                            DetailRow(label: "Version") {
291
-                                Text(extractOSVersion(currentSnapshot.osVersion))
292
-                                    .foregroundStyle(.secondary)
293
-                                    .font(.caption.monospacedDigit())
294
-                            }
295
-                            Divider()
296
-                            DetailRow(label: "Build") {
297
-                                Text(extractBuildNumber(currentSnapshot.osVersion))
298
-                                    .foregroundStyle(.secondary)
299
-                                    .font(.caption.monospacedDigit())
300
-                            }
301
-                        }
302
-                        .padding(.top, 8)
303
-                    } label: {
304
-                        HStack(spacing: 8) {
305
-                            Image(systemName: "iphone")
306
-                                .font(.system(size: 16, weight: .semibold))
307
-                            Text(deviceDisplayName)
308
-                                .font(.subheadline.weight(.semibold))
309
-                        }
310
-                    }
311
-                    .padding(12)
312
-                    .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12))
313
-
314
-                    // Comparison (if baseline exists)
315
-                    if let baseline {
316
-                        comparisonSection(baseline: baseline)
317
-                    }
318
-
319
-                    Spacer()
320
-                }
321
-                .padding(16)
322
-            }
323
-            .navigationTitle("Snapshot")
324
-            .navigationBarTitleDisplayMode(.inline)
325
-        }
326
-    }
327
-
328
-    @ViewBuilder
329
-    private func comparisonSection(baseline: HealthSnapshot) -> some View {
330
-        let delta = archiveRecordChangeCount ?? 0
331
-        let deltaPercent = computeDeltaPercent(delta: delta, baseline: baseline)
332
-        let affectedMetricCount = archiveAffectedMetricCount ?? 0
333
-        let isSignificant = delta > 0 || affectedMetricCount > 0 || (deltaPercent > 10)
334
-
335
-        DisclosureGroup {
336
-            VStack(alignment: .leading, spacing: 12) {
337
-                DetailRow(label: "Baseline") {
338
-                    Text(baseline.timestamp, format: .dateTime.month().day().hour().minute())
339
-                        .foregroundStyle(.secondary)
340
-                }
341
-                Divider()
342
-                DetailRow(label: "Time Span") {
343
-                    let days = Calendar.current.dateComponents([.day], from: baseline.timestamp, to: currentSnapshot.timestamp).day ?? 0
344
-                    Text(days == 0 ? "Same day" : "\(days) days")
345
-                        .foregroundStyle(.secondary)
346
-                }
347
-                if archiveTypeRows != nil {
348
-                    Divider()
349
-                    DetailRow(label: "Changed Metrics") {
350
-                        Text("\(affectedMetricCount)")
351
-                            .foregroundStyle(.secondary)
352
-                    }
353
-                    Divider()
354
-                    DetailRow(label: "Record Changes") {
355
-                        Text("\(delta)")
356
-                            .foregroundStyle(.secondary)
357
-                    }
358
-                }
359
-            }
360
-            .padding(.top, 8)
361
-        } label: {
362
-            HStack(spacing: 8) {
363
-                Image(systemName: "arrow.left.and.right.square")
364
-                    .font(.system(size: 16, weight: .semibold))
365
-                Text("Comparison")
366
-                    .font(.subheadline.weight(.semibold))
367
-                Spacer()
368
-                if isSignificant {
369
-                    SeverityBadge(delta: delta)
370
-                        .frame(height: 24)
371
-                } else {
372
-                    Text("–")
373
-                        .font(.caption2.weight(.semibold))
374
-                        .foregroundStyle(.secondary)
375
-                }
376
-            }
377
-        }
378
-        .padding(12)
379
-        .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12))
380
-    }
381
-
382
-    private func shortOSVersion(_ full: String) -> Text {
383
-        if full.hasPrefix("iOS ") {
384
-            let version = full.dropFirst(4).prefix(while: { $0 != " " })
385
-            return Text("iOS \(version)")
386
-        }
387
-        return Text(full)
388
-    }
389
-
390
-    private func extractOSVersion(_ full: String) -> String {
391
-        if full.hasPrefix("iOS ") {
392
-            let versionPart = full.dropFirst(4).prefix(while: { $0 != " " && $0 != "(" })
393
-            return String(versionPart)
394
-        }
395
-        return full
396
-    }
397
-    
398
-    private func extractBuildNumber(_ full: String) -> String {
399
-        if let start = full.firstIndex(of: "("), let end = full.firstIndex(of: ")") {
400
-            let buildPart = String(full[full.index(after: start)..<end])
401
-            return buildPart.hasPrefix("Build ") ? String(buildPart.dropFirst(6)) : buildPart
402
-        }
403
-        return full
404
-    }
405
-
406
-    private func computeDeltaPercent(delta: Int, baseline: HealthSnapshot) -> Double {
407
-        if let archiveTypeRows {
408
-            let baselineTotal = archiveTypeRows.reduce(0) { $0 + ($1.previousCount ?? 0) }
409
-            return baselineTotal > 0 ? Double(delta) / Double(baselineTotal) * 100 : 0
410
-        }
411
-
412
-        let baselineTotal = baseline.hasCurrentCachedSummary ? baseline.cachedRecordCount : 0
413
-        return baselineTotal > 0 ? Double(delta) / Double(baselineTotal) * 100 : 0
414
-    }
415
-
416
-    private func statCompact(label: String, value: String) -> some View {
417
-        VStack(alignment: .center, spacing: 2) {
418
-            Text(label)
419
-                .font(.caption2.weight(.medium))
420
-            Text(value)
421
-                .font(.subheadline.weight(.semibold).monospacedDigit())
422
-                .foregroundStyle(.primary)
423
-        }
424
-        .frame(maxWidth: .infinity)
425
-    }
426
-
427
-    private var evolutionSection: some View {
428
-        Section("Data Types") {
429
-            if let archiveTypeRows {
430
-                if archiveTypeRows.isEmpty {
431
-                    Text("No data types are available for this snapshot.")
432
-                        .foregroundStyle(.secondary)
433
-                } else {
434
-                    ForEach(archiveTypeRows) { row in
435
-                        NavigationLink {
436
-                            DataTypeSnapshotDetailView(
437
-                                snapshot: currentSnapshot,
438
-                                typeIdentifier: row.typeIdentifier,
439
-                                displayName: row.displayName
440
-                            )
441
-                        } label: {
442
-                            SnapshotArchiveTypeRowView(row: row, hasBaseline: baseline != nil)
443
-                        }
444
-                    }
445
-                }
446
-            } else if baseline == nil {
447
-                Text("This snapshot starts the chain, so no baseline comparison is available.")
448
-                    .foregroundStyle(.secondary)
449
-            } else {
450
-                Text("Cached metric summary unavailable for this snapshot.")
451
-                    .foregroundStyle(.secondary)
452
-            }
453
-        }
454
-    }
455
-}
456
-
457
-private struct SnapshotArchiveTypeRow: Identifiable {
458
-    let typeIdentifier: String
459
-    let displayName: String
460
-    let currentCount: Int
461
-    let previousCount: Int?
462
-    let appearedCount: Int
463
-    let disappearedCount: Int
464
-    let representationChangedCount: Int
465
-    let earliestStartDate: Date?
466
-    let latestEndDate: Date?
467
-
468
-    var id: String { typeIdentifier }
469
-
470
-    var recordChangeCount: Int {
471
-        appearedCount + disappearedCount + representationChangedCount
472
-    }
473
-
474
-    var hasChanges: Bool {
475
-        currentDelta != 0 || recordChangeCount > 0
476
-    }
477
-
478
-    var currentDelta: Int {
479
-        guard let previousCount else { return currentCount }
480
-        return currentCount - previousCount
481
-    }
482
-}
483
-
484
-private struct SnapshotArchiveTypeRowView: View {
485
-    let row: SnapshotArchiveTypeRow
486
-    let hasBaseline: Bool
487
-
488
-    private var countText: String {
489
-        "\(row.currentCount)"
490
-    }
491
-
492
-    private var changeLabel: String {
493
-        guard hasBaseline else { return "Stored" }
494
-        if row.disappearedCount > 0 {
495
-            return "\(row.disappearedCount) missing"
496
-        }
497
-        if row.appearedCount > 0 {
498
-            return "\(row.appearedCount) new"
499
-        }
500
-        if row.representationChangedCount > 0 {
501
-            return "\(row.representationChangedCount) changed"
502
-        }
503
-        if row.currentDelta != 0 {
504
-            let prefix = row.currentDelta > 0 ? "+" : ""
505
-            return "\(prefix)\(row.currentDelta) records"
506
-        }
507
-        return "No changes"
508
-    }
509
-
510
-    private var changeColor: Color {
511
-        guard hasBaseline else { return .secondary }
512
-        if row.disappearedCount > 0 { return .criticalRed }
513
-        if row.hasChanges { return .warningAmber }
514
-        return .secondary
515
-    }
516
-
517
-    var body: some View {
518
-        HStack(spacing: 12) {
519
-            VStack(alignment: .leading, spacing: 3) {
520
-                Text(row.displayName)
521
-                    .font(.subheadline)
522
-                Text(row.typeIdentifier)
523
-                    .font(.caption2)
524
-                    .foregroundStyle(.secondary)
525
-                    .lineLimit(1)
526
-                    .truncationMode(.middle)
527
-            }
528
-
529
-            Spacer()
530
-
531
-            VStack(alignment: .trailing, spacing: 4) {
532
-                Text(countText)
533
-                    .font(.subheadline.monospacedDigit())
534
-                    .foregroundStyle(.primary)
535
-                Text(changeLabel)
536
-                    .font(.caption.weight(.semibold))
537
-                    .foregroundStyle(changeColor)
538
-            }
539
-        }
540
-        .accessibilityElement(children: .combine)
541
-    }
542
-}
543
-
544
-private struct SnapshotDataRangeIndicator: View {
545
-    let oldestRecordDate: Date?
546
-    let newestRecordDate: Date?
547
-    let quality: SnapshotQuality
548
-
549
-    private var hasDateRange: Bool {
550
-        oldestRecordDate != nil && newestRecordDate != nil
551
-    }
552
-
553
-    private var daySpan: Int? {
554
-        guard let oldest = oldestRecordDate, let newest = newestRecordDate else { return nil }
555
-        return Calendar.current.dateComponents([.day], from: oldest, to: newest).day ?? 0
556
-    }
557
-
558
-    var body: some View {
559
-        VStack(spacing: 12) {
560
-            HStack(spacing: 8) {
561
-                Text("Data Range")
562
-                    .font(.headline.weight(.semibold))
563
-
564
-                Spacer()
565
-
566
-                qualityBadge
567
-            }
568
-
569
-            if hasDateRange {
570
-                dateRangeVisualization
571
-            } else {
572
-                Text("No dated records available")
573
-                    .font(.subheadline)
574
-                    .foregroundStyle(.secondary)
575
-                    .frame(maxWidth: .infinity, alignment: .center)
576
-                    .padding(.vertical, 16)
577
-            }
578
-        }
579
-        .padding(16)
580
-        .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12))
581
-    }
582
-
583
-    @ViewBuilder
584
-    private var qualityBadge: some View {
585
-        if quality != .complete {
586
-            Label("Incomplete", systemImage: "exclamationmark.triangle.fill")
587
-                .font(.caption.weight(.medium))
588
-                .foregroundStyle(Color.warningAmber)
589
-                .padding(.horizontal, 8)
590
-                .padding(.vertical, 4)
591
-                .background(Color.warningAmber.opacity(0.12), in: Capsule())
592
-        }
593
-    }
594
-
595
-    @ViewBuilder
596
-    private var dateRangeVisualization: some View {
597
-        if let oldest = oldestRecordDate, let newest = newestRecordDate, let span = daySpan {
598
-            VStack(spacing: 12) {
599
-                HStack(alignment: .top, spacing: 12) {
600
-                    VStack(alignment: .center, spacing: 4) {
601
-                        Image(systemName: "calendar.badge.clock")
602
-                            .font(.system(size: 16, weight: .semibold))
603
-                            .foregroundStyle(Color.healthyGreen)
604
-
605
-                        VStack(alignment: .center, spacing: 2) {
606
-                            Text("Oldest record")
607
-                                .font(.caption2.weight(.medium))
608
-                                .foregroundStyle(.secondary)
609
-                            Text(oldest, format: .dateTime.month().day().year())
610
-                                .font(.caption.weight(.semibold))
611
-                        }
612
-                    }
613
-                    .frame(maxWidth: .infinity)
614
-
615
-                    VStack(alignment: .center, spacing: 4) {
616
-                        Text("\(span)")
617
-                            .font(.system(size: 18, weight: .semibold).monospacedDigit())
618
-                            .foregroundStyle(.primary)
619
-
620
-                        Text("days")
621
-                            .font(.caption2.weight(.medium))
622
-                            .foregroundStyle(.secondary)
623
-                    }
624
-
625
-                    VStack(alignment: .center, spacing: 4) {
626
-                        Image(systemName: "calendar.badge.clock")
627
-                            .font(.system(size: 16, weight: .semibold))
628
-                            .foregroundStyle(Color.accentColor)
629
-
630
-                        VStack(alignment: .center, spacing: 2) {
631
-                            Text("Newest record")
632
-                                .font(.caption2.weight(.medium))
633
-                                .foregroundStyle(.secondary)
634
-                            Text(newest, format: .dateTime.month().day().year())
635
-                                .font(.caption.weight(.semibold))
636
-                        }
637
-                    }
638
-                    .frame(maxWidth: .infinity)
639
-                }
640
-
641
-                timelineBar
642
-            }
643
-        }
644
-    }
645
-
646
-    @ViewBuilder
647
-    private var timelineBar: some View {
648
-        if oldestRecordDate != nil, newestRecordDate != nil {
649
-            ZStack(alignment: .leading) {
650
-                RoundedRectangle(cornerRadius: 3)
651
-                    .fill(Color(.systemGray5))
652
-
653
-                RoundedRectangle(cornerRadius: 3)
654
-                    .fill(
655
-                        LinearGradient(
656
-                            gradient: Gradient(colors: [Color.healthyGreen, Color.accentColor]),
657
-                            startPoint: .leading,
658
-                            endPoint: .trailing
659
-                        )
660
-                    )
661
-                    .opacity(0.7)
662
-            }
663
-            .frame(height: 4)
664
-        }
665
-    }
666
-}
667
-
668
-private struct DetailRow<Content: View>: View {
669
-    let label: String
670
-    @ViewBuilder let content: () -> Content
671
-
672
-    var body: some View {
673
-        HStack {
674
-            Text(label)
675
-            Spacer()
676
-            content()
677
-        }
678
-    }
679
-}
680
-
681
-private struct ShareSheet: UIViewControllerRepresentable {
682
-    let items: [Any]
683
-
684
-    func makeUIViewController(context: Context) -> UIActivityViewController {
685
-        UIActivityViewController(activityItems: items, applicationActivities: nil)
686
-    }
687
-
688
-    func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
689
-}
690
-
691
-#Preview {
692
-    NavigationStack {
693
-        SnapshotDetailView(
694
-            snapshot: HealthSnapshot(
695
-                timestamp: .now,
696
-                osVersion: "iOS 26.4",
697
-                deviceName: "Preview iPhone",
698
-                deviceID: "preview-device"
699
-            ),
700
-            baseline: nil,
701
-            profile: LocalDeviceProfile(deviceID: "preview-device")
702
-        )
703
-    }
704
-    .modelContainer(for: [HealthSnapshot.self], inMemory: true)
705
-}