@@ -304,11 +304,11 @@ The following modules involve non-trivial logic and should be reviewed carefully |
||
| 304 | 304 |
|
| 305 | 305 |
| Module | File | Description | |
| 306 | 306 |
|--------|------|-------------| |
| 307 |
-| **Change Explainer** | `Services/AnomalyDetector.swift` *(legacy name)* | Classify appeared/disappeared/representation-changed records without assuming loss | |
|
| 308 |
-| **Consolidation Heuristics** | `Services/DivergenceEngine.swift` *(legacy name)* | Compare aggregates, intervals, and density to identify likely HealthKit consolidation | |
|
| 309 |
-| **Fingerprinter** | `Services/SampleFingerprinter.swift` | Record matching via sample and semantic hashes | |
|
| 310 |
-| **Snapshot Comparator** | `Services/SnapshotComparator.swift` | Diff between observations in one local device timeline | |
|
| 311 |
-| **Distribution Comparator** | `Services/SnapshotDiffService.swift` | Daily per-type distribution diff to distinguish detail thinning from aggregate change | |
|
| 307 |
+| **Archive Diff Queries** | `Services/SQLiteHealthArchiveStore.swift` | SQL-first appeared/disappeared/representation-changed counts and paged record diffs | |
|
| 308 |
+| **Consolidation Evidence** | `Services/SQLiteHealthArchiveStore.swift` | Aggregate, coverage, density, source, and uncertainty evidence for HealthKit consolidation-like changes | |
|
| 309 |
+| **Archive Fingerprinting** | `Services/HashService.swift` and archive write path | Stable sample/content hashes used by differential storage and exports | |
|
| 310 |
+| **UI/Report Cache** | `Services/CoreDataArchiveCacheStore.swift` | Rebuildable cached observation/type/diff/export/health rows for UI/reporting | |
|
| 311 |
+| **Export Manifesting** | `Services/SQLiteHealthArchiveStore.swift` | Paged JSON export and manifest hashing without full archive materialization | |
|
| 312 | 312 |
|
| 313 | 313 |
**Guidelines for algorithm modules:** |
| 314 | 314 |
- Document assumptions explicitly (e.g., "HealthProbe can only preserve detail it observed") |
@@ -29,7 +29,7 @@ There are no real deployments, only test installations. Existing prototype datab |
||
| 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 | 31 |
| UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker. Dashboard status reads archive/cache observation rows and shows cache health, with SwiftData retained only for capture/review actions; Snapshots timeline reads archive/cache rows when available and no longer queries `SnapshotDelta` for list summaries; snapshot detail summaries/type rows require Core Data cache rows and no longer fall back to `SnapshotDelta`/`TypeDelta`; Data Types list rows no longer fall back to SwiftData `TypeCount` traversal; data type detail reads Core Data type/diff summaries, uses SQLite `diffRecords` for paged drill-down, and no longer queries `SnapshotDelta`/`TypeDelta` or rebuilds legacy detail caches from the UI; record-change evolution and temporal distribution screens now receive DTO rows/cache input instead of querying SwiftData directly; export preview reads the archive export API before showing/exporting JSON; simplified detail mode replaces heavy charts with summary rows on small/accessibility layouts or when enabled in Settings; visible change labels now use neutral new/missing/change-review language, with already-existing SwiftData detail cache as transition fallback | Remove remaining SwiftData navigation handles | |
| 32 |
-| Diff/change explanation | SQL diff summaries, paged diff records, aggregate comparisons, and consolidation-evidence labels exist; legacy anomaly/count-drop review has been removed from active flows | Continue moving remaining SwiftData fallback detail paths to archive/cache DTOs | |
|
| 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 | |
| 35 | 35 |
| Recovery workflows | Not supported | Preserve export/archive structure for external recovery tools only | |
@@ -231,6 +231,7 @@ Checklist: |
||
| 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 | 233 |
- [x] Data type detail uses Core Data/SQLite `diffSummary` when archive observation ids exist and no longer queries `SnapshotDelta`/`TypeDelta` or rebuilds legacy detail caches from the UI. |
| 234 |
+- [x] Remove unused legacy Type Evolution timeline and direct `HealthSnapshot.typeCounts` diff helper. |
|
| 234 | 235 |
- [x] Data type new/missing drill-down pages through SQLite `diffRecords` when archive observation ids exist. |
| 235 | 236 |
- [x] Diff detail fully uses cached summary plus paged SQLite DTOs. |
| 236 | 237 |
- [x] Record-change evolution chart uses DTO inputs and archive/cache lookups instead of direct SwiftData queries. |
@@ -134,6 +134,9 @@ The following SwiftData dependencies were removed from active flows: |
||
| 134 | 134 |
`TypeCount.detailCache` rows from the UI. It reads Core Data/SQLite diff |
| 135 | 135 |
summaries first and only displays an already-existing legacy detail cache as a |
| 136 | 136 |
transition fallback. |
| 137 |
+- The unused `HealthProbe/Views/DataTypes/TypeEvolutionTimeline.swift` legacy |
|
| 138 |
+ chart was deleted, and `SnapshotDiffService` no longer contains direct |
|
| 139 |
+ `HealthSnapshot.typeCounts` diff traversal. |
|
| 137 | 140 |
- `HealthProbe/Models/AnomalyRecord.swift`, |
| 138 | 141 |
`HealthProbe/Models/AnomalyType.swift`, and |
| 139 | 142 |
`HealthProbe/Services/AnomalyDetector.swift` were deleted. The app no longer |
@@ -33,58 +33,3 @@ enum DiffFilter: String, CaseIterable {
|
||
| 33 | 33 |
case increased = "Increased" |
| 34 | 34 |
case decreased = "Decreased" |
| 35 | 35 |
} |
| 36 |
- |
|
| 37 |
-final class SnapshotDiffService {
|
|
| 38 |
- static let shared = SnapshotDiffService() |
|
| 39 |
- |
|
| 40 |
- func diff(current: HealthSnapshot, baseline: HealthSnapshot) -> [TypeDiff] {
|
|
| 41 |
- let baselineMap = Dictionary( |
|
| 42 |
- uniqueKeysWithValues: (baseline.typeCounts ?? []).map { ($0.typeIdentifier, $0.count) }
|
|
| 43 |
- ) |
|
| 44 |
- return (current.typeCounts ?? []).map { tc in
|
|
| 45 |
- let prior = baselineMap[tc.typeIdentifier] |
|
| 46 |
- return TypeDiff( |
|
| 47 |
- id: tc.typeIdentifier, |
|
| 48 |
- typeIdentifier: tc.typeIdentifier, |
|
| 49 |
- displayName: tc.displayName, |
|
| 50 |
- currentCount: tc.count, |
|
| 51 |
- previousCount: prior ?? 0, |
|
| 52 |
- previousTracked: prior != nil, |
|
| 53 |
- appearedCount: 0, |
|
| 54 |
- disappearedCount: 0, |
|
| 55 |
- representationChangedCount: 0 |
|
| 56 |
- ) |
|
| 57 |
- }.sorted { $0.displayName.localizedCompare($1.displayName) == .orderedAscending }
|
|
| 58 |
- } |
|
| 59 |
- |
|
| 60 |
- func totalAbsoluteChange(current: HealthSnapshot, baseline: HealthSnapshot) -> Int {
|
|
| 61 |
- let baselineMap = Dictionary( |
|
| 62 |
- uniqueKeysWithValues: (baseline.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
|
|
| 63 |
- ) |
|
| 64 |
- |
|
| 65 |
- return (current.typeCounts ?? []).reduce(0) { partial, currentType in
|
|
| 66 |
- guard currentType.quality == .complete, |
|
| 67 |
- let previousType = baselineMap[currentType.typeIdentifier], |
|
| 68 |
- previousType.quality == .complete else {
|
|
| 69 |
- return partial |
|
| 70 |
- } |
|
| 71 |
- |
|
| 72 |
- return partial + abs(currentType.count - previousType.count) |
|
| 73 |
- } |
|
| 74 |
- } |
|
| 75 |
- |
|
| 76 |
- func apply(filter: DiffFilter, to diffs: [TypeDiff]) -> [TypeDiff] {
|
|
| 77 |
- switch filter {
|
|
| 78 |
- case .all: return diffs |
|
| 79 |
- case .changed: return diffs.filter { $0.previousTracked && $0.hasChanges }
|
|
| 80 |
- case .increased: return diffs.filter { $0.previousTracked && $0.delta > 0 }
|
|
| 81 |
- case .decreased: return diffs.filter { $0.previousTracked && $0.delta < 0 }
|
|
| 82 |
- } |
|
| 83 |
- } |
|
| 84 |
- |
|
| 85 |
- func nearest(to targetDate: Date, in snapshots: [HealthSnapshot]) -> HealthSnapshot? {
|
|
| 86 |
- snapshots |
|
| 87 |
- .filter { $0.timestamp <= targetDate }
|
|
| 88 |
- .max { $0.timestamp < $1.timestamp }
|
|
| 89 |
- } |
|
| 90 |
-} |
|
@@ -54,15 +54,27 @@ enum SnapshotPDFExporter {
|
||
| 54 | 54 |
|
| 55 | 55 |
let baselineData: SnapshotReportData.BaselineData? |
| 56 | 56 |
if let baseline {
|
| 57 |
- let svc = SnapshotDiffService.shared |
|
| 57 |
+ let baselineCountByIdentifier = Dictionary( |
|
| 58 |
+ uniqueKeysWithValues: (baseline.typeCounts ?? []).map { ($0.typeIdentifier, $0.count) }
|
|
| 59 |
+ ) |
|
| 60 |
+ let comparableCurrentCounts = (snapshot.typeCounts ?? []).filter { currentType in
|
|
| 61 |
+ currentType.quality == .complete && |
|
| 62 |
+ (baseline.typeCounts ?? []).contains {
|
|
| 63 |
+ $0.typeIdentifier == currentType.typeIdentifier && $0.quality == .complete |
|
| 64 |
+ } |
|
| 65 |
+ } |
|
| 66 |
+ let totalChange = comparableCurrentCounts.reduce(0) { partial, currentType in
|
|
| 67 |
+ partial + abs(currentType.count - (baselineCountByIdentifier[currentType.typeIdentifier] ?? 0)) |
|
| 68 |
+ } |
|
| 69 |
+ let changedCount = comparableCurrentCounts.filter {
|
|
| 70 |
+ $0.count != (baselineCountByIdentifier[$0.typeIdentifier] ?? 0) |
|
| 71 |
+ }.count |
|
| 72 |
+ |
|
| 58 | 73 |
baselineData = SnapshotReportData.BaselineData( |
| 59 | 74 |
timestamp: baseline.timestamp, |
| 60 |
- totalChange: svc.totalAbsoluteChange(current: snapshot, baseline: baseline), |
|
| 61 |
- changedCount: svc.diff(current: snapshot, baseline: baseline) |
|
| 62 |
- .filter { $0.previousTracked && $0.delta != 0 }.count,
|
|
| 63 |
- countByIdentifier: Dictionary( |
|
| 64 |
- uniqueKeysWithValues: (baseline.typeCounts ?? []).map { ($0.typeIdentifier, $0.count) }
|
|
| 65 |
- ) |
|
| 75 |
+ totalChange: totalChange, |
|
| 76 |
+ changedCount: changedCount, |
|
| 77 |
+ countByIdentifier: baselineCountByIdentifier |
|
| 66 | 78 |
) |
| 67 | 79 |
} else {
|
| 68 | 80 |
baselineData = nil |
@@ -73,7 +73,9 @@ final class SnapshotsViewModel {
|
||
| 73 | 73 |
return selectedBaseline |
| 74 | 74 |
case .relativeTime(let interval): |
| 75 | 75 |
let target = snapshot.timestamp.addingTimeInterval(-interval) |
| 76 |
- return SnapshotDiffService.shared.nearest(to: target, in: snapshots) |
|
| 76 |
+ return snapshots |
|
| 77 |
+ .filter { $0.timestamp <= target }
|
|
| 78 |
+ .max { $0.timestamp < $1.timestamp }
|
|
| 77 | 79 |
} |
| 78 | 80 |
} |
| 79 | 81 |
|
@@ -1,182 +0,0 @@ |
||
| 1 |
-import SwiftUI |
|
| 2 |
- |
|
| 3 |
-/// Visual timeline showing how a data type's count evolved across snapshots |
|
| 4 |
-struct TypeEvolutionTimeline: View {
|
|
| 5 |
- let snapshots: [HealthSnapshot] |
|
| 6 |
- let typeIdentifier: String |
|
| 7 |
- let displayName: String |
|
| 8 |
- let currentSnapshotID: UUID |
|
| 9 |
- |
|
| 10 |
- private var validDataPoints: [(snapshot: HealthSnapshot, count: Int)] {
|
|
| 11 |
- snapshots |
|
| 12 |
- .compactMap { snapshot in
|
|
| 13 |
- guard let typeCount = snapshot.typeCounts?.first(where: { $0.typeIdentifier == typeIdentifier }),
|
|
| 14 |
- !typeCount.isUnsupported, |
|
| 15 |
- typeCount.count >= 0 else {
|
|
| 16 |
- return nil |
|
| 17 |
- } |
|
| 18 |
- return (snapshot, typeCount.count) |
|
| 19 |
- } |
|
| 20 |
- .sorted { $0.snapshot.timestamp < $1.snapshot.timestamp }
|
|
| 21 |
- } |
|
| 22 |
- |
|
| 23 |
- private var minCount: Int {
|
|
| 24 |
- validDataPoints.map(\.count).min() ?? 0 |
|
| 25 |
- } |
|
| 26 |
- |
|
| 27 |
- private var maxCount: Int {
|
|
| 28 |
- validDataPoints.map(\.count).max() ?? 1 |
|
| 29 |
- } |
|
| 30 |
- |
|
| 31 |
- private var countRange: Int {
|
|
| 32 |
- max(maxCount - minCount, 1) |
|
| 33 |
- } |
|
| 34 |
- |
|
| 35 |
- var body: some View {
|
|
| 36 |
- if validDataPoints.count < 2 {
|
|
| 37 |
- return AnyView(EmptyView()) |
|
| 38 |
- } |
|
| 39 |
- |
|
| 40 |
- return AnyView( |
|
| 41 |
- VStack(alignment: .leading, spacing: 8) {
|
|
| 42 |
- Text("Evolution")
|
|
| 43 |
- .font(.headline.weight(.semibold)) |
|
| 44 |
- |
|
| 45 |
- HStack(spacing: 4) {
|
|
| 46 |
- ForEach(Array(validDataPoints.enumerated()), id: \.offset) { index, item in
|
|
| 47 |
- timelinePoint( |
|
| 48 |
- snapshot: item.snapshot, |
|
| 49 |
- count: item.count, |
|
| 50 |
- index: index, |
|
| 51 |
- isLast: index == validDataPoints.count - 1 |
|
| 52 |
- ) |
|
| 53 |
- |
|
| 54 |
- if index < validDataPoints.count - 1 {
|
|
| 55 |
- timelineConnector( |
|
| 56 |
- from: validDataPoints[index].count, |
|
| 57 |
- to: validDataPoints[index + 1].count |
|
| 58 |
- ) |
|
| 59 |
- } |
|
| 60 |
- } |
|
| 61 |
- } |
|
| 62 |
- |
|
| 63 |
- compactStatsRow |
|
| 64 |
- } |
|
| 65 |
- .padding(16) |
|
| 66 |
- .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12)) |
|
| 67 |
- ) |
|
| 68 |
- } |
|
| 69 |
- |
|
| 70 |
- private func timelinePoint( |
|
| 71 |
- snapshot: HealthSnapshot, |
|
| 72 |
- count: Int, |
|
| 73 |
- index: Int, |
|
| 74 |
- isLast: Bool |
|
| 75 |
- ) -> some View {
|
|
| 76 |
- let normalizedHeight = CGFloat(count - minCount) / CGFloat(countRange) |
|
| 77 |
- let isCurrentSnapshot = snapshot.id == currentSnapshotID |
|
| 78 |
- let barHeight = 40 + normalizedHeight * 40 |
|
| 79 |
- |
|
| 80 |
- return VStack(spacing: 8) {
|
|
| 81 |
- VStack(spacing: 0) {
|
|
| 82 |
- Spacer(minLength: 80 - barHeight) |
|
| 83 |
- |
|
| 84 |
- RoundedRectangle(cornerRadius: 4) |
|
| 85 |
- .fill(isCurrentSnapshot ? Color.accentColor : Color.secondary.opacity(0.6)) |
|
| 86 |
- .frame(height: barHeight) |
|
| 87 |
- |
|
| 88 |
- Spacer(minLength: 0) |
|
| 89 |
- } |
|
| 90 |
- .frame(height: 80) |
|
| 91 |
- |
|
| 92 |
- Text("\(count)")
|
|
| 93 |
- .font(.caption.weight(.medium).monospacedDigit()) |
|
| 94 |
- .foregroundStyle(.secondary) |
|
| 95 |
- |
|
| 96 |
- Text(snapshot.timestamp, format: .dateTime.month().day()) |
|
| 97 |
- .font(.caption2) |
|
| 98 |
- .foregroundStyle(.tertiary) |
|
| 99 |
- .lineLimit(1) |
|
| 100 |
- } |
|
| 101 |
- .frame(maxWidth: .infinity) |
|
| 102 |
- } |
|
| 103 |
- |
|
| 104 |
- private func timelineConnector(from: Int, to: Int) -> some View {
|
|
| 105 |
- let fromHeight = 40 + CGFloat(from - minCount) / CGFloat(countRange) * 40 |
|
| 106 |
- let toHeight = 40 + CGFloat(to - minCount) / CGFloat(countRange) * 40 |
|
| 107 |
- let heightDiff = abs(toHeight - fromHeight) |
|
| 108 |
- |
|
| 109 |
- let isIncreasing = to > from |
|
| 110 |
- let percentChange = from > 0 ? Double(to - from) / Double(from) * 100 : 0 |
|
| 111 |
- let isSignificantIncrease = isIncreasing && percentChange > 10 |
|
| 112 |
- |
|
| 113 |
- return VStack {
|
|
| 114 |
- Spacer() |
|
| 115 |
- .frame(height: fromHeight) |
|
| 116 |
- |
|
| 117 |
- if isSignificantIncrease {
|
|
| 118 |
- HStack(spacing: 2) {
|
|
| 119 |
- Spacer(minLength: 0) |
|
| 120 |
- Text("▲")
|
|
| 121 |
- .font(.system(size: 10, weight: .bold)) |
|
| 122 |
- .foregroundStyle(Color.healthyGreen) |
|
| 123 |
- Spacer(minLength: 0) |
|
| 124 |
- } |
|
| 125 |
- .frame(height: heightDiff.isZero ? 1 : heightDiff) |
|
| 126 |
- } else {
|
|
| 127 |
- Color.clear |
|
| 128 |
- .frame(height: heightDiff.isZero ? 1 : heightDiff) |
|
| 129 |
- } |
|
| 130 |
- |
|
| 131 |
- Spacer() |
|
| 132 |
- } |
|
| 133 |
- .frame(height: 80) |
|
| 134 |
- } |
|
| 135 |
- |
|
| 136 |
- private var compactStatsRow: some View {
|
|
| 137 |
- HStack(spacing: 16) {
|
|
| 138 |
- statItem(label: "Min", value: minCount) |
|
| 139 |
- Divider() |
|
| 140 |
- statItem(label: "Max", value: maxCount) |
|
| 141 |
- Divider() |
|
| 142 |
- if let latest = validDataPoints.last?.count {
|
|
| 143 |
- statItem(label: "Latest", value: latest) |
|
| 144 |
- } |
|
| 145 |
- } |
|
| 146 |
- .font(.caption) |
|
| 147 |
- .foregroundStyle(.secondary) |
|
| 148 |
- } |
|
| 149 |
- |
|
| 150 |
- private func statItem(label: String, value: Int) -> some View {
|
|
| 151 |
- VStack(alignment: .center, spacing: 2) {
|
|
| 152 |
- Text(label) |
|
| 153 |
- .font(.caption2.weight(.medium)) |
|
| 154 |
- Text("\(value)")
|
|
| 155 |
- .font(.caption.weight(.semibold).monospacedDigit()) |
|
| 156 |
- .foregroundStyle(.primary) |
|
| 157 |
- } |
|
| 158 |
- .frame(maxWidth: .infinity) |
|
| 159 |
- } |
|
| 160 |
-} |
|
| 161 |
- |
|
| 162 |
-#Preview {
|
|
| 163 |
- TypeEvolutionTimeline( |
|
| 164 |
- snapshots: [ |
|
| 165 |
- HealthSnapshot(timestamp: Date.now.addingTimeInterval(-86400 * 5), osVersion: "iOS 26", deviceName: "iPhone", deviceID: "device1"), |
|
| 166 |
- HealthSnapshot(timestamp: Date.now.addingTimeInterval(-86400 * 4), osVersion: "iOS 26", deviceName: "iPhone", deviceID: "device1"), |
|
| 167 |
- HealthSnapshot(timestamp: Date.now.addingTimeInterval(-86400 * 3), osVersion: "iOS 26", deviceName: "iPhone", deviceID: "device1"), |
|
| 168 |
- HealthSnapshot(timestamp: Date.now.addingTimeInterval(-86400 * 2), osVersion: "iOS 26", deviceName: "iPhone", deviceID: "device1"), |
|
| 169 |
- HealthSnapshot(timestamp: Date.now.addingTimeInterval(-86400), osVersion: "iOS 26", deviceName: "iPhone", deviceID: "device1"), |
|
| 170 |
- HealthSnapshot(timestamp: Date.now, osVersion: "iOS 26", deviceName: "iPhone", deviceID: "device1") |
|
| 171 |
- ].map { snap in
|
|
| 172 |
- snap.typeCounts = [ |
|
| 173 |
- TypeCount(typeIdentifier: "HKQuantityTypeIdentifierStepCount", displayName: "Steps", count: Int.random(in: 800...1500)) |
|
| 174 |
- ] |
|
| 175 |
- return snap |
|
| 176 |
- }, |
|
| 177 |
- typeIdentifier: "HKQuantityTypeIdentifierStepCount", |
|
| 178 |
- displayName: "Step Count", |
|
| 179 |
- currentSnapshotID: UUID() |
|
| 180 |
- ) |
|
| 181 |
- .padding() |
|
| 182 |
-} |
|