Showing 4 changed files with 23 additions and 129 deletions
+3 -3
HealthProbe/Doc/04-project/IMPLEMENTATION_STATUS.md
@@ -1,6 +1,6 @@
1 1
 # HealthProbe - Implementation Status
2 2
 
3
-**Last Updated:** 2026-05-25
3
+**Last Updated:** 2026-05-26
4 4
 
5 5
 ## Current Reality
6 6
 
@@ -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/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 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 navigation handles before removing `ModelContainer` |
31
-| UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker. Dashboard status reads archive/cache observation rows and shows cache health, with SwiftData retained only for capture/review actions; Snapshots timeline reads archive/cache rows when available and no longer queries `SnapshotDelta` for list summaries; snapshot detail summaries/type rows require Core Data cache rows and no longer fall back to `SnapshotDelta`/`TypeDelta`; Data Types list rows no longer fall back to SwiftData `TypeCount` traversal; data type detail reads Core Data type/diff summaries and uses SQLite `diffRecords` for paged drill-down; 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 SwiftData detail cache as transition fallback | Remove remaining SwiftData navigation handles |
31
+| UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker. Dashboard status reads archive/cache observation rows and shows cache health, with SwiftData retained only for capture/review actions; Snapshots timeline reads archive/cache rows when available and no longer queries `SnapshotDelta` for list summaries; snapshot detail summaries/type rows require Core Data cache rows and no longer fall back to `SnapshotDelta`/`TypeDelta`; Data Types list rows no longer fall back to SwiftData `TypeCount` traversal; data type detail reads Core Data type/diff summaries, uses SQLite `diffRecords` for paged drill-down, and no longer queries `SnapshotDelta`/`TypeDelta` or rebuilds legacy detail caches from the UI; 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 remaining SwiftData navigation handles |
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 | 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 |
@@ -48,7 +48,7 @@ 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 22 SwiftData-backed files for launch container, capture review actions, navigation handles, some charts, and PDF paths.
51
+- Current UI/cache layers still depend on 19 SwiftData-backed files for launch container, capture review actions, navigation handles, some transition detail paths, and PDF paths.
52 52
 - 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.
53 53
 - Legacy SwiftData-only snapshots can show diffs without archive-backed values; they are now 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.
+2 -2
HealthProbe/Doc/04-project/Refactoring-Plan.md
@@ -1,6 +1,6 @@
1 1
 # HealthProbe - Database-Led Refactoring Plan
2 2
 
3
-**Last Updated:** 2026-05-25
3
+**Last Updated:** 2026-05-26
4 4
 **Status:** Active planning document
5 5
 
6 6
 ## Goal
@@ -230,7 +230,7 @@ Checklist:
230 230
   summaries and no longer falls back to legacy `SnapshotDelta`/`TypeDelta`
