Showing 3 changed files with 238 additions and 15 deletions
+4 -3
HealthProbe/Doc/04-project/IMPLEMENTATION_STATUS.md
@@ -28,7 +28,7 @@ There are no real deployments, only test installations. Existing prototype datab
28 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 | Move Snapshots/Data Types from SwiftData previews to archive/cache DTOs |
29 29
 | Core Data cache | Initial programmatic Core Data model, full-cache rebuild service, read DTOs for observation/type/health rows, and Dashboard archive-cache status wiring are in place | Move Snapshots/Data Types to cache DTOs and add targeted partial invalidation |
30 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 | Treat as disposable prototype data; reset/ignore during v2 transition |
31
-| UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker. Snapshots timeline and Data Types list prefer Core Data cache rows when archive observation ids exist; data type detail/drill-down uses SQLite `diffSummary`/`diffRecords`, with SwiftData detail cache as transition fallback | Move observation/detail screens to cached summaries plus paged SQLite DTOs |
31
+| UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker. Snapshots timeline, snapshot detail summaries/type rows, and Data Types list prefer Core Data cache rows when archive observation ids exist; data type detail/drill-down uses SQLite `diffSummary`/`diffRecords`, with SwiftData detail cache as transition fallback | Finish remaining detail charts/export previews on paged SQLite DTOs |
32 32
 | Diff/change explanation | Prototype/legacy anomaly logic exists | Move heavy diffing into SQLite and use neutral change classifications |
33 33
 | Export | Prototype scoped JSON export exists | Add recovery-compatible manifests and streaming/paged export |
34 34
 | Legacy device support | Not implemented | Remove SwiftData dependency and simplify heavy views for low-memory devices |
@@ -49,8 +49,8 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m
49 49
 - SwiftData currently blocks iOS 15-era device support.
50 50
 - Existing `Anomaly*` model/service names are legacy language.
51 51
 - Some screens still imply snapshot-count monitoring rather than Time Machine inspection.
52
-- Current UI/cache layers still depend on SwiftData prototype models for navigation handles and detail screens.
53
-- Snapshots timeline, Data Types list rows, and record drill-down are archive/cache-backed for new archive v2 observations when cache rows exist, but observation/detail navigation still uses SwiftData snapshot handles during the transition.
52
+- Current UI/cache layers still depend on SwiftData prototype models for navigation handles, some charts, and export/PDF paths.
53
+- Snapshots timeline, snapshot detail summary/type rows, Data Types list rows, and record drill-down are archive/cache-backed for new archive v2 observations when cache rows exist, but navigation still uses SwiftData snapshot handles during the transition.
54 54
 - Legacy SwiftData-only snapshots can show diffs without archive-backed values; they are now reset for archive v2 test installs rather than migrated.
55 55
 - Existing implementation may decode or cache too much data for low-end devices.
56 56
 - Old prototype database compatibility is no longer required.
@@ -63,6 +63,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m
63 63
 - [ ] No recurring complete snapshot copies are written for high-volume types.
64 64
 - [x] SQL diff between two observations runs without loading full datasets into Swift arrays.
65 65
 - [x] Snapshots timeline rows use Core Data cached observation counts/change summaries when cache rows are available.
66
+- [x] Snapshot detail summary/type rows use Core Data cached summaries plus SQLite diff summaries when archive observation ids are available.
66 67
 - [x] Data Types list rows use Core Data cached counts plus SQLite diff summaries when archive observation ids are available.
67 68
 - [x] Data type added/disappeared drill-down pages records from SQLite diff queries when archive observation ids are available.
68 69
 - [x] Expensive counts used by reports/UI are cached and rebuildable.
+1 -1
HealthProbe/Doc/04-project/Refactoring-Plan.md
@@ -223,7 +223,7 @@ Checklist:
223 223
 - [ ] Replace direct SwiftData `@Query` dependencies for target screens.
224 224
 - [ ] Dashboard reads Core Data cache.
