Showing 5 changed files with 146 additions and 76 deletions
+1 -1
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/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, snapshot detail summaries/type rows, and Data Types list prefer Core Data cache rows when archive observation ids exist; data type detail reads Core Data type/diff summaries and uses SQLite `diffRecords` for paged drill-down; 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, snapshot detail summaries/type rows, and Data Types list prefer Core Data cache rows when archive observation ids exist; data type detail reads Core Data type/diff summaries and uses SQLite `diffRecords` for paged drill-down; record-change evolution chart now receives DTO rows 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 |
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 |
+1 -0
HealthProbe/Doc/04-project/Refactoring-Plan.md
@@ -229,6 +229,7 @@ Checklist:
229 229
 - [x] Data type detail uses SQLite `diffSummary` when archive observation ids exist.
230 230
 - [x] Data type new/missing drill-down pages through SQLite `diffRecords` when archive observation ids exist.
231 231
 - [x] Diff detail fully uses cached summary plus paged SQLite DTOs.
232
+- [x] Record-change evolution chart uses DTO inputs and archive/cache lookups instead of direct SwiftData queries.
232 233
 - [x] Data type screens use target change labels.
233 234
 - [x] Export preview uses export query/manifest APIs.
234 235
 - [x] Archive status reflects SQLite/Core Data cache health.
+4 -2
HealthProbe/Doc/04-project/SwiftData-Retirement-Inventory.md
@@ -9,7 +9,7 @@ local settings stored outside SwiftData where needed.
9 9
 
10 10
 ## Current Count
11 11
 
12
-After removing the legacy anomaly/count-drop alerting flow, 22 app files still
12
+After moving the record-change evolution chart to archive/cache DTO inputs, 21 app files still
13 13
 have SwiftData imports.
14 14
 
15 15
 ## Launch Container
@@ -70,7 +70,6 @@ types:
70 70
 - `HealthProbe/Views/Dashboard/DashboardView.swift`
71 71
 - `HealthProbe/Views/DataTypes/DataTypeTemporalDistributionView.swift`
72 72
 - `HealthProbe/Views/DataTypes/DataTypesView.swift`
73
-- `HealthProbe/Views/DataTypes/RecordChangeEvolutionChart.swift`
74 73
 - `HealthProbe/Views/Snapshots/DataTypeSnapshotDetailView.swift`
75 74
 - `HealthProbe/Views/Snapshots/SnapshotDetailView.swift`
76 75
 - `HealthProbe/Views/Snapshots/SnapshotsView.swift`
@@ -111,6 +110,9 @@ The following SwiftData dependencies were removed from active flows:
111 110
 - `HealthProbe/Views/Dashboard/DashboardView.swift` no longer queries
112 111
   `HealthSnapshot` for status rows; Dashboard status now uses archive/cache rows
113 112
   only. SwiftData remains there for capture/review actions.
113
+- `HealthProbe/Views/DataTypes/RecordChangeEvolutionChart.swift` now accepts a
114
+  small `RecordChangeEvolutionSnapshot` DTO and loads archive/cache counts
115
+  without importing SwiftData or querying `SnapshotDelta`.
114 116
 - `HealthProbe/Models/AnomalyRecord.swift`,
115 117
   `HealthProbe/Models/AnomalyType.swift`, and
116 118
   `HealthProbe/Services/AnomalyDetector.swift` were deleted. The app no longer
+85 -72
HealthProbe/Views/DataTypes/RecordChangeEvolutionChart.swift
@@ -1,25 +1,75 @@
1 1
 import SwiftUI
