@@ -26,9 +26,9 @@ There are no real deployments, only test installations. Existing prototype datab |
||
| 26 | 26 |
| Product docs | Updated | Keep `HealthProbe/Doc/README.md` as canonical index | |
| 27 | 27 |
| HealthKit capture | Capture now opens one archive observation per user-visible snapshot and attaches HealthKit pages, deleted-object evidence, and type verification to that observation id before finishing it | Continue moving UI/cache reads to archive-backed observation ids | |
| 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 |
-| 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 | |
|
| 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 | 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. Dashboard status prefers archive/cache observation rows and shows cache health; Snapshots timeline, snapshot detail summaries/type rows, and Data Types list prefer Core Data cache rows when archive observation ids exist; data type detail/drill-down uses SQLite `diffSummary`/`diffRecords`; visible change labels now use neutral new/missing/change-review language, with SwiftData detail cache as transition fallback | Finish remaining detail charts/export previews on paged SQLite DTOs | |
|
| 31 |
+| UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker. Dashboard status prefers archive/cache observation rows and shows cache health; Snapshots timeline, snapshot detail summaries/type rows, and Data Types list prefer Core Data cache rows when archive observation ids exist; data type detail reads Core Data type/diff summaries and uses SQLite `diffRecords` for paged drill-down; visible change labels now use neutral new/missing/change-review language, with SwiftData detail cache as transition fallback | Finish export previews on paged SQLite 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 | |
@@ -66,6 +66,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m |
||
| 66 | 66 |
- [x] Snapshot detail summary/type rows use Core Data cached summaries plus SQLite diff summaries when archive observation ids are available. |
| 67 | 67 |
- [x] Data Types list rows use Core Data cached counts plus SQLite diff summaries when archive observation ids are available. |
| 68 | 68 |
- [x] Data type new/missing drill-down pages records from SQLite diff queries when archive observation ids are available. |
| 69 |
+- [x] Data type diff detail and evolution summaries prefer Core Data cache rows when archive observation ids are available. |
|
| 69 | 70 |
- [x] Expensive counts used by reports/UI are cached and rebuildable. |
| 70 | 71 |
- [x] Deleting Core Data cache and rebuilding from SQLite restores UI/report summaries. |
| 71 | 72 |
- [x] Dashboard surfaces SQLite/Core Data cache health, cache schema, cache errors, and latest archive observation counts. |
@@ -227,7 +227,7 @@ Checklist: |
||
| 227 | 227 |
- [x] Data Types list rows prefer Core Data cached counts plus SQLite `diffSummary` when archive observation ids exist. |
| 228 | 228 |
- [x] Data type detail uses SQLite `diffSummary` when archive observation ids exist. |
| 229 | 229 |
- [x] Data type new/missing drill-down pages through SQLite `diffRecords` when archive observation ids exist. |
| 230 |
-- [ ] Diff detail fully uses cached summary plus paged SQLite DTOs. |
|
| 230 |
+- [x] Diff detail fully uses cached summary plus paged SQLite DTOs. |
|
| 231 | 231 |
- [x] Data type screens use target change labels. |
| 232 | 232 |
- [ ] Export preview uses export query/manifest APIs. |
| 233 | 233 |
- [x] Archive status reflects SQLite/Core Data cache health. |
@@ -54,6 +54,22 @@ struct CachedArchiveTypeSummary: Equatable, Identifiable, Sendable {
|
||
| 54 | 54 |
var id: String { "\(observationID)|\(sampleTypeIdentifier)" }
|
| 55 | 55 |
} |
| 56 | 56 |
|
| 57 |
+struct CachedArchiveDiffSummary: Equatable, Identifiable, Sendable {
|
|
| 58 |
+ let fromObservationID: Int64 |
|
| 59 |
+ let toObservationID: Int64 |
|
| 60 |
+ let sampleTypeIdentifier: String? |
|
| 61 |
+ let appearedCount: Int |
|
| 62 |
+ let disappearedCount: Int |
|
| 63 |
+ let representationChangedCount: Int |
|
| 64 |
+ let consolidationLikely: Bool |
|
| 65 |
+ let uncertaintyReason: String? |
|
| 66 |
+ let computedAt: Date |
|
| 67 |
+ |
|
| 68 |
+ var id: String {
|
|
| 69 |
+ "\(fromObservationID)|\(toObservationID)|\(sampleTypeIdentifier ?? "*")" |
|
| 70 |
+ } |
|
| 71 |
+} |
|
| 72 |
+ |
|
| 57 | 73 |
struct CachedArchiveHealthStatus: Equatable, Sendable {
|
| 58 | 74 |
let archiveSchemaVersion: Int |
| 59 | 75 |
let cacheSchemaVersion: Int |
@@ -165,6 +181,30 @@ final class CoreDataArchiveCacheStore {
|
||
| 165 | 181 |
return try container.viewContext.fetch(request).map(Self.typeSummary) |
| 166 | 182 |
} |
| 167 | 183 |
|
| 184 |
+ func diffSummary( |
|
| 185 |
+ fromObservationID: Int64, |
|
| 186 |
+ toObservationID: Int64, |
|
| 187 |
+ sampleTypeIdentifier: String? |
|
| 188 |
+ ) throws -> CachedArchiveDiffSummary? {
|
|
| 189 |
+ let request = NSFetchRequest<NSManagedObject>(entityName: "CachedDiffSummary") |
|
| 190 |
+ if let sampleTypeIdentifier {
|
|
| 191 |
+ request.predicate = NSPredicate( |
|
| 192 |
+ format: "fromObservationID == %lld AND toObservationID == %lld AND sampleTypeIdentifier == %@", |
|
| 193 |
+ fromObservationID, |
|
| 194 |
+ toObservationID, |
|
| 195 |
+ sampleTypeIdentifier |
|
| 196 |
+ ) |
|
| 197 |
+ } else {
|
|
| 198 |
+ request.predicate = NSPredicate( |
|
| 199 |
+ format: "fromObservationID == %lld AND toObservationID == %lld AND sampleTypeIdentifier == nil", |
|
| 200 |
+ fromObservationID, |
|
| 201 |
+ toObservationID |
|
| 202 |
+ ) |
|
| 203 |
+ } |
|
| 204 |
+ request.fetchLimit = 1 |
|
| 205 |
+ return try container.viewContext.fetch(request).first.map(Self.diffSummary) |
|
| 206 |
+ } |
|
| 207 |
+ |
|
| 168 | 208 |
func latestArchiveHealthStatus() throws -> CachedArchiveHealthStatus? {
|
| 169 | 209 |
let request = NSFetchRequest<NSManagedObject>(entityName: "CachedArchiveHealth") |
| 170 | 210 |
request.sortDescriptors = [NSSortDescriptor(key: "computedAt", ascending: false)] |
@@ -489,6 +529,20 @@ private extension CoreDataArchiveCacheStore {
|
||
| 489 | 529 |
) |
| 490 | 530 |
} |
| 491 | 531 |
|
| 532 |
+ nonisolated static func diffSummary(_ object: NSManagedObject) -> CachedArchiveDiffSummary {
|
|
| 533 |
+ CachedArchiveDiffSummary( |
|
| 534 |
+ fromObservationID: object.value(forKey: "fromObservationID") as? Int64 ?? 0, |
|
| 535 |
+ toObservationID: object.value(forKey: "toObservationID") as? Int64 ?? 0, |
|
| 536 |
+ sampleTypeIdentifier: object.value(forKey: "sampleTypeIdentifier") as? String, |
|
| 537 |
+ appearedCount: Int(object.value(forKey: "appearedCount") as? Int64 ?? 0), |
|
| 538 |
+ disappearedCount: Int(object.value(forKey: "disappearedCount") as? Int64 ?? 0), |
|
| 539 |
+ representationChangedCount: Int(object.value(forKey: "representationChangedCount") as? Int64 ?? 0), |
|
| 540 |
+ consolidationLikely: object.value(forKey: "consolidationLikely") as? Bool ?? false, |
|
| 541 |
+ uncertaintyReason: object.value(forKey: "uncertaintyReason") as? String, |
|
| 542 |
+ computedAt: object.value(forKey: "computedAt") as? Date ?? Date(timeIntervalSince1970: 0) |
|
| 543 |
+ ) |
|
| 544 |
+ } |
|
| 545 |
+ |
|
| 492 | 546 |
nonisolated static func archiveHealthStatus(_ object: NSManagedObject) -> CachedArchiveHealthStatus {
|
| 493 | 547 |
CachedArchiveHealthStatus( |
| 494 | 548 |
archiveSchemaVersion: Int(object.value(forKey: "archiveSchemaVersion") as? Int64 ?? 0), |
@@ -8,6 +8,8 @@ struct RecordChangeEvolutionChart: View {
|
||
| 8 | 8 |
let displayName: String |
| 9 | 9 |
|
| 10 | 10 |
@Query private var allDeltas: [SnapshotDelta] |
| 11 |
+ @State private var cachedCountsByObservationID: [Int64: Int] = [:] |
|
| 12 |
+ @State private var cachedDiffsByPair: [ArchiveDiffKey: RecordChangeDiff]? |
|
| 11 | 13 |
|
| 12 | 14 |
private var sortedSnapshots: [HealthSnapshot] {
|
| 13 | 15 |
snapshots.sorted(by: HealthSnapshot.timelineSort) |
@@ -25,23 +27,24 @@ struct RecordChangeEvolutionChart: View {
|
||
| 25 | 27 |
} |
| 26 | 28 |
|
| 27 | 29 |
private var diffTaskID: String {
|
| 28 |
- ([typeIdentifier, currentSnapshotID.uuidString] + contextSnapshots.map { $0.id.uuidString }).joined(separator: "|")
|
|
| 30 |
+ ( |
|
| 31 |
+ [typeIdentifier, currentSnapshotID.uuidString] |
|
| 32 |
+ + contextSnapshots.map { "\($0.id.uuidString):\($0.archiveObservationID ?? -1)" }
|
|
| 33 |
+ ) |
|
| 34 |
+ .joined(separator: "|") |
|
| 29 | 35 |
} |
| 30 | 36 |
|
| 31 | 37 |
private var maxCount: Int {
|
| 32 |
- let counts = contextSnapshots.compactMap { snapshot in
|
|
| 33 |
- snapshot.typeCounts?.first { $0.typeIdentifier == typeIdentifier }?.count
|
|
| 34 |
- } |
|
| 38 |
+ let counts = contextSnapshots.map(countForSnapshot) |
|
| 35 | 39 |
return counts.max() ?? 1 |
| 36 | 40 |
} |
| 37 | 41 |
|
| 38 | 42 |
private var chartPoints: [ChartPoint] {
|
| 39 | 43 |
contextSnapshots.map { snapshot in
|
| 40 |
- let typeCount = snapshot.typeCounts?.first { $0.typeIdentifier == typeIdentifier }
|
|
| 41 | 44 |
let previousSnapshot = snapshot.previousInTimeline(sortedSnapshots) |
| 42 | 45 |
return ChartPoint( |
| 43 | 46 |
snapshot: snapshot, |
| 44 |
- count: max(typeCount?.count ?? 0, 0), |
|
| 47 |
+ count: max(countForSnapshot(snapshot), 0), |
|
| 45 | 48 |
diff: recordDiff(current: snapshot, previous: previousSnapshot) |
| 46 | 49 |
) |
| 47 | 50 |
} |
@@ -61,6 +64,13 @@ struct RecordChangeEvolutionChart: View {
|
||
| 61 | 64 |
private func recordDiff(current: HealthSnapshot, previous: HealthSnapshot?) -> RecordChangeDiff {
|
| 62 | 65 |
guard let previous = previous else { return RecordChangeDiff() }
|
| 63 | 66 |
|
| 67 |
+ if let currentObservationID = current.archiveObservationID, |
|
| 68 |
+ let previousObservationID = previous.archiveObservationID, |
|
| 69 |
+ let cachedDiffsByPair {
|
|
| 70 |
+ let key = ArchiveDiffKey(fromObservationID: previousObservationID, toObservationID: currentObservationID) |
|
| 71 |
+ return cachedDiffsByPair[key] ?? RecordChangeDiff(isExact: true) |
|
| 72 |
+ } |
|
| 73 |
+ |
|
| 64 | 74 |
if let typeCount = current.typeCounts?.first(where: { $0.typeIdentifier == typeIdentifier }),
|
| 65 | 75 |
let cache = typeCount.detailCache, |
| 66 | 76 |
cache.matchesBaseline(previous.id) {
|
@@ -102,6 +112,9 @@ struct RecordChangeEvolutionChart: View {
|
||
| 102 | 112 |
emptyState |
| 103 | 113 |
} else {
|
| 104 | 114 |
evolutionView |
| 115 |
+ .task(id: diffTaskID) {
|
|
| 116 |
+ await loadArchiveEvolutionCache() |
|
| 117 |
+ } |
|
| 105 | 118 |
} |
| 106 | 119 |
} |
| 107 | 120 |
|
@@ -145,7 +158,7 @@ struct RecordChangeEvolutionChart: View {
|
||
| 145 | 158 |
Text("Min")
|
| 146 | 159 |
.font(.caption) |
| 147 | 160 |
.foregroundStyle(.secondary) |
| 148 |
- Text("\(contextSnapshots.compactMap { $0.typeCounts?.first { $0.typeIdentifier == typeIdentifier }?.count }.min() ?? 0)")
|
|
| 161 |
+ Text("\(contextSnapshots.map(countForSnapshot).min() ?? 0)")
|
|
| 149 | 162 |
.font(.caption.weight(.semibold)) |
| 150 | 163 |
.monospacedDigit() |
| 151 | 164 |
} |
@@ -156,9 +169,8 @@ struct RecordChangeEvolutionChart: View {
|
||
| 156 | 169 |
Text("Current")
|
| 157 | 170 |
.font(.caption) |
| 158 | 171 |
.foregroundStyle(.secondary) |
| 159 |
- if let current = contextSnapshots.first(where: { $0.id == currentSnapshotID }),
|
|
| 160 |
- let count = current.typeCounts?.first(where: { $0.typeIdentifier == typeIdentifier })?.count {
|
|
| 161 |
- Text("\(count)")
|
|
| 172 |
+ if let current = contextSnapshots.first(where: { $0.id == currentSnapshotID }) {
|
|
| 173 |
+ Text("\(countForSnapshot(current))")
|
|
| 162 | 174 |
.font(.caption.weight(.semibold)) |
| 163 | 175 |
.monospacedDigit() |
| 164 | 176 |
.foregroundStyle(Color.accentColor) |
@@ -302,6 +314,61 @@ struct RecordChangeEvolutionChart: View {
|
||
| 302 | 314 |
Text(label) |
| 303 | 315 |
} |
| 304 | 316 |
} |
| 317 |
+ |
|
| 318 |
+ private func countForSnapshot(_ snapshot: HealthSnapshot) -> Int {
|
|
| 319 |
+ if let observationID = snapshot.archiveObservationID, |
|
| 320 |
+ let cachedCount = cachedCountsByObservationID[observationID] {
|
|
| 321 |
+ return cachedCount |
|
| 322 |
+ } |
|
| 323 |
+ |
|
| 324 |
+ return snapshot.typeCounts?.first { $0.typeIdentifier == typeIdentifier }?.count ?? 0
|
|
| 325 |
+ } |
|
| 326 |
+ |
|
| 327 |
+ @MainActor |
|
| 328 |
+ private func loadArchiveEvolutionCache() async {
|
|
| 329 |
+ let archiveSnapshots = contextSnapshots.filter { $0.archiveObservationID != nil }
|
|
| 330 |
+ guard !archiveSnapshots.isEmpty else {
|
|
| 331 |
+ cachedCountsByObservationID = [:] |
|
| 332 |
+ cachedDiffsByPair = nil |
|
| 333 |
+ return |
|
| 334 |
+ } |
|
| 335 |
+ |
|
| 336 |
+ do {
|
|
| 337 |
+ let cache = try CoreDataArchiveCacheStore() |
|
| 338 |
+ var counts: [Int64: Int] = [:] |
|
| 339 |
+ var diffs: [ArchiveDiffKey: RecordChangeDiff] = [:] |
|
| 340 |
+ |
|
| 341 |
+ for snapshot in archiveSnapshots {
|
|
| 342 |
+ guard let observationID = snapshot.archiveObservationID else { continue }
|
|
| 343 |
+ let summary = try cache.typeSummaries(observationID: observationID) |
|
| 344 |
+ .first { $0.sampleTypeIdentifier == typeIdentifier }
|
|
| 345 |
+ counts[observationID] = summary?.visibleRecordCount ?? 0 |
|
| 346 |
+ |
|
| 347 |
+ guard let previous = snapshot.previousInTimeline(sortedSnapshots), |
|
| 348 |
+ let previousObservationID = previous.archiveObservationID else {
|
|
| 349 |
+ continue |
|
| 350 |
+ } |
|
| 351 |
+ |
|
| 352 |
+ if let diff = try cache.diffSummary( |
|
| 353 |
+ fromObservationID: previousObservationID, |
|
| 354 |
+ toObservationID: observationID, |
|
| 355 |
+ sampleTypeIdentifier: typeIdentifier |
|
| 356 |
+ ) {
|
|
| 357 |
+ diffs[ArchiveDiffKey(fromObservationID: previousObservationID, toObservationID: observationID)] = RecordChangeDiff( |
|
| 358 |
+ added: diff.appearedCount, |
|
| 359 |
+ disappeared: diff.disappearedCount, |
|
| 360 |
+ isExact: true |
|
| 361 |
+ ) |
|
| 362 |
+ } |
|
| 363 |
+ } |
|
| 364 |
+ |
|
| 365 |
+ cachedCountsByObservationID = counts |
|
| 366 |
+ cachedDiffsByPair = diffs |
|
| 367 |
+ } catch {
|
|
| 368 |
+ cachedCountsByObservationID = [:] |
|
| 369 |
+ cachedDiffsByPair = nil |
|
| 370 |
+ } |
|
| 371 |
+ } |
|
| 305 | 372 |
} |
| 306 | 373 |
|
| 307 | 374 |
private struct ChartPoint {
|
@@ -326,6 +393,11 @@ private struct RecordChangeDiff {
|
||
| 326 | 393 |
} |
| 327 | 394 |
} |
| 328 | 395 |
|
| 396 |
+private struct ArchiveDiffKey: Hashable {
|
|
| 397 |
+ let fromObservationID: Int64 |
|
| 398 |
+ let toObservationID: Int64 |
|
| 399 |
+} |
|
| 400 |
+ |
|
| 329 | 401 |
#Preview {
|
| 330 | 402 |
let mockSnapshots = (0..<7).map { idx in
|
| 331 | 403 |
let snapshot = HealthSnapshot( |
@@ -16,6 +16,8 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 16 | 16 |
@State private var showDisappearedRecords = false |
| 17 | 17 |
@State private var showTemporalDistribution = false |
| 18 | 18 |
@State private var detailCacheDiagnostic: String? |
| 19 |
+ @State private var currentCachedTypeSummary: CachedArchiveTypeSummary? |
|
| 20 |
+ @State private var previousCachedTypeSummary: CachedArchiveTypeSummary? |
|
| 19 | 21 |
|
| 20 | 22 |
private var currentSnapshot: HealthSnapshot {
|
| 21 | 23 |
displayedSnapshot ?? snapshot |
@@ -91,11 +93,11 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 91 | 93 |
} |
| 92 | 94 |
|
| 93 | 95 |
private var quickCurrentCountValue: Int {
|
| 94 |
- max(currentTypeCount?.count ?? 0, 0) |
|
| 96 |
+ max(currentCachedTypeSummary?.visibleRecordCount ?? currentTypeCount?.count ?? 0, 0) |
|
| 95 | 97 |
} |
| 96 | 98 |
|
| 97 | 99 |
private var quickPreviousCountValue: Int {
|
| 98 |
- max(previousTypeCount?.count ?? 0, 0) |
|
| 100 |
+ max(previousCachedTypeSummary?.visibleRecordCount ?? previousTypeCount?.count ?? 0, 0) |
|
| 99 | 101 |
} |
| 100 | 102 |
|
| 101 | 103 |
private var quickAddedDisappeared: (added: Int, disappeared: Int, exact: Bool) {
|
@@ -117,12 +119,20 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 117 | 119 |
previousSnapshot?.archiveObservationID |
| 118 | 120 |
} |
| 119 | 121 |
|
| 122 |
+ private var isTypeTrackedInCurrentContext: Bool {
|
|
| 123 |
+ currentTypeCount != nil || |
|
| 124 |
+ previousTypeCount != nil || |
|
| 125 |
+ currentCachedTypeSummary != nil || |
|
| 126 |
+ previousCachedTypeSummary != nil || |
|
| 127 |
+ currentArchiveObservationID != nil |
|
| 128 |
+ } |
|
| 129 |
+ |
|
| 120 | 130 |
var body: some View {
|
| 121 | 131 |
ScrollView {
|
| 122 | 132 |
VStack(spacing: 16) {
|
| 123 | 133 |
if previousSnapshot == nil {
|
| 124 | 134 |
emptyStateContent("No baseline available for this device.", icon: "clock.badge.questionmark")
|
| 125 |
- } else if currentTypeCount == nil && previousTypeCount == nil {
|
|
| 135 |
+ } else if !isTypeTrackedInCurrentContext {
|
|
| 126 | 136 |
emptyStateContent("Data type not tracked in selected snapshots.", icon: "eye.slash")
|
| 127 | 137 |
} else {
|
| 128 | 138 |
dataRangeSection |
@@ -149,6 +159,7 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 149 | 159 |
} |
| 150 | 160 |
} |
| 151 | 161 |
.task(id: diffTaskID) {
|
| 162 |
+ await loadArchiveTypeSummaries() |
|
| 152 | 163 |
await loadRecordDiff() |
| 153 | 164 |
} |
| 154 | 165 |
.navigationDestination(isPresented: $showTemporalDistribution) {
|
@@ -231,10 +242,10 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 231 | 242 |
|
| 232 | 243 |
@ViewBuilder |
| 233 | 244 |
private var dataRangeSection: some View {
|
| 234 |
- if currentTypeCount != nil {
|
|
| 245 |
+ if currentTypeCount != nil || currentCachedTypeSummary != nil {
|
|
| 235 | 246 |
DataTypeRangeIndicator( |
| 236 |
- earliestDate: currentTypeCount?.earliestDate, |
|
| 237 |
- latestDate: currentTypeCount?.latestDate, |
|
| 247 |
+ earliestDate: currentCachedTypeSummary?.earliestStartDate ?? currentTypeCount?.earliestDate, |
|
| 248 |
+ latestDate: currentCachedTypeSummary?.latestEndDate ?? currentTypeCount?.latestDate, |
|
| 238 | 249 |
quality: currentTypeCount?.quality ?? .complete |
| 239 | 250 |
) |
| 240 | 251 |
} |
@@ -247,12 +258,12 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 247 | 258 |
case .loaded(let diff): |
| 248 | 259 |
RecordChangeComparisonCard( |
| 249 | 260 |
displayName: displayName, |
| 250 |
- currentCount: currentTypeCount?.count ?? 0, |
|
| 251 |
- previousCount: previousTypeCount?.count, |
|
| 261 |
+ currentCount: quickCurrentCountValue, |
|
| 262 |
+ previousCount: previousCachedTypeSummary?.visibleRecordCount ?? previousTypeCount?.count, |
|
| 252 | 263 |
addedCount: diff.addedCount, |
| 253 | 264 |
disappearedCount: diff.disappearedCount, |
| 254 |
- isCurrentValid: (currentTypeCount?.count ?? 0) >= 0, |
|
| 255 |
- isPreviousTracked: previousTypeCount != nil, |
|
| 265 |
+ isCurrentValid: quickCurrentCountValue >= 0, |
|
| 266 |
+ isPreviousTracked: previousTypeCount != nil || previousCachedTypeSummary != nil, |
|
| 256 | 267 |
onAddedTap: {
|
| 257 | 268 |
if diff.addedCount > 0 {
|
| 258 | 269 |
showAddedRecords = true |
@@ -365,7 +376,7 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 365 | 376 |
|
| 366 | 377 |
@ViewBuilder |
| 367 | 378 |
private var recordChangeEvolutionSection: some View {
|
| 368 |
- if previousSnapshot != nil, currentTypeCount != nil {
|
|
| 379 |
+ if previousSnapshot != nil, currentTypeCount != nil || currentCachedTypeSummary != nil {
|
|
| 369 | 380 |
RecordChangeEvolutionChart( |
| 370 | 381 |
snapshots: timelineSnapshots, |
| 371 | 382 |
currentSnapshotID: currentSnapshot.id, |
@@ -514,8 +525,8 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 514 | 525 |
return |
| 515 | 526 |
} |
| 516 | 527 |
|
| 517 |
- let currentCount = currentTypeCount?.count ?? 0 |
|
| 518 |
- let previousCount = previousTypeCount?.count ?? 0 |
|
| 528 |
+ let currentCount = currentCachedTypeSummary?.visibleRecordCount ?? currentTypeCount?.count ?? 0 |
|
| 529 |
+ let previousCount = previousCachedTypeSummary?.visibleRecordCount ?? previousTypeCount?.count ?? 0 |
|
| 519 | 530 |
|
| 520 | 531 |
guard currentCount >= 0, previousCount >= 0 else {
|
| 521 | 532 |
detailCacheDiagnostic = "counts-invalid current=\(currentCount) previous=\(previousCount)" |
@@ -525,6 +536,21 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 525 | 536 |
|
| 526 | 537 |
if let previousArchiveObservationID, |
| 527 | 538 |
let currentArchiveObservationID {
|
| 539 |
+ do {
|
|
| 540 |
+ let cache = try CoreDataArchiveCacheStore() |
|
| 541 |
+ if let cached = try cache.diffSummary( |
|
| 542 |
+ fromObservationID: previousArchiveObservationID, |
|
| 543 |
+ toObservationID: currentArchiveObservationID, |
|
| 544 |
+ sampleTypeIdentifier: typeIdentifier |
|
| 545 |
+ ) {
|
|
| 546 |
+ detailCacheDiagnostic = "resolver-v6 phase=core-data-diff-cache" |
|
| 547 |
+ diffState = .loaded(DataTypeRecordDiff(cached: cached)) |
|
| 548 |
+ return |
|
| 549 |
+ } |
|
| 550 |
+ } catch {
|
|
| 551 |
+ detailCacheDiagnostic = "core-data-diff-cache-failed \(error.localizedDescription)" |
|
| 552 |
+ } |
|
| 553 |
+ |
|
| 528 | 554 |
do {
|
| 529 | 555 |
let summary = try await SQLiteHealthArchiveStore.shared.diffSummary(HealthArchiveDiffRequest( |
| 530 | 556 |
fromObservationID: previousArchiveObservationID, |
@@ -549,6 +575,36 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 549 | 575 |
diffState = .loaded(DataTypeRecordDiff(cache: cache)) |
| 550 | 576 |
} |
| 551 | 577 |
|
| 578 |
+ @MainActor |
|
| 579 |
+ private func loadArchiveTypeSummaries() async {
|
|
| 580 |
+ guard currentArchiveObservationID != nil || previousArchiveObservationID != nil else {
|
|
| 581 |
+ currentCachedTypeSummary = nil |
|
| 582 |
+ previousCachedTypeSummary = nil |
|
| 583 |
+ return |
|
| 584 |
+ } |
|
| 585 |
+ |
|
| 586 |
+ do {
|
|
| 587 |
+ let cache = try CoreDataArchiveCacheStore() |
|
| 588 |
+ if let currentArchiveObservationID {
|
|
| 589 |
+ currentCachedTypeSummary = try cache.typeSummaries(observationID: currentArchiveObservationID) |
|
| 590 |
+ .first { $0.sampleTypeIdentifier == typeIdentifier }
|
|
| 591 |
+ } else {
|
|
| 592 |
+ currentCachedTypeSummary = nil |
|
| 593 |
+ } |
|
| 594 |
+ |
|
| 595 |
+ if let previousArchiveObservationID {
|
|
| 596 |
+ previousCachedTypeSummary = try cache.typeSummaries(observationID: previousArchiveObservationID) |
|
| 597 |
+ .first { $0.sampleTypeIdentifier == typeIdentifier }
|
|
| 598 |
+ } else {
|
|
| 599 |
+ previousCachedTypeSummary = nil |
|
| 600 |
+ } |
|
| 601 |
+ } catch {
|
|
| 602 |
+ currentCachedTypeSummary = nil |
|
| 603 |
+ previousCachedTypeSummary = nil |
|
| 604 |
+ detailCacheDiagnostic = "core-data-type-cache-failed \(error.localizedDescription)" |
|
| 605 |
+ } |
|
| 606 |
+ } |
|
| 607 |
+ |
|
| 552 | 608 |
@MainActor |
| 553 | 609 |
private func currentDetailCacheResolution() -> TypeCountDetailCacheResolution? {
|
| 554 | 610 |
if isCurrentTypeContentAliasToPrevious {
|
@@ -628,6 +684,13 @@ private struct DataTypeRecordDiff: Equatable, Sendable {
|
||
| 628 | 684 |
self.disappearedRecords = [] |
| 629 | 685 |
} |
| 630 | 686 |
|
| 687 |
+ init(cached: CachedArchiveDiffSummary) {
|
|
| 688 |
+ self.addedCount = cached.appearedCount |
|
| 689 |
+ self.disappearedCount = cached.disappearedCount |
|
| 690 |
+ self.addedRecords = [] |
|
| 691 |
+ self.disappearedRecords = [] |
|
| 692 |
+ } |
|
| 693 |
+ |
|
| 631 | 694 |
var isPreviewLimited: Bool {
|
| 632 | 695 |
addedCount > Self.previewLimit || disappearedCount > Self.previewLimit |
| 633 | 696 |
} |
@@ -66,6 +66,15 @@ final class CoreDataArchiveCacheStoreTests: XCTestCase {
|
||
| 66 | 66 |
XCTAssertEqual(summaries.count, 1) |
| 67 | 67 |
XCTAssertEqual(summaries.first?.sampleTypeIdentifier, HKQuantityTypeIdentifier.stepCount.rawValue) |
| 68 | 68 |
|
| 69 |
+ let diff = try XCTUnwrap(cache.diffSummary( |
|
| 70 |
+ fromObservationID: 1, |
|
| 71 |
+ toObservationID: 2, |
|
| 72 |
+ sampleTypeIdentifier: HKQuantityTypeIdentifier.stepCount.rawValue |
|
| 73 |
+ )) |
|
| 74 |
+ XCTAssertEqual(diff.appearedCount, 1) |
|
| 75 |
+ XCTAssertEqual(diff.disappearedCount, 0) |
|
| 76 |
+ XCTAssertEqual(diff.representationChangedCount, 0) |
|
| 77 |
+ |
|
| 69 | 78 |
let health = try XCTUnwrap(cache.latestArchiveHealthStatus()) |
| 70 | 79 |
XCTAssertEqual(health.archiveSchemaVersion, 2) |
| 71 | 80 |
XCTAssertEqual(health.lastIntegrityStatus, "ok") |