@@ -25,10 +25,10 @@ There are no real deployments, only test installations. Existing prototype datab |
||
| 25 | 25 |
|------|----------------|--------------------| |
| 26 | 26 |
| Product docs | Updated | Keep `HealthProbe/Doc/README.md` as canonical index | |
| 27 | 27 |
| HealthKit capture | Capture now opens one archive observation per user-visible snapshot and attaches HealthKit pages, deleted-object evidence, and type verification to that observation id before finishing it | Continue moving UI/cache reads to archive-backed observation ids | |
| 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 | |
|
| 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 | Continue replacing remaining transition detail/PDF paths with 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 |
-| 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 root no longer imports SwiftData and opens archive/cache-backed detail rows; 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 | |
|
| 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 transition detail/PDF paths before removing `ModelContainer` | |
|
| 31 |
+| UI | Prototype exists; Dashboard status reads archive/cache observation rows and shows cache health, with SwiftData retained only for capture/review actions; Snapshots and Data Types tab roots no longer import SwiftData, load Core Data cached observation rows, and open archive/cache-backed detail rows; `SnapshotArchiveDetailView` and `DataTypeArchiveDetailView` read Core Data type/diff summaries and page record drill-down through SQLite; legacy `SnapshotDetailView`/`DataTypeSnapshotDetailView` remain as transition views outside the active tab roots; 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 or isolate remaining SwiftData transition detail/PDF paths | |
|
| 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; the old direct `HealthSnapshot.typeCounts` diff helper has been retired | 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 | |
@@ -38,7 +38,7 @@ There are no real deployments, only test installations. Existing prototype datab |
||
| 38 | 38 |
|
| 39 | 39 |
Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.md). |
| 40 | 40 |
|
| 41 |
-1. Move Snapshots/Data Types from SwiftData model reads to Core Data/cache DTOs. |
|
| 41 |
+1. Remove or isolate remaining SwiftData transition detail/PDF paths. |
|
| 42 | 42 |
2. Add targeted cache invalidation for affected observation/type ranges. |
| 43 | 43 |
3. Finish remaining UI language cleanup from anomaly/status to observation/diff/export where legacy model names still leak into active flows. |
| 44 | 44 |
4. Complete recovery-compatible export metadata, CSV output, and reproducibility checks. |
@@ -48,8 +48,8 @@ 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 18 SwiftData-backed files for launch container, capture review actions, Snapshots navigation handles, some transition detail paths, and PDF paths. |
|
| 52 |
-- Snapshots timeline, snapshot detail summary/type rows, Data Types list/detail rows, and record drill-down are archive/cache-backed for new archive v2 observations when cache rows exist, but Snapshots navigation still uses SwiftData snapshot handles during the transition. |
|
| 51 |
+- Current UI/cache layers still depend on 17 SwiftData-backed files for launch container, capture review actions, legacy transition detail paths, model definitions, and PDF paths. |
|
| 52 |
+- Snapshots timeline/detail rows, Data Types list/detail rows, and record drill-down are archive/cache-backed for new archive v2 observations when cache rows exist. |
|
| 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. |
| 55 | 55 |
- Old prototype database compatibility is no longer required. |
@@ -62,7 +62,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m |
||
| 62 | 62 |
- [ ] No recurring complete snapshot copies are written for high-volume types. |
| 63 | 63 |
- [x] SQL diff between two observations runs without loading full datasets into Swift arrays. |
| 64 | 64 |
- [x] Snapshots timeline rows use Core Data cached observation counts/change summaries when cache rows are available. |
| 65 |
-- [x] Snapshot detail summary/type rows use Core Data cached summaries plus SQLite diff summaries when archive observation ids are available. |
|
| 65 |
+- [x] Snapshot tab navigation/detail rows use Core Data cached summaries plus SQLite diff summaries when archive observation ids are available. |
|
| 66 | 66 |
- [x] Data Types list rows use Core Data cached counts plus SQLite diff summaries when archive observation ids are available. |
| 67 | 67 |
- [x] Data type new/missing drill-down pages records from SQLite diff queries when archive observation ids are available. |
| 68 | 68 |
- [x] Data type diff detail and evolution summaries prefer Core Data cache rows when archive observation ids are available. |
@@ -182,7 +182,9 @@ Checklist: |
||
| 182 | 182 |
- [x] Include archive schema/cache schema/version/hash fields on rebuilt rows. |
| 183 | 183 |
- [x] Implement delete-cache-and-rebuild flow. |
| 184 | 184 |
- [x] Add cache schema/version and rebuild tests. |
| 185 |
-- [ ] Wire Core Data cache into UI-facing view models. |
|
| 185 |
+- [ ] Wire Core Data cache into remaining UI-facing view models. Dashboard |
|
| 186 |
+ status, Snapshots root/detail, Data Types root/detail, record-change |
|
| 187 |
+ evolution, and temporal distribution now use cache/archive DTOs. |
|
| 186 | 188 |
- [ ] Add targeted partial invalidation for affected observation/type ranges. |
| 187 | 189 |
|
| 188 | 190 |
Acceptance: |
@@ -224,13 +226,12 @@ Checklist: |
||
| 224 | 226 |
- [x] Dashboard status reads Core Data cached observation rows and cache health, |
| 225 | 227 |
with SwiftData retained only for capture/review actions. |
| 226 | 228 |
- [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. |
|
| 229 |
-- [x] Observation detail uses cached summary/type rows plus SQLite diff |
|
| 230 |
- summaries and no longer falls back to legacy `SnapshotDelta`/`TypeDelta` |
|
| 231 |
- rows. |
|
| 229 |
+ longer query `SnapshotDelta` list summaries. |
|
| 230 |
+- [x] Observation root and archive detail use cached summary/type rows plus |
|
| 231 |
+ SQLite diff summaries and no longer require SwiftData navigation handles. |
|
| 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 | 233 |
- [x] Data Types root reads Core Data cached observation rows directly and no longer imports SwiftData. |
| 234 |
+- [x] Snapshots root reads Core Data cached observation rows directly and no longer imports SwiftData. |
|
| 234 | 235 |
- [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. |
| 235 | 236 |
- [x] Remove unused legacy Type Evolution timeline and direct `HealthSnapshot.typeCounts` diff helper. |
| 236 | 237 |
- [x] Data type new/missing drill-down pages through SQLite `diffRecords` when archive observation ids exist. |
@@ -259,8 +260,9 @@ Checklist: |
||
| 259 | 260 |
calibration, local device profile settings, and operation logging have been |
| 260 | 261 |
moved to local Codable stores and removed from `ModelContainer`; Settings |
| 261 | 262 |
data maintenance now uses the rebuildable Core Data cache; legacy |
| 262 |
- anomaly/count-drop review has been deleted; SwiftData snapshot/navigation |
|
| 263 |
- handles remain. |
|
| 263 |
+ anomaly/count-drop review has been deleted; Snapshots/Data Types tab roots no |
|
| 264 |
+ longer import SwiftData; Dashboard capture/review actions and legacy |
|
| 265 |
+ transition detail/PDF paths remain. |
|
| 264 | 266 |
- [ ] Remove/disable `ModelContainer` as required for target builds. |
| 265 | 267 |
- [x] Add prototype-store ignore/delete/reset path for test installs. |
| 266 | 268 |
- [ ] Verify no old-store compatibility layer remains in active flows. |
@@ -9,9 +9,10 @@ local settings stored outside SwiftData where needed. |
||
| 9 | 9 |
|
| 10 | 10 |
## Current Count |
| 11 | 11 |
|
| 12 |
-After moving the Data Types root to archive/cache observations, 18 app files |
|
| 13 |
-still have SwiftData imports because capture, Snapshots navigation, and legacy |
|
| 14 |
-detail transition paths still use prototype snapshot handles. |
|
| 12 |
+After moving the Snapshots and Data Types tab roots to archive/cache |
|
| 13 |
+observations, 17 app files still have SwiftData imports because capture, |
|
| 14 |
+Dashboard review actions, legacy detail transition paths, model definitions, and |
|
| 15 |
+PDF/export transition paths still use prototype snapshot handles. |
|
| 15 | 16 |
|
| 16 | 17 |
## Launch Container |
| 17 | 18 |
|
@@ -70,11 +71,8 @@ types: |
||
| 70 | 71 |
- `HealthProbe/Views/Dashboard/DashboardView.swift` |
| 71 | 72 |
- `HealthProbe/Views/Snapshots/DataTypeSnapshotDetailView.swift` |
| 72 | 73 |
- `HealthProbe/Views/Snapshots/SnapshotDetailView.swift` |
| 73 |
-- `HealthProbe/Views/Snapshots/SnapshotsView.swift` |
|
| 74 | 74 |
|
| 75 | 75 |
Retirement path: |
| 76 |
-- replace tab-root `@Query` snapshot lists with Core Data cache observation |
|
| 77 |
- queries plus archive ids; |
|
| 78 | 76 |
- replace detail navigation parameters from SwiftData models to observation/type |
| 79 | 77 |
DTOs; |
| 80 | 78 |
- remove remaining snapshot/cache SwiftData rows from active flows; |
@@ -127,6 +125,10 @@ The following SwiftData dependencies were removed from active flows: |
||
| 127 | 125 |
SwiftData or queries `HealthSnapshot`; it loads Core Data cached observation |
| 128 | 126 |
rows and opens `DataTypeArchiveDetailView`, an archive/cache-only detail view |
| 129 | 127 |
with paged SQLite new/missing record drill-down. |
| 128 |
+- `HealthProbe/Views/Snapshots/SnapshotsView.swift` no longer imports |
|
| 129 |
+ SwiftData or queries `HealthSnapshot`; it loads Core Data cached observation |
|
| 130 |
+ rows and opens `SnapshotArchiveDetailView`, an archive/cache-only detail view |
|
| 131 |
+ that feeds Data Type drill-down through observation ids and cached summaries. |
|
| 130 | 132 |
- `HealthProbe/Views/Snapshots/SnapshotDetailView.swift` no longer queries |
| 131 | 133 |
`SnapshotDelta`/`TypeDelta` or carries the old SwiftData type-delta/chart |
| 132 | 134 |
fallback. Snapshot detail type rows now require archive/cache summaries; the |
@@ -150,4 +152,5 @@ The following SwiftData dependencies were removed from active flows: |
||
| 150 | 152 |
## Next Recommended Slices |
| 151 | 153 |
|
| 152 | 154 |
1. Move `DashboardView` capture review actions away from `ModelContext`. |
| 153 |
-2. Replace Snapshots/Data Types navigation handles with archive/cache DTOs. |
|
| 155 |
+2. Delete or isolate unused SwiftData snapshot/type detail transition views once |
|
| 156 |
+ PDF/export and any remaining preview paths have archive/cache replacements. |
|
@@ -24,7 +24,7 @@ enum ComparisonMode: Hashable {
|
||
| 24 | 24 |
@Observable |
| 25 | 25 |
final class SnapshotsViewModel {
|
| 26 | 26 |
var comparisonMode: ComparisonMode = .previous |
| 27 |
- var selectedBaseline: HealthSnapshot? |
|
| 27 |
+ var selectedBaselineObservationID: Int64? |
|
| 28 | 28 |
var archiveRows: [CachedArchiveObservationRow]? |
| 29 | 29 |
var archiveRowsError: String? |
| 30 | 30 |
|
@@ -41,49 +41,53 @@ final class SnapshotsViewModel {
|
||
| 41 | 41 |
} |
| 42 | 42 |
} |
| 43 | 43 |
|
| 44 |
- func baselines(for snapshots: [HealthSnapshot]) -> [UUID: HealthSnapshot] {
|
|
| 45 |
- let orderedDescending = snapshots.sorted { $0.timestamp > $1.timestamp }
|
|
| 44 |
+ func baselines(for rows: [CachedArchiveObservationRow]) -> [Int64: CachedArchiveObservationRow] {
|
|
| 45 |
+ let orderedDescending = rows.sorted { $0.observedAt > $1.observedAt }
|
|
| 46 | 46 |
|
| 47 |
- return snapshots.reduce(into: [UUID: HealthSnapshot]()) { partial, snapshot in
|
|
| 48 |
- partial[snapshot.id] = baseline( |
|
| 49 |
- for: snapshot, |
|
| 50 |
- in: snapshots, |
|
| 47 |
+ return rows.reduce(into: [Int64: CachedArchiveObservationRow]()) { partial, row in
|
|
| 48 |
+ partial[row.observationID] = baseline( |
|
| 49 |
+ for: row, |
|
| 50 |
+ in: rows, |
|
| 51 | 51 |
orderedDescending: orderedDescending |
| 52 | 52 |
) |
| 53 | 53 |
} |
| 54 | 54 |
} |
| 55 | 55 |
|
| 56 |
- func baseline(for snapshot: HealthSnapshot, in snapshots: [HealthSnapshot]) -> HealthSnapshot? {
|
|
| 56 |
+ func baseline( |
|
| 57 |
+ for row: CachedArchiveObservationRow, |
|
| 58 |
+ in rows: [CachedArchiveObservationRow] |
|
| 59 |
+ ) -> CachedArchiveObservationRow? {
|
|
| 57 | 60 |
baseline( |
| 58 |
- for: snapshot, |
|
| 59 |
- in: snapshots, |
|
| 60 |
- orderedDescending: snapshots.sorted { $0.timestamp > $1.timestamp }
|
|
| 61 |
+ for: row, |
|
| 62 |
+ in: rows, |
|
| 63 |
+ orderedDescending: rows.sorted { $0.observedAt > $1.observedAt }
|
|
| 61 | 64 |
) |
| 62 | 65 |
} |
| 63 | 66 |
|
| 64 | 67 |
private func baseline( |
| 65 |
- for snapshot: HealthSnapshot, |
|
| 66 |
- in snapshots: [HealthSnapshot], |
|
| 67 |
- orderedDescending: [HealthSnapshot] |
|
| 68 |
- ) -> HealthSnapshot? {
|
|
| 68 |
+ for row: CachedArchiveObservationRow, |
|
| 69 |
+ in rows: [CachedArchiveObservationRow], |
|
| 70 |
+ orderedDescending: [CachedArchiveObservationRow] |
|
| 71 |
+ ) -> CachedArchiveObservationRow? {
|
|
| 69 | 72 |
switch comparisonMode {
|
| 70 | 73 |
case .previous: |
| 71 |
- return orderedDescending.first { $0.timestamp < snapshot.timestamp }
|
|
| 74 |
+ return orderedDescending.first { $0.observedAt < row.observedAt }
|
|
| 72 | 75 |
case .selected: |
| 73 |
- return selectedBaseline |
|
| 76 |
+ guard let selectedBaselineObservationID else { return nil }
|
|
| 77 |
+ return rows.first { $0.observationID == selectedBaselineObservationID }
|
|
| 74 | 78 |
case .relativeTime(let interval): |
| 75 |
- let target = snapshot.timestamp.addingTimeInterval(-interval) |
|
| 76 |
- return snapshots |
|
| 77 |
- .filter { $0.timestamp <= target }
|
|
| 78 |
- .max { $0.timestamp < $1.timestamp }
|
|
| 79 |
+ let target = row.observedAt.addingTimeInterval(-interval) |
|
| 80 |
+ return rows |
|
| 81 |
+ .filter { $0.observedAt <= target }
|
|
| 82 |
+ .max { $0.observedAt < $1.observedAt }
|
|
| 79 | 83 |
} |
| 80 | 84 |
} |
| 81 | 85 |
|
| 82 |
- func toggleBaseline(_ snapshot: HealthSnapshot) {
|
|
| 83 |
- if selectedBaseline?.id == snapshot.id {
|
|
| 84 |
- selectedBaseline = nil |
|
| 86 |
+ func toggleBaseline(_ row: CachedArchiveObservationRow) {
|
|
| 87 |
+ if selectedBaselineObservationID == row.observationID {
|
|
| 88 |
+ selectedBaselineObservationID = nil |
|
| 85 | 89 |
} else {
|
| 86 |
- selectedBaseline = snapshot |
|
| 90 |
+ selectedBaselineObservationID = row.observationID |
|
| 87 | 91 |
comparisonMode = .selected |
| 88 | 92 |
} |
| 89 | 93 |
} |
@@ -0,0 +1,305 @@ |
||
| 1 |
+import SwiftUI |
|
| 2 |
+ |
|
| 3 |
+struct SnapshotArchiveDetailView: View {
|
|
| 4 |
+ let row: CachedArchiveObservationRow |
|
| 5 |
+ let baseline: CachedArchiveObservationRow? |
|
| 6 |
+ let timelineRows: [CachedArchiveObservationRow] |
|
| 7 |
+ |
|
| 8 |
+ @State private var typeRows: [SnapshotArchiveTypeSummaryRow] = [] |
|
| 9 |
+ @State private var loadError: String? |
|
| 10 |
+ |
|
| 11 |
+ private var timelineContexts: [DataTypeSnapshotContext] {
|
|
| 12 |
+ timelineRows |
|
| 13 |
+ .sorted { $0.observedAt > $1.observedAt }
|
|
| 14 |
+ .map {
|
|
| 15 |
+ DataTypeSnapshotContext( |
|
| 16 |
+ observationID: $0.observationID, |
|
| 17 |
+ observedAt: $0.observedAt |
|
| 18 |
+ ) |
|
| 19 |
+ } |
|
| 20 |
+ } |
|
| 21 |
+ |
|
| 22 |
+ private var currentContext: DataTypeSnapshotContext {
|
|
| 23 |
+ DataTypeSnapshotContext( |
|
| 24 |
+ observationID: row.observationID, |
|
| 25 |
+ observedAt: row.observedAt |
|
| 26 |
+ ) |
|
| 27 |
+ } |
|
| 28 |
+ |
|
| 29 |
+ private var baselineContext: DataTypeSnapshotContext? {
|
|
| 30 |
+ baseline.map {
|
|
| 31 |
+ DataTypeSnapshotContext( |
|
| 32 |
+ observationID: $0.observationID, |
|
| 33 |
+ observedAt: $0.observedAt |
|
| 34 |
+ ) |
|
| 35 |
+ } |
|
| 36 |
+ } |
|
| 37 |
+ |
|
| 38 |
+ private var dataRange: (earliest: Date?, latest: Date?) {
|
|
| 39 |
+ ( |
|
| 40 |
+ typeRows.compactMap(\.earliestStartDate).min(), |
|
| 41 |
+ typeRows.compactMap(\.latestEndDate).max() |
|
| 42 |
+ ) |
|
| 43 |
+ } |
|
| 44 |
+ |
|
| 45 |
+ private var taskID: String {
|
|
| 46 |
+ "\(baseline?.observationID ?? -1)|\(row.observationID)" |
|
| 47 |
+ } |
|
| 48 |
+ |
|
| 49 |
+ var body: some View {
|
|
| 50 |
+ List {
|
|
| 51 |
+ summarySection |
|
| 52 |
+ typeSection |
|
| 53 |
+ } |
|
| 54 |
+ .navigationTitle("Snapshot")
|
|
| 55 |
+ .navigationBarTitleDisplayMode(.inline) |
|
| 56 |
+ .task(id: taskID) {
|
|
| 57 |
+ await loadTypeRows() |
|
| 58 |
+ } |
|
| 59 |
+ } |
|
| 60 |
+ |
|
| 61 |
+ private var summarySection: some View {
|
|
| 62 |
+ Section {
|
|
| 63 |
+ DataTypeRangeIndicator( |
|
| 64 |
+ earliestDate: dataRange.earliest, |
|
| 65 |
+ latestDate: dataRange.latest, |
|
| 66 |
+ quality: .complete |
|
| 67 |
+ ) |
|
| 68 |
+ .listRowInsets(EdgeInsets()) |
|
| 69 |
+ .listRowBackground(Color.clear) |
|
| 70 |
+ |
|
| 71 |
+ SnapshotArchiveSummaryRow(label: "Metrics", value: "\(row.trackedTypeCount)") |
|
| 72 |
+ SnapshotArchiveSummaryRow(label: "Records", value: "\(row.visibleRecordCount)") |
|
| 73 |
+ |
|
| 74 |
+ if let baseline {
|
|
| 75 |
+ SnapshotArchiveSummaryRow( |
|
| 76 |
+ label: "Baseline", |
|
| 77 |
+ value: baseline.observedAt.formatted(.dateTime.month().day().hour().minute()) |
|
| 78 |
+ ) |
|
| 79 |
+ SnapshotArchiveSummaryRow( |
|
| 80 |
+ label: "Record Changes", |
|
| 81 |
+ value: "\(row.appearedCount + row.disappearedCount + row.representationChangedCount)" |
|
| 82 |
+ ) |
|
| 83 |
+ } |
|
| 84 |
+ |
|
| 85 |
+ if let loadError {
|
|
| 86 |
+ Label(loadError, systemImage: "exclamationmark.triangle.fill") |
|
| 87 |
+ .font(.caption) |
|
| 88 |
+ .foregroundStyle(Color.warningAmber) |
|
| 89 |
+ } |
|
| 90 |
+ } |
|
| 91 |
+ } |
|
| 92 |
+ |
|
| 93 |
+ private var typeSection: some View {
|
|
| 94 |
+ Section("Data Types") {
|
|
| 95 |
+ if typeRows.isEmpty {
|
|
| 96 |
+ Text("No data types are available for this observation.")
|
|
| 97 |
+ .foregroundStyle(.secondary) |
|
| 98 |
+ } else {
|
|
| 99 |
+ ForEach(typeRows) { typeRow in
|
|
| 100 |
+ if let baselineContext {
|
|
| 101 |
+ NavigationLink {
|
|
| 102 |
+ DataTypeArchiveDetailView( |
|
| 103 |
+ current: currentContext, |
|
| 104 |
+ baseline: baselineContext, |
|
| 105 |
+ timeline: timelineContexts, |
|
| 106 |
+ typeIdentifier: typeRow.typeIdentifier, |
|
| 107 |
+ displayName: typeRow.displayName, |
|
| 108 |
+ initialDiff: typeRow.typeDiff |
|
| 109 |
+ ) |
|
| 110 |
+ } label: {
|
|
| 111 |
+ SnapshotArchiveTypeSummaryRowView(row: typeRow, hasBaseline: true) |
|
| 112 |
+ } |
|
| 113 |
+ } else {
|
|
| 114 |
+ SnapshotArchiveTypeSummaryRowView(row: typeRow, hasBaseline: false) |
|
| 115 |
+ } |
|
| 116 |
+ } |
|
| 117 |
+ } |
|
| 118 |
+ } |
|
| 119 |
+ } |
|
| 120 |
+ |
|
| 121 |
+ @MainActor |
|
| 122 |
+ private func loadTypeRows() async {
|
|
| 123 |
+ do {
|
|
| 124 |
+ let cache = try CoreDataArchiveCacheStore() |
|
| 125 |
+ let currentSummaries = try cache.typeSummaries(observationID: row.observationID) |
|
| 126 |
+ let previousSummaries: [CachedArchiveTypeSummary] |
|
| 127 |
+ if let baseline {
|
|
| 128 |
+ previousSummaries = try cache.typeSummaries(observationID: baseline.observationID) |
|
| 129 |
+ } else {
|
|
| 130 |
+ previousSummaries = [] |
|
| 131 |
+ } |
|
| 132 |
+ let currentByType = Dictionary(uniqueKeysWithValues: currentSummaries.map { ($0.sampleTypeIdentifier, $0) })
|
|
| 133 |
+ let previousByType = Dictionary(uniqueKeysWithValues: previousSummaries.map { ($0.sampleTypeIdentifier, $0) })
|
|
| 134 |
+ let typeIdentifiers = Set(currentByType.keys).union(previousByType.keys) |
|
| 135 |
+ |
|
| 136 |
+ var rows: [SnapshotArchiveTypeSummaryRow] = [] |
|
| 137 |
+ rows.reserveCapacity(typeIdentifiers.count) |
|
| 138 |
+ |
|
| 139 |
+ for typeIdentifier in typeIdentifiers {
|
|
| 140 |
+ let summary = currentByType[typeIdentifier] |
|
| 141 |
+ let previousSummary = previousByType[typeIdentifier] |
|
| 142 |
+ let diffSummary: CachedArchiveDiffSummary? |
|
| 143 |
+ if let baseline {
|
|
| 144 |
+ diffSummary = try cache.diffSummary( |
|
| 145 |
+ fromObservationID: baseline.observationID, |
|
| 146 |
+ toObservationID: row.observationID, |
|
| 147 |
+ sampleTypeIdentifier: typeIdentifier |
|
| 148 |
+ ) |
|
| 149 |
+ } else {
|
|
| 150 |
+ diffSummary = nil |
|
| 151 |
+ } |
|
| 152 |
+ |
|
| 153 |
+ rows.append(SnapshotArchiveTypeSummaryRow( |
|
| 154 |
+ typeIdentifier: typeIdentifier, |
|
| 155 |
+ displayName: summary?.displayName ?? previousSummary?.displayName ?? typeIdentifier, |
|
| 156 |
+ currentCount: summary?.visibleRecordCount ?? 0, |
|
| 157 |
+ previousCount: previousSummary?.visibleRecordCount, |
|
| 158 |
+ appearedCount: diffSummary?.appearedCount ?? summary?.appearedCount ?? 0, |
|
| 159 |
+ disappearedCount: diffSummary?.disappearedCount ?? summary?.disappearedCount ?? 0, |
|
| 160 |
+ representationChangedCount: diffSummary?.representationChangedCount ?? summary?.representationChangedCount ?? 0, |
|
| 161 |
+ earliestStartDate: summary?.earliestStartDate ?? previousSummary?.earliestStartDate, |
|
| 162 |
+ latestEndDate: summary?.latestEndDate ?? previousSummary?.latestEndDate |
|
| 163 |
+ )) |
|
| 164 |
+ } |
|
| 165 |
+ |
|
| 166 |
+ typeRows = rows.sorted {
|
|
| 167 |
+ $0.displayName.localizedCompare($1.displayName) == .orderedAscending |
|
| 168 |
+ } |
|
| 169 |
+ loadError = nil |
|
| 170 |
+ } catch {
|
|
| 171 |
+ typeRows = [] |
|
| 172 |
+ loadError = error.localizedDescription |
|
| 173 |
+ } |
|
| 174 |
+ } |
|
| 175 |
+} |
|
| 176 |
+ |
|
| 177 |
+private struct SnapshotArchiveSummaryRow: View {
|
|
| 178 |
+ let label: String |
|
| 179 |
+ let value: String |
|
| 180 |
+ |
|
| 181 |
+ var body: some View {
|
|
| 182 |
+ HStack {
|
|
| 183 |
+ Text(label) |
|
| 184 |
+ Spacer() |
|
| 185 |
+ Text(value) |
|
| 186 |
+ .foregroundStyle(.secondary) |
|
| 187 |
+ .monospacedDigit() |
|
| 188 |
+ } |
|
| 189 |
+ } |
|
| 190 |
+} |
|
| 191 |
+ |
|
| 192 |
+private struct SnapshotArchiveTypeSummaryRow: Identifiable {
|
|
| 193 |
+ let typeIdentifier: String |
|
| 194 |
+ let displayName: String |
|
| 195 |
+ let currentCount: Int |
|
| 196 |
+ let previousCount: Int? |
|
| 197 |
+ let appearedCount: Int |
|
| 198 |
+ let disappearedCount: Int |
|
| 199 |
+ let representationChangedCount: Int |
|
| 200 |
+ let earliestStartDate: Date? |
|
| 201 |
+ let latestEndDate: Date? |
|
| 202 |
+ |
|
| 203 |
+ var id: String { typeIdentifier }
|
|
| 204 |
+ |
|
| 205 |
+ var currentDelta: Int {
|
|
| 206 |
+ guard let previousCount else { return currentCount }
|
|
| 207 |
+ return currentCount - previousCount |
|
| 208 |
+ } |
|
| 209 |
+ |
|
| 210 |
+ var recordChangeCount: Int {
|
|
| 211 |
+ appearedCount + disappearedCount + representationChangedCount |
|
| 212 |
+ } |
|
| 213 |
+ |
|
| 214 |
+ var hasChanges: Bool {
|
|
| 215 |
+ currentDelta != 0 || recordChangeCount > 0 |
|
| 216 |
+ } |
|
| 217 |
+ |
|
| 218 |
+ var typeDiff: TypeDiff {
|
|
| 219 |
+ TypeDiff( |
|
| 220 |
+ id: typeIdentifier, |
|
| 221 |
+ typeIdentifier: typeIdentifier, |
|
| 222 |
+ displayName: displayName, |
|
| 223 |
+ currentCount: currentCount, |
|
| 224 |
+ previousCount: previousCount ?? 0, |
|
| 225 |
+ previousTracked: previousCount != nil, |
|
| 226 |
+ appearedCount: appearedCount, |
|
| 227 |
+ disappearedCount: disappearedCount, |
|
| 228 |
+ representationChangedCount: representationChangedCount |
|
| 229 |
+ ) |
|
| 230 |
+ } |
|
| 231 |
+} |
|
| 232 |
+ |
|
| 233 |
+private struct SnapshotArchiveTypeSummaryRowView: View {
|
|
| 234 |
+ let row: SnapshotArchiveTypeSummaryRow |
|
| 235 |
+ let hasBaseline: Bool |
|
| 236 |
+ |
|
| 237 |
+ private var changeLabel: String {
|
|
| 238 |
+ guard hasBaseline else { return "Stored" }
|
|
| 239 |
+ if row.disappearedCount > 0 { return "\(row.disappearedCount) missing" }
|
|
| 240 |
+ if row.appearedCount > 0 { return "\(row.appearedCount) new" }
|
|
| 241 |
+ if row.representationChangedCount > 0 { return "\(row.representationChangedCount) changed" }
|
|
| 242 |
+ if row.currentDelta != 0 {
|
|
| 243 |
+ let prefix = row.currentDelta > 0 ? "+" : "" |
|
| 244 |
+ return "\(prefix)\(row.currentDelta) records" |
|
| 245 |
+ } |
|
| 246 |
+ return "No changes" |
|
| 247 |
+ } |
|
| 248 |
+ |
|
| 249 |
+ private var changeColor: Color {
|
|
| 250 |
+ guard hasBaseline else { return .secondary }
|
|
| 251 |
+ if row.disappearedCount > 0 { return .criticalRed }
|
|
| 252 |
+ if row.hasChanges { return .warningAmber }
|
|
| 253 |
+ return .secondary |
|
| 254 |
+ } |
|
| 255 |
+ |
|
| 256 |
+ var body: some View {
|
|
| 257 |
+ HStack(spacing: 12) {
|
|
| 258 |
+ VStack(alignment: .leading, spacing: 3) {
|
|
| 259 |
+ Text(row.displayName) |
|
| 260 |
+ .font(.subheadline) |
|
| 261 |
+ Text(row.typeIdentifier) |
|
| 262 |
+ .font(.caption2) |
|
| 263 |
+ .foregroundStyle(.secondary) |
|
| 264 |
+ .lineLimit(1) |
|
| 265 |
+ .truncationMode(.middle) |
|
| 266 |
+ } |
|
| 267 |
+ |
|
| 268 |
+ Spacer() |
|
| 269 |
+ |
|
| 270 |
+ VStack(alignment: .trailing, spacing: 4) {
|
|
| 271 |
+ Text("\(row.currentCount)")
|
|
| 272 |
+ .font(.subheadline.monospacedDigit()) |
|
| 273 |
+ .foregroundStyle(.primary) |
|
| 274 |
+ Text(changeLabel) |
|
| 275 |
+ .font(.caption.weight(.semibold)) |
|
| 276 |
+ .foregroundStyle(changeColor) |
|
| 277 |
+ } |
|
| 278 |
+ } |
|
| 279 |
+ .accessibilityElement(children: .combine) |
|
| 280 |
+ } |
|
| 281 |
+} |
|
| 282 |
+ |
|
| 283 |
+#Preview {
|
|
| 284 |
+ NavigationStack {
|
|
| 285 |
+ SnapshotArchiveDetailView( |
|
| 286 |
+ row: CachedArchiveObservationRow( |
|
| 287 |
+ observationID: 2, |
|
| 288 |
+ observedAt: .now, |
|
| 289 |
+ status: "completed", |
|
| 290 |
+ triggerReason: "manual", |
|
| 291 |
+ timeZoneIdentifier: nil, |
|
| 292 |
+ trackedTypeCount: 12, |
|
| 293 |
+ visibleRecordCount: 2000, |
|
| 294 |
+ appearedCount: 40, |
|
| 295 |
+ disappearedCount: 10, |
|
| 296 |
+ representationChangedCount: 3, |
|
| 297 |
+ archiveSchemaVersion: 2, |
|
| 298 |
+ cacheSchemaVersion: 1, |
|
| 299 |
+ computedAt: .now |
|
| 300 |
+ ), |
|
| 301 |
+ baseline: nil, |
|
| 302 |
+ timelineRows: [] |
|
| 303 |
+ ) |
|
| 304 |
+ } |
|
| 305 |
+} |
|
@@ -1,66 +1,27 @@ |
||
| 1 | 1 |
import SwiftUI |
| 2 |
-import SwiftData |
|
| 3 | 2 |
|
| 4 | 3 |
struct SnapshotsView: View {
|
| 5 |
- @Environment(\.modelContext) private var modelContext |
|
| 6 |
- @Query(sort: \HealthSnapshot.timestamp, order: .reverse) private var allSnapshots: [HealthSnapshot] |
|
| 7 | 4 |
@State private var viewModel = SnapshotsViewModel() |
| 8 |
- @State private var profileMap: [String: LocalDeviceProfile] = [:] |
|
| 9 | 5 |
|
| 10 |
- private var displayedSnapshots: [HealthSnapshot] {
|
|
| 11 |
- guard let deviceID = localDeviceID else { return [] }
|
|
| 12 |
- return allSnapshots.filter { $0.deviceID == deviceID }
|
|
| 6 |
+ private var archiveRows: [CachedArchiveObservationRow] {
|
|
| 7 |
+ viewModel.archiveRows ?? [] |
|
| 13 | 8 |
} |
| 14 | 9 |
|
| 15 | 10 |
private var hasTimelineRows: Bool {
|
| 16 |
- !(viewModel.archiveRows?.isEmpty ?? true) || !displayedSnapshots.isEmpty |
|
| 17 |
- } |
|
| 18 |
- |
|
| 19 |
- private var timelineReloadID: String {
|
|
| 20 |
- [ |
|
| 21 |
- String(allSnapshots.count), |
|
| 22 |
- allSnapshots.compactMap(\.archiveObservationID).map(String.init).joined(separator: ",") |
|
| 23 |
- ].joined(separator: "|") |
|
| 11 |
+ !archiveRows.isEmpty |
|
| 24 | 12 |
} |
| 25 | 13 |
|
| 26 | 14 |
private var snapshotItems: [SnapshotListItem] {
|
| 27 |
- let baselines = viewModel.baselines(for: displayedSnapshots) |
|
| 28 |
- |
|
| 29 |
- if let archiveRows = viewModel.archiveRows {
|
|
| 30 |
- let snapshotsByObservationID = Dictionary(uniqueKeysWithValues: displayedSnapshots.compactMap { snapshot in
|
|
| 31 |
- snapshot.archiveObservationID.map { ($0, snapshot) }
|
|
| 32 |
- }) |
|
| 33 |
- |
|
| 34 |
- return archiveRows.map { row in
|
|
| 35 |
- let snapshot = snapshotsByObservationID[row.observationID] |
|
| 36 |
- return SnapshotListItem( |
|
| 37 |
- snapshot: snapshot, |
|
| 38 |
- baseline: snapshot.flatMap { baselines[$0.id] },
|
|
| 39 |
- archiveRow: row, |
|
| 40 |
- showsDeltaSummary: viewModel.comparisonMode == .previous |
|
| 41 |
- ) |
|
| 42 |
- } |
|
| 43 |
- } |
|
| 44 |
- |
|
| 45 |
- return displayedSnapshots.map { snapshot in
|
|
| 15 |
+ let baselines = viewModel.baselines(for: archiveRows) |
|
| 16 |
+ return archiveRows.map { row in
|
|
| 46 | 17 |
SnapshotListItem( |
| 47 |
- snapshot: snapshot, |
|
| 48 |
- baseline: baselines[snapshot.id] ?? nil, |
|
| 49 |
- archiveRow: nil, |
|
| 18 |
+ archiveRow: row, |
|
| 19 |
+ baseline: baselines[row.observationID], |
|
| 50 | 20 |
showsDeltaSummary: viewModel.comparisonMode == .previous |
| 51 | 21 |
) |
| 52 | 22 |
} |
| 53 | 23 |
} |
| 54 | 24 |
|
| 55 |
- private var localDeviceID: String? {
|
|
| 56 |
- let currentID = AppSettings.currentDeviceID |
|
| 57 |
- if allSnapshots.contains(where: { $0.deviceID == currentID }) {
|
|
| 58 |
- return currentID |
|
| 59 |
- } |
|
| 60 |
- |
|
| 61 |
- return allSnapshots.first?.deviceID |
|
| 62 |
- } |
|
| 63 |
- |
|
| 64 | 25 |
var body: some View {
|
| 65 | 26 |
NavigationStack {
|
| 66 | 27 |
Group {
|
@@ -76,72 +37,45 @@ struct SnapshotsView: View {
|
||
| 76 | 37 |
} |
| 77 | 38 |
.navigationTitle("Snapshots")
|
| 78 | 39 |
.toolbar { toolbarContent }
|
| 79 |
- .task(id: timelineReloadID) {
|
|
| 80 |
- loadDeviceProfiles() |
|
| 40 |
+ .task {
|
|
| 81 | 41 |
await viewModel.loadArchiveRows() |
| 82 | 42 |
} |
| 83 | 43 |
} |
| 84 | 44 |
} |
| 85 | 45 |
|
| 86 |
- // MARK: - List |
|
| 87 |
- |
|
| 88 | 46 |
private var snapshotList: some View {
|
| 89 | 47 |
List(snapshotItems) { item in
|
| 90 |
- if let snapshot = item.snapshot {
|
|
| 91 |
- NavigationLink {
|
|
| 92 |
- SnapshotDetailView( |
|
| 93 |
- snapshot: snapshot, |
|
| 94 |
- baseline: item.baseline, |
|
| 95 |
- profile: profileMap[snapshot.deviceID] |
|
| 96 |
- ) |
|
| 97 |
- } label: {
|
|
| 98 |
- SnapshotRow( |
|
| 99 |
- snapshot: snapshot, |
|
| 100 |
- baseline: item.baseline, |
|
| 101 |
- archiveRow: item.archiveRow, |
|
| 102 |
- showsDeltaSummary: item.showsDeltaSummary, |
|
| 103 |
- isSelectedBaseline: viewModel.selectedBaseline?.id == snapshot.id, |
|
| 104 |
- profile: profileMap[snapshot.deviceID] |
|
| 105 |
- ) |
|
| 106 |
- } |
|
| 107 |
- .swipeActions(edge: .leading) {
|
|
| 108 |
- Button {
|
|
| 109 |
- viewModel.toggleBaseline(snapshot) |
|
| 110 |
- viewModel.comparisonMode = .selected |
|
| 111 |
- } label: {
|
|
| 112 |
- Label( |
|
| 113 |
- viewModel.selectedBaseline?.id == snapshot.id ? "Unset Baseline" : "Set as Baseline", |
|
| 114 |
- systemImage: viewModel.selectedBaseline?.id == snapshot.id ? "pin.slash" : "pin" |
|
| 115 |
- ) |
|
| 116 |
- } |
|
| 117 |
- .tint(.indigo) |
|
| 118 |
- } |
|
| 119 |
- .swipeActions(edge: .trailing) {
|
|
| 120 |
- Button(role: .destructive) {
|
|
| 121 |
- do {
|
|
| 122 |
- try SnapshotLifecycleService.delete(snapshot, context: modelContext) |
|
| 123 |
- } catch {
|
|
| 124 |
- // Keep the list responsive; delete failures can be retried. |
|
| 125 |
- } |
|
| 126 |
- } label: {
|
|
| 127 |
- Label("Delete", systemImage: "trash")
|
|
| 128 |
- } |
|
| 129 |
- } |
|
| 130 |
- } else {
|
|
| 131 |
- SnapshotRow( |
|
| 132 |
- snapshot: nil, |
|
| 48 |
+ NavigationLink {
|
|
| 49 |
+ SnapshotArchiveDetailView( |
|
| 50 |
+ row: item.archiveRow, |
|
| 133 | 51 |
baseline: item.baseline, |
| 52 |
+ timelineRows: archiveRows |
|
| 53 |
+ ) |
|
| 54 |
+ } label: {
|
|
| 55 |
+ SnapshotRow( |
|
| 134 | 56 |
archiveRow: item.archiveRow, |
| 135 | 57 |
showsDeltaSummary: item.showsDeltaSummary, |
| 136 |
- isSelectedBaseline: false, |
|
| 137 |
- profile: nil |
|
| 58 |
+ isSelectedBaseline: viewModel.selectedBaselineObservationID == item.archiveRow.observationID |
|
| 138 | 59 |
) |
| 139 | 60 |
} |
| 61 |
+ .swipeActions(edge: .leading) {
|
|
| 62 |
+ Button {
|
|
| 63 |
+ viewModel.toggleBaseline(item.archiveRow) |
|
| 64 |
+ viewModel.comparisonMode = .selected |
|
| 65 |
+ } label: {
|
|
| 66 |
+ Label( |
|
| 67 |
+ viewModel.selectedBaselineObservationID == item.archiveRow.observationID ? "Unset Baseline" : "Set as Baseline", |
|
| 68 |
+ systemImage: viewModel.selectedBaselineObservationID == item.archiveRow.observationID ? "pin.slash" : "pin" |
|
| 69 |
+ ) |
|
| 70 |
+ } |
|
| 71 |
+ .tint(.indigo) |
|
| 72 |
+ } |
|
| 73 |
+ } |
|
| 74 |
+ .refreshable {
|
|
| 75 |
+ await viewModel.loadArchiveRows() |
|
| 140 | 76 |
} |
| 141 | 77 |
} |
| 142 | 78 |
|
| 143 |
- // MARK: - Toolbar |
|
| 144 |
- |
|
| 145 | 79 |
@ToolbarContentBuilder |
| 146 | 80 |
private var toolbarContent: some ToolbarContent {
|
| 147 | 81 |
ToolbarItem(placement: .navigationBarTrailing) {
|
@@ -151,7 +85,7 @@ struct SnapshotsView: View {
|
||
| 151 | 85 |
ForEach(ComparisonMode.relativeOptions, id: \.interval) { opt in
|
| 152 | 86 |
Text(opt.label).tag(ComparisonMode.relativeTime(opt.interval)) |
| 153 | 87 |
} |
| 154 |
- if viewModel.selectedBaseline != nil {
|
|
| 88 |
+ if viewModel.selectedBaselineObservationID != nil {
|
|
| 155 | 89 |
Text("Selected Baseline").tag(ComparisonMode.selected)
|
| 156 | 90 |
} |
| 157 | 91 |
} |
@@ -161,129 +95,73 @@ struct SnapshotsView: View {
|
||
| 161 | 95 |
} |
| 162 | 96 |
} |
| 163 | 97 |
} |
| 164 |
- |
|
| 165 |
- private func loadDeviceProfiles() {
|
|
| 166 |
- let profiles = LocalDeviceProfileStore.allProfiles() |
|
| 167 |
- profileMap = Dictionary(uniqueKeysWithValues: profiles.compactMap {
|
|
| 168 |
- $0.deviceID.isEmpty ? nil : ($0.deviceID, $0) |
|
| 169 |
- }) |
|
| 170 |
- } |
|
| 171 | 98 |
} |
| 172 | 99 |
|
| 173 | 100 |
private struct SnapshotListItem: Identifiable {
|
| 174 |
- let snapshot: HealthSnapshot? |
|
| 175 |
- let baseline: HealthSnapshot? |
|
| 176 |
- let archiveRow: CachedArchiveObservationRow? |
|
| 101 |
+ let archiveRow: CachedArchiveObservationRow |
|
| 102 |
+ let baseline: CachedArchiveObservationRow? |
|
| 177 | 103 |
let showsDeltaSummary: Bool |
| 178 | 104 |
|
| 179 |
- var id: String {
|
|
| 180 |
- if let archiveRow {
|
|
| 181 |
- return "archive-\(archiveRow.observationID)" |
|
| 182 |
- } |
|
| 183 |
- return snapshot?.id.uuidString ?? "missing-snapshot-row" |
|
| 105 |
+ var id: Int64 {
|
|
| 106 |
+ archiveRow.observationID |
|
| 184 | 107 |
} |
| 185 | 108 |
} |
| 186 | 109 |
|
| 187 |
-// MARK: - Row |
|
| 188 |
- |
|
| 189 | 110 |
private struct SnapshotRow: View {
|
| 190 |
- let snapshot: HealthSnapshot? |
|
| 191 |
- let baseline: HealthSnapshot? |
|
| 192 |
- let archiveRow: CachedArchiveObservationRow? |
|
| 111 |
+ let archiveRow: CachedArchiveObservationRow |
|
| 193 | 112 |
let showsDeltaSummary: Bool |
| 194 | 113 |
let isSelectedBaseline: Bool |
| 195 |
- let profile: LocalDeviceProfile? |
|
| 196 | 114 |
|
| 197 | 115 |
private static let dateFormatter: DateFormatter = {
|
| 198 |
- let f = DateFormatter() |
|
| 199 |
- f.dateStyle = .medium |
|
| 200 |
- f.timeStyle = .short |
|
| 201 |
- return f |
|
| 116 |
+ let formatter = DateFormatter() |
|
| 117 |
+ formatter.dateStyle = .medium |
|
| 118 |
+ formatter.timeStyle = .short |
|
| 119 |
+ return formatter |
|
| 202 | 120 |
}() |
| 203 | 121 |
|
| 204 |
- private var observedAt: Date {
|
|
| 205 |
- archiveRow?.observedAt ?? snapshot?.timestamp ?? Date(timeIntervalSince1970: 0) |
|
| 206 |
- } |
|
| 207 |
- |
|
| 208 |
- private var deviceDisplayName: String {
|
|
| 209 |
- if let name = profile?.name, !name.isEmpty { return name }
|
|
| 210 |
- guard let snapshot else { return "Local archive" }
|
|
| 211 |
- return snapshot.deviceName.isEmpty ? "Unknown device" : snapshot.deviceName |
|
| 212 |
- } |
|
| 213 |
- |
|
| 214 |
- private var deviceColor: Color {
|
|
| 215 |
- DeviceColor(rawValue: profile?.colorTag ?? "")?.color ?? .neutralGray |
|
| 122 |
+ private var metricCountLabel: String {
|
|
| 123 |
+ archiveRow.trackedTypeCount == 1 |
|
| 124 |
+ ? "1 metric" |
|
| 125 |
+ : "\(archiveRow.trackedTypeCount) metrics" |
|
| 216 | 126 |
} |
| 217 | 127 |
|
| 218 |
- private var metricCountLabel: String? {
|
|
| 219 |
- if let archiveRow {
|
|
| 220 |
- return archiveRow.trackedTypeCount == 1 |
|
| 221 |
- ? "1 metric" |
|
| 222 |
- : "\(archiveRow.trackedTypeCount) metrics" |
|
| 223 |
- } |
|
| 224 |
- |
|
| 225 |
- guard let snapshot else { return nil }
|
|
| 226 |
- guard snapshot.hasCurrentCachedSummary else { return nil }
|
|
| 227 |
- return snapshot.cachedTypeCount == 1 ? "1 metric" : "\(snapshot.cachedTypeCount) metrics" |
|
| 228 |
- } |
|
| 229 |
- |
|
| 230 |
- private var recordCountLabel: String? {
|
|
| 231 |
- guard let archiveRow else { return nil }
|
|
| 232 |
- return archiveRow.visibleRecordCount == 1 |
|
| 128 |
+ private var recordCountLabel: String {
|
|
| 129 |
+ archiveRow.visibleRecordCount == 1 |
|
| 233 | 130 |
? "1 record" |
| 234 | 131 |
: "\(archiveRow.visibleRecordCount) records" |
| 235 | 132 |
} |
| 236 | 133 |
|
| 237 |
- private var deltaSummaryText: String? {
|
|
| 238 |
- if let archiveRow {
|
|
| 239 |
- let appeared = archiveRow.appearedCount |
|
| 240 |
- let disappeared = archiveRow.disappearedCount |
|
| 241 |
- let changed = archiveRow.representationChangedCount |
|
| 242 |
- let total = appeared + disappeared + changed |
|
| 243 |
- guard total > 0 else { return "No record changes" }
|
|
| 244 |
- |
|
| 245 |
- var parts: [String] = [] |
|
| 246 |
- if appeared > 0 { parts.append("\(appeared) new") }
|
|
| 247 |
- if disappeared > 0 { parts.append("\(disappeared) missing") }
|
|
| 248 |
- if changed > 0 { parts.append("\(changed) changed") }
|
|
| 249 |
- return parts.joined(separator: " • ") |
|
| 250 |
- } |
|
| 134 |
+ private var deltaSummaryText: String {
|
|
| 135 |
+ let appeared = archiveRow.appearedCount |
|
| 136 |
+ let disappeared = archiveRow.disappearedCount |
|
| 137 |
+ let changed = archiveRow.representationChangedCount |
|
| 138 |
+ let total = appeared + disappeared + changed |
|
| 139 |
+ guard total > 0 else { return "No record changes" }
|
|
| 251 | 140 |
|
| 252 |
- return nil |
|
| 141 |
+ var parts: [String] = [] |
|
| 142 |
+ if appeared > 0 { parts.append("\(appeared) new") }
|
|
| 143 |
+ if disappeared > 0 { parts.append("\(disappeared) missing") }
|
|
| 144 |
+ if changed > 0 { parts.append("\(changed) changed") }
|
|
| 145 |
+ return parts.joined(separator: " • ") |
|
| 253 | 146 |
} |
| 254 | 147 |
|
| 255 | 148 |
private var deltaSummaryColor: Color {
|
| 256 |
- if let archiveRow {
|
|
| 257 |
- if archiveRow.disappearedCount > 0 { return Color.criticalRed }
|
|
| 258 |
- if archiveRow.appearedCount + archiveRow.representationChangedCount > 0 { return Color.warningAmber }
|
|
| 259 |
- return Color.healthyGreen |
|
| 260 |
- } |
|
| 261 |
- |
|
| 262 |
- return .secondary |
|
| 149 |
+ if archiveRow.disappearedCount > 0 { return Color.criticalRed }
|
|
| 150 |
+ if archiveRow.appearedCount + archiveRow.representationChangedCount > 0 { return Color.warningAmber }
|
|
| 151 |
+ return Color.healthyGreen |
|
| 263 | 152 |
} |
| 264 | 153 |
|
| 265 | 154 |
private var deltaSummaryIconName: String {
|
| 266 |
- if let archiveRow {
|
|
| 267 |
- let hasChanges = archiveRow.appearedCount |
|
| 268 |
- + archiveRow.disappearedCount |
|
| 269 |
- + archiveRow.representationChangedCount > 0 |
|
| 270 |
- return hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle" |
|
| 271 |
- } |
|
| 272 |
- |
|
| 273 |
- return "checkmark.circle" |
|
| 274 |
- } |
|
| 275 |
- |
|
| 276 |
- private var hasOSVersionChange: Bool {
|
|
| 277 |
- guard let snapshot, let baseline else { return false }
|
|
| 278 |
- let currentVersion = snapshot.osVersion.trimmingCharacters(in: .whitespacesAndNewlines) |
|
| 279 |
- let baselineVersion = baseline.osVersion.trimmingCharacters(in: .whitespacesAndNewlines) |
|
| 280 |
- return !currentVersion.isEmpty && !baselineVersion.isEmpty && currentVersion != baselineVersion |
|
| 155 |
+ let hasChanges = archiveRow.appearedCount |
|
| 156 |
+ + archiveRow.disappearedCount |
|
| 157 |
+ + archiveRow.representationChangedCount > 0 |
|
| 158 |
+ return hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle" |
|
| 281 | 159 |
} |
| 282 | 160 |
|
| 283 | 161 |
var body: some View {
|
| 284 | 162 |
VStack(alignment: .leading, spacing: 4) {
|
| 285 | 163 |
HStack {
|
| 286 |
- Text(Self.dateFormatter.string(from: observedAt)) |
|
| 164 |
+ Text(Self.dateFormatter.string(from: archiveRow.observedAt)) |
|
| 287 | 165 |
.font(.subheadline.weight(.semibold)) |
| 288 | 166 |
Spacer() |
| 289 | 167 |
if isSelectedBaseline {
|
@@ -296,40 +174,24 @@ private struct SnapshotRow: View {
|
||
| 296 | 174 |
|
| 297 | 175 |
HStack(spacing: 6) {
|
| 298 | 176 |
Circle() |
| 299 |
- .fill(deviceColor) |
|
| 177 |
+ .fill(Color.neutralGray) |
|
| 300 | 178 |
.frame(width: 8, height: 8) |
| 301 |
- Text(deviceDisplayName) |
|
| 179 |
+ Text("Local archive")
|
|
| 302 | 180 |
.font(.caption) |
| 303 | 181 |
.foregroundStyle(.secondary) |
| 304 |
- if let metricCountLabel {
|
|
| 305 |
- Label(metricCountLabel, systemImage: "list.bullet.rectangle") |
|
| 306 |
- .font(.caption) |
|
| 307 |
- .foregroundStyle(.secondary) |
|
| 308 |
- } |
|
| 309 |
- if let recordCountLabel {
|
|
| 310 |
- Label(recordCountLabel, systemImage: "doc.text.magnifyingglass") |
|
| 311 |
- .font(.caption) |
|
| 312 |
- .foregroundStyle(.secondary) |
|
| 313 |
- } |
|
| 314 |
- if hasOSVersionChange {
|
|
| 315 |
- Label("OS \(snapshot?.osVersion ?? "")", systemImage: "gearshape.fill")
|
|
| 316 |
- .font(.caption) |
|
| 317 |
- .foregroundStyle(Color.warningAmber) |
|
| 318 |
- .accessibilityLabel("OS version changed to \(snapshot?.osVersion ?? "")")
|
|
| 319 |
- } |
|
| 320 |
- } |
|
| 321 |
- |
|
| 322 |
- // Chain indicators |
|
| 323 |
- chainIndicators |
|
| 324 |
- |
|
| 325 |
- if let snapshot, snapshot.snapshotQuality != SnapshotQuality.complete {
|
|
| 326 |
- Label("Incomplete snapshot", systemImage: "exclamationmark.triangle")
|
|
| 182 |
+ Label(metricCountLabel, systemImage: "list.bullet.rectangle") |
|
| 327 | 183 |
.font(.caption) |
| 328 |
- .foregroundStyle(Color.warningAmber) |
|
| 184 |
+ .foregroundStyle(.secondary) |
|
| 185 |
+ Label(recordCountLabel, systemImage: "doc.text.magnifyingglass") |
|
| 186 |
+ .font(.caption) |
|
| 187 |
+ .foregroundStyle(.secondary) |
|
| 329 | 188 |
} |
| 330 | 189 |
|
| 331 |
- if showsDeltaSummary, |
|
| 332 |
- let deltaSummaryText {
|
|
| 190 |
+ Label("Archive observation \(archiveRow.observationID)", systemImage: "externaldrive")
|
|
| 191 |
+ .font(.caption) |
|
| 192 |
+ .foregroundStyle(.secondary) |
|
| 193 |
+ |
|
| 194 |
+ if showsDeltaSummary {
|
|
| 333 | 195 |
HStack(spacing: 4) {
|
| 334 | 196 |
Image(systemName: deltaSummaryIconName) |
| 335 | 197 |
Text(deltaSummaryText) |
@@ -341,45 +203,9 @@ private struct SnapshotRow: View {
|
||
| 341 | 203 |
.padding(.vertical, 2) |
| 342 | 204 |
.accessibilityElement(children: .combine) |
| 343 | 205 |
} |
| 344 |
- |
|
| 345 |
- @ViewBuilder |
|
| 346 |
- private var chainIndicators: some View {
|
|
| 347 |
- if let archiveRow, snapshot == nil {
|
|
| 348 |
- Label("Archive observation \(archiveRow.observationID)", systemImage: "externaldrive")
|
|
| 349 |
- .font(.caption) |
|
| 350 |
- .foregroundStyle(.secondary) |
|
| 351 |
- } |
|
| 352 |
- |
|
| 353 |
- if let snapshot {
|
|
| 354 |
- if snapshot.isChainStart && snapshot.recoveredDeviceID {
|
|
| 355 |
- Label("DB reset / recovered device ID", systemImage: "arrow.clockwise.icloud")
|
|
| 356 |
- .font(.caption) |
|
| 357 |
- .foregroundStyle(.secondary) |
|
| 358 |
- } else if snapshot.isChainStart {
|
|
| 359 |
- Label("Chain start", systemImage: "link.badge.plus")
|
|
| 360 |
- .font(.caption) |
|
| 361 |
- .foregroundStyle(.secondary) |
|
| 362 |
- } |
|
| 363 |
- if snapshot.isPostRestore && !snapshot.isPostRestoreInferred {
|
|
| 364 |
- Label("Post-restore baseline", systemImage: "clock.arrow.circlepath")
|
|
| 365 |
- .font(.caption) |
|
| 366 |
- .foregroundStyle(.secondary) |
|
| 367 |
- } else if snapshot.isPostRestore && snapshot.isPostRestoreInferred {
|
|
| 368 |
- Label("Post-restore baseline (inferred)", systemImage: "clock.arrow.circlepath")
|
|
| 369 |
- .font(.caption) |
|
| 370 |
- .foregroundStyle(.secondary) |
|
| 371 |
- } |
|
| 372 |
- if snapshot.triggerReason == "observerCallback" {
|
|
| 373 |
- Label("Observer-triggered snapshot", systemImage: "waveform")
|
|
| 374 |
- .font(.caption) |
|
| 375 |
- .foregroundStyle(.secondary) |
|
| 376 |
- } |
|
| 377 |
- } |
|
| 378 |
- } |
|
| 379 | 206 |
} |
| 380 | 207 |
|
| 381 | 208 |
#Preview {
|
| 382 | 209 |
SnapshotsView() |
| 383 |
- .modelContainer(for: [HealthSnapshot.self, TypeCount.self, HealthRecord.self, TypeDistributionBin.self], inMemory: true) |
|
| 384 | 210 |
.environment(AppSettings()) |
| 385 | 211 |
} |