Showing 7 changed files with 151 additions and 68 deletions
+69 -0
HealthProbe/Services/SQLiteHealthArchiveStore.swift
@@ -93,6 +93,14 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
93 93
         return try observationRows(limit: limit, db: db)
94 94
     }
95 95
 
96
+    func typeSummaries(observationID: Int64, limit: Int? = nil) async throws -> [CachedArchiveTypeSummary] {
97
+        let db = try openDatabase()
98
+        defer { sqlite3_close(db) }
99
+        try prepareSchemaIfNeeded(db)
100
+
101
+        return try typeSummaries(observationID: observationID, limit: limit, db: db)
102
+    }
103
+
96 104
     func upsertSamples(_ samples: [HKSample], observedAt: Date) async throws -> HealthArchiveWriteSummary {
97 105
         guard !samples.isEmpty else {
98 106
             return HealthArchiveWriteSummary(insertedCount: 0, updatedCount: 0, unchangedCount: 0)
@@ -2334,6 +2342,67 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
2334 2342
         return rows
2335 2343
     }
2336 2344
 
2345
+    private func typeSummaries(
2346
+        observationID: Int64,
2347
+        limit: Int?,
2348
+        db: OpaquePointer?
2349
+    ) throws -> [CachedArchiveTypeSummary] {
2350
+        let limitClause = limit == nil ? "" : "LIMIT ?"
2351
+        let sql = """
2352
+        SELECT
2353
+            s.observation_id,
2354
+            t.type_identifier,
2355
+            t.display_name,
2356
+            s.visible_record_count,
2357
+            s.appeared_count,
2358
+            s.disappeared_count,
2359
+            s.representation_changed_count,
2360
+            s.earliest_start_date,
2361
+            s.latest_end_date,
2362
+            s.value_sum,
2363
+            s.value_max,
2364
+            s.aggregate_hash
2365
+        FROM observation_type_summaries s
2366
+        JOIN sample_types t ON t.id = s.sample_type_id
2367
+        WHERE s.observation_id = ?
2368
+        ORDER BY t.display_name, t.type_identifier
2369
+        \(limitClause)
2370
+        """
2371
+
2372
+        var rows: [CachedArchiveTypeSummary] = []
2373
+        try withStatement(sql, db: db) { statement in
2374
+            bindInt64(observationID, to: 1, in: statement)
2375
+            if let limit {
2376
+                bindInt(max(limit, 0), to: 2, in: statement)
2377
+            }
2378
+
2379
+            var stepResult = sqlite3_step(statement)
2380
+            while stepResult == SQLITE_ROW {
2381
+                rows.append(CachedArchiveTypeSummary(
2382
+                    observationID: sqlite3_column_int64(statement, 0),
2383
+                    sampleTypeIdentifier: columnText(statement, 1) ?? "",
2384
+                    displayName: columnText(statement, 2),
2385
+                    visibleRecordCount: columnInt(statement, 3) ?? 0,
2386
+                    appearedCount: columnInt(statement, 4) ?? 0,
2387
+                    disappearedCount: columnInt(statement, 5) ?? 0,
2388
+                    representationChangedCount: columnInt(statement, 6) ?? 0,
2389
+                    earliestStartDate: columnUnixDate(statement, 7),
2390
+                    latestEndDate: columnUnixDate(statement, 8),
2391
+                    valueSum: columnDouble(statement, 9),
2392
+                    valueMax: columnDouble(statement, 10),
2393
+                    aggregateHash: columnText(statement, 11),
2394
+                    computedAt: Date()
2395
+                ))
2396
+                stepResult = sqlite3_step(statement)
2397
+            }
2398
+
2399
+            guard stepResult == SQLITE_DONE else {
2400
+                throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db))
2401
+            }
2402
+        }
2403
+        return rows
2404
+    }
2405
+
2337 2406
     private func updateObservationStatus(
2338 2407
         observationID: Int64,
2339 2408
         status: String,
+4 -4
HealthProbe/ViewModels/DashboardViewModel.swift
@@ -319,12 +319,12 @@ final class DashboardViewModel {
319 319
         snapshotProgress = .idle
320 320
     }
321 321
 
322
-    func loadArchiveCacheStatus() {
322
+    @MainActor
323
+    func loadArchiveCacheStatus() async {
323 324
         do {
324
-            let cache = try CoreDataArchiveCacheStore()
325
-            archiveObservationRows = try cache.observationRows(limit: 2)
325
+            archiveObservationRows = try await SQLiteHealthArchiveStore.shared.observationRows(limit: 2)
326 326
             latestArchiveObservation = archiveObservationRows.first
327
-            archiveHealthStatus = try cache.latestArchiveHealthStatus()
327
+            archiveHealthStatus = try CoreDataArchiveCacheStore().latestArchiveHealthStatus()
328 328
             archiveCacheError = nil
329 329
         } catch {
330 330
             archiveObservationRows = []
+33 -13
HealthProbe/ViewModels/DataTypesViewModel.swift
@@ -42,8 +42,7 @@ final class DataTypesViewModel {
42 42
     @MainActor
43 43
     func loadArchiveRows(limit: Int = 200) async {
44 44
         do {
45
-            let cache = try CoreDataArchiveCacheStore()
46
-            observationRows = try cache.observationRows(limit: limit)
45
+            observationRows = try await SQLiteHealthArchiveStore.shared.observationRows(limit: limit)
47 46
             observationRowsError = nil
48 47
         } catch {
49 48
             observationRows = []
@@ -60,12 +59,17 @@ final class DataTypesViewModel {
60 59
         }
61 60
 
62 61
         do {
63
-            let cache = try CoreDataArchiveCacheStore()
64
-            let currentSummaries = try cache.typeSummaries(observationID: current.observationID)
65
-            let baselineSummaries = try cache.typeSummaries(observationID: baseline.observationID)
62
+            let archiveStore = SQLiteHealthArchiveStore.shared
63
+            let currentSummaries = try await archiveStore.typeSummaries(observationID: current.observationID)
64
+            let baselineSummaries = try await archiveStore.typeSummaries(observationID: baseline.observationID)
66 65
             let currentByType = Dictionary(uniqueKeysWithValues: currentSummaries.map { ($0.sampleTypeIdentifier, $0) })
67 66
             let baselineByType = Dictionary(uniqueKeysWithValues: baselineSummaries.map { ($0.sampleTypeIdentifier, $0) })
68 67
             let allTypeIdentifiers = Set(currentByType.keys).union(baselineByType.keys)
68
+            let canUseMaterializedDiffs = isImmediatePreviousBaseline(
69
+                baseline,
70
+                for: current,
71
+                in: snapshots
72
+            )
69 73
 
70 74
             var archiveRows: [TypeDiff] = []
71 75
             archiveRows.reserveCapacity(allTypeIdentifiers.count)
@@ -73,11 +77,16 @@ final class DataTypesViewModel {
73 77
             for typeIdentifier in allTypeIdentifiers {
74 78
                 let summary = currentByType[typeIdentifier]
75 79
                 let baselineSummary = baselineByType[typeIdentifier]
76
-                let diff = try await SQLiteHealthArchiveStore.shared.diffSummary(HealthArchiveDiffRequest(
77
-                    fromObservationID: baseline.observationID,
78
-                    toObservationID: current.observationID,
79
-                    sampleTypeIdentifier: typeIdentifier
80
-                ))
80
+                let diff: HealthArchiveDiffSummary?
81
+                if canUseMaterializedDiffs {
82
+                    diff = nil
83
+                } else {
84
+                    diff = try await archiveStore.diffSummary(HealthArchiveDiffRequest(
85
+                        fromObservationID: baseline.observationID,
86
+                        toObservationID: current.observationID,
87
+                        sampleTypeIdentifier: typeIdentifier
88
+                    ))
89
+                }
81 90
                 archiveRows.append(TypeDiff(
82 91
                     id: typeIdentifier,
83 92
                     typeIdentifier: typeIdentifier,
@@ -85,9 +94,9 @@ final class DataTypesViewModel {
85 94
                     currentCount: summary?.visibleRecordCount ?? 0,
86 95
                     previousCount: baselineSummary?.visibleRecordCount ?? 0,
87 96
                     previousTracked: baselineSummary != nil,
88
-                    appearedCount: diff.appearedCount,
89
-                    disappearedCount: diff.disappearedCount,
90
-                    representationChangedCount: diff.representationChangedCount
97
+                    appearedCount: diff?.appearedCount ?? summary?.appearedCount ?? 0,
98
+                    disappearedCount: diff?.disappearedCount ?? summary?.disappearedCount ?? 0,
99
+                    representationChangedCount: diff?.representationChangedCount ?? summary?.representationChangedCount ?? 0
91 100
                 ))
92 101
             }
93 102
 
@@ -101,6 +110,17 @@ final class DataTypesViewModel {
101 110
         }
102 111
     }
103 112
 
113
+    private func isImmediatePreviousBaseline(
114
+        _ baseline: DataTypeSnapshotContext,
115
+        for snapshot: DataTypeSnapshotContext,
116
+        in snapshots: [DataTypeSnapshotContext]
117
+    ) -> Bool {
118
+        snapshots
119
+            .filter { $0.observedAt < snapshot.observedAt }
120
+            .max { $0.observedAt < $1.observedAt }?
121
+            .observationID == baseline.observationID
122
+    }
123
+
104 124
     private func resolveBaseline(
105 125
         for snapshot: DataTypeSnapshotContext,
106 126
         in snapshots: [DataTypeSnapshotContext]
+1 -1
HealthProbe/Views/Dashboard/DashboardView.swift
@@ -55,7 +55,7 @@ struct DashboardView: View {
55 55
         }
56 56
         .task {
57 57
             currentDeviceProfile = LocalDeviceProfileStore.profile(for: AppSettings.currentDeviceID)
58
-            viewModel.loadArchiveCacheStatus()
58
+            await viewModel.loadArchiveCacheStatus()
59 59
             if !didAutoRequestPermissions && !HealthKitService.shared.hasRequestedPermissionsBefore {
60 60
                 didAutoRequestPermissions = true
61 61
                 await viewModel.requestAuthorization()
+22 -30
HealthProbe/Views/DataTypes/DataTypeArchiveDetailView.swift
@@ -19,6 +19,13 @@ struct DataTypeArchiveDetailView: View {
19 19
         "\(baseline.observationID)|\(current.observationID)|\(typeIdentifier)"
20 20
     }
21 21
 
22
+    private var usesImmediatePreviousBaseline: Bool {
23
+        timeline
24
+            .filter { $0.observedAt < current.observedAt }
25
+            .max { $0.observedAt < $1.observedAt }?
26
+            .observationID == baseline.observationID
27
+    }
28
+
22 29
     private var currentCount: Int {
23 30
         currentSummary?.visibleRecordCount ?? diff.currentCount
24 31
     }
@@ -141,37 +148,22 @@ struct DataTypeArchiveDetailView: View {
141 148
     @MainActor
142 149
     private func loadArchiveDetail() async {
143 150
         do {
144
-            let cache = try CoreDataArchiveCacheStore()
145
-            currentSummary = try cache.typeSummaries(observationID: current.observationID)
151
+            let archiveStore = SQLiteHealthArchiveStore.shared
152
+            currentSummary = try await archiveStore.typeSummaries(observationID: current.observationID)
146 153
                 .first { $0.sampleTypeIdentifier == typeIdentifier }
147
-            baselineSummary = try cache.typeSummaries(observationID: baseline.observationID)
154
+            baselineSummary = try await archiveStore.typeSummaries(observationID: baseline.observationID)
148 155
                 .first { $0.sampleTypeIdentifier == typeIdentifier }
149 156
 
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
157
+            let archiveDiff: HealthArchiveDiffSummary?
158
+            if usesImmediatePreviousBaseline {
159
+                archiveDiff = nil
160
+            } else {
161
+                archiveDiff = try await archiveStore.diffSummary(HealthArchiveDiffRequest(
162
+                    fromObservationID: baseline.observationID,
163
+                    toObservationID: current.observationID,
164
+                    sampleTypeIdentifier: typeIdentifier
165
+                ))
168 166
             }
169
-
170
-            let archiveDiff = try await SQLiteHealthArchiveStore.shared.diffSummary(HealthArchiveDiffRequest(
171
-                fromObservationID: baseline.observationID,
172
-                toObservationID: current.observationID,
173
-                sampleTypeIdentifier: typeIdentifier
174
-            ))
175 167
             diff = TypeDiff(
176 168
                 id: typeIdentifier,
177 169
                 typeIdentifier: typeIdentifier,
@@ -179,9 +171,9 @@ struct DataTypeArchiveDetailView: View {
179 171
                 currentCount: currentSummary?.visibleRecordCount ?? 0,
180 172
                 previousCount: baselineSummary?.visibleRecordCount ?? 0,
181 173
                 previousTracked: baselineSummary != nil,
182
-                appearedCount: archiveDiff.appearedCount,
183
-                disappearedCount: archiveDiff.disappearedCount,
184
-                representationChangedCount: archiveDiff.representationChangedCount
174
+                appearedCount: archiveDiff?.appearedCount ?? currentSummary?.appearedCount ?? 0,
175
+                disappearedCount: archiveDiff?.disappearedCount ?? currentSummary?.disappearedCount ?? 0,
176
+                representationChangedCount: archiveDiff?.representationChangedCount ?? currentSummary?.representationChangedCount ?? 0
185 177
             )
186 178
             loadError = nil
187 179
         } catch {
+7 -13
HealthProbe/Views/DataTypes/RecordChangeEvolutionChart.swift
@@ -367,13 +367,13 @@ struct RecordChangeEvolutionChart: View {
367 367
         }
368 368
 
369 369
         do {
370
-            let cache = try CoreDataArchiveCacheStore()
370
+            let archiveStore = SQLiteHealthArchiveStore.shared
371 371
             var counts: [Int64: Int] = [:]
372 372
             var diffs: [ArchiveDiffKey: RecordChangeDiff] = [:]
373 373
 
374 374
             for snapshot in archiveSnapshots {
375 375
                 guard let observationID = snapshot.archiveObservationID else { continue }
376
-                let summary = try cache.typeSummaries(observationID: observationID)
376
+                let summary = try await archiveStore.typeSummaries(observationID: observationID)
377 377
                     .first { $0.sampleTypeIdentifier == typeIdentifier }
378 378
                 counts[observationID] = summary?.visibleRecordCount ?? 0
379 379
 
@@ -382,17 +382,11 @@ struct RecordChangeEvolutionChart: View {
382 382
                     continue
383 383
                 }
384 384
 
385
-                if let diff = try cache.diffSummary(
386
-                    fromObservationID: previousObservationID,
387
-                    toObservationID: observationID,
388
-                    sampleTypeIdentifier: typeIdentifier
389
-                ) {
390
-                    diffs[ArchiveDiffKey(fromObservationID: previousObservationID, toObservationID: observationID)] = RecordChangeDiff(
391
-                        added: diff.appearedCount,
392
-                        disappeared: diff.disappearedCount,
393
-                        isExact: true
394
-                    )
395
-                }
385
+                diffs[ArchiveDiffKey(fromObservationID: previousObservationID, toObservationID: observationID)] = RecordChangeDiff(
386
+                    added: summary?.appearedCount ?? 0,
387
+                    disappeared: summary?.disappearedCount ?? 0,
388
+                    isExact: true
389
+                )
396 390
             }
397 391
 
398 392
             cachedCountsByObservationID = counts
+15 -7
HealthProbe/Views/Snapshots/SnapshotArchiveDetailView.swift
@@ -46,6 +46,13 @@ struct SnapshotArchiveDetailView: View {
46 46
         "\(baseline?.observationID ?? -1)|\(row.observationID)"
47 47
     }
48 48
 
49
+    private var immediatePreviousBaselineID: Int64? {
50
+        timelineRows
51
+            .filter { $0.observedAt < row.observedAt }
52
+            .max { $0.observedAt < $1.observedAt }?
53
+            .observationID
54
+    }
55
+
49 56
     var body: some View {
50 57
         List {
51 58
             summarySection
@@ -121,17 +128,18 @@ struct SnapshotArchiveDetailView: View {
121 128
     @MainActor
122 129
     private func loadTypeRows() async {
123 130
         do {
124
-            let cache = try CoreDataArchiveCacheStore()
125
-            let currentSummaries = try cache.typeSummaries(observationID: row.observationID)
131
+            let archiveStore = SQLiteHealthArchiveStore.shared
132
+            let currentSummaries = try await archiveStore.typeSummaries(observationID: row.observationID)
126 133
             let previousSummaries: [CachedArchiveTypeSummary]
127 134
             if let baseline {
128
-                previousSummaries = try cache.typeSummaries(observationID: baseline.observationID)
135
+                previousSummaries = try await archiveStore.typeSummaries(observationID: baseline.observationID)
129 136
             } else {
130 137
                 previousSummaries = []
131 138
             }
132 139
             let currentByType = Dictionary(uniqueKeysWithValues: currentSummaries.map { ($0.sampleTypeIdentifier, $0) })
133 140
             let previousByType = Dictionary(uniqueKeysWithValues: previousSummaries.map { ($0.sampleTypeIdentifier, $0) })
134 141
             let typeIdentifiers = Set(currentByType.keys).union(previousByType.keys)
142
+            let canUseMaterializedDiffs = baseline?.observationID == immediatePreviousBaselineID
135 143
 
136 144
             var rows: [SnapshotArchiveTypeSummaryRow] = []
137 145
             rows.reserveCapacity(typeIdentifiers.count)
@@ -139,13 +147,13 @@ struct SnapshotArchiveDetailView: View {
139 147
             for typeIdentifier in typeIdentifiers {
140 148
                 let summary = currentByType[typeIdentifier]
141 149
                 let previousSummary = previousByType[typeIdentifier]
142
-                let diffSummary: CachedArchiveDiffSummary?
143
-                if let baseline {
144
-                    diffSummary = try cache.diffSummary(
150
+                let diffSummary: HealthArchiveDiffSummary?
151
+                if let baseline, !canUseMaterializedDiffs {
152
+                    diffSummary = try await archiveStore.diffSummary(HealthArchiveDiffRequest(
145 153
                         fromObservationID: baseline.observationID,
146 154
                         toObservationID: row.observationID,
147 155
                         sampleTypeIdentifier: typeIdentifier
148
-                    )
156
+                    ))
149 157
                 } else {
150 158
                     diffSummary = nil
151 159
                 }