@@ -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, |
@@ -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 = [] |
@@ -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] |
@@ -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() |
@@ -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 {
|
@@ -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 |
@@ -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 |
} |