@@ -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, 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 | |
|
| 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 | |
|
| 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 | |
@@ -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 19 SwiftData-backed files for launch container, capture review actions, navigation handles, some transition detail paths, and PDF paths. |
|
| 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. |
|
| 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. |
|
| 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. |
@@ -230,6 +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 Types root reads Core Data cached observation rows directly and no longer imports SwiftData. |
|
| 233 | 234 |
- [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 | 235 |
- [x] Remove unused legacy Type Evolution timeline and direct `HealthSnapshot.typeCounts` diff helper. |
| 235 | 236 |
- [x] Data type new/missing drill-down pages through SQLite `diffRecords` when archive observation ids exist. |
@@ -9,9 +9,9 @@ local settings stored outside SwiftData where needed. |
||
| 9 | 9 |
|
| 10 | 10 |
## Current Count |
| 11 | 11 |
|
| 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. |
|
| 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. |
|
| 15 | 15 |
|
| 16 | 16 |
## Launch Container |
| 17 | 17 |
|
@@ -68,7 +68,6 @@ types: |
||
| 68 | 68 |
|
| 69 | 69 |
- `HealthProbe/ViewModels/DashboardViewModel.swift` |
| 70 | 70 |
- `HealthProbe/Views/Dashboard/DashboardView.swift` |
| 71 |
-- `HealthProbe/Views/DataTypes/DataTypesView.swift` |
|
| 72 | 71 |
- `HealthProbe/Views/Snapshots/DataTypeSnapshotDetailView.swift` |
| 73 | 72 |
- `HealthProbe/Views/Snapshots/SnapshotDetailView.swift` |
| 74 | 73 |
- `HealthProbe/Views/Snapshots/SnapshotsView.swift` |
@@ -124,6 +123,10 @@ The following SwiftData dependencies were removed from active flows: |
||
| 124 | 123 |
small observation contexts and builds rows from Core Data cache + SQLite |
| 125 | 124 |
archive diff APIs. It no longer falls back to legacy direct SwiftData |
| 126 | 125 |
`TypeCount` relationship traversal. |
| 126 |
+- `HealthProbe/Views/DataTypes/DataTypesView.swift` no longer imports |
|
| 127 |
+ SwiftData or queries `HealthSnapshot`; it loads Core Data cached observation |
|
| 128 |
+ rows and opens `DataTypeArchiveDetailView`, an archive/cache-only detail view |
|
| 129 |
+ with paged SQLite new/missing record drill-down. |
|
| 127 | 130 |
- `HealthProbe/Views/Snapshots/SnapshotDetailView.swift` no longer queries |
| 128 | 131 |
`SnapshotDelta`/`TypeDelta` or carries the old SwiftData type-delta/chart |
| 129 | 132 |
fallback. Snapshot detail type rows now require archive/cache summaries; the |
@@ -1,18 +1,30 @@ |
||
| 1 | 1 |
import Foundation |
| 2 | 2 |
|
| 3 | 3 |
struct DataTypeSnapshotContext: Equatable, Sendable {
|
| 4 |
- let id: UUID |
|
| 4 |
+ let observationID: Int64 |
|
| 5 | 5 |
let observedAt: Date |
| 6 |
- let archiveObservationID: Int64? |
|
| 7 | 6 |
} |
| 8 | 7 |
|
| 9 | 8 |
@Observable |
| 10 | 9 |
final class DataTypesViewModel {
|
| 11 | 10 |
var filter: DiffFilter = .all |
| 12 | 11 |
var comparisonMode: ComparisonMode = .previous |
| 12 |
+ var observationRows: [CachedArchiveObservationRow] = [] |
|
| 13 |
+ var observationRowsError: String? |
|
| 13 | 14 |
var archiveDiffs: [TypeDiff]? |
| 14 | 15 |
var archiveDiffError: String? |
| 15 | 16 |
|
| 17 |
+ var observationContexts: [DataTypeSnapshotContext] {
|
|
| 18 |
+ observationRows |
|
| 19 |
+ .sorted { $0.observedAt > $1.observedAt }
|
|
| 20 |
+ .map {
|
|
| 21 |
+ DataTypeSnapshotContext( |
|
| 22 |
+ observationID: $0.observationID, |
|
| 23 |
+ observedAt: $0.observedAt |
|
| 24 |
+ ) |
|
| 25 |
+ } |
|
| 26 |
+ } |
|
| 27 |
+ |
|
| 16 | 28 |
func diffs() -> [TypeDiff] {
|
| 17 | 29 |
guard let archiveDiffs else { return [] }
|
| 18 | 30 |
return apply(filter: filter, to: archiveDiffs) |
@@ -27,20 +39,30 @@ final class DataTypesViewModel {
|
||
| 27 | 39 |
archiveDiffError = nil |
| 28 | 40 |
} |
| 29 | 41 |
|
| 42 |
+ @MainActor |
|
| 43 |
+ func loadArchiveRows(limit: Int = 200) async {
|
|
| 44 |
+ do {
|
|
| 45 |
+ let cache = try CoreDataArchiveCacheStore() |
|
| 46 |
+ observationRows = try cache.observationRows(limit: limit) |
|
| 47 |
+ observationRowsError = nil |
|
| 48 |
+ } catch {
|
|
| 49 |
+ observationRows = [] |
|
| 50 |
+ observationRowsError = error.localizedDescription |
|
| 51 |
+ } |
|
| 52 |
+ } |
|
| 53 |
+ |
|
| 30 | 54 |
@MainActor |
| 31 | 55 |
func loadArchiveDiffs(current: DataTypeSnapshotContext?, snapshots: [DataTypeSnapshotContext]) async {
|
| 32 | 56 |
guard let current, |
| 33 |
- let baseline = resolveBaseline(for: current, in: snapshots), |
|
| 34 |
- let currentObservationID = current.archiveObservationID, |
|
| 35 |
- let baselineObservationID = baseline.archiveObservationID else {
|
|
| 57 |
+ let baseline = resolveBaseline(for: current, in: snapshots) else {
|
|
| 36 | 58 |
clearArchiveDiffs() |
| 37 | 59 |
return |
| 38 | 60 |
} |
| 39 | 61 |
|
| 40 | 62 |
do {
|
| 41 | 63 |
let cache = try CoreDataArchiveCacheStore() |
| 42 |
- let currentSummaries = try cache.typeSummaries(observationID: currentObservationID) |
|
| 43 |
- let baselineSummaries = try cache.typeSummaries(observationID: baselineObservationID) |
|
| 64 |
+ let currentSummaries = try cache.typeSummaries(observationID: current.observationID) |
|
| 65 |
+ let baselineSummaries = try cache.typeSummaries(observationID: baseline.observationID) |
|
| 44 | 66 |
let currentByType = Dictionary(uniqueKeysWithValues: currentSummaries.map { ($0.sampleTypeIdentifier, $0) })
|
| 45 | 67 |
let baselineByType = Dictionary(uniqueKeysWithValues: baselineSummaries.map { ($0.sampleTypeIdentifier, $0) })
|
| 46 | 68 |
let allTypeIdentifiers = Set(currentByType.keys).union(baselineByType.keys) |
@@ -52,8 +74,8 @@ final class DataTypesViewModel {
|
||
| 52 | 74 |
let summary = currentByType[typeIdentifier] |
| 53 | 75 |
let baselineSummary = baselineByType[typeIdentifier] |
| 54 | 76 |
let diff = try await SQLiteHealthArchiveStore.shared.diffSummary(HealthArchiveDiffRequest( |
| 55 |
- fromObservationID: baselineObservationID, |
|
| 56 |
- toObservationID: currentObservationID, |
|
| 77 |
+ fromObservationID: baseline.observationID, |
|
| 78 |
+ toObservationID: current.observationID, |
|
| 57 | 79 |
sampleTypeIdentifier: typeIdentifier |
| 58 | 80 |
)) |
| 59 | 81 |
archiveRows.append(TypeDiff( |
@@ -0,0 +1,237 @@ |
||
| 1 |
+import SwiftUI |
|
| 2 |
+ |
|
| 3 |
+struct DataTypeArchiveDetailView: View {
|
|
| 4 |
+ let current: DataTypeSnapshotContext |
|
| 5 |
+ let baseline: DataTypeSnapshotContext |
|
| 6 |
+ let timeline: [DataTypeSnapshotContext] |
|
| 7 |
+ let typeIdentifier: String |
|
| 8 |
+ let displayName: String |
|
| 9 |
+ let initialDiff: TypeDiff |
|
| 10 |
+ |
|
| 11 |
+ @State private var currentSummary: CachedArchiveTypeSummary? |
|
| 12 |
+ @State private var baselineSummary: CachedArchiveTypeSummary? |
|
| 13 |
+ @State private var diff: TypeDiff |
|
| 14 |
+ @State private var loadError: String? |
|
| 15 |
+ @State private var showAddedRecords = false |
|
| 16 |
+ @State private var showDisappearedRecords = false |
|
| 17 |
+ |
|
| 18 |
+ private var taskID: String {
|
|
| 19 |
+ "\(baseline.observationID)|\(current.observationID)|\(typeIdentifier)" |
|
| 20 |
+ } |
|
| 21 |
+ |
|
| 22 |
+ private var currentCount: Int {
|
|
| 23 |
+ currentSummary?.visibleRecordCount ?? diff.currentCount |
|
| 24 |
+ } |
|
| 25 |
+ |
|
| 26 |
+ private var baselineCount: Int {
|
|
| 27 |
+ baselineSummary?.visibleRecordCount ?? diff.previousCount |
|
| 28 |
+ } |
|
| 29 |
+ |
|
| 30 |
+ private var evolutionSnapshots: [RecordChangeEvolutionSnapshot] {
|
|
| 31 |
+ let sorted = timeline.sorted { $0.observedAt < $1.observedAt }
|
|
| 32 |
+ return sorted.enumerated().map { index, context in
|
|
| 33 |
+ RecordChangeEvolutionSnapshot( |
|
| 34 |
+ id: context.chartID, |
|
| 35 |
+ timestamp: context.observedAt, |
|
| 36 |
+ localSequenceNumber: index, |
|
| 37 |
+ previousSnapshotID: index > 0 ? sorted[index - 1].chartID : nil, |
|
| 38 |
+ archiveObservationID: context.observationID, |
|
| 39 |
+ count: context.observationID == current.observationID ? currentCount : 0 |
|
| 40 |
+ ) |
|
| 41 |
+ } |
|
| 42 |
+ } |
|
| 43 |
+ |
|
| 44 |
+ init( |
|
| 45 |
+ current: DataTypeSnapshotContext, |
|
| 46 |
+ baseline: DataTypeSnapshotContext, |
|
| 47 |
+ timeline: [DataTypeSnapshotContext], |
|
| 48 |
+ typeIdentifier: String, |
|
| 49 |
+ displayName: String, |
|
| 50 |
+ initialDiff: TypeDiff |
|
| 51 |
+ ) {
|
|
| 52 |
+ self.current = current |
|
| 53 |
+ self.baseline = baseline |
|
| 54 |
+ self.timeline = timeline |
|
| 55 |
+ self.typeIdentifier = typeIdentifier |
|
| 56 |
+ self.displayName = displayName |
|
| 57 |
+ self.initialDiff = initialDiff |
|
| 58 |
+ _diff = State(initialValue: initialDiff) |
|
| 59 |
+ } |
|
| 60 |
+ |
|
| 61 |
+ var body: some View {
|
|
| 62 |
+ ScrollView {
|
|
| 63 |
+ VStack(spacing: 16) {
|
|
| 64 |
+ DataTypeRangeIndicator( |
|
| 65 |
+ earliestDate: currentSummary?.earliestStartDate, |
|
| 66 |
+ latestDate: currentSummary?.latestEndDate, |
|
| 67 |
+ quality: .complete |
|
| 68 |
+ ) |
|
| 69 |
+ |
|
| 70 |
+ RecordChangeComparisonCard( |
|
| 71 |
+ displayName: displayName, |
|
| 72 |
+ currentCount: currentCount, |
|
| 73 |
+ previousCount: baselineSummary == nil && !diff.previousTracked ? nil : baselineCount, |
|
| 74 |
+ addedCount: diff.appearedCount, |
|
| 75 |
+ disappearedCount: diff.disappearedCount, |
|
| 76 |
+ isCurrentValid: currentCount >= 0, |
|
| 77 |
+ isPreviousTracked: baselineSummary != nil || diff.previousTracked, |
|
| 78 |
+ onAddedTap: {
|
|
| 79 |
+ if diff.appearedCount > 0 {
|
|
| 80 |
+ showAddedRecords = true |
|
| 81 |
+ } |
|
| 82 |
+ }, |
|
| 83 |
+ onDisappearedTap: {
|
|
| 84 |
+ if diff.disappearedCount > 0 {
|
|
| 85 |
+ showDisappearedRecords = true |
|
| 86 |
+ } |
|
| 87 |
+ } |
|
| 88 |
+ ) |
|
| 89 |
+ |
|
| 90 |
+ RecordChangeEvolutionChart( |
|
| 91 |
+ snapshots: evolutionSnapshots, |
|
| 92 |
+ currentSnapshotID: current.chartID, |
|
| 93 |
+ typeIdentifier: typeIdentifier, |
|
| 94 |
+ displayName: displayName |
|
| 95 |
+ ) |
|
| 96 |
+ |
|
| 97 |
+ if let loadError {
|
|
| 98 |
+ Label(loadError, systemImage: "exclamationmark.triangle.fill") |
|
| 99 |
+ .font(.caption) |
|
| 100 |
+ .foregroundStyle(Color.warningAmber) |
|
| 101 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 102 |
+ } |
|
| 103 |
+ } |
|
| 104 |
+ .padding(16) |
|
| 105 |
+ } |
|
| 106 |
+ .navigationTitle(displayName) |
|
| 107 |
+ .navigationBarTitleDisplayMode(.inline) |
|
| 108 |
+ .task(id: taskID) {
|
|
| 109 |
+ await loadArchiveDetail() |
|
| 110 |
+ } |
|
| 111 |
+ .navigationDestination(isPresented: $showAddedRecords) {
|
|
| 112 |
+ DataTypeRecordListView( |
|
| 113 |
+ title: "New Records", |
|
| 114 |
+ displayName: displayName, |
|
| 115 |
+ totalCount: diff.appearedCount, |
|
| 116 |
+ mode: .addedDiff( |
|
| 117 |
+ typeIdentifier: typeIdentifier, |
|
| 118 |
+ afterDate: baseline.observedAt, |
|
| 119 |
+ beforeDate: current.observedAt, |
|
| 120 |
+ fromObservationID: baseline.observationID, |
|
| 121 |
+ toObservationID: current.observationID |
|
| 122 |
+ ), |
|
| 123 |
+ tint: Color.healthyGreen |
|
| 124 |
+ ) |
|
| 125 |
+ } |
|
| 126 |
+ .navigationDestination(isPresented: $showDisappearedRecords) {
|
|
| 127 |
+ DataTypeRecordListView( |
|
| 128 |
+ title: "Missing Records", |
|
| 129 |
+ displayName: displayName, |
|
| 130 |
+ totalCount: diff.disappearedCount, |
|
| 131 |
+ mode: .disappearedDiff( |
|
| 132 |
+ typeIdentifier: typeIdentifier, |
|
| 133 |
+ fromObservationID: baseline.observationID, |
|
| 134 |
+ toObservationID: current.observationID |
|
| 135 |
+ ), |
|
| 136 |
+ tint: Color.criticalRed |
|
| 137 |
+ ) |
|
| 138 |
+ } |
|
| 139 |
+ } |
|
| 140 |
+ |
|
| 141 |
+ @MainActor |
|
| 142 |
+ private func loadArchiveDetail() async {
|
|
| 143 |
+ do {
|
|
| 144 |
+ let cache = try CoreDataArchiveCacheStore() |
|
| 145 |
+ currentSummary = try cache.typeSummaries(observationID: current.observationID) |
|
| 146 |
+ .first { $0.sampleTypeIdentifier == typeIdentifier }
|
|
| 147 |
+ baselineSummary = try cache.typeSummaries(observationID: baseline.observationID) |
|
| 148 |
+ .first { $0.sampleTypeIdentifier == typeIdentifier }
|
|
| 149 |
+ |
|
| 150 |
+ if let cachedDiff = try cache.diffSummary( |
|
| 151 |
+ fromObservationID: baseline.observationID, |
|
| 152 |
+ toObservationID: current.observationID, |
|
| 153 |
+ sampleTypeIdentifier: typeIdentifier |
|
| 154 |
+ ) {
|
|
| 155 |
+ diff = TypeDiff( |
|
| 156 |
+ id: typeIdentifier, |
|
| 157 |
+ typeIdentifier: typeIdentifier, |
|
| 158 |
+ displayName: currentSummary?.displayName ?? baselineSummary?.displayName ?? displayName, |
|
| 159 |
+ currentCount: currentSummary?.visibleRecordCount ?? 0, |
|
| 160 |
+ previousCount: baselineSummary?.visibleRecordCount ?? 0, |
|
| 161 |
+ previousTracked: baselineSummary != nil, |
|
| 162 |
+ appearedCount: cachedDiff.appearedCount, |
|
| 163 |
+ disappearedCount: cachedDiff.disappearedCount, |
|
| 164 |
+ representationChangedCount: cachedDiff.representationChangedCount |
|
| 165 |
+ ) |
|
| 166 |
+ loadError = nil |
|
| 167 |
+ return |
|
| 168 |
+ } |
|
| 169 |
+ |
|
| 170 |
+ let archiveDiff = try await SQLiteHealthArchiveStore.shared.diffSummary(HealthArchiveDiffRequest( |
|
| 171 |
+ fromObservationID: baseline.observationID, |
|
| 172 |
+ toObservationID: current.observationID, |
|
| 173 |
+ sampleTypeIdentifier: typeIdentifier |
|
| 174 |
+ )) |
|
| 175 |
+ diff = TypeDiff( |
|
| 176 |
+ id: typeIdentifier, |
|
| 177 |
+ typeIdentifier: typeIdentifier, |
|
| 178 |
+ displayName: currentSummary?.displayName ?? baselineSummary?.displayName ?? displayName, |
|
| 179 |
+ currentCount: currentSummary?.visibleRecordCount ?? 0, |
|
| 180 |
+ previousCount: baselineSummary?.visibleRecordCount ?? 0, |
|
| 181 |
+ previousTracked: baselineSummary != nil, |
|
| 182 |
+ appearedCount: archiveDiff.appearedCount, |
|
| 183 |
+ disappearedCount: archiveDiff.disappearedCount, |
|
| 184 |
+ representationChangedCount: archiveDiff.representationChangedCount |
|
| 185 |
+ ) |
|
| 186 |
+ loadError = nil |
|
| 187 |
+ } catch {
|
|
| 188 |
+ loadError = error.localizedDescription |
|
| 189 |
+ } |
|
| 190 |
+ } |
|
| 191 |
+} |
|
| 192 |
+ |
|
| 193 |
+private extension DataTypeSnapshotContext {
|
|
| 194 |
+ var chartID: UUID {
|
|
| 195 |
+ let masked = UInt64(bitPattern: observationID) & 0xffff_ffff_ffff |
|
| 196 |
+ let suffix = String(format: "%012llx", masked) |
|
| 197 |
+ return UUID(uuidString: "00000000-0000-0000-0000-\(suffix)") ?? UUID() |
|
| 198 |
+ } |
|
| 199 |
+} |
|
| 200 |
+ |
|
| 201 |
+#Preview {
|
|
| 202 |
+ NavigationStack {
|
|
| 203 |
+ DataTypeArchiveDetailView( |
|
| 204 |
+ current: DataTypeSnapshotContext( |
|
| 205 |
+ observationID: 2, |
|
| 206 |
+ observedAt: Date.now |
|
| 207 |
+ ), |
|
| 208 |
+ baseline: DataTypeSnapshotContext( |
|
| 209 |
+ observationID: 1, |
|
| 210 |
+ observedAt: Date.now.addingTimeInterval(-86_400) |
|
| 211 |
+ ), |
|
| 212 |
+ timeline: [ |
|
| 213 |
+ DataTypeSnapshotContext( |
|
| 214 |
+ observationID: 1, |
|
| 215 |
+ observedAt: Date.now.addingTimeInterval(-86_400) |
|
| 216 |
+ ), |
|
| 217 |
+ DataTypeSnapshotContext( |
|
| 218 |
+ observationID: 2, |
|
| 219 |
+ observedAt: Date.now |
|
| 220 |
+ ) |
|
| 221 |
+ ], |
|
| 222 |
+ typeIdentifier: "HKQuantityTypeIdentifierStepCount", |
|
| 223 |
+ displayName: "Step Count", |
|
| 224 |
+ initialDiff: TypeDiff( |
|
| 225 |
+ id: "HKQuantityTypeIdentifierStepCount", |
|
| 226 |
+ typeIdentifier: "HKQuantityTypeIdentifierStepCount", |
|
| 227 |
+ displayName: "Step Count", |
|
| 228 |
+ currentCount: 1200, |
|
| 229 |
+ previousCount: 980, |
|
| 230 |
+ previousTracked: true, |
|
| 231 |
+ appearedCount: 240, |
|
| 232 |
+ disappearedCount: 20, |
|
| 233 |
+ representationChangedCount: 0 |
|
| 234 |
+ ) |
|
| 235 |
+ ) |
|
| 236 |
+ } |
|
| 237 |
+} |
|
@@ -1,35 +1,14 @@ |
||
| 1 | 1 |
import SwiftUI |
| 2 |
-import SwiftData |
|
| 3 | 2 |
|
| 4 | 3 |
struct DataTypesView: View {
|
| 5 |
- @Query(sort: \HealthSnapshot.timestamp, order: .reverse) private var allSnapshots: [HealthSnapshot] |
|
| 6 | 4 |
@State private var viewModel = DataTypesViewModel() |
| 7 | 5 |
|
| 8 |
- private var displayedSnapshots: [HealthSnapshot] {
|
|
| 9 |
- guard let deviceID = localDeviceID else { return [] }
|
|
| 10 |
- return allSnapshots.filter { $0.deviceID == deviceID }
|
|
| 11 |
- } |
|
| 12 |
- |
|
| 13 |
- private var latest: HealthSnapshot? { displayedSnapshots.first }
|
|
| 14 |
- |
|
| 15 | 6 |
private var displayedSnapshotContexts: [DataTypeSnapshotContext] {
|
| 16 |
- displayedSnapshots.map {
|
|
| 17 |
- DataTypeSnapshotContext( |
|
| 18 |
- id: $0.id, |
|
| 19 |
- observedAt: $0.timestamp, |
|
| 20 |
- archiveObservationID: $0.archiveObservationID |
|
| 21 |
- ) |
|
| 22 |
- } |
|
| 7 |
+ viewModel.observationContexts |
|
| 23 | 8 |
} |
| 24 | 9 |
|
| 25 | 10 |
private var latestContext: DataTypeSnapshotContext? {
|
| 26 |
- latest.map {
|
|
| 27 |
- DataTypeSnapshotContext( |
|
| 28 |
- id: $0.id, |
|
| 29 |
- observedAt: $0.timestamp, |
|
| 30 |
- archiveObservationID: $0.archiveObservationID |
|
| 31 |
- ) |
|
| 32 |
- } |
|
| 11 |
+ displayedSnapshotContexts.first |
|
| 33 | 12 |
} |
| 34 | 13 |
|
| 35 | 14 |
private var currentBaseline: DataTypeSnapshotContext? {
|
@@ -39,25 +18,17 @@ struct DataTypesView: View {
|
||
| 39 | 18 |
|
| 40 | 19 |
private var archiveDiffTaskID: String {
|
| 41 | 20 |
[ |
| 42 |
- latestContext?.id.uuidString ?? "none", |
|
| 43 |
- currentBaseline?.id.uuidString ?? "none", |
|
| 21 |
+ "\(latestContext?.observationID ?? -1)", |
|
| 22 |
+ "\(currentBaseline?.observationID ?? -1)", |
|
| 23 |
+ "\(viewModel.observationRows.count)", |
|
| 44 | 24 |
String(describing: viewModel.comparisonMode) |
| 45 | 25 |
].joined(separator: "|") |
| 46 | 26 |
} |
| 47 | 27 |
|
| 48 |
- private var localDeviceID: String? {
|
|
| 49 |
- let currentID = AppSettings.currentDeviceID |
|
| 50 |
- if allSnapshots.contains(where: { $0.deviceID == currentID }) {
|
|
| 51 |
- return currentID |
|
| 52 |
- } |
|
| 53 |
- |
|
| 54 |
- return allSnapshots.first?.deviceID |
|
| 55 |
- } |
|
| 56 |
- |
|
| 57 | 28 |
var body: some View {
|
| 58 | 29 |
NavigationStack {
|
| 59 | 30 |
Group {
|
| 60 |
- if displayedSnapshots.count < 2 {
|
|
| 31 |
+ if displayedSnapshotContexts.count < 2 {
|
|
| 61 | 32 |
EmptyStateView( |
| 62 | 33 |
icon: "waveform.path.ecg", |
| 63 | 34 |
title: "Not Enough Data", |
@@ -70,6 +41,7 @@ struct DataTypesView: View {
|
||
| 70 | 41 |
.navigationTitle("Data Types")
|
| 71 | 42 |
.toolbar { filterPicker }
|
| 72 | 43 |
.task(id: archiveDiffTaskID) {
|
| 44 |
+ await viewModel.loadArchiveRows() |
|
| 73 | 45 |
await viewModel.loadArchiveDiffs(current: latestContext, snapshots: displayedSnapshotContexts) |
| 74 | 46 |
} |
| 75 | 47 |
} |
@@ -90,11 +62,15 @@ struct DataTypesView: View {
|
||
| 90 | 62 |
} else {
|
| 91 | 63 |
ForEach(diffs) { diff in
|
| 92 | 64 |
NavigationLink(destination: {
|
| 93 |
- if let latest = latest {
|
|
| 94 |
- DataTypeSnapshotDetailView( |
|
| 95 |
- snapshot: latest, |
|
| 65 |
+ if let latestContext, |
|
| 66 |
+ let currentBaseline {
|
|
| 67 |
+ DataTypeArchiveDetailView( |
|
| 68 |
+ current: latestContext, |
|
| 69 |
+ baseline: currentBaseline, |
|
| 70 |
+ timeline: displayedSnapshotContexts, |
|
| 96 | 71 |
typeIdentifier: diff.typeIdentifier, |
| 97 |
- displayName: diff.displayName |
|
| 72 |
+ displayName: diff.displayName, |
|
| 73 |
+ initialDiff: diff |
|
| 98 | 74 |
) |
| 99 | 75 |
} |
| 100 | 76 |
}) {
|
@@ -254,6 +230,5 @@ private enum DeltaIndicator {
|
||
| 254 | 230 |
|
| 255 | 231 |
#Preview {
|
| 256 | 232 |
DataTypesView() |
| 257 |
- .modelContainer(for: [HealthSnapshot.self, TypeCount.self, HealthRecord.self, TypeDistributionBin.self], inMemory: true) |
|
| 258 | 233 |
.environment(AppSettings()) |
| 259 | 234 |
} |