@@ -1,6 +1,6 @@ |
||
| 1 | 1 |
# HealthProbe - Implementation Status |
| 2 | 2 |
|
| 3 |
-**Last Updated:** 2026-05-25 |
|
| 3 |
+**Last Updated:** 2026-05-26 |
|
| 4 | 4 |
|
| 5 | 5 |
## Current Reality |
| 6 | 6 |
|
@@ -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 require Core Data cache rows and no longer fall back to `SnapshotDelta`/`TypeDelta`; Data Types list rows no longer fall 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 | |
|
| 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 require Core Data cache rows and no longer fall back to `SnapshotDelta`/`TypeDelta`; Data Types list rows no longer fall back to SwiftData `TypeCount` traversal; data type detail reads Core Data type/diff summaries, uses SQLite `diffRecords` for paged drill-down, and no longer queries `SnapshotDelta`/`TypeDelta` or rebuilds legacy detail caches from the UI; 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 already-existing 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 | |
@@ -48,7 +48,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m |
||
| 48 | 48 |
|
| 49 | 49 |
- SwiftData currently blocks iOS 15-era device support. |
| 50 | 50 |
- Some screens still imply snapshot-count monitoring rather than Time Machine inspection. |
| 51 |
-- Current UI/cache layers still depend on 22 SwiftData-backed files for launch container, capture review actions, navigation handles, some charts, and PDF paths. |
|
| 51 |
+- Current UI/cache layers still depend on 19 SwiftData-backed files for launch container, capture review actions, navigation handles, some transition detail paths, and PDF paths. |
|
| 52 | 52 |
- Snapshots timeline, snapshot detail summary/type rows, Data Types list rows, and record drill-down are archive/cache-backed for new archive v2 observations when cache rows exist, but navigation still uses SwiftData snapshot handles during the transition. |
| 53 | 53 |
- Legacy SwiftData-only snapshots can show diffs without archive-backed values; they are now reset for archive v2 test installs rather than migrated. |
| 54 | 54 |
- Capture strategy and some legacy SwiftData transition paths may still decode or cache too much data for low-end devices. |
@@ -1,6 +1,6 @@ |
||
| 1 | 1 |
# HealthProbe - Database-Led Refactoring Plan |
| 2 | 2 |
|
| 3 |
-**Last Updated:** 2026-05-25 |
|
| 3 |
+**Last Updated:** 2026-05-26 |
|
| 4 | 4 |
**Status:** Active planning document |
| 5 | 5 |
|
| 6 | 6 |
## Goal |
@@ -230,7 +230,7 @@ Checklist: |
||
| 230 | 230 |
summaries and no longer falls back to legacy `SnapshotDelta`/`TypeDelta` |
| 231 | 231 |
rows. |
| 232 | 232 |
- [x] Data Types list rows use Core Data cached counts plus SQLite `diffSummary` and no longer fall back to SwiftData `TypeCount` traversal. |
| 233 |
-- [x] Data type detail uses SQLite `diffSummary` when archive observation ids exist. |
|
| 233 |
+- [x] Data type detail uses Core Data/SQLite `diffSummary` when archive observation ids exist and no longer queries `SnapshotDelta`/`TypeDelta` or rebuilds legacy detail caches from the UI. |
|
| 234 | 234 |
- [x] Data type new/missing drill-down pages through SQLite `diffRecords` when archive observation ids exist. |
| 235 | 235 |
- [x] Diff detail fully uses cached summary plus paged SQLite DTOs. |
| 236 | 236 |
- [x] Record-change evolution chart uses DTO inputs and archive/cache lookups instead of direct SwiftData queries. |
@@ -1,6 +1,6 @@ |
||
| 1 | 1 |
# SwiftData Retirement Inventory |
| 2 | 2 |
|
| 3 |
-**Last Updated:** 2026-05-25 |
|
| 3 |
+**Last Updated:** 2026-05-26 |
|
| 4 | 4 |
|
| 5 | 5 |
This inventory supports Milestone 9 in [`Refactoring-Plan.md`](Refactoring-Plan.md). |
| 6 | 6 |
SwiftData is legacy/prototype infrastructure only. The target app must launch |
@@ -9,8 +9,9 @@ local settings stored outside SwiftData where needed. |
||
| 9 | 9 |
|
| 10 | 10 |
## Current Count |
| 11 | 11 |
|
| 12 |
-After moving temporal distribution to cache DTO inputs, 19 app files still |
|
| 13 |
-have SwiftData imports. |
|
| 12 |
+After removing the data type detail `SnapshotDelta` query, 19 app files still |
|
| 13 |
+have SwiftData imports because active navigation still uses legacy snapshot |
|
| 14 |
+handles. |
|
| 14 | 15 |
|
| 15 | 16 |
## Launch Container |
| 16 | 17 |
|
@@ -128,6 +129,11 @@ The following SwiftData dependencies were removed from active flows: |
||
| 128 | 129 |
fallback. Snapshot detail type rows now require archive/cache summaries; the |
| 129 | 130 |
temporary SwiftData dependency is limited to snapshot navigation, metadata, |
| 130 | 131 |
and PDF export handles. |
| 132 |
+- `HealthProbe/Views/Snapshots/DataTypeSnapshotDetailView.swift` no longer |
|
| 133 |
+ queries `SnapshotDelta`/`TypeDelta` and no longer rebuilds legacy |
|
| 134 |
+ `TypeCount.detailCache` rows from the UI. It reads Core Data/SQLite diff |
|
| 135 |
+ summaries first and only displays an already-existing legacy detail cache as a |
|
| 136 |
+ transition fallback. |
|
| 131 | 137 |
- `HealthProbe/Models/AnomalyRecord.swift`, |
| 132 | 138 |
`HealthProbe/Models/AnomalyType.swift`, and |
| 133 | 139 |
`HealthProbe/Services/AnomalyDetector.swift` were deleted. The app no longer |
@@ -7,11 +7,9 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 7 | 7 |
let displayName: String |
| 8 | 8 |
|
| 9 | 9 |
@Environment(AppSettings.self) private var appSettings |
| 10 |
- @Environment(\.modelContext) private var modelContext |
|
| 11 | 10 |
@Environment(\.dynamicTypeSize) private var dynamicTypeSize |
| 12 | 11 |
@Environment(\.horizontalSizeClass) private var horizontalSizeClass |
| 13 | 12 |
@Query(sort: \HealthSnapshot.timestamp) private var allSnapshots: [HealthSnapshot] |
| 14 |
- @Query private var allDeltas: [SnapshotDelta] |
|
| 15 | 13 |
|
| 16 | 14 |
@State private var displayedSnapshot: HealthSnapshot? |
| 17 | 15 |
@State private var diffState: RecordDiffState = .idle |
@@ -73,18 +71,6 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 73 | 71 |
) |
| 74 | 72 |
} |
| 75 | 73 |
|
| 76 |
- private var currentDelta: SnapshotDelta? {
|
|
| 77 |
- guard let previousSnapshot else { return nil }
|
|
| 78 |
- return allDeltas.first {
|
|
| 79 |
- $0.toSnapshotID == currentSnapshot.id && |
|
| 80 |
- $0.fromSnapshotID == previousSnapshot.id |
|
| 81 |
- } |
|
| 82 |
- } |
|
| 83 |
- |
|
| 84 |
- private var currentTypeDelta: TypeDelta? {
|
|
| 85 |
- currentDelta?.typeDeltas?.first { $0.typeIdentifier == typeIdentifier }
|
|
| 86 |
- } |
|
| 87 |
- |
|
| 88 | 74 |
private var diffTaskID: String {
|
| 89 | 75 |
[ |
| 90 | 76 |
currentSnapshot.id.uuidString, |
@@ -123,14 +109,14 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 123 | 109 |
return (cache.addedCount, cache.disappearedCount, true) |
| 124 | 110 |
} |
| 125 | 111 |
|
| 126 |
- let net = (currentTypeDelta?.countDelta ?? (quickCurrentCountValue - quickPreviousCountValue)) |
|
| 112 |
+ let net = quickCurrentCountValue - quickPreviousCountValue |
|
| 127 | 113 |
return (max(net, 0), max(-net, 0), false) |
| 128 | 114 |
} |
| 129 | 115 |
|
| 130 | 116 |
private var recordEvolutionSnapshots: [RecordChangeEvolutionSnapshot] {
|
| 131 | 117 |
timelineSnapshots.map { snapshot in
|
| 132 | 118 |
let count = max(typeCount(in: snapshot)?.count ?? 0, 0) |
| 133 |
- let fallback = recordEvolutionFallback(for: snapshot) |
|
| 119 |
+ let fallback = recordEvolutionCachedFallback(for: snapshot) |
|
| 134 | 120 |
return RecordChangeEvolutionSnapshot( |
| 135 | 121 |
id: snapshot.id, |
| 136 | 122 |
timestamp: snapshot.timestamp, |
@@ -277,35 +263,6 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 277 | 263 |
} |
| 278 | 264 |
} |
| 279 | 265 |
|
| 280 |
- // MARK: - Optimized Content |
|
| 281 |
- |
|
| 282 |
- @ViewBuilder |
|
| 283 |
- private var metricComparison: some View {
|
|
| 284 |
- if previousSnapshot == nil {
|
|
| 285 |
- EmptyView() |
|
| 286 |
- } else {
|
|
| 287 |
- MetricComparisonCard( |
|
| 288 |
- currentValue: currentTypeCount?.count ?? 0, |
|
| 289 |
- previousValue: previousTypeCount?.count, |
|
| 290 |
- displayName: displayName, |
|
| 291 |
- isCurrentValid: (currentTypeCount?.count ?? 0) >= 0, |
|
| 292 |
- isPreviousTracked: previousTypeCount != nil |
|
| 293 |
- ) |
|
| 294 |
- } |
|
| 295 |
- } |
|
| 296 |
- |
|
| 297 |
- @ViewBuilder |
|
| 298 |
- private var typeEvolutionSection: some View {
|
|
| 299 |
- if previousSnapshot != nil {
|
|
| 300 |
- TypeEvolutionTimeline( |
|
| 301 |
- snapshots: timelineSnapshots, |
|
| 302 |
- typeIdentifier: typeIdentifier, |
|
| 303 |
- displayName: displayName, |
|
| 304 |
- currentSnapshotID: currentSnapshot.id |
|
| 305 |
- ) |
|
| 306 |
- } |
|
| 307 |
- } |
|
| 308 |
- |
|
| 309 | 266 |
@ViewBuilder |
| 310 | 267 |
private var dataRangeSection: some View {
|
| 311 | 268 |
if currentTypeCount != nil || currentCachedTypeSummary != nil {
|
@@ -545,23 +502,6 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 545 | 502 |
.background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12)) |
| 546 | 503 |
} |
| 547 | 504 |
|
| 548 |
- private func typeDeltaSummaryText(_ typeDelta: TypeDelta) -> String {
|
|
| 549 |
- switch typeDelta.transition {
|
|
| 550 |
- case .unchanged: |
|
| 551 |
- return "No metric-level change recorded." |
|
| 552 |
- case .changed: |
|
| 553 |
- if typeDelta.countDelta == 0 {
|
|
| 554 |
- return "Content changed while count stayed the same." |
|
| 555 |
- } |
|
| 556 |
- let prefix = typeDelta.countDelta > 0 ? "+" : "" |
|
| 557 |
- return "Count delta: \(prefix)\(typeDelta.countDelta)." |
|
| 558 |
- case .appeared: |
|
| 559 |
- return "Metric is new in this observation." |
|
| 560 |
- case .disappeared: |
|
| 561 |
- return "Metric is missing from this observation." |
|
| 562 |
- } |
|
| 563 |
- } |
|
| 564 |
- |
|
| 565 | 505 |
private func quickStat(label: String, value: String, color: Color = .primary) -> some View {
|
| 566 | 506 |
VStack(alignment: .leading, spacing: 2) {
|
| 567 | 507 |
Text(label) |
@@ -574,7 +514,7 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 574 | 514 |
.frame(maxWidth: .infinity, alignment: .leading) |
| 575 | 515 |
} |
| 576 | 516 |
|
| 577 |
- private func recordEvolutionFallback(for snapshot: HealthSnapshot) -> (added: Int, disappeared: Int, exact: Bool) {
|
|
| 517 |
+ private func recordEvolutionCachedFallback(for snapshot: HealthSnapshot) -> (added: Int, disappeared: Int, exact: Bool) {
|
|
| 578 | 518 |
guard let previous = snapshot.previousInTimeline(timelineSnapshots) else {
|
| 579 | 519 |
return (0, 0, false) |
| 580 | 520 |
} |
@@ -584,30 +524,7 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 584 | 524 |
return (cache.addedCount, cache.disappearedCount, true) |
| 585 | 525 |
} |
| 586 | 526 |
|
| 587 |
- guard let delta = allDeltas.first(where: {
|
|
| 588 |
- $0.fromSnapshotID == previous.id && |
|
| 589 |
- $0.toSnapshotID == snapshot.id |
|
| 590 |
- }), |
|
| 591 |
- let typeDelta = delta.typeDeltas?.first(where: { $0.typeIdentifier == typeIdentifier }) else {
|
|
| 592 |
- return (0, 0, false) |
|
| 593 |
- } |
|
| 594 |
- |
|
| 595 |
- switch typeDelta.transition {
|
|
| 596 |
- case .unchanged: |
|
| 597 |
- return (0, 0, false) |
|
| 598 |
- case .changed: |
|
| 599 |
- if typeDelta.countDelta > 0 {
|
|
| 600 |
- return (typeDelta.countDelta, 0, false) |
|
| 601 |
- } |
|
| 602 |
- if typeDelta.countDelta < 0 {
|
|
| 603 |
- return (0, abs(typeDelta.countDelta), false) |
|
| 604 |
- } |
|
| 605 |
- return (0, 0, false) |
|
| 606 |
- case .appeared: |
|
| 607 |
- return (max(typeDelta.countDelta, 1), 0, false) |
|
| 608 |
- case .disappeared: |
|
| 609 |
- return (0, max(abs(typeDelta.countDelta), 1), false) |
|
| 610 |
- } |
|
| 527 |
+ return (0, 0, false) |
|
| 611 | 528 |
} |
| 612 | 529 |
|
| 613 | 530 |
private func addedRecordListMode(previous: HealthSnapshot) -> RecordListMode {
|
@@ -696,14 +613,14 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 696 | 613 |
} |
| 697 | 614 |
} |
| 698 | 615 |
|
| 699 |
- let resolution = currentDetailCacheResolution() |
|
| 700 |
- detailCacheDiagnostic = resolution?.diagnostic ?? detailCacheDiagnostic |
|
| 701 |
- guard let cache = resolution?.cache else {
|
|
| 702 |
- diffState = .unavailable |
|
| 616 |
+ if let cache = currentTypeCount?.detailCache, |
|
| 617 |
+ cache.matchesBaseline(previousSnapshot?.id) {
|
|
| 618 |
+ detailCacheDiagnostic = "resolver-v7 phase=legacy-detail-cache-read-only" |
|
| 619 |
+ diffState = .loaded(DataTypeRecordDiff(cache: cache)) |
|
| 703 | 620 |
return |
| 704 | 621 |
} |
| 705 | 622 |
|
| 706 |
- diffState = .loaded(DataTypeRecordDiff(cache: cache)) |
|
| 623 |
+ diffState = .unavailable |
|
| 707 | 624 |
} |
| 708 | 625 |
|
| 709 | 626 |
@MainActor |
@@ -736,35 +653,6 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 736 | 653 |
} |
| 737 | 654 |
} |
| 738 | 655 |
|
| 739 |
- @MainActor |
|
| 740 |
- private func currentDetailCacheResolution() -> TypeCountDetailCacheResolution? {
|
|
| 741 |
- if isCurrentTypeContentAliasToPrevious {
|
|
| 742 |
- return TypeCountDetailCacheResolution( |
|
| 743 |
- cache: nil, |
|
| 744 |
- diagnostic: "alias-to-previous" |
|
| 745 |
- ) |
|
| 746 |
- } |
|
| 747 |
- |
|
| 748 |
- if let cache = currentTypeCount?.detailCache, |
|
| 749 |
- cache.matchesBaseline(previousSnapshot?.id) {
|
|
| 750 |
- return TypeCountDetailCacheResolution( |
|
| 751 |
- cache: cache, |
|
| 752 |
- diagnostic: "resolver-v4 phase=cache-hit-view" |
|
| 753 |
- ) |
|
| 754 |
- } |
|
| 755 |
- |
|
| 756 |
- guard let currentTypeCount, |
|
| 757 |
- let previousSnapshot else {
|
|
| 758 |
- return nil |
|
| 759 |
- } |
|
| 760 |
- |
|
| 761 |
- return currentTypeCount.resolveDetailCacheWithDiagnostics( |
|
| 762 |
- previous: previousTypeCount, |
|
| 763 |
- baselineSnapshotID: previousSnapshot.id, |
|
| 764 |
- context: modelContext, |
|
| 765 |
- source: "dataTypeDetail" |
|
| 766 |
- ) |
|
| 767 |
- } |
|
| 768 | 656 |
} |
| 769 | 657 |
|
| 770 | 658 |
private enum RecordDiffState: Equatable {
|