Showing 8 changed files with 32 additions and 251 deletions
+5 -5
HealthProbe/Doc/00-agent-guides/AGENTS.md
@@ -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")
+1 -1
HealthProbe/Doc/04-project/IMPLEMENTATION_STATUS.md
@@ -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 |
+1 -0
HealthProbe/Doc/04-project/Refactoring-Plan.md
@@ -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.
+3 -0
HealthProbe/Doc/04-project/SwiftData-Retirement-Inventory.md
@@ -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
+0 -55
HealthProbe/Services/SnapshotDiffService.swift
@@ -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
-}
+19 -7
HealthProbe/Utilities/SnapshotPDFExporter.swift
@@ -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
+3 -1
HealthProbe/ViewModels/SnapshotsViewModel.swift
@@ -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
 
+0 -182
HealthProbe/Views/DataTypes/TypeEvolutionTimeline.swift
@@ -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
-}