@@ -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 | |
@@ -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. |
@@ -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 |
@@ -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) })
|
@@ -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 |
} |
@@ -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 |
} |