Showing 5 changed files with 122 additions and 6 deletions
+3 -2
HealthProbe/Doc/04-project/IMPLEMENTATION_STATUS.md
@@ -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.
+1 -0
HealthProbe/Doc/04-project/Refactoring-Plan.md
@@ -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.
+16 -2
HealthProbe/Services/SnapshotDiffService.swift
@@ -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
         }
+66 -0
HealthProbe/ViewModels/DataTypesViewModel.swift
@@ -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 {
+36 -2
HealthProbe/Views/DataTypes/DataTypesView.swift
@@ -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 {