231 231
   rows.
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
-- [x] Data type detail uses SQLite `diffSummary` when archive observation ids exist.
233
+- [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.
234 234
 - [x] Data type new/missing drill-down pages through SQLite `diffRecords` when archive observation ids exist.
235 235
 - [x] Diff detail fully uses cached summary plus paged SQLite DTOs.
236 236
 - [x] Record-change evolution chart uses DTO inputs and archive/cache lookups instead of direct SwiftData queries.
+9 -3
HealthProbe/Doc/04-project/SwiftData-Retirement-Inventory.md
@@ -1,6 +1,6 @@
1 1
 # SwiftData Retirement Inventory
2 2
 
3
-**Last Updated:** 2026-05-25
3
+**Last Updated:** 2026-05-26
4 4
 
5 5
 This inventory supports Milestone 9 in [`Refactoring-Plan.md`](Refactoring-Plan.md).
6 6
 SwiftData is legacy/prototype infrastructure only. The target app must launch
@@ -9,8 +9,9 @@ local settings stored outside SwiftData where needed.
9 9
 
10 10
 ## Current Count
11 11
 
12
-After moving temporal distribution to cache DTO inputs, 19 app files still
13
-have SwiftData imports.
12
+After removing the data type detail `SnapshotDelta` query, 19 app files still
13
+have SwiftData imports because active navigation still uses legacy snapshot
14
+handles.
14 15
 
15 16
 ## Launch Container
16 17
 
@@ -128,6 +129,11 @@ The following SwiftData dependencies were removed from active flows:
128 129
   fallback. Snapshot detail type rows now require archive/cache summaries; the
129 130
   temporary SwiftData dependency is limited to snapshot navigation, metadata,
130 131
   and PDF export handles.
132
+- `HealthProbe/Views/Snapshots/DataTypeSnapshotDetailView.swift` no longer
133
+  queries `SnapshotDelta`/`TypeDelta` and no longer rebuilds legacy
134
+  `TypeCount.detailCache` rows from the UI. It reads Core Data/SQLite diff
135
+  summaries first and only displays an already-existing legacy detail cache as a
136
+  transition fallback.
131 137
 - `HealthProbe/Models/AnomalyRecord.swift`,
132 138
   `HealthProbe/Models/AnomalyType.swift`, and
133 139
   `HealthProbe/Services/AnomalyDetector.swift` were deleted. The app no longer
+9 -121
HealthProbe/Views/Snapshots/DataTypeSnapshotDetailView.swift
@@ -7,11 +7,9 @@ struct DataTypeSnapshotDetailView: View {
7 7
     let displayName: String
8 8
 
9 9
     @Environment(AppSettings.self) private var appSettings
10
-    @Environment(\.modelContext) private var modelContext
11 10
     @Environment(\.dynamicTypeSize) private var dynamicTypeSize
12 11
     @Environment(\.horizontalSizeClass) private var horizontalSizeClass
13 12
     @Query(sort: \HealthSnapshot.timestamp) private var allSnapshots: [HealthSnapshot]
14
-    @Query private var allDeltas: [SnapshotDelta]
15 13
 
16 14
     @State private var displayedSnapshot: HealthSnapshot?
17 15
     @State private var diffState: RecordDiffState = .idle
@@ -73,18 +71,6 @@ struct DataTypeSnapshotDetailView: View {
73 71
         )
74 72
     }
75 73
 
76
-    private var currentDelta: SnapshotDelta? {
77
-        guard let previousSnapshot else { return nil }
78
-        return allDeltas.first {
79
-            $0.toSnapshotID == currentSnapshot.id &&
80
-            $0.fromSnapshotID == previousSnapshot.id
81
-        }
82
-    }
83
-
84
-    private var currentTypeDelta: TypeDelta? {
85
-        currentDelta?.typeDeltas?.first { $0.typeIdentifier == typeIdentifier }
86
-    }
87
-
88 74
     private var diffTaskID: String {
89 75
         [
90 76
             currentSnapshot.id.uuidString,
@@ -123,14 +109,14 @@ struct DataTypeSnapshotDetailView: View {
123 109
             return (cache.addedCount, cache.disappearedCount, true)
124 110
         }
125 111
 
126
-        let net = (currentTypeDelta?.countDelta ?? (quickCurrentCountValue - quickPreviousCountValue))
112
+        let net = quickCurrentCountValue - quickPreviousCountValue
127 113
         return (max(net, 0), max(-net, 0), false)
128 114
     }
129 115
 
130 116
     private var recordEvolutionSnapshots: [RecordChangeEvolutionSnapshot] {
131 117
         timelineSnapshots.map { snapshot in
132 118
             let count = max(typeCount(in: snapshot)?.count ?? 0, 0)
133
-            let fallback = recordEvolutionFallback(for: snapshot)
119
+            let fallback = recordEvolutionCachedFallback(for: snapshot)
134 120
             return RecordChangeEvolutionSnapshot(
135 121
                 id: snapshot.id,
136 122
                 timestamp: snapshot.timestamp,
@@ -277,35 +263,6 @@ struct DataTypeSnapshotDetailView: View {
277 263
         }
278 264
     }
279 265
 
280
-    // MARK: - Optimized Content
281
-
282
-    @ViewBuilder
283
-    private var metricComparison: some View {
284
-        if previousSnapshot == nil {
285
-            EmptyView()
286
-        } else {
287
-            MetricComparisonCard(
288
-                currentValue: currentTypeCount?.count ?? 0,
289
-                previousValue: previousTypeCount?.count,
290
-                displayName: displayName,
291
-                isCurrentValid: (currentTypeCount?.count ?? 0) >= 0,
292
-                isPreviousTracked: previousTypeCount != nil
293
-            )
294
-        }
295
-    }
296
-
297
-    @ViewBuilder
298
-    private var typeEvolutionSection: some View {
299
-        if previousSnapshot != nil {
300
-            TypeEvolutionTimeline(
301
-                snapshots: timelineSnapshots,
302
-                typeIdentifier: typeIdentifier,
303
-                displayName: displayName,
304
-                currentSnapshotID: currentSnapshot.id
305
-            )
306
-        }
307
-    }
308
-
309 266
     @ViewBuilder
310 267
     private var dataRangeSection: some View {
311 268
         if currentTypeCount != nil || currentCachedTypeSummary != nil {
@@ -545,23 +502,6 @@ struct DataTypeSnapshotDetailView: View {
545 502
         .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12))
546 503
     }
547 504
 
548
-    private func typeDeltaSummaryText(_ typeDelta: TypeDelta) -> String {
549
-        switch typeDelta.transition {
550
-        case .unchanged:
551
-            return "No metric-level change recorded."
552
-        case .changed:
553
-            if typeDelta.countDelta == 0 {
554
-                return "Content changed while count stayed the same."
555
-            }
556
-            let prefix = typeDelta.countDelta > 0 ? "+" : ""
557
-            return "Count delta: \(prefix)\(typeDelta.countDelta)."
558
-        case .appeared:
559
-            return "Metric is new in this observation."
560
-        case .disappeared:
561
-            return "Metric is missing from this observation."
562
-        }
563
-    }
564
-
565 505
     private func quickStat(label: String, value: String, color: Color = .primary) -> some View {
566 506
         VStack(alignment: .leading, spacing: 2) {
567 507
             Text(label)
@@ -574,7 +514,7 @@ struct DataTypeSnapshotDetailView: View {
574 514
         .frame(maxWidth: .infinity, alignment: .leading)
575 515
     }
576 516
 
577
-    private func recordEvolutionFallback(for snapshot: HealthSnapshot) -> (added: Int, disappeared: Int, exact: Bool) {
517
+    private func recordEvolutionCachedFallback(for snapshot: HealthSnapshot) -> (added: Int, disappeared: Int, exact: Bool) {
578 518
         guard let previous = snapshot.previousInTimeline(timelineSnapshots) else {
579 519
             return (0, 0, false)
580 520
         }
@@ -584,30 +524,7 @@ struct DataTypeSnapshotDetailView: View {
584 524
             return (cache.addedCount, cache.disappearedCount, true)
585 525
         }
586 526
 
587
-        guard let delta = allDeltas.first(where: {
588
-            $0.fromSnapshotID == previous.id &&
589
-            $0.toSnapshotID == snapshot.id
590
-        }),
591
-        let typeDelta = delta.typeDeltas?.first(where: { $0.typeIdentifier == typeIdentifier }) else {
592
-            return (0, 0, false)
593
-        }
594
-
595
-        switch typeDelta.transition {
596
-        case .unchanged:
597
-            return (0, 0, false)
598
-        case .changed:
599
-            if typeDelta.countDelta > 0 {
600
-                return (typeDelta.countDelta, 0, false)
601
-            }
602
-            if typeDelta.countDelta < 0 {
603
-                return (0, abs(typeDelta.countDelta), false)
604
-            }
605
-            return (0, 0, false)
606
-        case .appeared:
607
-            return (max(typeDelta.countDelta, 1), 0, false)
608
-        case .disappeared:
609
-            return (0, max(abs(typeDelta.countDelta), 1), false)
610
-        }
527
+        return (0, 0, false)
611 528
     }
612 529
 
613 530
     private func addedRecordListMode(previous: HealthSnapshot) -> RecordListMode {
@@ -696,14 +613,14 @@ struct DataTypeSnapshotDetailView: View {
696 613
             }
697 614
         }
698 615
 
699
-        let resolution = currentDetailCacheResolution()
700
-        detailCacheDiagnostic = resolution?.diagnostic ?? detailCacheDiagnostic
701
-        guard let cache = resolution?.cache else {
702
-            diffState = .unavailable
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))
703 620
             return
704 621
         }
705 622
 
706
-        diffState = .loaded(DataTypeRecordDiff(cache: cache))
623
+        diffState = .unavailable
707 624
     }
708 625
 
709 626
     @MainActor
@@ -736,35 +653,6 @@ struct DataTypeSnapshotDetailView: View {
736 653
         }
737 654
     }
738 655
 
739
-    @MainActor
740
-    private func currentDetailCacheResolution() -> TypeCountDetailCacheResolution? {
741
-        if isCurrentTypeContentAliasToPrevious {
742
-            return TypeCountDetailCacheResolution(
743
-                cache: nil,
744
-                diagnostic: "alias-to-previous"
745
-            )
746
-        }
747
-
748
-        if let cache = currentTypeCount?.detailCache,
749
-           cache.matchesBaseline(previousSnapshot?.id) {
750
-            return TypeCountDetailCacheResolution(
751
-                cache: cache,
752
-                diagnostic: "resolver-v4 phase=cache-hit-view"
753
-            )
754
-        }
755
-
756
-        guard let currentTypeCount,
757
-              let previousSnapshot else {
758
-            return nil
759
-        }
760
-
761
-        return currentTypeCount.resolveDetailCacheWithDiagnostics(
762
-            previous: previousTypeCount,
763
-            baselineSnapshotID: previousSnapshot.id,
764
-            context: modelContext,
765
-            source: "dataTypeDetail"
766
-        )
767
-    }
768 656
 }
769 657
 
770 658
 private enum RecordDiffState: Equatable {