2
-import SwiftData
2
+
3
+struct RecordChangeEvolutionSnapshot: Identifiable, Equatable, Sendable {
4
+    let id: UUID
5
+    let timestamp: Date
6
+    let localSequenceNumber: Int
7
+    let previousSnapshotID: UUID?
8
+    let archiveObservationID: Int64?
9
+    let count: Int
10
+    let fallbackAdded: Int
11
+    let fallbackDisappeared: Int
12
+    let fallbackIsExact: Bool
13
+
14
+    init(
15
+        id: UUID,
16
+        timestamp: Date,
17
+        localSequenceNumber: Int = 0,
18
+        previousSnapshotID: UUID? = nil,
19
+        archiveObservationID: Int64? = nil,
20
+        count: Int,
21
+        fallbackAdded: Int = 0,
22
+        fallbackDisappeared: Int = 0,
23
+        fallbackIsExact: Bool = false
24
+    ) {
25
+        self.id = id
26
+        self.timestamp = timestamp
27
+        self.localSequenceNumber = localSequenceNumber
28
+        self.previousSnapshotID = previousSnapshotID
29
+        self.archiveObservationID = archiveObservationID
30
+        self.count = count
31
+        self.fallbackAdded = fallbackAdded
32
+        self.fallbackDisappeared = fallbackDisappeared
33
+        self.fallbackIsExact = fallbackIsExact
34
+    }
35
+
36
+    nonisolated fileprivate static func timelineSort(_ lhs: RecordChangeEvolutionSnapshot, _ rhs: RecordChangeEvolutionSnapshot) -> Bool {
37
+        if lhs.timestamp != rhs.timestamp {
38
+            return lhs.timestamp < rhs.timestamp
39
+        }
40
+        if lhs.localSequenceNumber != rhs.localSequenceNumber {
41
+            return lhs.localSequenceNumber < rhs.localSequenceNumber
42
+        }
43
+        return lhs.id.uuidString < rhs.id.uuidString
44
+    }
45
+
46
+    fileprivate var fallbackDiff: RecordChangeDiff {
47
+        RecordChangeDiff(
48
+            added: fallbackAdded,
49
+            disappeared: fallbackDisappeared,
50
+            isExact: fallbackIsExact
51
+        )
52
+    }
53
+}
3 54
 
