Showing 6 changed files with 61 additions and 92 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; 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 |
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 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 |
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
@@ -230,6 +230,7 @@ Checklist:
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 232
 - [x] Record-change evolution chart uses DTO inputs and archive/cache lookups instead of direct SwiftData queries.
233
+- [x] Temporal distribution screen uses a cache DTO input instead of SwiftData queries or `ModelContext`.
233 234
 - [x] Data type screens use target change labels.
234 235
 - [x] Export preview uses export query/manifest APIs.
235 236
 - [x] Archive status reflects SQLite/Core Data cache health.
+5 -3
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 moving the record-change evolution chart to archive/cache DTO inputs, 21 app files still
12
+After moving temporal distribution to cache DTO inputs, 19 app files still
13 13
 have SwiftData imports.
14 14
 
15 15
 ## Launch Container
@@ -66,9 +66,7 @@ These active surfaces still use `@Query`, `ModelContext`, or SwiftData model
66 66
 types:
67 67
 
68 68
 - `HealthProbe/ViewModels/DashboardViewModel.swift`
69
-- `HealthProbe/ViewModels/DataTypeTemporalDistributionViewModel.swift`
70 69
 - `HealthProbe/Views/Dashboard/DashboardView.swift`
71
-- `HealthProbe/Views/DataTypes/DataTypeTemporalDistributionView.swift`
72 70
 - `HealthProbe/Views/DataTypes/DataTypesView.swift`
73 71
 - `HealthProbe/Views/Snapshots/DataTypeSnapshotDetailView.swift`
74 72
 - `HealthProbe/Views/Snapshots/SnapshotDetailView.swift`
@@ -113,6 +111,10 @@ The following SwiftData dependencies were removed from active flows:
113 111
 - `HealthProbe/Views/DataTypes/RecordChangeEvolutionChart.swift` now accepts a
114 112
   small `RecordChangeEvolutionSnapshot` DTO and loads archive/cache counts
115 113
   without importing SwiftData or querying `SnapshotDelta`.
114
+- `HealthProbe/ViewModels/DataTypeTemporalDistributionViewModel.swift` and
115
+  `HealthProbe/Views/DataTypes/DataTypeTemporalDistributionView.swift` now read
116
+  a `TemporalDistributionInput` DTO backed by `TypeCountDetailCache`; they no
117
+  longer import SwiftData, query timeline snapshots, or require `ModelContext`.
116 118
 - `HealthProbe/Models/AnomalyRecord.swift`,
117 119
   `HealthProbe/Models/AnomalyType.swift`, and
118 120
   `HealthProbe/Services/AnomalyDetector.swift` were deleted. The app no longer
+10 -20
HealthProbe/ViewModels/DataTypeTemporalDistributionViewModel.swift
@@ -1,5 +1,11 @@
1 1
 import Foundation
2
-import SwiftData
2
+
3
+struct TemporalDistributionInput: Equatable, Sendable {
4
+    let displayName: String
5
+    let currentCount: Int
6
+    let previousCount: Int
7
+    let detailCache: TypeCountDetailCache
8
+}
3 9
 