225 225
 - [x] Observation timeline rows read Core Data cache when available, while retaining SwiftData handles for detail navigation during transition.
226
-- [ ] Observation detail uses cached summaries plus paged SQLite DTOs.
226
+- [x] Observation detail uses cached summary/type rows plus SQLite diff summaries when archive observation ids exist.
227 227
 - [x] Data Types list rows prefer Core Data cached counts plus SQLite `diffSummary` when archive observation ids exist.
228 228
 - [x] Data type detail uses SQLite `diffSummary` when archive observation ids exist.
229 229
 - [x] Data type added/disappeared drill-down pages through SQLite `diffRecords` when archive observation ids exist.
+233 -11
HealthProbe/Views/Snapshots/SnapshotDetailView.swift
@@ -11,6 +11,8 @@ struct SnapshotDetailView: View {
11 11
     @Query(sort: \HealthSnapshot.timestamp) private var allSnapshots: [HealthSnapshot]
12 12
     @Query private var allDeltas: [SnapshotDelta]
13 13
     @State private var displayedSnapshot: HealthSnapshot?
14
+    @State private var archiveTypeRows: [SnapshotArchiveTypeRow]?
15
+    @State private var archiveTypeError: String?
14 16
 
15 17
     private var currentSnapshot: HealthSnapshot {
16 18
         displayedSnapshot ?? snapshot
@@ -29,6 +31,46 @@ struct SnapshotDetailView: View {
29 31
             .sorted { $0.displayName.localizedCompare($1.displayName) == .orderedAscending }
30 32
     }
31 33
 
34
+    private var archiveReloadID: String {
35
+        [
36
+            currentSnapshot.id.uuidString,
37
+            String(currentSnapshot.archiveObservationID ?? -1),
38
+            String(baseline?.archiveObservationID ?? -1)
39
+        ].joined(separator: "|")
40
+    }
41
+
42
+    private var summaryTypeCount: Int? {
43
+        if let archiveTypeRows {
44
+            return archiveTypeRows.count
45
+        }
46
+        guard currentSnapshot.hasCurrentCachedSummary else { return nil }
47
+        return currentSnapshot.cachedTypeCount
48
+    }
49
+
50
+    private var summaryRecordCount: Int? {
51
+        if let archiveTypeRows {
52
+            return archiveTypeRows.reduce(0) { $0 + $1.currentCount }
53
+        }
54
+        guard currentSnapshot.hasCurrentCachedSummary else { return nil }
55
+        return currentSnapshot.cachedRecordCount
56
+    }
57
+
58
+    private var summaryEarliestRecordDate: Date? {
59
+        archiveTypeRows?.compactMap(\.earliestStartDate).min() ?? currentSnapshot.cachedEarliestRecordDate
60
+    }
61
+
62
+    private var summaryLatestRecordDate: Date? {
63
+        archiveTypeRows?.compactMap(\.latestEndDate).max() ?? currentSnapshot.cachedLatestRecordDate
64
+    }
65
+
66
+    private var archiveRecordChangeCount: Int? {
67
+        archiveTypeRows?.reduce(0) { $0 + $1.recordChangeCount }
68
+    }
69
+
70
+    private var archiveAffectedMetricCount: Int? {
71
+        archiveTypeRows?.filter(\.hasChanges).count
72
+    }
73
+
32 74
     private var deviceDisplayName: String {
33 75
         if let name = profile?.name, !name.isEmpty { return name }
34 76
         return currentSnapshot.deviceName.isEmpty ? "Unknown device" : currentSnapshot.deviceName
@@ -98,6 +140,9 @@ struct SnapshotDetailView: View {
98 140
                     .ignoresSafeArea()
99 141
             }
100 142
         }
143
+        .task(id: archiveReloadID) {
144
+            await loadArchiveTypeRows()
145
+        }
101 146
     }
102 147
 
103 148
     private func exportAsPDF() {
@@ -121,6 +166,73 @@ struct SnapshotDetailView: View {
121 166
         }
122 167
     }
123 168
 
169
+    @MainActor
170
+    private func loadArchiveTypeRows() async {
171
+        guard let currentObservationID = currentSnapshot.archiveObservationID else {
172
+            archiveTypeRows = nil
173
+            archiveTypeError = nil
174
+            return
175
+        }
176
+
177
+        do {
178
+            let cache = try CoreDataArchiveCacheStore()
179
+            let currentSummaries = try cache.typeSummaries(observationID: currentObservationID)
180
+            let baselineObservationID = baseline?.archiveObservationID
181
+            let baselineSummaries = try baselineObservationID.map {
182
+                try cache.typeSummaries(observationID: $0)
183
+            } ?? []
184
+
185
+            let currentByType = Dictionary(uniqueKeysWithValues: currentSummaries.map { ($0.sampleTypeIdentifier, $0) })
186
+            let baselineByType = Dictionary(uniqueKeysWithValues: baselineSummaries.map { ($0.sampleTypeIdentifier, $0) })
187
+            let allTypeIdentifiers = Set(currentByType.keys).union(baselineByType.keys)
188
+
189
+            var rows: [SnapshotArchiveTypeRow] = []
190
+            rows.reserveCapacity(allTypeIdentifiers.count)
191
+
192
+            for typeIdentifier in allTypeIdentifiers {
193
+                let current = currentByType[typeIdentifier]
194
+                let baselineSummary = baselineByType[typeIdentifier]
195
+                let diff: HealthArchiveDiffSummary
196
+                if let baselineObservationID {
197
+                    diff = try await SQLiteHealthArchiveStore.shared.diffSummary(HealthArchiveDiffRequest(
198
+                        fromObservationID: baselineObservationID,
199
+                        toObservationID: currentObservationID,
200
+                        sampleTypeIdentifier: typeIdentifier
201
+                    ))
202
+                } else {
203
+                    diff = HealthArchiveDiffSummary(
204
+                        fromObservationID: currentObservationID,
205
+                        toObservationID: currentObservationID,
206
+                        sampleTypeIdentifier: typeIdentifier,
207
+                        appearedCount: 0,
208
+                        disappearedCount: 0,
209
+                        representationChangedCount: 0
210
+                    )
211
+                }
212
+
213
+                rows.append(SnapshotArchiveTypeRow(
214
+                    typeIdentifier: typeIdentifier,
215
+                    displayName: current?.displayName ?? baselineSummary?.displayName ?? typeIdentifier,
216
+                    currentCount: current?.visibleRecordCount ?? 0,
217
+                    previousCount: baselineSummary?.visibleRecordCount,
218
+                    appearedCount: diff.appearedCount,
219
+                    disappearedCount: diff.disappearedCount,
220
+                    representationChangedCount: diff.representationChangedCount,
221
+                    earliestStartDate: current?.earliestStartDate,
222
+                    latestEndDate: current?.latestEndDate
223
+                ))
224
+            }
225
+
226
+            archiveTypeRows = rows.sorted {
227
+                $0.displayName.localizedCompare($1.displayName) == .orderedAscending
228
+            }
229
+            archiveTypeError = nil
230
+        } catch {
231
+            archiveTypeRows = nil
232
+            archiveTypeError = error.localizedDescription
233
+        }
234
+    }
235
+
124 236
     @ViewBuilder
125 237
     private var snapshotToolbarTitle: some View {
126 238
         if #available(iOS 26.0, *) {
@@ -161,18 +273,19 @@ struct SnapshotDetailView: View {
161 273
 
162 274
                     // Data Range
163 275
                     SnapshotDataRangeIndicator(
164
-                        oldestRecordDate: currentSnapshot.cachedEarliestRecordDate,
165
-                        newestRecordDate: currentSnapshot.cachedLatestRecordDate,
276
+                        oldestRecordDate: summaryEarliestRecordDate,
277
+                        newestRecordDate: summaryLatestRecordDate,
166 278
                         quality: currentSnapshot.snapshotQuality
167 279
                     )
168 280
 
169 281
                     // Summary Stats (compact)
170 282
                     VStack(spacing: 12) {
171
-                        if currentSnapshot.hasCurrentCachedSummary {
283
+                        if let summaryTypeCount,
284
+                           let summaryRecordCount {
172 285
                             HStack(spacing: 16) {
173
-                                statCompact(label: "Types", value: "\(currentSnapshot.cachedTypeCount)")
286
+                                statCompact(label: "Types", value: "\(summaryTypeCount)")
174 287
                                 Divider()
175
-                                statCompact(label: "Records", value: "\(currentSnapshot.cachedRecordCount)")
288
+                                statCompact(label: "Records", value: "\(summaryRecordCount)")
176 289
                             }
177 290
                             .font(.caption)
178 291
                             .foregroundStyle(.secondary)
@@ -229,9 +342,9 @@ struct SnapshotDetailView: View {
229 342
 
230 343
     @ViewBuilder
231 344
     private func comparisonSection(baseline: HealthSnapshot) -> some View {
232
-        let delta = currentDeltaSummary?.absoluteRecordChangeCount ?? 0
345
+        let delta = archiveRecordChangeCount ?? currentDeltaSummary?.absoluteRecordChangeCount ?? 0
233 346
         let deltaPercent = computeDeltaPercent(delta: delta, baseline: baseline)
234
-        let affectedMetricCount = currentDeltaSummary?.affectedMetricCount ?? 0
347
+        let affectedMetricCount = archiveAffectedMetricCount ?? currentDeltaSummary?.affectedMetricCount ?? 0
235 348
         let isSignificant = delta > 0 || affectedMetricCount > 0 || (deltaPercent > 10)
236 349
 
237 350
         DisclosureGroup {
@@ -246,15 +359,15 @@ struct SnapshotDetailView: View {
246 359
                     Text(days == 0 ? "Same day" : "\(days) days")
247 360
                         .foregroundStyle(.secondary)
248 361
                 }
249
-                if let summary = currentDeltaSummary {
362
+                if archiveTypeRows != nil || currentDeltaSummary != nil {
250 363
                     Divider()
251 364
                     DetailRow(label: "Changed Metrics") {
252
-                        Text("\(summary.affectedMetricCount)")
365
+                        Text("\(affectedMetricCount)")
253 366
                             .foregroundStyle(.secondary)
254 367
                     }
255 368
                     Divider()
256 369
                     DetailRow(label: "Record Changes") {
257
-                        Text("\(summary.absoluteRecordChangeCount)")
370
+                        Text("\(delta)")
258 371
                             .foregroundStyle(.secondary)
259 372
                     }
260 373
                 }
@@ -306,6 +419,11 @@ struct SnapshotDetailView: View {
306 419
     }
307 420
 
308 421
     private func computeDeltaPercent(delta: Int, baseline: HealthSnapshot) -> Double {
422
+        if let archiveTypeRows {
423
+            let baselineTotal = archiveTypeRows.reduce(0) { $0 + ($1.previousCount ?? 0) }
424
+            return baselineTotal > 0 ? Double(delta) / Double(baselineTotal) * 100 : 0
425
+        }
426
+
309 427
         let baselineTotal = baseline.hasCurrentCachedSummary ? baseline.cachedRecordCount : 0
310 428
         return baselineTotal > 0 ? Double(delta) / Double(baselineTotal) * 100 : 0
311 429
     }
@@ -323,7 +441,24 @@ struct SnapshotDetailView: View {
323 441
 
324 442
     private var evolutionSection: some View {
325 443
         Section("Data Types") {
326
-            if baseline == nil {
444
+            if let archiveTypeRows {
445
+                if archiveTypeRows.isEmpty {
446
+                    Text("No data types are available for this snapshot.")
447
+                        .foregroundStyle(.secondary)
448
+                } else {
449
+                    ForEach(archiveTypeRows) { row in
450
+                        NavigationLink {
451
+                            DataTypeSnapshotDetailView(
452
+                                snapshot: currentSnapshot,
453
+                                typeIdentifier: row.typeIdentifier,
454
+                                displayName: row.displayName
455
+                            )
456
+                        } label: {
457
+                            SnapshotArchiveTypeRowView(row: row, hasBaseline: baseline != nil)
458
+                        }
459
+                    }
460
+                }
461
+            } else if baseline == nil {
327 462
                 Text("This snapshot starts the chain, so no baseline comparison is available.")
328 463
                     .foregroundStyle(.secondary)
329 464
             } else if currentDelta == nil {
@@ -349,6 +484,93 @@ struct SnapshotDetailView: View {
349 484
     }
350 485
 }
351 486
 
487
+private struct SnapshotArchiveTypeRow: Identifiable {
488
+    let typeIdentifier: String
489
+    let displayName: String
490
+    let currentCount: Int
491
+    let previousCount: Int?
492
+    let appearedCount: Int
493
+    let disappearedCount: Int
494
+    let representationChangedCount: Int
495
+    let earliestStartDate: Date?
496
+    let latestEndDate: Date?
497
+
498
+    var id: String { typeIdentifier }
499
+
500
+    var recordChangeCount: Int {
501
+        appearedCount + disappearedCount + representationChangedCount
502
+    }
503
+
504
+    var hasChanges: Bool {
505
+        currentDelta != 0 || recordChangeCount > 0
506
+    }
507
+
508
+    var currentDelta: Int {
509
+        guard let previousCount else { return currentCount }
510
+        return currentCount - previousCount
511
+    }
512
+}
513
+
514
+private struct SnapshotArchiveTypeRowView: View {
515
+    let row: SnapshotArchiveTypeRow
516
+    let hasBaseline: Bool
517
+
518
+    private var countText: String {
519
+        "\(row.currentCount)"
520
+    }
521
+
522
+    private var changeLabel: String {
523
+        guard hasBaseline else { return "Stored" }
524
+        if row.disappearedCount > 0 {
525
+            return "\(row.disappearedCount) disappeared"
526
+        }
527
+        if row.appearedCount > 0 {
528
+            return "\(row.appearedCount) added"
529
+        }
530
+        if row.representationChangedCount > 0 {
531
+            return "\(row.representationChangedCount) changed"
532
+        }
533
+        if row.currentDelta != 0 {
534
+            let prefix = row.currentDelta > 0 ? "+" : ""
535
+            return "\(prefix)\(row.currentDelta) records"
536
+        }
537
+        return "No changes"
538
+    }
539
+
540
+    private var changeColor: Color {
541
+        guard hasBaseline else { return .secondary }
542
+        if row.disappearedCount > 0 { return .criticalRed }
543
+        if row.hasChanges { return .warningAmber }
544
+        return .secondary
545
+    }
546
+
547
+    var body: some View {
548
+        HStack(spacing: 12) {
549
+            VStack(alignment: .leading, spacing: 3) {
550
+                Text(row.displayName)
551
+                    .font(.subheadline)
552
+                Text(row.typeIdentifier)
553
+                    .font(.caption2)
554
+                    .foregroundStyle(.secondary)
555
+                    .lineLimit(1)
556
+                    .truncationMode(.middle)
557
+            }
558
+
559
+            Spacer()
560
+
561
+            VStack(alignment: .trailing, spacing: 4) {
562
+                Text(countText)
563
+                    .font(.subheadline.monospacedDigit())
564
+                    .foregroundStyle(.primary)
565
+                Text(changeLabel)
566
+                    .font(.caption.weight(.semibold))
567
+                    .foregroundStyle(changeColor)
568
+            }
569
+        }
570
+        .accessibilityElement(children: .combine)
571
+    }
572
+}
573
+
352 574
 private struct SnapshotTypeDeltaRow: View {
353 575
     let typeDelta: TypeDelta
354 576