@@ -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 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 | |
|
| 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 and Data Types list prefer Core Data cache rows when archive observation ids exist, with Data Types diff rows no longer falling 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 | |
|
| 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 | |
@@ -227,7 +227,7 @@ Checklist: |
||
| 227 | 227 |
longer query `SnapshotDelta` list summaries, while retaining SwiftData handles |
| 228 | 228 |
for detail navigation during transition. |
| 229 | 229 |
- [x] Observation detail uses cached summary/type rows plus SQLite diff summaries when archive observation ids exist. |
| 230 |
-- [x] Data Types list rows prefer Core Data cached counts plus SQLite `diffSummary` when archive observation ids exist. |
|
| 230 |
+- [x] Data Types list rows use Core Data cached counts plus SQLite `diffSummary` and no longer fall back to SwiftData `TypeCount` traversal. |
|
| 231 | 231 |
- [x] Data type detail uses SQLite `diffSummary` when archive observation ids exist. |
| 232 | 232 |
- [x] Data type new/missing drill-down pages through SQLite `diffRecords` when archive observation ids exist. |
| 233 | 233 |
- [x] Diff detail fully uses cached summary plus paged SQLite DTOs. |
@@ -119,6 +119,10 @@ The following SwiftData dependencies were removed from active flows: |
||
| 119 | 119 |
`SnapshotDelta` or runs `DeltaService` list-summary repair. Timeline change |
| 120 | 120 |
summaries come from archive/cache rows when available; SwiftData remains there |
| 121 | 121 |
only for temporary snapshot navigation/deletion handles. |
| 122 |
+- `HealthProbe/ViewModels/DataTypesViewModel.swift` now resolves baselines from |
|
| 123 |
+ small observation contexts and builds rows from Core Data cache + SQLite |
|
| 124 |
+ archive diff APIs. It no longer falls back to `SnapshotDiffService.diff(...)` |
|
| 125 |
+ over SwiftData `TypeCount` relationships. |
|
| 122 | 126 |
- `HealthProbe/Models/AnomalyRecord.swift`, |
| 123 | 127 |
`HealthProbe/Models/AnomalyType.swift`, and |
| 124 | 128 |
`HealthProbe/Services/AnomalyDetector.swift` were deleted. The app no longer |
@@ -1,5 +1,11 @@ |
||
| 1 | 1 |
import Foundation |
| 2 | 2 |
|
| 3 |
+struct DataTypeSnapshotContext: Equatable, Sendable {
|
|
| 4 |
+ let id: UUID |
|
| 5 |
+ let observedAt: Date |
|
| 6 |
+ let archiveObservationID: Int64? |
|
| 7 |
+} |
|
| 8 |
+ |
|
| 3 | 9 |
@Observable |
| 4 | 10 |
final class DataTypesViewModel {
|
| 5 | 11 |
var filter: DiffFilter = .all |
@@ -7,19 +13,12 @@ final class DataTypesViewModel {
|
||
| 7 | 13 |
var archiveDiffs: [TypeDiff]? |
| 8 | 14 |
var archiveDiffError: String? |
| 9 | 15 |
|
| 10 |
- private let diffService = SnapshotDiffService.shared |
|
| 11 |
- |
|
| 12 |
- func diffs(current: HealthSnapshot?, snapshots: [HealthSnapshot]) -> [TypeDiff] {
|
|
| 13 |
- if let archiveDiffs {
|
|
| 14 |
- return diffService.apply(filter: filter, to: archiveDiffs) |
|
| 15 |
- } |
|
| 16 |
- guard let current else { return [] }
|
|
| 17 |
- guard let baseline = resolveBaseline(for: current, in: snapshots) else { return [] }
|
|
| 18 |
- let all = diffService.diff(current: current, baseline: baseline) |
|
| 19 |
- return diffService.apply(filter: filter, to: all) |
|
| 16 |
+ func diffs() -> [TypeDiff] {
|
|
| 17 |
+ guard let archiveDiffs else { return [] }
|
|
| 18 |
+ return apply(filter: filter, to: archiveDiffs) |
|
| 20 | 19 |
} |
| 21 | 20 |
|
| 22 |
- func baseline(for snapshot: HealthSnapshot, in snapshots: [HealthSnapshot]) -> HealthSnapshot? {
|
|
| 21 |
+ func baseline(for snapshot: DataTypeSnapshotContext, in snapshots: [DataTypeSnapshotContext]) -> DataTypeSnapshotContext? {
|
|
| 23 | 22 |
resolveBaseline(for: snapshot, in: snapshots) |
| 24 | 23 |
} |
| 25 | 24 |
|
@@ -29,7 +28,7 @@ final class DataTypesViewModel {
|
||
| 29 | 28 |
} |
| 30 | 29 |
|
| 31 | 30 |
@MainActor |
| 32 |
- func loadArchiveDiffs(current: HealthSnapshot?, snapshots: [HealthSnapshot]) async {
|
|
| 31 |
+ func loadArchiveDiffs(current: DataTypeSnapshotContext?, snapshots: [DataTypeSnapshotContext]) async {
|
|
| 33 | 32 |
guard let current, |
| 34 | 33 |
let baseline = resolveBaseline(for: current, in: snapshots), |
| 35 | 34 |
let currentObservationID = current.archiveObservationID, |
@@ -80,16 +79,30 @@ final class DataTypesViewModel {
|
||
| 80 | 79 |
} |
| 81 | 80 |
} |
| 82 | 81 |
|
| 83 |
- private func resolveBaseline(for snapshot: HealthSnapshot, in snapshots: [HealthSnapshot]) -> HealthSnapshot? {
|
|
| 84 |
- let sorted = snapshots.sorted { $0.timestamp > $1.timestamp }
|
|
| 82 |
+ private func resolveBaseline( |
|
| 83 |
+ for snapshot: DataTypeSnapshotContext, |
|
| 84 |
+ in snapshots: [DataTypeSnapshotContext] |
|
| 85 |
+ ) -> DataTypeSnapshotContext? {
|
|
| 86 |
+ let sorted = snapshots.sorted { $0.observedAt > $1.observedAt }
|
|
| 85 | 87 |
switch comparisonMode {
|
| 86 | 88 |
case .previous: |
| 87 |
- return sorted.first { $0.timestamp < snapshot.timestamp }
|
|
| 89 |
+ return sorted.first { $0.observedAt < snapshot.observedAt }
|
|
| 88 | 90 |
case .selected: |
| 89 | 91 |
return nil // DataTypesView uses .previous by default; selection lives in SnapshotsTab |
| 90 | 92 |
case .relativeTime(let interval): |
| 91 |
- let target = snapshot.timestamp.addingTimeInterval(-interval) |
|
| 92 |
- return diffService.nearest(to: target, in: snapshots) |
|
| 93 |
+ let target = snapshot.observedAt.addingTimeInterval(-interval) |
|
| 94 |
+ return snapshots |
|
| 95 |
+ .filter { $0.observedAt <= target }
|
|
| 96 |
+ .max { $0.observedAt < $1.observedAt }
|
|
| 97 |
+ } |
|
| 98 |
+ } |
|
| 99 |
+ |
|
| 100 |
+ private func apply(filter: DiffFilter, to diffs: [TypeDiff]) -> [TypeDiff] {
|
|
| 101 |
+ switch filter {
|
|
| 102 |
+ case .all: return diffs |
|
| 103 |
+ case .changed: return diffs.filter { $0.previousTracked && $0.hasChanges }
|
|
| 104 |
+ case .increased: return diffs.filter { $0.previousTracked && $0.delta > 0 }
|
|
| 105 |
+ case .decreased: return diffs.filter { $0.previousTracked && $0.delta < 0 }
|
|
| 93 | 106 |
} |
| 94 | 107 |
} |
| 95 | 108 |
} |
@@ -12,14 +12,34 @@ struct DataTypesView: View {
|
||
| 12 | 12 |
|
| 13 | 13 |
private var latest: HealthSnapshot? { displayedSnapshots.first }
|
| 14 | 14 |
|
| 15 |
- private var currentBaseline: HealthSnapshot? {
|
|
| 16 |
- guard let latest else { return nil }
|
|
| 17 |
- return viewModel.baseline(for: latest, in: displayedSnapshots) |
|
| 15 |
+ private var displayedSnapshotContexts: [DataTypeSnapshotContext] {
|
|
| 16 |
+ displayedSnapshots.map {
|
|
| 17 |
+ DataTypeSnapshotContext( |
|
| 18 |
+ id: $0.id, |
|
| 19 |
+ observedAt: $0.timestamp, |
|
| 20 |
+ archiveObservationID: $0.archiveObservationID |
|
| 21 |
+ ) |
|
| 22 |
+ } |
|
| 23 |
+ } |
|
| 24 |
+ |
|
| 25 |
+ private var latestContext: DataTypeSnapshotContext? {
|
|
| 26 |
+ latest.map {
|
|
| 27 |
+ DataTypeSnapshotContext( |
|
| 28 |
+ id: $0.id, |
|
| 29 |
+ observedAt: $0.timestamp, |
|
| 30 |
+ archiveObservationID: $0.archiveObservationID |
|
| 31 |
+ ) |
|
| 32 |
+ } |
|
| 33 |
+ } |
|
| 34 |
+ |
|
| 35 |
+ private var currentBaseline: DataTypeSnapshotContext? {
|
|
| 36 |
+ guard let latestContext else { return nil }
|
|
| 37 |
+ return viewModel.baseline(for: latestContext, in: displayedSnapshotContexts) |
|
| 18 | 38 |
} |
| 19 | 39 |
|
| 20 | 40 |
private var archiveDiffTaskID: String {
|
| 21 | 41 |
[ |
| 22 |
- latest?.id.uuidString ?? "none", |
|
| 42 |
+ latestContext?.id.uuidString ?? "none", |
|
| 23 | 43 |
currentBaseline?.id.uuidString ?? "none", |
| 24 | 44 |
String(describing: viewModel.comparisonMode) |
| 25 | 45 |
].joined(separator: "|") |
@@ -50,7 +70,7 @@ struct DataTypesView: View {
|
||
| 50 | 70 |
.navigationTitle("Data Types")
|
| 51 | 71 |
.toolbar { filterPicker }
|
| 52 | 72 |
.task(id: archiveDiffTaskID) {
|
| 53 |
- await viewModel.loadArchiveDiffs(current: latest, snapshots: displayedSnapshots) |
|
| 73 |
+ await viewModel.loadArchiveDiffs(current: latestContext, snapshots: displayedSnapshotContexts) |
|
| 54 | 74 |
} |
| 55 | 75 |
} |
| 56 | 76 |
} |
@@ -58,7 +78,7 @@ struct DataTypesView: View {
|
||
| 58 | 78 |
// MARK: - List |
| 59 | 79 |
|
| 60 | 80 |
private var typeList: some View {
|
| 61 |
- let diffs = viewModel.diffs(current: latest, snapshots: displayedSnapshots) |
|
| 81 |
+ let diffs = viewModel.diffs() |
|
| 62 | 82 |
return List {
|
| 63 | 83 |
comparisonModeHeader |
| 64 | 84 |
if diffs.isEmpty {
|