4 10
 enum BinningStrategy: String, CaseIterable, Equatable {
5 11
     case day = "Zi"
@@ -115,20 +121,17 @@ final class DataTypeTemporalDistributionViewModel: Sendable {
115 121
     private var disappearedByDate: [Date: Int] = [:]
116 122
     private var unchangedByDate: [Date: Int] = [:]
117 123
 
118
-    func load(current: TypeCount?, previous: TypeCount?, context: ModelContext) async {
124
+    func load(input: TemporalDistributionInput?) async {
119 125
         defer { isLoading = false }
120 126
         error = nil
121 127
         hasData = false
122 128
 
123
-        guard let current else {
129
+        guard let input else {
124 130
             error = "No current snapshot data"
125 131
             return
126 132
         }
127 133
 
128
-        guard let cache = resolveDetailCache(current: current, previous: previous, context: context) else {
129
-            error = "Record detail data could not be computed for this snapshot pair."
130
-            return
131
-        }
134
+        let cache = input.detailCache
132 135
 
133 136
         guard let minDate = cache.earliestRecordDate,
134 137
               let maxDate = cache.latestRecordDate else {
@@ -143,19 +146,6 @@ final class DataTypeTemporalDistributionViewModel: Sendable {
143 146
         await rebuildBinsBackground()
144 147
     }
145 148
 
146
-    private func resolveDetailCache(
147
-        current: TypeCount,
148
-        previous: TypeCount?,
149
-        context: ModelContext
150
-    ) -> TypeCountDetailCache? {
151
-        let baselineID = previous?.snapshot?.id
152
-        guard let cache = current.detailCache,
153
-              cache.matchesBaseline(baselineID) else {
154
-            return nil
155
-        }
156
-        return cache
157
-    }
158
-
159 149
     private func indexDailyBins(_ bins: [TypeCountDailyChangeBin]) {
160 150
         addedByDate = Dictionary(uniqueKeysWithValues: bins.map { ($0.dayStart, $0.added) })
161 151
         disappearedByDate = Dictionary(uniqueKeysWithValues: bins.map { ($0.dayStart, $0.disappeared) })
+29 -64
HealthProbe/Views/DataTypes/DataTypeTemporalDistributionView.swift
@@ -1,26 +1,15 @@
1 1
 import SwiftUI
2
-import SwiftData
3 2
 
4 3
 struct DataTypeTemporalDistributionView: View {
5
-    let current: TypeCount?
6
-    let previous: TypeCount?
4
+    let input: TemporalDistributionInput?
7 5
     let displayName: String
8 6
 
9 7
     @Environment(AppSettings.self) private var appSettings
10
-    @Environment(\.modelContext) private var modelContext
11 8
     @Environment(\.dynamicTypeSize) private var dynamicTypeSize
12 9
     @Environment(\.horizontalSizeClass) private var horizontalSizeClass
13 10
     @State private var viewModel = DataTypeTemporalDistributionViewModel()
14
-    @State private var isRecomputing = false
15 11
     @State private var isZoomed: Bool = false
16
-    @State private var displayedSnapshot: HealthSnapshot?
17 12
     @State private var contentWidth: CGFloat = 744
18
-    @Query(sort: \HealthSnapshot.timestamp) private var allSnapshots: [HealthSnapshot]
19
-    @Environment(\.verticalSizeClass) private var verticalSizeClass
20
-
21
-    private var isLandscape: Bool {
22
-        horizontalSizeClass == .regular && verticalSizeClass == .compact
23
-    }
24 13
 
25 14
     private var canResetZoom: Bool {
26 15
         guard let dateRange = viewModel.dateRange,
@@ -29,24 +18,6 @@ struct DataTypeTemporalDistributionView: View {
29 18
                abs(dateRange.end.timeIntervalSince(displayedRange.end)) > 86400
30 19
     }
31 20
 
32
-    private var currentSnapshot: HealthSnapshot? {
33
-        displayedSnapshot ?? (current?.snapshot ?? allSnapshots.first)
34
-    }
35
-
36
-    private var timelineSnapshots: [HealthSnapshot] {
37
-        allSnapshots
38
-            .filter { current?.snapshot?.deviceID ?? "" == $0.deviceID }
39
-            .sorted(by: HealthSnapshot.timelineSort)
40
-    }
41
-
42
-    private var previousSnapshot: HealthSnapshot? {
43
-        currentSnapshot?.previousInTimeline(timelineSnapshots)
44
-    }
45
-
46
-    private var nextSnapshot: HealthSnapshot? {
47
-        currentSnapshot?.nextInTimeline(timelineSnapshots)
48
-    }
49
-
50 21
     private var usesSimplifiedDetailUI: Bool {
51 22
         LegacyUIMode.isEnabled(
52 23
             forceEnabled: appSettings.simplifiedUIModeEnabled,
@@ -95,7 +66,7 @@ struct DataTypeTemporalDistributionView: View {
95 66
             }
96 67
         }
97 68
         .task {
98
-            await viewModel.load(current: current, previous: previous, context: modelContext)
69
+            await viewModel.load(input: input)
99 70
         }
100 71
     }
101 72
 
@@ -180,15 +151,6 @@ struct DataTypeTemporalDistributionView: View {
180 151
             .padding(16)
181 152
         }
182 153
         .background(Color(.systemBackground))
183
-        .safeAreaInset(edge: .top, spacing: 0) {
184
-            if !timelineSnapshots.isEmpty {
185
-                SnapshotNavigationHeader(
186
-                    snapshots: timelineSnapshots,
187
-                    currentSnapshot: currentSnapshot,
188
-                    onSnapshotSelected: { displayedSnapshot = $0 }
189
-                )
190
-            }
191
-        }
192 154
     }
193 155
 
194 156
     private var simplifiedDistributionRows: some View {
@@ -271,7 +233,7 @@ struct DataTypeTemporalDistributionView: View {
271 233
                     Text("Comparing")
272 234
                         .font(.caption)
273 235
                         .foregroundStyle(.secondary)
274
-                    Text(current?.displayName ?? "–")
236
+                    Text(input?.displayName ?? displayName)
275 237
                         .font(.caption)
276 238
                         .foregroundStyle(.primary)
277 239
                         .lineLimit(1)
@@ -289,7 +251,7 @@ struct DataTypeTemporalDistributionView: View {
289 251
     }
290 252
 
291 253
     private var sampleCountText: String {
292
-        let count = (current?.count ?? 0) + (previous?.count ?? 0)
254
+        let count = (input?.currentCount ?? 0) + (input?.previousCount ?? 0)
293 255
         return formatHumanReadable(count)
294 256
     }
295 257
 
@@ -388,36 +350,39 @@ struct DataTypeTemporalDistributionView: View {
388 350
 #Preview {
389 351
     NavigationStack {
390 352
         DataTypeTemporalDistributionView(
391
-            current: .preview,
392
-            previous: .preview,
353
+            input: .preview,
393 354
             displayName: "Sleep"
394 355
         )
395 356
     }
396
-    .modelContainer(for: [HealthSnapshot.self, TypeCount.self, HealthRecord.self, TypeDistributionBin.self], inMemory: true)
397 357
     .environment(AppSettings())
398 358
 }
399 359
 
400
-extension TypeCount {
401
-    static var preview: TypeCount {
402
-        let count = TypeCount(
403
-            typeIdentifier: "HKCategoryTypeIdentifierSleepAnalysis",
404
-            displayName: "Sleep",
405
-            count: 1000
406
-        )
407
-
408
-        let mockRecords = (0..<1000).map { idx in
409
-            let date = Calendar.current.date(byAdding: .day, value: -idx, to: Date.now) ?? .now
410
-            return HealthRecordValue(
411
-                typeIdentifier: count.typeIdentifier,
412
-                sampleUUIDHash: UUID().uuidString,
413
-                recordFingerprint: "fp-\(idx)",
414
-                startDate: date,
415
-                endDate: date.addingTimeInterval(28800),
416
-                displayValue: nil
360
+extension TemporalDistributionInput {
361
+    static var preview: TemporalDistributionInput {
362
+        let now = Date.now
363
+        let bins = (0..<60).map { idx in
364
+            TypeCountDailyChangeBin(
365
+                dayStart: Calendar.current.date(byAdding: .day, value: -idx, to: now) ?? now,
366
+                added: idx.isMultiple(of: 3) ? 8 : 2,
367
+                disappeared: idx.isMultiple(of: 5) ? 3 : 0,
368
+                unchanged: 40 + idx
417 369
             )
418 370
         }
419 371
 
420
-        count.setRecordValues(mockRecords)
421
-        return count
372
+        return TemporalDistributionInput(
373
+            displayName: "Sleep",
374
+            currentCount: 1_000,
375
+            previousCount: 940,
376
+            detailCache: TypeCountDetailCache(
377
+                baselineSnapshotID: UUID(),
378
+                addedCount: 160,
379
+                disappearedCount: 48,
380
+                addedPreviewRecords: [],
381
+                disappearedPreviewRecords: [],
382
+                dailyChangeBins: bins,
383
+                earliestRecordDate: bins.last?.dayStart,
384
+                latestRecordDate: bins.first?.dayStart
385
+            )
386
+        )
422 387
     }
423 388
 }
+15 -4
HealthProbe/Views/Snapshots/DataTypeSnapshotDetailView.swift
@@ -56,9 +56,21 @@ struct DataTypeSnapshotDetailView: View {
56 56
     }
57 57
 
58 58
     private var hasTemporalDistributionCache: Bool {
59
+        temporalDistributionInput != nil
60
+    }
61
+
62
+    private var temporalDistributionInput: TemporalDistributionInput? {
59 63
         guard let currentTypeCount,
60
-              let previousSnapshot else { return false }
61
-        return currentTypeCount.detailCache?.matchesBaseline(previousSnapshot.id) == true
64
+              let previousSnapshot,
65
+              let cache = currentTypeCount.detailCache,
66
+              cache.matchesBaseline(previousSnapshot.id) else { return nil }
67
+
68
+        return TemporalDistributionInput(
69
+            displayName: currentTypeCount.displayName,
70
+            currentCount: currentTypeCount.count,
71
+            previousCount: previousTypeCount?.count ?? 0,
72
+            detailCache: cache
73
+        )
62 74
     }
63 75
 
64 76
     private var currentDelta: SnapshotDelta? {
@@ -207,8 +219,7 @@ struct DataTypeSnapshotDetailView: View {
207 219
         }
208 220
         .navigationDestination(isPresented: $showTemporalDistribution) {
209 221
             DataTypeTemporalDistributionView(
210
-                current: currentTypeCount,
211
-                previous: previousTypeCount,
222
+                input: temporalDistributionInput,
212 223
                 displayName: displayName
213 224
             )
214 225
         }