4 55
 struct RecordChangeEvolutionChart: View {
5
-    let snapshots: [HealthSnapshot]
56
+    let snapshots: [RecordChangeEvolutionSnapshot]
6 57
     let currentSnapshotID: UUID
7 58
     let typeIdentifier: String
8 59
     let displayName: String
9 60
 
10
-    @Query private var allDeltas: [SnapshotDelta]
11 61
     @State private var cachedCountsByObservationID: [Int64: Int] = [:]
12 62
     @State private var cachedDiffsByPair: [ArchiveDiffKey: RecordChangeDiff]?
13 63
 
14
-    private var sortedSnapshots: [HealthSnapshot] {
15
-        snapshots.sorted(by: HealthSnapshot.timelineSort)
64
+    private var sortedSnapshots: [RecordChangeEvolutionSnapshot] {
65
+        snapshots.sorted(by: RecordChangeEvolutionSnapshot.timelineSort)
16 66
     }
17 67
 
18 68
     private var currentIndex: Int? {
19 69
         sortedSnapshots.firstIndex { $0.id == currentSnapshotID }
20 70
     }
21 71
 
22
-    private var contextSnapshots: [HealthSnapshot] {
72
+    private var contextSnapshots: [RecordChangeEvolutionSnapshot] {
23 73
         guard let idx = currentIndex else { return [] }
24 74
         let start = max(0, idx - 3)
25 75
         let end = min(sortedSnapshots.count, idx + 4)
@@ -41,7 +91,7 @@ struct RecordChangeEvolutionChart: View {
41 91
 
42 92
     private var chartPoints: [ChartPoint] {
43 93
         contextSnapshots.map { snapshot in
44
-            let previousSnapshot = snapshot.previousInTimeline(sortedSnapshots)
94
+            let previousSnapshot = previousSnapshot(for: snapshot)
45 95
             return ChartPoint(
46 96
                 snapshot: snapshot,
47 97
                 count: max(countForSnapshot(snapshot), 0),
@@ -61,7 +111,10 @@ struct RecordChangeEvolutionChart: View {
61 111
         chartPoints.contains { $0.diff.added > 0 || $0.diff.disappeared > 0 }
62 112
     }
63 113
 
64
-    private func recordDiff(current: HealthSnapshot, previous: HealthSnapshot?) -> RecordChangeDiff {
114
+    private func recordDiff(
115
+        current: RecordChangeEvolutionSnapshot,
116
+        previous: RecordChangeEvolutionSnapshot?
117
+    ) -> RecordChangeDiff {
65 118
         guard let previous = previous else { return RecordChangeDiff() }
66 119
 
67 120
         if let currentObservationID = current.archiveObservationID,
@@ -71,40 +124,7 @@ struct RecordChangeEvolutionChart: View {
71 124
             return cachedDiffsByPair[key] ?? RecordChangeDiff(isExact: true)
72 125
         }
73 126
 
74
-        if let typeCount = current.typeCounts?.first(where: { $0.typeIdentifier == typeIdentifier }),
75
-           let cache = typeCount.detailCache,
76
-           cache.matchesBaseline(previous.id) {
77
-            return RecordChangeDiff(
78
-                added: cache.addedCount,
79
-                disappeared: cache.disappearedCount,
80
-                isExact: true
81
-            )
82
-        }
83
-
84
-        guard let delta = allDeltas.first(where: {
85
-            $0.fromSnapshotID == previous.id &&
86
-            $0.toSnapshotID == current.id
87
-        }),
88
-        let typeDelta = delta.typeDeltas?.first(where: { $0.typeIdentifier == typeIdentifier }) else {
89
-            return RecordChangeDiff()
90
-        }
91
-
92
-        switch typeDelta.transition {
93
-        case .unchanged:
94
-            return RecordChangeDiff()
95
-        case .changed:
96
-            if typeDelta.countDelta > 0 {
97
-                return RecordChangeDiff(added: typeDelta.countDelta)
98
-            }
99
-            if typeDelta.countDelta < 0 {
100
-                return RecordChangeDiff(disappeared: abs(typeDelta.countDelta))
101
-            }
102
-            return RecordChangeDiff()
103
-        case .appeared:
104
-            return RecordChangeDiff(added: max(typeDelta.countDelta, 1))
105
-        case .disappeared:
106
-            return RecordChangeDiff(disappeared: max(abs(typeDelta.countDelta), 1))
107
-        }
127
+        return current.fallbackDiff
108 128
     }
109 129
 
110 130
     var body: some View {
@@ -315,13 +335,26 @@ struct RecordChangeEvolutionChart: View {
315 335
         }
316 336
     }
317 337
 
318
-    private func countForSnapshot(_ snapshot: HealthSnapshot) -> Int {
338
+    private func countForSnapshot(_ snapshot: RecordChangeEvolutionSnapshot) -> Int {
319 339
         if let observationID = snapshot.archiveObservationID,
320 340
            let cachedCount = cachedCountsByObservationID[observationID] {
321 341
             return cachedCount
322 342
         }
323 343
 
324
-        return snapshot.typeCounts?.first { $0.typeIdentifier == typeIdentifier }?.count ?? 0
344
+        return snapshot.count
345
+    }
346
+
347
+    private func previousSnapshot(for snapshot: RecordChangeEvolutionSnapshot) -> RecordChangeEvolutionSnapshot? {
348
+        if let previousSnapshotID = snapshot.previousSnapshotID,
349
+           let previous = sortedSnapshots.first(where: { $0.id == previousSnapshotID }) {
350
+            return previous
351
+        }
352
+
353
+        guard let index = sortedSnapshots.firstIndex(where: { $0.id == snapshot.id }),
354
+              index > 0 else {
355
+            return nil
356
+        }
357
+        return sortedSnapshots[index - 1]
325 358
     }
326 359
 
327 360
     @MainActor
@@ -344,7 +377,7 @@ struct RecordChangeEvolutionChart: View {
344 377
                     .first { $0.sampleTypeIdentifier == typeIdentifier }
345 378
                 counts[observationID] = summary?.visibleRecordCount ?? 0
346 379
 
347
-                guard let previous = snapshot.previousInTimeline(sortedSnapshots),
380
+                guard let previous = previousSnapshot(for: snapshot),
348 381
                       let previousObservationID = previous.archiveObservationID else {
349 382
                     continue
350 383
                 }
@@ -372,7 +405,7 @@ struct RecordChangeEvolutionChart: View {
372 405
 }
373 406
 
374 407
 private struct ChartPoint {
375
-    let snapshot: HealthSnapshot
408
+    let snapshot: RecordChangeEvolutionSnapshot
376 409
     let count: Int
377 410
     let diff: RecordChangeDiff
378 411
 }
@@ -400,34 +433,14 @@ private struct ArchiveDiffKey: Hashable {
400 433
 
401 434
 #Preview {
402 435
     let mockSnapshots = (0..<7).map { idx in
403
-        let snapshot = HealthSnapshot(
436
+        RecordChangeEvolutionSnapshot(
437
+            id: UUID(),
404 438
             timestamp: Calendar.current.date(byAdding: .day, value: idx - 3, to: Date.now) ?? .now,
405
-            osVersion: "iOS 26.4",
406
-            deviceName: "Preview iPhone",
407
-            deviceID: "preview-device"
439
+            localSequenceNumber: idx,
440
+            count: Int.random(in: 5000..<15000),
441
+            fallbackAdded: idx.isMultiple(of: 2) ? 240 : 0,
442
+            fallbackDisappeared: idx.isMultiple(of: 3) ? 120 : 0
408 443
         )
409
-
410
-        let recordCount = Int.random(in: 5000..<15000)
411
-        let mockRecords = (0..<recordCount).map { i in
412
-            HealthRecordValue(
413
-                typeIdentifier: "HKQuantityTypeIdentifierStepCount",
414
-                sampleUUIDHash: UUID().uuidString,
415
-                recordFingerprint: "fp-\(idx)-\(i)",
416
-                startDate: Date.now.addingTimeInterval(-Double(i * 3600)),
417
-                endDate: Date.now.addingTimeInterval(-Double(i * 3600) + 3600),
418
-                displayValue: nil
419
-            )
420
-        }
421
-
422
-        let typeCount = TypeCount(
423
-            typeIdentifier: "HKQuantityTypeIdentifierStepCount",
424
-            displayName: "Step Count",
425
-            count: recordCount
426
-        )
427
-        typeCount.setRecordValues(mockRecords)
428
-        snapshot.typeCounts = [typeCount]
429
-
430
-        return snapshot
431 444
     }
432 445
 
433 446
     RecordChangeEvolutionChart(
+55 -1
HealthProbe/Views/Snapshots/DataTypeSnapshotDetailView.swift
@@ -115,6 +115,24 @@ struct DataTypeSnapshotDetailView: View {
115 115
         return (max(net, 0), max(-net, 0), false)
116 116
     }
117 117
 
118
+    private var recordEvolutionSnapshots: [RecordChangeEvolutionSnapshot] {
119
+        timelineSnapshots.map { snapshot in
120
+            let count = max(typeCount(in: snapshot)?.count ?? 0, 0)
121
+            let fallback = recordEvolutionFallback(for: snapshot)
122
+            return RecordChangeEvolutionSnapshot(
123
+                id: snapshot.id,
124
+                timestamp: snapshot.timestamp,
125
+                localSequenceNumber: snapshot.localSequenceNumber,
126
+                previousSnapshotID: snapshot.previousSnapshotID,
127
+                archiveObservationID: snapshot.archiveObservationID,
128
+                count: count,
129
+                fallbackAdded: fallback.added,
130
+                fallbackDisappeared: fallback.disappeared,
131
+                fallbackIsExact: fallback.exact
132
+            )
133
+        }
134
+    }
135
+
118 136
     private var simplifiedRecordCounts: (added: Int, disappeared: Int, exact: Bool) {
119 137
         if case .loaded(let diff) = diffState {
120 138
             return (diff.addedCount, diff.disappearedCount, true)
@@ -415,7 +433,7 @@ struct DataTypeSnapshotDetailView: View {
415 433
     private var recordChangeEvolutionSection: some View {
416 434
         if previousSnapshot != nil, currentTypeCount != nil || currentCachedTypeSummary != nil {
417 435
             RecordChangeEvolutionChart(
418
-                snapshots: timelineSnapshots,
436
+                snapshots: recordEvolutionSnapshots,
419 437
                 currentSnapshotID: currentSnapshot.id,
420 438
                 typeIdentifier: typeIdentifier,
421 439
                 displayName: displayName
@@ -545,6 +563,42 @@ struct DataTypeSnapshotDetailView: View {
545 563
         .frame(maxWidth: .infinity, alignment: .leading)
546 564
     }
547 565
 
566
+    private func recordEvolutionFallback(for snapshot: HealthSnapshot) -> (added: Int, disappeared: Int, exact: Bool) {
567
+        guard let previous = snapshot.previousInTimeline(timelineSnapshots) else {
568
+            return (0, 0, false)
569
+        }
570
+
571
+        if let cache = typeCount(in: snapshot)?.detailCache,
572
+           cache.matchesBaseline(previous.id) {
573
+            return (cache.addedCount, cache.disappearedCount, true)
574
+        }
575
+
576
+        guard let delta = allDeltas.first(where: {
577
+            $0.fromSnapshotID == previous.id &&
578
+            $0.toSnapshotID == snapshot.id
579
+        }),
580
+        let typeDelta = delta.typeDeltas?.first(where: { $0.typeIdentifier == typeIdentifier }) else {
581
+            return (0, 0, false)
582
+        }
583
+
584
+        switch typeDelta.transition {
585
+        case .unchanged:
586
+            return (0, 0, false)
587
+        case .changed:
588
+            if typeDelta.countDelta > 0 {
589
+                return (typeDelta.countDelta, 0, false)
590
+            }
591
+            if typeDelta.countDelta < 0 {
592
+                return (0, abs(typeDelta.countDelta), false)
593
+            }
594
+            return (0, 0, false)
595
+        case .appeared:
596
+            return (max(typeDelta.countDelta, 1), 0, false)
597
+        case .disappeared:
598
+            return (0, max(abs(typeDelta.countDelta), 1), false)
599
+        }
600
+    }
601
+
548 602
     private func addedRecordListMode(previous: HealthSnapshot) -> RecordListMode {
549 603
         if let fromObservationID = previous.archiveObservationID,
550 604
            let toObservationID = currentArchiveObservationID {