@@ -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 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; 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 | |
@@ -223,7 +223,9 @@ Checklist: |
||
| 223 | 223 |
- [ ] Replace direct SwiftData `@Query` dependencies for target screens. |
| 224 | 224 |
- [x] Dashboard status reads Core Data cached observation rows and cache health, |
| 225 | 225 |
with SwiftData retained only for capture/review actions. |
| 226 |
-- [x] Observation timeline rows read Core Data cache when available, while retaining SwiftData handles for detail navigation during transition. |
|
| 226 |
+- [x] Observation timeline rows read Core Data cache when available and no |
|
| 227 |
+ longer query `SnapshotDelta` list summaries, while retaining SwiftData handles |
|
| 228 |
+ for detail navigation during transition. |
|
| 227 | 229 |
- [x] Observation detail uses cached summary/type rows plus SQLite diff summaries when archive observation ids exist. |
| 228 | 230 |
- [x] Data Types list rows prefer Core Data cached counts plus SQLite `diffSummary` when archive observation ids exist. |
| 229 | 231 |
- [x] Data type detail uses SQLite `diffSummary` when archive observation ids exist. |
@@ -115,6 +115,10 @@ The following SwiftData dependencies were removed from active flows: |
||
| 115 | 115 |
`HealthProbe/Views/DataTypes/DataTypeTemporalDistributionView.swift` now read |
| 116 | 116 |
a `TemporalDistributionInput` DTO backed by `TypeCountDetailCache`; they no |
| 117 | 117 |
longer import SwiftData, query timeline snapshots, or require `ModelContext`. |
| 118 |
+- `HealthProbe/Views/Snapshots/SnapshotsView.swift` no longer queries |
|
| 119 |
+ `SnapshotDelta` or runs `DeltaService` list-summary repair. Timeline change |
|
| 120 |
+ summaries come from archive/cache rows when available; SwiftData remains there |
|
| 121 |
+ only for temporary snapshot navigation/deletion handles. |
|
| 118 | 122 |
- `HealthProbe/Models/AnomalyRecord.swift`, |
| 119 | 123 |
`HealthProbe/Models/AnomalyType.swift`, and |
| 120 | 124 |
`HealthProbe/Services/AnomalyDetector.swift` were deleted. The app no longer |
@@ -4,7 +4,6 @@ import SwiftData |
||
| 4 | 4 |
struct SnapshotsView: View {
|
| 5 | 5 |
@Environment(\.modelContext) private var modelContext |
| 6 | 6 |
@Query(sort: \HealthSnapshot.timestamp, order: .reverse) private var allSnapshots: [HealthSnapshot] |
| 7 |
- @Query private var allDeltas: [SnapshotDelta] |
|
| 8 | 7 |
@State private var viewModel = SnapshotsViewModel() |
| 9 | 8 |
@State private var profileMap: [String: LocalDeviceProfile] = [:] |
| 10 | 9 |
|
@@ -20,16 +19,12 @@ struct SnapshotsView: View {
|
||
| 20 | 19 |
private var timelineReloadID: String {
|
| 21 | 20 |
[ |
| 22 | 21 |
String(allSnapshots.count), |
| 23 |
- allSnapshots.compactMap(\.archiveObservationID).map(String.init).joined(separator: ","), |
|
| 24 |
- String(allDeltas.count) |
|
| 22 |
+ allSnapshots.compactMap(\.archiveObservationID).map(String.init).joined(separator: ",") |
|
| 25 | 23 |
].joined(separator: "|") |
| 26 | 24 |
} |
| 27 | 25 |
|
| 28 | 26 |
private var snapshotItems: [SnapshotListItem] {
|
| 29 | 27 |
let baselines = viewModel.baselines(for: displayedSnapshots) |
| 30 |
- let deltaSummaryBySnapshotID = allDeltas.reduce(into: [UUID: SnapshotDeltaListSummary]()) { partial, delta in
|
|
| 31 |
- partial[delta.toSnapshotID] = delta.listSummary |
|
| 32 |
- } |
|
| 33 | 28 |
|
| 34 | 29 |
if let archiveRows = viewModel.archiveRows {
|
| 35 | 30 |
let snapshotsByObservationID = Dictionary(uniqueKeysWithValues: displayedSnapshots.compactMap { snapshot in
|
@@ -42,7 +37,6 @@ struct SnapshotsView: View {
|
||
| 42 | 37 |
snapshot: snapshot, |
| 43 | 38 |
baseline: snapshot.flatMap { baselines[$0.id] },
|
| 44 | 39 |
archiveRow: row, |
| 45 |
- deltaSummary: snapshot.flatMap { deltaSummaryBySnapshotID[$0.id] },
|
|
| 46 | 40 |
showsDeltaSummary: viewModel.comparisonMode == .previous |
| 47 | 41 |
) |
| 48 | 42 |
} |
@@ -53,7 +47,6 @@ struct SnapshotsView: View {
|
||
| 53 | 47 |
snapshot: snapshot, |
| 54 | 48 |
baseline: baselines[snapshot.id] ?? nil, |
| 55 | 49 |
archiveRow: nil, |
| 56 |
- deltaSummary: deltaSummaryBySnapshotID[snapshot.id], |
|
| 57 | 50 |
showsDeltaSummary: viewModel.comparisonMode == .previous |
| 58 | 51 |
) |
| 59 | 52 |
} |
@@ -83,9 +76,6 @@ struct SnapshotsView: View {
|
||
| 83 | 76 |
} |
| 84 | 77 |
.navigationTitle("Snapshots")
|
| 85 | 78 |
.toolbar { toolbarContent }
|
| 86 |
- .task(id: allDeltas.count) {
|
|
| 87 |
- repairDeltaListSummariesIfNeeded() |
|
| 88 |
- } |
|
| 89 | 79 |
.task(id: timelineReloadID) {
|
| 90 | 80 |
loadDeviceProfiles() |
| 91 | 81 |
await viewModel.loadArchiveRows() |
@@ -109,7 +99,6 @@ struct SnapshotsView: View {
|
||
| 109 | 99 |
snapshot: snapshot, |
| 110 | 100 |
baseline: item.baseline, |
| 111 | 101 |
archiveRow: item.archiveRow, |
| 112 |
- deltaSummary: item.deltaSummary, |
|
| 113 | 102 |
showsDeltaSummary: item.showsDeltaSummary, |
| 114 | 103 |
isSelectedBaseline: viewModel.selectedBaseline?.id == snapshot.id, |
| 115 | 104 |
profile: profileMap[snapshot.deviceID] |
@@ -143,7 +132,6 @@ struct SnapshotsView: View {
|
||
| 143 | 132 |
snapshot: nil, |
| 144 | 133 |
baseline: item.baseline, |
| 145 | 134 |
archiveRow: item.archiveRow, |
| 146 |
- deltaSummary: item.deltaSummary, |
|
| 147 | 135 |
showsDeltaSummary: item.showsDeltaSummary, |
| 148 | 136 |
isSelectedBaseline: false, |
| 149 | 137 |
profile: nil |
@@ -174,14 +162,6 @@ struct SnapshotsView: View {
|
||
| 174 | 162 |
} |
| 175 | 163 |
} |
| 176 | 164 |
|
| 177 |
- private func repairDeltaListSummariesIfNeeded() {
|
|
| 178 |
- do {
|
|
| 179 |
- _ = try DeltaService.rebuildMissingListSummaries(context: modelContext, maxCount: 64) |
|
| 180 |
- } catch {
|
|
| 181 |
- // Keep the list responsive even if summary repair fails. |
|
| 182 |
- } |
|
| 183 |
- } |
|
| 184 |
- |
|
| 185 | 165 |
private func loadDeviceProfiles() {
|
| 186 | 166 |
let profiles = LocalDeviceProfileStore.allProfiles() |
| 187 | 167 |
profileMap = Dictionary(uniqueKeysWithValues: profiles.compactMap {
|
@@ -194,7 +174,6 @@ private struct SnapshotListItem: Identifiable {
|
||
| 194 | 174 |
let snapshot: HealthSnapshot? |
| 195 | 175 |
let baseline: HealthSnapshot? |
| 196 | 176 |
let archiveRow: CachedArchiveObservationRow? |
| 197 |
- let deltaSummary: SnapshotDeltaListSummary? |
|
| 198 | 177 |
let showsDeltaSummary: Bool |
| 199 | 178 |
|
| 200 | 179 |
var id: String {
|
@@ -211,7 +190,6 @@ private struct SnapshotRow: View {
|
||
| 211 | 190 |
let snapshot: HealthSnapshot? |
| 212 | 191 |
let baseline: HealthSnapshot? |
| 213 | 192 |
let archiveRow: CachedArchiveObservationRow? |
| 214 |
- let deltaSummary: SnapshotDeltaListSummary? |
|
| 215 | 193 |
let showsDeltaSummary: Bool |
| 216 | 194 |
let isSelectedBaseline: Bool |
| 217 | 195 |
let profile: LocalDeviceProfile? |
@@ -271,31 +249,7 @@ private struct SnapshotRow: View {
|
||
| 271 | 249 |
return parts.joined(separator: " • ") |
| 272 | 250 |
} |
| 273 | 251 |
|
| 274 |
- guard let deltaSummary else { return nil }
|
|
| 275 |
- |
|
| 276 |
- var parts: [String] = [] |
|
| 277 |
- |
|
| 278 |
- if deltaSummary.absoluteRecordChangeCount > 0 {
|
|
| 279 |
- parts.append("\(deltaSummary.absoluteRecordChangeCount) record change\(deltaSummary.absoluteRecordChangeCount == 1 ? "" : "s")")
|
|
| 280 |
- } |
|
| 281 |
- |
|
| 282 |
- if deltaSummary.changedMetricCount > 0 {
|
|
| 283 |
- parts.append("\(deltaSummary.changedMetricCount) metric change\(deltaSummary.changedMetricCount == 1 ? "" : "s")")
|
|
| 284 |
- } |
|
| 285 |
- |
|
| 286 |
- if deltaSummary.appearedMetricCount > 0 {
|
|
| 287 |
- parts.append("\(deltaSummary.appearedMetricCount) metric new")
|
|
| 288 |
- } |
|
| 289 |
- |
|
| 290 |
- if deltaSummary.disappearedMetricCount > 0 {
|
|
| 291 |
- parts.append("\(deltaSummary.disappearedMetricCount) metric missing")
|
|
| 292 |
- } |
|
| 293 |
- |
|
| 294 |
- if parts.isEmpty {
|
|
| 295 |
- return "No changes" |
|
| 296 |
- } |
|
| 297 |
- |
|
| 298 |
- return parts.joined(separator: " • ") |
|
| 252 |
+ return nil |
|
| 299 | 253 |
} |
| 300 | 254 |
|
| 301 | 255 |
private var deltaSummaryColor: Color {
|
@@ -305,10 +259,7 @@ private struct SnapshotRow: View {
|
||
| 305 | 259 |
return Color.healthyGreen |
| 306 | 260 |
} |
| 307 | 261 |
|
| 308 |
- guard let deltaSummary else { return .secondary }
|
|
| 309 |
- if deltaSummary.disappearedMetricCount > 0 { return Color.criticalRed }
|
|
| 310 |
- if deltaSummary.hasChanges { return Color.warningAmber }
|
|
| 311 |
- return Color.healthyGreen |
|
| 262 |
+ return .secondary |
|
| 312 | 263 |
} |
| 313 | 264 |
|
| 314 | 265 |
private var deltaSummaryIconName: String {
|
@@ -319,9 +270,7 @@ private struct SnapshotRow: View {
|
||
| 319 | 270 |
return hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle" |
| 320 | 271 |
} |
| 321 | 272 |
|
| 322 |
- return deltaSummary == nil || deltaSummary?.hasChanges == false |
|
| 323 |
- ? "checkmark.circle" |
|
| 324 |
- : "arrow.triangle.2.circlepath" |
|
| 273 |
+ return "checkmark.circle" |
|
| 325 | 274 |
} |
| 326 | 275 |
|
| 327 | 276 |
private var hasOSVersionChange: Bool {
|
@@ -431,6 +380,6 @@ private struct SnapshotRow: View {
|
||
| 431 | 380 |
|
| 432 | 381 |
#Preview {
|
| 433 | 382 |
SnapshotsView() |
| 434 |
- .modelContainer(for: [HealthSnapshot.self, SnapshotDelta.self, TypeDelta.self, TypeCount.self, HealthRecord.self, TypeDistributionBin.self], inMemory: true) |
|
| 383 |
+ .modelContainer(for: [HealthSnapshot.self, TypeCount.self, HealthRecord.self, TypeDistributionBin.self], inMemory: true) |
|
| 435 | 384 |
.environment(AppSettings()) |
| 436 | 385 |
} |