@@ -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/health rows, and Dashboard archive-cache status wiring are in place | Move Snapshots/Data Types 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 | Treat as disposable prototype data; reset/ignore during v2 transition | |
| 31 |
-| UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker. Data type detail uses SQLite `diffSummary` and record drill-down pages SQLite `diffRecords` when archive observation ids exist, with SwiftData detail cache as transition fallback | Move observation timeline and Data Types list to Core Data cache DTOs | |
|
| 31 |
+| UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker. Data Types list prefers Core Data type summaries plus SQLite `diffSummary` when archive observation ids exist, and data type detail/drill-down uses SQLite `diffSummary`/`diffRecords`, with SwiftData detail cache as transition fallback | Move observation timeline fully to Core Data cache DTOs | |
|
| 32 | 32 |
| Diff/change explanation | Prototype/legacy anomaly logic exists | Move heavy diffing into SQLite and use neutral change classifications | |
| 33 | 33 |
| Export | Prototype scoped JSON export exists | Add recovery-compatible manifests and streaming/paged export | |
| 34 | 34 |
| Legacy device support | Not implemented | Remove SwiftData dependency and simplify heavy views for low-memory devices | |
@@ -50,7 +50,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m |
||
| 50 | 50 |
- Existing `Anomaly*` model/service names are legacy language. |
| 51 | 51 |
- Some screens still imply snapshot-count monitoring rather than Time Machine inspection. |
| 52 | 52 |
- Current UI/cache layers still depend on SwiftData prototype models. |
| 53 |
-- Data type record drill-down is archive-backed for new archive v2 observations, but observation timeline/Data Types list still begin from SwiftData snapshots. |
|
| 53 |
+- Data Types list rows and record drill-down are archive-backed for new archive v2 observations when cache rows exist, but the observation timeline still begins from SwiftData snapshots. |
|
| 54 | 54 |
- Legacy SwiftData-only snapshots can show diffs without archive-backed values; they are now reset for archive v2 test installs rather than migrated. |
| 55 | 55 |
- Existing implementation may decode or cache too much data for low-end devices. |
| 56 | 56 |
- Old prototype database compatibility is no longer required. |
@@ -62,6 +62,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m |
||
| 62 | 62 |
- [x] SQLite archive v2 can reconstruct records visible at observation T. |
| 63 | 63 |
- [ ] No recurring complete snapshot copies are written for high-volume types. |
| 64 | 64 |
- [x] SQL diff between two observations runs without loading full datasets into Swift arrays. |
| 65 |
+- [x] Data Types list rows use Core Data cached counts plus SQLite diff summaries when archive observation ids are available. |
|
| 65 | 66 |
- [x] Data type added/disappeared drill-down pages records from SQLite diff queries when archive observation ids are available. |
| 66 | 67 |
- [x] Expensive counts used by reports/UI are cached and rebuildable. |
| 67 | 68 |
- [x] Deleting Core Data cache and rebuilding from SQLite restores UI/report summaries. |
@@ -224,6 +224,7 @@ Checklist: |
||
| 224 | 224 |
- [ ] Dashboard reads Core Data cache. |
| 225 | 225 |
- [ ] Observation timeline reads Core Data cache. |
| 226 | 226 |
- [ ] Observation detail uses cached summaries plus paged SQLite DTOs. |
| 227 |
+- [x] Data Types list rows prefer Core Data cached counts plus SQLite `diffSummary` when archive observation ids exist. |
|
| 227 | 228 |
- [x] Data type detail uses SQLite `diffSummary` when archive observation ids exist. |
| 228 | 229 |
- [x] Data type added/disappeared drill-down pages through SQLite `diffRecords` when archive observation ids exist. |
| 229 | 230 |
- [ ] Diff detail fully uses cached summary plus paged SQLite DTOs. |
@@ -7,9 +7,20 @@ struct TypeDiff: Identifiable {
|
||
| 7 | 7 |
let currentCount: Int |
| 8 | 8 |
let previousCount: Int |
| 9 | 9 |
let previousTracked: Bool |
| 10 |
+ let appearedCount: Int |
|
| 11 |
+ let disappearedCount: Int |
|
| 12 |
+ let representationChangedCount: Int |
|
| 10 | 13 |
|
| 11 | 14 |
var delta: Int { currentCount - previousCount }
|
| 12 | 15 |
|
| 16 |
+ var recordChangeCount: Int {
|
|
| 17 |
+ appearedCount + disappearedCount + representationChangedCount |
|
| 18 |
+ } |
|
| 19 |
+ |
|
| 20 |
+ var hasChanges: Bool {
|
|
| 21 |
+ delta != 0 || recordChangeCount > 0 |
|
| 22 |
+ } |
|
| 23 |
+ |
|
| 13 | 24 |
var percentChange: Double? {
|
| 14 | 25 |
guard previousTracked, previousCount > 0 else { return nil }
|
| 15 | 26 |
return Double(delta) / Double(previousCount) * 100 |
@@ -38,7 +49,10 @@ final class SnapshotDiffService {
|
||
| 38 | 49 |
displayName: tc.displayName, |
| 39 | 50 |
currentCount: tc.count, |
| 40 | 51 |
previousCount: prior ?? 0, |
| 41 |
- previousTracked: prior != nil |
|
| 52 |
+ previousTracked: prior != nil, |
|
| 53 |
+ appearedCount: 0, |
|
| 54 |
+ disappearedCount: 0, |
|
| 55 |
+ representationChangedCount: 0 |
|
| 42 | 56 |
) |
| 43 | 57 |
}.sorted { $0.displayName.localizedCompare($1.displayName) == .orderedAscending }
|
| 44 | 58 |
} |
@@ -62,7 +76,7 @@ final class SnapshotDiffService {
|
||
| 62 | 76 |
func apply(filter: DiffFilter, to diffs: [TypeDiff]) -> [TypeDiff] {
|
| 63 | 77 |
switch filter {
|
| 64 | 78 |
case .all: return diffs |
| 65 |
- case .changed: return diffs.filter { $0.previousTracked && $0.delta != 0 }
|
|
| 79 |
+ case .changed: return diffs.filter { $0.previousTracked && $0.hasChanges }
|
|
| 66 | 80 |
case .increased: return diffs.filter { $0.previousTracked && $0.delta > 0 }
|
| 67 | 81 |
case .decreased: return diffs.filter { $0.previousTracked && $0.delta < 0 }
|
| 68 | 82 |
} |
@@ -4,16 +4,82 @@ import Foundation |
||
| 4 | 4 |
final class DataTypesViewModel {
|
| 5 | 5 |
var filter: DiffFilter = .all |
| 6 | 6 |
var comparisonMode: ComparisonMode = .previous |
| 7 |
+ var archiveDiffs: [TypeDiff]? |
|
| 8 |
+ var archiveDiffError: String? |
|
| 7 | 9 |
|
| 8 | 10 |
private let diffService = SnapshotDiffService.shared |
| 9 | 11 |
|
| 10 | 12 |
func diffs(current: HealthSnapshot?, snapshots: [HealthSnapshot]) -> [TypeDiff] {
|
| 13 |
+ if let archiveDiffs {
|
|
| 14 |
+ return diffService.apply(filter: filter, to: archiveDiffs) |
|
| 15 |
+ } |
|
| 11 | 16 |
guard let current else { return [] }
|
| 12 | 17 |
guard let baseline = resolveBaseline(for: current, in: snapshots) else { return [] }
|
| 13 | 18 |
let all = diffService.diff(current: current, baseline: baseline) |
| 14 | 19 |
return diffService.apply(filter: filter, to: all) |
| 15 | 20 |
} |
| 16 | 21 |
|
| 22 |
+ func baseline(for snapshot: HealthSnapshot, in snapshots: [HealthSnapshot]) -> HealthSnapshot? {
|
|
| 23 |
+ resolveBaseline(for: snapshot, in: snapshots) |
|
| 24 |
+ } |
|
| 25 |
+ |
|
| 26 |
+ func clearArchiveDiffs() {
|
|
| 27 |
+ archiveDiffs = nil |
|
| 28 |
+ archiveDiffError = nil |
|
| 29 |
+ } |
|
| 30 |
+ |
|
| 31 |
+ @MainActor |
|
| 32 |
+ func loadArchiveDiffs(current: HealthSnapshot?, snapshots: [HealthSnapshot]) async {
|
|
| 33 |
+ guard let current, |
|
| 34 |
+ let baseline = resolveBaseline(for: current, in: snapshots), |
|
| 35 |
+ let currentObservationID = current.archiveObservationID, |
|
| 36 |
+ let baselineObservationID = baseline.archiveObservationID else {
|
|
| 37 |
+ clearArchiveDiffs() |
|
| 38 |
+ return |
|
| 39 |
+ } |
|
| 40 |
+ |
|
| 41 |
+ do {
|
|
| 42 |
+ let cache = try CoreDataArchiveCacheStore() |
|
| 43 |
+ let currentSummaries = try cache.typeSummaries(observationID: currentObservationID) |
|
| 44 |
+ let baselineSummaries = try cache.typeSummaries(observationID: baselineObservationID) |
|
| 45 |
+ let currentByType = Dictionary(uniqueKeysWithValues: currentSummaries.map { ($0.sampleTypeIdentifier, $0) })
|
|
| 46 |
+ let baselineByType = Dictionary(uniqueKeysWithValues: baselineSummaries.map { ($0.sampleTypeIdentifier, $0) })
|
|
| 47 |
+ let allTypeIdentifiers = Set(currentByType.keys).union(baselineByType.keys) |
|
| 48 |
+ |
|
| 49 |
+ var archiveRows: [TypeDiff] = [] |
|
| 50 |
+ archiveRows.reserveCapacity(allTypeIdentifiers.count) |
|
| 51 |
+ |
|
| 52 |
+ for typeIdentifier in allTypeIdentifiers {
|
|
| 53 |
+ let summary = currentByType[typeIdentifier] |
|
| 54 |
+ let baselineSummary = baselineByType[typeIdentifier] |
|
| 55 |
+ let diff = try await SQLiteHealthArchiveStore.shared.diffSummary(HealthArchiveDiffRequest( |
|
| 56 |
+ fromObservationID: baselineObservationID, |
|
| 57 |
+ toObservationID: currentObservationID, |
|
| 58 |
+ sampleTypeIdentifier: typeIdentifier |
|
| 59 |
+ )) |
|
| 60 |
+ archiveRows.append(TypeDiff( |
|
| 61 |
+ id: typeIdentifier, |
|
| 62 |
+ typeIdentifier: typeIdentifier, |
|
| 63 |
+ displayName: summary?.displayName ?? baselineSummary?.displayName ?? typeIdentifier, |
|
| 64 |
+ currentCount: summary?.visibleRecordCount ?? 0, |
|
| 65 |
+ previousCount: baselineSummary?.visibleRecordCount ?? 0, |
|
| 66 |
+ previousTracked: baselineSummary != nil, |
|
| 67 |
+ appearedCount: diff.appearedCount, |
|
| 68 |
+ disappearedCount: diff.disappearedCount, |
|
| 69 |
+ representationChangedCount: diff.representationChangedCount |
|
| 70 |
+ )) |
|
| 71 |
+ } |
|
| 72 |
+ |
|
| 73 |
+ archiveDiffs = archiveRows.sorted {
|
|
| 74 |
+ $0.displayName.localizedCompare($1.displayName) == .orderedAscending |
|
| 75 |
+ } |
|
| 76 |
+ archiveDiffError = nil |
|
| 77 |
+ } catch {
|
|
| 78 |
+ archiveDiffs = nil |
|
| 79 |
+ archiveDiffError = error.localizedDescription |
|
| 80 |
+ } |
|
| 81 |
+ } |
|
| 82 |
+ |
|
| 17 | 83 |
private func resolveBaseline(for snapshot: HealthSnapshot, in snapshots: [HealthSnapshot]) -> HealthSnapshot? {
|
| 18 | 84 |
let sorted = snapshots.sorted { $0.timestamp > $1.timestamp }
|
| 19 | 85 |
switch comparisonMode {
|
@@ -12,6 +12,19 @@ struct DataTypesView: View {
|
||
| 12 | 12 |
|
| 13 | 13 |
private var latest: HealthSnapshot? { displayedSnapshots.first }
|
| 14 | 14 |
|
| 15 |
+ private var currentBaseline: HealthSnapshot? {
|
|
| 16 |
+ guard let latest else { return nil }
|
|
| 17 |
+ return viewModel.baseline(for: latest, in: displayedSnapshots) |
|
| 18 |
+ } |
|
| 19 |
+ |
|
| 20 |
+ private var archiveDiffTaskID: String {
|
|
| 21 |
+ [ |
|
| 22 |
+ latest?.id.uuidString ?? "none", |
|
| 23 |
+ currentBaseline?.id.uuidString ?? "none", |
|
| 24 |
+ String(describing: viewModel.comparisonMode) |
|
| 25 |
+ ].joined(separator: "|") |
|
| 26 |
+ } |
|
| 27 |
+ |
|
| 15 | 28 |
private var localDeviceID: String? {
|
| 16 | 29 |
let currentID = AppSettings.currentDeviceID |
| 17 | 30 |
if allSnapshots.contains(where: { $0.deviceID == currentID }) {
|
@@ -36,6 +49,9 @@ struct DataTypesView: View {
|
||
| 36 | 49 |
} |
| 37 | 50 |
.navigationTitle("Data Types")
|
| 38 | 51 |
.toolbar { filterPicker }
|
| 52 |
+ .task(id: archiveDiffTaskID) {
|
|
| 53 |
+ await viewModel.loadArchiveDiffs(current: latest, snapshots: displayedSnapshots) |
|
| 54 |
+ } |
|
| 39 | 55 |
} |
| 40 | 56 |
} |
| 41 | 57 |
|
@@ -110,6 +126,7 @@ private struct TypeDiffRow: View {
|
||
| 110 | 126 |
if !diff.previousTracked { return .new }
|
| 111 | 127 |
if diff.delta > 0 { return .increase }
|
| 112 | 128 |
if diff.delta < 0 { return .decrease }
|
| 129 |
+ if diff.recordChangeCount > 0 { return .changed }
|
|
| 113 | 130 |
return .stable |
| 114 | 131 |
} |
| 115 | 132 |
|
@@ -128,6 +145,12 @@ private struct TypeDiffRow: View {
|
||
| 128 | 145 |
metricCompact("Before", nil, .secondary)
|
| 129 | 146 |
} |
| 130 | 147 |
} |
| 148 |
+ |
|
| 149 |
+ if diff.recordChangeCount > 0 {
|
|
| 150 |
+ Text(recordChangeText) |
|
| 151 |
+ .font(.caption2.weight(.medium)) |
|
| 152 |
+ .foregroundStyle(Color.warningAmber) |
|
| 153 |
+ } |
|
| 131 | 154 |
} |
| 132 | 155 |
|
| 133 | 156 |
Spacer() |
@@ -178,6 +201,11 @@ private struct TypeDiffRow: View {
|
||
| 178 | 201 |
case .stable: |
| 179 | 202 |
EmptyView() |
| 180 | 203 |
|
| 204 |
+ case .changed: |
|
| 205 |
+ Image(systemName: "arrow.triangle.2.circlepath") |
|
| 206 |
+ .font(.system(size: 13, weight: .semibold)) |
|
| 207 |
+ .foregroundStyle(Color.warningAmber) |
|
| 208 |
+ |
|
| 181 | 209 |
case .new: |
| 182 | 210 |
Image(systemName: "sparkles") |
| 183 | 211 |
.font(.system(size: 12, weight: .semibold)) |
@@ -187,15 +215,21 @@ private struct TypeDiffRow: View {
|
||
| 187 | 215 |
|
| 188 | 216 |
private var accessibilityDescription: String {
|
| 189 | 217 |
if diff.previousTracked {
|
| 190 |
- return "\(diff.displayName). Current: \(diff.currentCount). Previous: \(diff.previousCount). Delta: \(diff.delta)." |
|
| 218 |
+ return "\(diff.displayName). Current: \(diff.currentCount). Previous: \(diff.previousCount). Delta: \(diff.delta). \(recordChangeText)." |
|
| 191 | 219 |
} else {
|
| 192 | 220 |
return "\(diff.displayName). Current: \(diff.currentCount). New data type in baseline." |
| 193 | 221 |
} |
| 194 | 222 |
} |
| 223 |
+ |
|
| 224 |
+ private var recordChangeText: String {
|
|
| 225 |
+ let count = diff.recordChangeCount |
|
| 226 |
+ guard count > 0 else { return "No record changes" }
|
|
| 227 |
+ return count == 1 ? "1 record change" : "\(count) record changes" |
|
| 228 |
+ } |
|
| 195 | 229 |
} |
| 196 | 230 |
|
| 197 | 231 |
private enum DeltaIndicator {
|
| 198 |
- case increase, decrease, stable, new |
|
| 232 |
+ case increase, decrease, stable, changed, new |
|
| 199 | 233 |
} |
| 200 | 234 |
|
| 201 | 235 |
#Preview {
|