@@ -152,6 +152,18 @@ final class TypeDistributionBin {
|
||
| 152 | 152 |
// snapshot to contain real changes for some metrics while long-stable metrics behave |
| 153 | 153 |
// as temporal aliases and skip per-type detail cache/diff work. |
| 154 | 154 |
|
| 155 |
+// Interface updated 2026-05-17 — see AGENTS.md |
|
| 156 |
+// Models/HealthSnapshot stores cached overview scalars for UI consumption: |
|
| 157 |
+// tracked type count, aggregate record count, and overall oldest/newest record dates. |
|
| 158 |
+// These values must be computed during snapshot save while TypeCount data is already |
|
| 159 |
+// in memory, so snapshot list/detail screens never recompute them by traversing |
|
| 160 |
+// snapshot.typeCounts on the UI thread. |
|
| 161 |
+ |
|
| 162 |
+// Interface updated 2026-05-17 — see AGENTS.md |
|
| 163 |
+// Models/SnapshotDelta stores cached list/detail summary scalars derived from TypeDelta. |
|
| 164 |
+// Overview screens consume these scalars and type-delta summaries directly instead of |
|
| 165 |
+// recalculating per-snapshot changes from HealthSnapshot.typeCounts. |
|
| 166 |
+ |
|
| 155 | 167 |
// Models/DetectedAnomaly.swift |
| 156 | 168 |
enum AnomalyType: String, Codable {
|
| 157 | 169 |
case historicalInsertion = "historical_insertion" |
@@ -2,11 +2,6 @@ import SwiftUI |
||
| 2 | 2 |
import SwiftData |
| 3 | 3 |
|
| 4 | 4 |
struct ContentView: View {
|
| 5 |
- @Environment(\.modelContext) private var modelContext |
|
| 6 |
- @AppStorage(AppSettings.typeDetailCacheBackfillVersionKey) |
|
| 7 |
- private var typeDetailCacheBackfillVersion: Int = 0 |
|
| 8 |
- @State private var didAttemptTypeDetailCacheBackfill = false |
|
| 9 |
- |
|
| 10 | 5 |
var body: some View {
|
| 11 | 6 |
TabView {
|
| 12 | 7 |
Tab("Dashboard", systemImage: "waveform.path.ecg") {
|
@@ -22,53 +17,11 @@ struct ContentView: View {
|
||
| 22 | 17 |
SettingsView() |
| 23 | 18 |
} |
| 24 | 19 |
} |
| 25 |
- .task {
|
|
| 26 |
- await rebuildTypeDetailCachesIfNeeded() |
|
| 27 |
- } |
|
| 28 |
- } |
|
| 29 |
- |
|
| 30 |
- @MainActor |
|
| 31 |
- private func rebuildTypeDetailCachesIfNeeded() async {
|
|
| 32 |
- guard !didAttemptTypeDetailCacheBackfill, |
|
| 33 |
- typeDetailCacheBackfillVersion < AppSettings.currentTypeDetailCacheBackfillVersion else {
|
|
| 34 |
- return |
|
| 35 |
- } |
|
| 36 |
- didAttemptTypeDetailCacheBackfill = true |
|
| 37 |
- |
|
| 38 |
- try? await Task.sleep(for: .seconds(2)) |
|
| 39 |
- MemoryLog.log("typeDetailCacheBackfill.begin", metadata: [
|
|
| 40 |
- "storedVersion": "\(typeDetailCacheBackfillVersion)", |
|
| 41 |
- "targetVersion": "\(AppSettings.currentTypeDetailCacheBackfillVersion)" |
|
| 42 |
- ]) |
|
| 43 |
- let memoryPulse = MemoryLog.startPulse("typeDetailCacheBackfill", metadata: [
|
|
| 44 |
- "maxTypeCounts": "1" |
|
| 45 |
- ]) |
|
| 46 |
- defer {
|
|
| 47 |
- memoryPulse.cancel() |
|
| 48 |
- MemoryLog.log("typeDetailCacheBackfill.end", metadata: [
|
|
| 49 |
- "storedVersion": "\(typeDetailCacheBackfillVersion)" |
|
| 50 |
- ]) |
|
| 51 |
- } |
|
| 52 |
- |
|
| 53 |
- do {
|
|
| 54 |
- let isComplete = try SnapshotLifecycleService.rebuildMissingDetailCaches( |
|
| 55 |
- context: modelContext, |
|
| 56 |
- maxTypeCounts: 1 |
|
| 57 |
- ) |
|
| 58 |
- MemoryLog.log("typeDetailCacheBackfill.result", metadata: [
|
|
| 59 |
- "isComplete": "\(isComplete)" |
|
| 60 |
- ]) |
|
| 61 |
- if isComplete {
|
|
| 62 |
- typeDetailCacheBackfillVersion = AppSettings.currentTypeDetailCacheBackfillVersion |
|
| 63 |
- } |
|
| 64 |
- } catch {
|
|
| 65 |
- assertionFailure("Failed to rebuild type detail caches: \(error)")
|
|
| 66 |
- } |
|
| 67 | 20 |
} |
| 68 | 21 |
} |
| 69 | 22 |
|
| 70 | 23 |
#Preview {
|
| 71 | 24 |
ContentView() |
| 72 |
- .modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self, DeviceProfile.self], inMemory: true) |
|
| 25 |
+ .modelContainer(for: [HealthSnapshot.self, SnapshotDelta.self, TypeDelta.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self, DeviceProfile.self], inMemory: true) |
|
| 73 | 26 |
.environment(AppSettings()) |
| 74 | 27 |
} |
@@ -14,6 +14,21 @@ struct HealthRecordValue: Codable, Hashable, Identifiable, Sendable {
|
||
| 14 | 14 |
enum HealthRecordArchive {
|
| 15 | 15 |
private static let compactMagic = Data([0x48, 0x50, 0x52, 0x41, 0x32]) // HPRA2 |
| 16 | 16 |
|
| 17 |
+ static func isCompact(_ data: Data) -> Bool {
|
|
| 18 |
+ data.starts(with: compactMagic) |
|
| 19 |
+ } |
|
| 20 |
+ |
|
| 21 |
+ static func compactedIfNeeded(_ data: Data) -> Data? {
|
|
| 22 |
+ if isCompact(data) {
|
|
| 23 |
+ return data |
|
| 24 |
+ } |
|
| 25 |
+ |
|
| 26 |
+ guard let decoded = decode(data) else {
|
|
| 27 |
+ return nil |
|
| 28 |
+ } |
|
| 29 |
+ return encode(decoded) |
|
| 30 |
+ } |
|
| 31 |
+ |
|
| 17 | 32 |
static func encode(_ values: [HealthRecordValue]) -> Data? {
|
| 18 | 33 |
guard let typeIdentifier = values.first?.typeIdentifier else {
|
| 19 | 34 |
return encodeCompact(typeIdentifier: "", values: []) |
@@ -29,7 +44,7 @@ enum HealthRecordArchive {
|
||
| 29 | 44 |
} |
| 30 | 45 |
|
| 31 | 46 |
static func forEachRecord(in data: Data, _ body: (HealthRecordValue) -> Void) -> Bool {
|
| 32 |
- if data.starts(with: compactMagic) {
|
|
| 47 |
+ if isCompact(data) {
|
|
| 33 | 48 |
return forEachCompactRecord(in: data, body) |
| 34 | 49 |
} |
| 35 | 50 |
|
@@ -47,9 +62,9 @@ enum HealthRecordArchive {
|
||
| 47 | 62 |
|
| 48 | 63 |
MemoryLog.log("healthRecordArchive.fingerprintSet.begin", metadata: [
|
| 49 | 64 |
"archive": MemoryLog.format(UInt64(data.count)), |
| 50 |
- "format": data.starts(with: compactMagic) ? "compact" : "plist" |
|
| 65 |
+ "format": isCompact(data) ? "compact" : "plist" |
|
| 51 | 66 |
]) |
| 52 |
- if data.starts(with: compactMagic) {
|
|
| 67 |
+ if isCompact(data) {
|
|
| 53 | 68 |
let fingerprints = compactFingerprintSet(from: data) |
| 54 | 69 |
MemoryLog.log("healthRecordArchive.fingerprintSet.end", metadata: [
|
| 55 | 70 |
"archive": MemoryLog.format(UInt64(data.count)), |
@@ -1,6 +1,7 @@ |
||
| 1 | 1 |
import Foundation |
| 2 | 2 |
import SwiftData |
| 3 | 3 |
|
| 4 |
+// Interface updated 2026-05-17 — see AGENTS.md |
|
| 4 | 5 |
@Model final class HealthSnapshot {
|
| 5 | 6 |
var id: UUID = UUID() |
| 6 | 7 |
var timestamp: Date = Date.now |
@@ -24,6 +25,11 @@ import SwiftData |
||
| 24 | 25 |
var monitoredTypeSetHash: String = "" |
| 25 | 26 |
var monitoredRegistryVersion: Int = 0 |
| 26 | 27 |
var yearlyCountTimezoneIdentifier: String = "" |
| 28 |
+ var cachedSummaryVersion: Int = 0 |
|
| 29 |
+ var cachedTypeCount: Int = 0 |
|
| 30 |
+ var cachedRecordCount: Int = 0 |
|
| 31 |
+ var cachedEarliestRecordDate: Date? |
|
| 32 |
+ var cachedLatestRecordDate: Date? |
|
| 27 | 33 |
@Relationship(deleteRule: .cascade, inverse: \TypeCount.snapshot) |
| 28 | 34 |
var typeCounts: [TypeCount]? = [] |
| 29 | 35 |
|
@@ -38,6 +44,41 @@ import SwiftData |
||
| 38 | 44 |
} |
| 39 | 45 |
|
| 40 | 46 |
extension HealthSnapshot {
|
| 47 |
+ static let currentCachedSummaryVersion = 1 |
|
| 48 |
+ |
|
| 49 |
+ static func timelineSort(_ lhs: HealthSnapshot, _ rhs: HealthSnapshot) -> Bool {
|
|
| 50 |
+ if lhs.timestamp != rhs.timestamp {
|
|
| 51 |
+ return lhs.timestamp < rhs.timestamp |
|
| 52 |
+ } |
|
| 53 |
+ if lhs.localSequenceNumber != rhs.localSequenceNumber {
|
|
| 54 |
+ return lhs.localSequenceNumber < rhs.localSequenceNumber |
|
| 55 |
+ } |
|
| 56 |
+ return lhs.id.uuidString < rhs.id.uuidString |
|
| 57 |
+ } |
|
| 58 |
+ |
|
| 59 |
+ func previousInTimeline(_ snapshots: [HealthSnapshot]) -> HealthSnapshot? {
|
|
| 60 |
+ if let previousSnapshotID, |
|
| 61 |
+ let previous = snapshots.first(where: { $0.id == previousSnapshotID }) {
|
|
| 62 |
+ return previous |
|
| 63 |
+ } |
|
| 64 |
+ |
|
| 65 |
+ let orderedSnapshots = snapshots.sorted(by: Self.timelineSort) |
|
| 66 |
+ guard let index = orderedSnapshots.firstIndex(where: { $0.id == id }),
|
|
| 67 |
+ index > 0 else {
|
|
| 68 |
+ return nil |
|
| 69 |
+ } |
|
| 70 |
+ return orderedSnapshots[index - 1] |
|
| 71 |
+ } |
|
| 72 |
+ |
|
| 73 |
+ func nextInTimeline(_ snapshots: [HealthSnapshot]) -> HealthSnapshot? {
|
|
| 74 |
+ let orderedSnapshots = snapshots.sorted(by: Self.timelineSort) |
|
| 75 |
+ guard let index = orderedSnapshots.firstIndex(where: { $0.id == id }),
|
|
| 76 |
+ index < orderedSnapshots.count - 1 else {
|
|
| 77 |
+ return nil |
|
| 78 |
+ } |
|
| 79 |
+ return orderedSnapshots[index + 1] |
|
| 80 |
+ } |
|
| 81 |
+ |
|
| 41 | 82 |
var snapshotQuality: SnapshotQuality {
|
| 42 | 83 |
get { SnapshotQuality(rawValue: snapshotQualityRaw) ?? .complete }
|
| 43 | 84 |
set { snapshotQualityRaw = newValue.rawValue }
|
@@ -55,4 +96,8 @@ extension HealthSnapshot {
|
||
| 55 | 96 |
var contentRepresentativeSnapshotID: UUID {
|
| 56 | 97 |
contentEquivalentSnapshotID ?? id |
| 57 | 98 |
} |
| 99 |
+ |
|
| 100 |
+ var hasCurrentCachedSummary: Bool {
|
|
| 101 |
+ cachedSummaryVersion >= Self.currentCachedSummaryVersion |
|
| 102 |
+ } |
|
| 58 | 103 |
} |
@@ -9,6 +9,11 @@ import SwiftData |
||
| 9 | 9 |
var computedAt: Date = Date.now |
| 10 | 10 |
var checksumBefore: String = "" |
| 11 | 11 |
var checksumAfter: String = "" |
| 12 |
+ var listSummaryVersion: Int = 0 |
|
| 13 |
+ var absoluteRecordChangeCount: Int = 0 |
|
| 14 |
+ var changedMetricCount: Int = 0 |
|
| 15 |
+ var appearedMetricCount: Int = 0 |
|
| 16 |
+ var disappearedMetricCount: Int = 0 |
|
| 12 | 17 |
var isCloudKitImported: Bool = false |
| 13 | 18 |
@Relationship(deleteRule: .cascade, inverse: \TypeDelta.delta) |
| 14 | 19 |
var typeDeltas: [TypeDelta]? = [] |
@@ -20,3 +25,31 @@ import SwiftData |
||
| 20 | 25 |
self.deviceID = deviceID |
| 21 | 26 |
} |
| 22 | 27 |
} |
| 28 |
+ |
|
| 29 |
+struct SnapshotDeltaListSummary {
|
|
| 30 |
+ let absoluteRecordChangeCount: Int |
|
| 31 |
+ let changedMetricCount: Int |
|
| 32 |
+ let appearedMetricCount: Int |
|
| 33 |
+ let disappearedMetricCount: Int |
|
| 34 |
+ |
|
| 35 |
+ var affectedMetricCount: Int {
|
|
| 36 |
+ changedMetricCount + appearedMetricCount + disappearedMetricCount |
|
| 37 |
+ } |
|
| 38 |
+ |
|
| 39 |
+ var hasChanges: Bool {
|
|
| 40 |
+ absoluteRecordChangeCount > 0 || affectedMetricCount > 0 |
|
| 41 |
+ } |
|
| 42 |
+} |
|
| 43 |
+ |
|
| 44 |
+extension SnapshotDelta {
|
|
| 45 |
+ static let currentListSummaryVersion = 1 |
|
| 46 |
+ |
|
| 47 |
+ var listSummary: SnapshotDeltaListSummary {
|
|
| 48 |
+ SnapshotDeltaListSummary( |
|
| 49 |
+ absoluteRecordChangeCount: absoluteRecordChangeCount, |
|
| 50 |
+ changedMetricCount: changedMetricCount, |
|
| 51 |
+ appearedMetricCount: appearedMetricCount, |
|
| 52 |
+ disappearedMetricCount: disappearedMetricCount |
|
| 53 |
+ ) |
|
| 54 |
+ } |
|
| 55 |
+} |
|
@@ -41,6 +41,10 @@ enum TypeCountDetailCacheArchive {
|
||
| 41 | 41 |
} |
| 42 | 42 |
|
| 43 | 43 |
enum TypeCountDetailCacheBuilder {
|
| 44 |
+ private static let bucketTargetArchiveBytes = 8 * 1_024 * 1_024 |
|
| 45 |
+ private static let minBucketCount = 4 |
|
| 46 |
+ private static let maxBucketCount = 32 |
|
| 47 |
+ |
|
| 44 | 48 |
static func build( |
| 45 | 49 |
current: TypeCount, |
| 46 | 50 |
previous: TypeCount?, |
@@ -48,88 +52,419 @@ enum TypeCountDetailCacheBuilder {
|
||
| 48 | 52 |
) -> TypeCountDetailCache? {
|
| 49 | 53 |
let metadata = buildMetadata(current: current, previous: previous) |
| 50 | 54 |
MemoryLog.log("typeCountDetailCache.build.begin", metadata: metadata)
|
| 51 |
- guard !MemoryLog.isFootprintAtOrAbove(MemoryLog.detailCacheBuildFootprintLimit) else {
|
|
| 52 |
- MemoryLog.log("typeCountDetailCache.build.skippedMemoryPressure", metadata: metadata.merging([
|
|
| 55 |
+ |
|
| 56 |
+ if current.count > 0, current.recordArchiveData == nil { return nil }
|
|
| 57 |
+ if let previous, previous.count > 0, previous.recordArchiveData == nil { return nil }
|
|
| 58 |
+ |
|
| 59 |
+ var accumulator = DetailCacheAccumulator() |
|
| 60 |
+ |
|
| 61 |
+ if MemoryLog.isFootprintAtOrAbove(MemoryLog.detailCacheBuildFootprintLimit) {
|
|
| 62 |
+ MemoryLog.log("typeCountDetailCache.build.fastPathSkippedMemoryPressure", metadata: metadata.merging([
|
|
| 53 | 63 |
"limit": MemoryLog.format(MemoryLog.detailCacheBuildFootprintLimit) |
| 54 | 64 |
]) { _, new in new })
|
| 55 |
- return nil |
|
| 65 |
+ guard buildBucketedDiffData( |
|
| 66 |
+ currentArchive: current.recordArchiveData, |
|
| 67 |
+ previousArchive: previous?.recordArchiveData, |
|
| 68 |
+ metadata: metadata, |
|
| 69 |
+ accumulator: &accumulator |
|
| 70 |
+ ) else {
|
|
| 71 |
+ return nil |
|
| 72 |
+ } |
|
| 73 |
+ } else if !buildFastDiffData( |
|
| 74 |
+ currentArchive: current.recordArchiveData, |
|
| 75 |
+ previousArchive: previous?.recordArchiveData, |
|
| 76 |
+ metadata: metadata, |
|
| 77 |
+ accumulator: &accumulator |
|
| 78 |
+ ) {
|
|
| 79 |
+ MemoryLog.log("typeCountDetailCache.build.fastPathFailedFallingBack", metadata: metadata)
|
|
| 80 |
+ accumulator = DetailCacheAccumulator() |
|
| 81 |
+ guard buildBucketedDiffData( |
|
| 82 |
+ currentArchive: current.recordArchiveData, |
|
| 83 |
+ previousArchive: previous?.recordArchiveData, |
|
| 84 |
+ metadata: metadata, |
|
| 85 |
+ accumulator: &accumulator |
|
| 86 |
+ ) else {
|
|
| 87 |
+ return nil |
|
| 88 |
+ } |
|
| 56 | 89 |
} |
| 57 | 90 |
|
| 58 |
- guard let currentFingerprints = HealthRecordArchive.fingerprintSet(from: current.recordArchiveData) else {
|
|
| 59 |
- MemoryLog.log("typeCountDetailCache.build.currentFingerprintFailed", metadata: metadata)
|
|
| 60 |
- return nil |
|
| 61 |
- } |
|
| 62 |
- MemoryLog.log("typeCountDetailCache.build.currentFingerprintsReady", metadata: metadata.merging([
|
|
| 63 |
- "currentFingerprintCount": "\(currentFingerprints.count)" |
|
| 91 |
+ let cache = TypeCountDetailCache( |
|
| 92 |
+ baselineSnapshotID: baselineSnapshotID, |
|
| 93 |
+ addedCount: accumulator.addedCount, |
|
| 94 |
+ disappearedCount: accumulator.disappearedCount, |
|
| 95 |
+ addedPreviewRecords: accumulator.addedPreview.records, |
|
| 96 |
+ disappearedPreviewRecords: accumulator.disappearedPreview.records, |
|
| 97 |
+ dailyChangeBins: accumulator.dailyBins, |
|
| 98 |
+ earliestRecordDate: accumulator.earliestRecordDate, |
|
| 99 |
+ latestRecordDate: accumulator.latestRecordDate |
|
| 100 |
+ ) |
|
| 101 |
+ MemoryLog.log("typeCountDetailCache.build.finished", metadata: metadata.merging([
|
|
| 102 |
+ "added": "\(cache.addedCount)", |
|
| 103 |
+ "disappeared": "\(cache.disappearedCount)", |
|
| 104 |
+ "dailyBins": "\(cache.dailyChangeBins.count)" |
|
| 64 | 105 |
]) { _, new in new })
|
| 65 |
- guard !MemoryLog.isFootprintAtOrAbove(MemoryLog.detailCacheBuildFootprintLimit) else {
|
|
| 66 |
- MemoryLog.log("typeCountDetailCache.build.skippedAfterCurrentFingerprints", metadata: metadata.merging([
|
|
| 67 |
- "limit": MemoryLog.format(MemoryLog.detailCacheBuildFootprintLimit) |
|
| 106 |
+ return cache |
|
| 107 |
+ } |
|
| 108 |
+ |
|
| 109 |
+ private static func buildFastDiffData( |
|
| 110 |
+ currentArchive: Data?, |
|
| 111 |
+ previousArchive: Data?, |
|
| 112 |
+ metadata: [String: String], |
|
| 113 |
+ accumulator: inout DetailCacheAccumulator |
|
| 114 |
+ ) -> Bool {
|
|
| 115 |
+ switch (currentArchive, previousArchive) {
|
|
| 116 |
+ case let (currentArchive?, previousArchive?): |
|
| 117 |
+ if currentArchive.count <= previousArchive.count {
|
|
| 118 |
+ return scanUsingCurrentFingerprintSet( |
|
| 119 |
+ currentArchive: currentArchive, |
|
| 120 |
+ previousArchive: previousArchive, |
|
| 121 |
+ metadata: metadata, |
|
| 122 |
+ accumulator: &accumulator |
|
| 123 |
+ ) |
|
| 124 |
+ } |
|
| 125 |
+ |
|
| 126 |
+ return scanUsingPreviousFingerprintSet( |
|
| 127 |
+ currentArchive: currentArchive, |
|
| 128 |
+ previousArchive: previousArchive, |
|
| 129 |
+ metadata: metadata, |
|
| 130 |
+ accumulator: &accumulator |
|
| 131 |
+ ) |
|
| 132 |
+ |
|
| 133 |
+ case let (currentArchive?, nil): |
|
| 134 |
+ guard HealthRecordArchive.forEachRecord(in: currentArchive, { record in
|
|
| 135 |
+ accumulator.add(record, as: .added) |
|
| 136 |
+ }) else {
|
|
| 137 |
+ MemoryLog.log("typeCountDetailCache.build.currentScanFailed", metadata: metadata)
|
|
| 138 |
+ return false |
|
| 139 |
+ } |
|
| 140 |
+ MemoryLog.log("typeCountDetailCache.build.currentScanFinished", metadata: metadata)
|
|
| 141 |
+ return true |
|
| 142 |
+ |
|
| 143 |
+ case let (nil, previousArchive?): |
|
| 144 |
+ guard HealthRecordArchive.forEachRecord(in: previousArchive, { record in
|
|
| 145 |
+ accumulator.add(record, as: .disappeared) |
|
| 146 |
+ }) else {
|
|
| 147 |
+ MemoryLog.log("typeCountDetailCache.build.previousScanFailed", metadata: metadata)
|
|
| 148 |
+ return false |
|
| 149 |
+ } |
|
| 150 |
+ MemoryLog.log("typeCountDetailCache.build.previousScanFinished", metadata: metadata)
|
|
| 151 |
+ return true |
|
| 152 |
+ |
|
| 153 |
+ case (nil, nil): |
|
| 154 |
+ return true |
|
| 155 |
+ } |
|
| 156 |
+ } |
|
| 157 |
+ |
|
| 158 |
+ private static func buildBucketedDiffData( |
|
| 159 |
+ currentArchive: Data?, |
|
| 160 |
+ previousArchive: Data?, |
|
| 161 |
+ metadata: [String: String], |
|
| 162 |
+ accumulator: inout DetailCacheAccumulator |
|
| 163 |
+ ) -> Bool {
|
|
| 164 |
+ switch (currentArchive, previousArchive) {
|
|
| 165 |
+ case let (currentArchive?, previousArchive?): |
|
| 166 |
+ let bucketCount = bucketCount(for: max(currentArchive.count, previousArchive.count)) |
|
| 167 |
+ MemoryLog.log("typeCountDetailCache.build.bucketedBegin", metadata: metadata.merging([
|
|
| 168 |
+ "bucketCount": "\(bucketCount)" |
|
| 68 | 169 |
]) { _, new in new })
|
| 69 |
- return nil |
|
| 170 |
+ |
|
| 171 |
+ let didBuild: Bool |
|
| 172 |
+ if currentArchive.count <= previousArchive.count {
|
|
| 173 |
+ didBuild = scanBucketsUsingCurrentFingerprintSet( |
|
| 174 |
+ currentArchive: currentArchive, |
|
| 175 |
+ previousArchive: previousArchive, |
|
| 176 |
+ bucketCount: bucketCount, |
|
| 177 |
+ metadata: metadata, |
|
| 178 |
+ accumulator: &accumulator |
|
| 179 |
+ ) |
|
| 180 |
+ } else {
|
|
| 181 |
+ didBuild = scanBucketsUsingPreviousFingerprintSet( |
|
| 182 |
+ currentArchive: currentArchive, |
|
| 183 |
+ previousArchive: previousArchive, |
|
| 184 |
+ bucketCount: bucketCount, |
|
| 185 |
+ metadata: metadata, |
|
| 186 |
+ accumulator: &accumulator |
|
| 187 |
+ ) |
|
| 188 |
+ } |
|
| 189 |
+ |
|
| 190 |
+ if didBuild {
|
|
| 191 |
+ MemoryLog.log("typeCountDetailCache.build.bucketedEnd", metadata: metadata.merging([
|
|
| 192 |
+ "bucketCount": "\(bucketCount)" |
|
| 193 |
+ ]) { _, new in new })
|
|
| 194 |
+ } |
|
| 195 |
+ return didBuild |
|
| 196 |
+ |
|
| 197 |
+ case let (currentArchive?, nil): |
|
| 198 |
+ guard HealthRecordArchive.forEachRecord(in: currentArchive, { record in
|
|
| 199 |
+ accumulator.add(record, as: .added) |
|
| 200 |
+ }) else {
|
|
| 201 |
+ MemoryLog.log("typeCountDetailCache.build.currentScanFailed", metadata: metadata)
|
|
| 202 |
+ return false |
|
| 203 |
+ } |
|
| 204 |
+ MemoryLog.log("typeCountDetailCache.build.currentScanFinished", metadata: metadata)
|
|
| 205 |
+ return true |
|
| 206 |
+ |
|
| 207 |
+ case let (nil, previousArchive?): |
|
| 208 |
+ guard HealthRecordArchive.forEachRecord(in: previousArchive, { record in
|
|
| 209 |
+ accumulator.add(record, as: .disappeared) |
|
| 210 |
+ }) else {
|
|
| 211 |
+ MemoryLog.log("typeCountDetailCache.build.previousScanFailed", metadata: metadata)
|
|
| 212 |
+ return false |
|
| 213 |
+ } |
|
| 214 |
+ MemoryLog.log("typeCountDetailCache.build.previousScanFinished", metadata: metadata)
|
|
| 215 |
+ return true |
|
| 216 |
+ |
|
| 217 |
+ case (nil, nil): |
|
| 218 |
+ return true |
|
| 70 | 219 |
} |
| 220 |
+ } |
|
| 71 | 221 |
|
| 72 |
- guard let previousFingerprints = HealthRecordArchive.fingerprintSet(from: previous?.recordArchiveData) else {
|
|
| 222 |
+ private static func scanUsingPreviousFingerprintSet( |
|
| 223 |
+ currentArchive: Data, |
|
| 224 |
+ previousArchive: Data, |
|
| 225 |
+ metadata: [String: String], |
|
| 226 |
+ accumulator: inout DetailCacheAccumulator |
|
| 227 |
+ ) -> Bool {
|
|
| 228 |
+ guard var unmatchedPreviousFingerprints = HealthRecordArchive.fingerprintSet(from: previousArchive) else {
|
|
| 73 | 229 |
MemoryLog.log("typeCountDetailCache.build.previousFingerprintFailed", metadata: metadata)
|
| 74 |
- return nil |
|
| 230 |
+ return false |
|
| 75 | 231 |
} |
| 76 | 232 |
MemoryLog.log("typeCountDetailCache.build.previousFingerprintsReady", metadata: metadata.merging([
|
| 77 |
- "previousFingerprintCount": "\(previousFingerprints.count)" |
|
| 233 |
+ "previousFingerprintCount": "\(unmatchedPreviousFingerprints.count)", |
|
| 234 |
+ "fingerprintStrategy": "previousOnly" |
|
| 78 | 235 |
]) { _, new in new })
|
| 79 | 236 |
guard !MemoryLog.isFootprintAtOrAbove(MemoryLog.detailCacheBuildFootprintLimit) else {
|
| 80 | 237 |
MemoryLog.log("typeCountDetailCache.build.skippedAfterPreviousFingerprints", metadata: metadata.merging([
|
| 81 | 238 |
"limit": MemoryLog.format(MemoryLog.detailCacheBuildFootprintLimit) |
| 82 | 239 |
]) { _, new in new })
|
| 83 |
- return nil |
|
| 240 |
+ return false |
|
| 84 | 241 |
} |
| 85 | 242 |
|
| 86 |
- if current.count > 0, current.recordArchiveData == nil { return nil }
|
|
| 87 |
- if let previous, previous.count > 0, previous.recordArchiveData == nil { return nil }
|
|
| 243 |
+ guard HealthRecordArchive.forEachRecord(in: currentArchive, { record in
|
|
| 244 |
+ if unmatchedPreviousFingerprints.remove(record.recordFingerprint) != nil {
|
|
| 245 |
+ accumulator.add(record, as: .unchanged) |
|
| 246 |
+ } else {
|
|
| 247 |
+ accumulator.add(record, as: .added) |
|
| 248 |
+ } |
|
| 249 |
+ }) else {
|
|
| 250 |
+ MemoryLog.log("typeCountDetailCache.build.currentScanFailed", metadata: metadata)
|
|
| 251 |
+ return false |
|
| 252 |
+ } |
|
| 253 |
+ MemoryLog.log("typeCountDetailCache.build.currentScanFinished", metadata: metadata)
|
|
| 88 | 254 |
|
| 89 |
- var accumulator = DetailCacheAccumulator() |
|
| 255 |
+ guard HealthRecordArchive.forEachRecord(in: previousArchive, { record in
|
|
| 256 |
+ if unmatchedPreviousFingerprints.contains(record.recordFingerprint) {
|
|
| 257 |
+ accumulator.add(record, as: .disappeared) |
|
| 258 |
+ } |
|
| 259 |
+ }) else {
|
|
| 260 |
+ MemoryLog.log("typeCountDetailCache.build.previousScanFailed", metadata: metadata)
|
|
| 261 |
+ return false |
|
| 262 |
+ } |
|
| 263 |
+ MemoryLog.log("typeCountDetailCache.build.previousScanFinished", metadata: metadata)
|
|
| 264 |
+ return true |
|
| 265 |
+ } |
|
| 90 | 266 |
|
| 91 |
- if let previousArchive = previous?.recordArchiveData {
|
|
| 267 |
+ private static func scanUsingCurrentFingerprintSet( |
|
| 268 |
+ currentArchive: Data, |
|
| 269 |
+ previousArchive: Data, |
|
| 270 |
+ metadata: [String: String], |
|
| 271 |
+ accumulator: inout DetailCacheAccumulator |
|
| 272 |
+ ) -> Bool {
|
|
| 273 |
+ guard var unmatchedCurrentFingerprints = HealthRecordArchive.fingerprintSet(from: currentArchive) else {
|
|
| 274 |
+ MemoryLog.log("typeCountDetailCache.build.currentFingerprintFailed", metadata: metadata)
|
|
| 275 |
+ return false |
|
| 276 |
+ } |
|
| 277 |
+ MemoryLog.log("typeCountDetailCache.build.currentFingerprintsReady", metadata: metadata.merging([
|
|
| 278 |
+ "currentFingerprintCount": "\(unmatchedCurrentFingerprints.count)", |
|
| 279 |
+ "fingerprintStrategy": "currentOnly" |
|
| 280 |
+ ]) { _, new in new })
|
|
| 281 |
+ guard !MemoryLog.isFootprintAtOrAbove(MemoryLog.detailCacheBuildFootprintLimit) else {
|
|
| 282 |
+ MemoryLog.log("typeCountDetailCache.build.skippedAfterCurrentFingerprints", metadata: metadata.merging([
|
|
| 283 |
+ "limit": MemoryLog.format(MemoryLog.detailCacheBuildFootprintLimit) |
|
| 284 |
+ ]) { _, new in new })
|
|
| 285 |
+ return false |
|
| 286 |
+ } |
|
| 287 |
+ |
|
| 288 |
+ guard HealthRecordArchive.forEachRecord(in: previousArchive, { record in
|
|
| 289 |
+ if unmatchedCurrentFingerprints.remove(record.recordFingerprint) == nil {
|
|
| 290 |
+ accumulator.add(record, as: .disappeared) |
|
| 291 |
+ } |
|
| 292 |
+ }) else {
|
|
| 293 |
+ MemoryLog.log("typeCountDetailCache.build.previousScanFailed", metadata: metadata)
|
|
| 294 |
+ return false |
|
| 295 |
+ } |
|
| 296 |
+ MemoryLog.log("typeCountDetailCache.build.previousScanFinished", metadata: metadata)
|
|
| 297 |
+ |
|
| 298 |
+ guard HealthRecordArchive.forEachRecord(in: currentArchive, { record in
|
|
| 299 |
+ if unmatchedCurrentFingerprints.contains(record.recordFingerprint) {
|
|
| 300 |
+ accumulator.add(record, as: .added) |
|
| 301 |
+ } else {
|
|
| 302 |
+ accumulator.add(record, as: .unchanged) |
|
| 303 |
+ } |
|
| 304 |
+ }) else {
|
|
| 305 |
+ MemoryLog.log("typeCountDetailCache.build.currentScanFailed", metadata: metadata)
|
|
| 306 |
+ return false |
|
| 307 |
+ } |
|
| 308 |
+ MemoryLog.log("typeCountDetailCache.build.currentScanFinished", metadata: metadata)
|
|
| 309 |
+ return true |
|
| 310 |
+ } |
|
| 311 |
+ |
|
| 312 |
+ private static func scanBucketsUsingPreviousFingerprintSet( |
|
| 313 |
+ currentArchive: Data, |
|
| 314 |
+ previousArchive: Data, |
|
| 315 |
+ bucketCount: Int, |
|
| 316 |
+ metadata: [String: String], |
|
| 317 |
+ accumulator: inout DetailCacheAccumulator |
|
| 318 |
+ ) -> Bool {
|
|
| 319 |
+ for bucketNumber in 0..<bucketCount {
|
|
| 320 |
+ var unmatchedPreviousFingerprints: Set<String> = [] |
|
| 92 | 321 |
guard HealthRecordArchive.forEachRecord(in: previousArchive, { record in
|
| 93 |
- if !currentFingerprints.contains(record.recordFingerprint) {
|
|
| 94 |
- accumulator.add(record, as: .disappeared) |
|
| 322 |
+ guard fingerprintBucketIndex(for: record.recordFingerprint, bucketCount: bucketCount) == bucketNumber else {
|
|
| 323 |
+ return |
|
| 95 | 324 |
} |
| 325 |
+ unmatchedPreviousFingerprints.insert(record.recordFingerprint) |
|
| 96 | 326 |
}) else {
|
| 97 |
- MemoryLog.log("typeCountDetailCache.build.previousScanFailed", metadata: metadata)
|
|
| 98 |
- return nil |
|
| 327 |
+ MemoryLog.log("typeCountDetailCache.build.previousFingerprintFailed", metadata: bucketMetadata(
|
|
| 328 |
+ metadata: metadata, |
|
| 329 |
+ bucketIndex: bucketNumber, |
|
| 330 |
+ bucketCount: bucketCount, |
|
| 331 |
+ strategy: "previousBucketed" |
|
| 332 |
+ )) |
|
| 333 |
+ return false |
|
| 99 | 334 |
} |
| 100 |
- MemoryLog.log("typeCountDetailCache.build.previousScanFinished", metadata: metadata)
|
|
| 101 |
- } |
|
| 102 | 335 |
|
| 103 |
- if let currentArchive = current.recordArchiveData {
|
|
| 104 | 336 |
guard HealthRecordArchive.forEachRecord(in: currentArchive, { record in
|
| 105 |
- if previousFingerprints.contains(record.recordFingerprint) {
|
|
| 337 |
+ guard fingerprintBucketIndex(for: record.recordFingerprint, bucketCount: bucketCount) == bucketNumber else {
|
|
| 338 |
+ return |
|
| 339 |
+ } |
|
| 340 |
+ if unmatchedPreviousFingerprints.remove(record.recordFingerprint) != nil {
|
|
| 106 | 341 |
accumulator.add(record, as: .unchanged) |
| 107 | 342 |
} else {
|
| 108 | 343 |
accumulator.add(record, as: .added) |
| 109 | 344 |
} |
| 110 | 345 |
}) else {
|
| 111 |
- MemoryLog.log("typeCountDetailCache.build.currentScanFailed", metadata: metadata)
|
|
| 112 |
- return nil |
|
| 346 |
+ MemoryLog.log("typeCountDetailCache.build.currentScanFailed", metadata: bucketMetadata(
|
|
| 347 |
+ metadata: metadata, |
|
| 348 |
+ bucketIndex: bucketNumber, |
|
| 349 |
+ bucketCount: bucketCount, |
|
| 350 |
+ strategy: "previousBucketed" |
|
| 351 |
+ )) |
|
| 352 |
+ return false |
|
| 353 |
+ } |
|
| 354 |
+ |
|
| 355 |
+ guard HealthRecordArchive.forEachRecord(in: previousArchive, { record in
|
|
| 356 |
+ guard fingerprintBucketIndex(for: record.recordFingerprint, bucketCount: bucketCount) == bucketNumber else {
|
|
| 357 |
+ return |
|
| 358 |
+ } |
|
| 359 |
+ if unmatchedPreviousFingerprints.contains(record.recordFingerprint) {
|
|
| 360 |
+ accumulator.add(record, as: .disappeared) |
|
| 361 |
+ } |
|
| 362 |
+ }) else {
|
|
| 363 |
+ MemoryLog.log("typeCountDetailCache.build.previousScanFailed", metadata: bucketMetadata(
|
|
| 364 |
+ metadata: metadata, |
|
| 365 |
+ bucketIndex: bucketNumber, |
|
| 366 |
+ bucketCount: bucketCount, |
|
| 367 |
+ strategy: "previousBucketed" |
|
| 368 |
+ )) |
|
| 369 |
+ return false |
|
| 113 | 370 |
} |
| 114 |
- MemoryLog.log("typeCountDetailCache.build.currentScanFinished", metadata: metadata)
|
|
| 115 | 371 |
} |
| 116 | 372 |
|
| 117 |
- let cache = TypeCountDetailCache( |
|
| 118 |
- baselineSnapshotID: baselineSnapshotID, |
|
| 119 |
- addedCount: accumulator.addedCount, |
|
| 120 |
- disappearedCount: accumulator.disappearedCount, |
|
| 121 |
- addedPreviewRecords: accumulator.addedPreview.records, |
|
| 122 |
- disappearedPreviewRecords: accumulator.disappearedPreview.records, |
|
| 123 |
- dailyChangeBins: accumulator.dailyBins, |
|
| 124 |
- earliestRecordDate: accumulator.earliestRecordDate, |
|
| 125 |
- latestRecordDate: accumulator.latestRecordDate |
|
| 126 |
- ) |
|
| 127 |
- MemoryLog.log("typeCountDetailCache.build.finished", metadata: metadata.merging([
|
|
| 128 |
- "added": "\(cache.addedCount)", |
|
| 129 |
- "disappeared": "\(cache.disappearedCount)", |
|
| 130 |
- "dailyBins": "\(cache.dailyChangeBins.count)" |
|
| 131 |
- ]) { _, new in new })
|
|
| 132 |
- return cache |
|
| 373 |
+ return true |
|
| 374 |
+ } |
|
| 375 |
+ |
|
| 376 |
+ private static func scanBucketsUsingCurrentFingerprintSet( |
|
| 377 |
+ currentArchive: Data, |
|
| 378 |
+ previousArchive: Data, |
|
| 379 |
+ bucketCount: Int, |
|
| 380 |
+ metadata: [String: String], |
|
| 381 |
+ accumulator: inout DetailCacheAccumulator |
|
| 382 |
+ ) -> Bool {
|
|
| 383 |
+ for bucketNumber in 0..<bucketCount {
|
|
| 384 |
+ var unmatchedCurrentFingerprints: Set<String> = [] |
|
| 385 |
+ guard HealthRecordArchive.forEachRecord(in: currentArchive, { record in
|
|
| 386 |
+ guard fingerprintBucketIndex(for: record.recordFingerprint, bucketCount: bucketCount) == bucketNumber else {
|
|
| 387 |
+ return |
|
| 388 |
+ } |
|
| 389 |
+ unmatchedCurrentFingerprints.insert(record.recordFingerprint) |
|
| 390 |
+ }) else {
|
|
| 391 |
+ MemoryLog.log("typeCountDetailCache.build.currentFingerprintFailed", metadata: bucketMetadata(
|
|
| 392 |
+ metadata: metadata, |
|
| 393 |
+ bucketIndex: bucketNumber, |
|
| 394 |
+ bucketCount: bucketCount, |
|
| 395 |
+ strategy: "currentBucketed" |
|
| 396 |
+ )) |
|
| 397 |
+ return false |
|
| 398 |
+ } |
|
| 399 |
+ |
|
| 400 |
+ guard HealthRecordArchive.forEachRecord(in: previousArchive, { record in
|
|
| 401 |
+ guard fingerprintBucketIndex(for: record.recordFingerprint, bucketCount: bucketCount) == bucketNumber else {
|
|
| 402 |
+ return |
|
| 403 |
+ } |
|
| 404 |
+ if unmatchedCurrentFingerprints.remove(record.recordFingerprint) == nil {
|
|
| 405 |
+ accumulator.add(record, as: .disappeared) |
|
| 406 |
+ } |
|
| 407 |
+ }) else {
|
|
| 408 |
+ MemoryLog.log("typeCountDetailCache.build.previousScanFailed", metadata: bucketMetadata(
|
|
| 409 |
+ metadata: metadata, |
|
| 410 |
+ bucketIndex: bucketNumber, |
|
| 411 |
+ bucketCount: bucketCount, |
|
| 412 |
+ strategy: "currentBucketed" |
|
| 413 |
+ )) |
|
| 414 |
+ return false |
|
| 415 |
+ } |
|
| 416 |
+ |
|
| 417 |
+ guard HealthRecordArchive.forEachRecord(in: currentArchive, { record in
|
|
| 418 |
+ guard fingerprintBucketIndex(for: record.recordFingerprint, bucketCount: bucketCount) == bucketNumber else {
|
|
| 419 |
+ return |
|
| 420 |
+ } |
|
| 421 |
+ if unmatchedCurrentFingerprints.contains(record.recordFingerprint) {
|
|
| 422 |
+ accumulator.add(record, as: .added) |
|
| 423 |
+ } else {
|
|
| 424 |
+ accumulator.add(record, as: .unchanged) |
|
| 425 |
+ } |
|
| 426 |
+ }) else {
|
|
| 427 |
+ MemoryLog.log("typeCountDetailCache.build.currentScanFailed", metadata: bucketMetadata(
|
|
| 428 |
+ metadata: metadata, |
|
| 429 |
+ bucketIndex: bucketNumber, |
|
| 430 |
+ bucketCount: bucketCount, |
|
| 431 |
+ strategy: "currentBucketed" |
|
| 432 |
+ )) |
|
| 433 |
+ return false |
|
| 434 |
+ } |
|
| 435 |
+ } |
|
| 436 |
+ |
|
| 437 |
+ return true |
|
| 438 |
+ } |
|
| 439 |
+ |
|
| 440 |
+ private static func bucketCount(for maxArchiveBytes: Int) -> Int {
|
|
| 441 |
+ let rawBucketCount = max(1, Int(ceil(Double(maxArchiveBytes) / Double(bucketTargetArchiveBytes)))) |
|
| 442 |
+ var bucketCount = 1 |
|
| 443 |
+ while bucketCount < rawBucketCount {
|
|
| 444 |
+ bucketCount <<= 1 |
|
| 445 |
+ } |
|
| 446 |
+ return min(max(bucketCount, minBucketCount), maxBucketCount) |
|
| 447 |
+ } |
|
| 448 |
+ |
|
| 449 |
+ private static func fingerprintBucketIndex(for fingerprint: String, bucketCount: Int) -> Int {
|
|
| 450 |
+ var hash: UInt64 = 1_469_598_103_934_665_603 |
|
| 451 |
+ for byte in fingerprint.utf8.prefix(12) {
|
|
| 452 |
+ hash ^= UInt64(byte) |
|
| 453 |
+ hash &*= 1_099_511_628_211 |
|
| 454 |
+ } |
|
| 455 |
+ return Int(hash % UInt64(bucketCount)) |
|
| 456 |
+ } |
|
| 457 |
+ |
|
| 458 |
+ private static func bucketMetadata( |
|
| 459 |
+ metadata: [String: String], |
|
| 460 |
+ bucketIndex: Int, |
|
| 461 |
+ bucketCount: Int, |
|
| 462 |
+ strategy: String |
|
| 463 |
+ ) -> [String: String] {
|
|
| 464 |
+ metadata.merging([ |
|
| 465 |
+ "bucket": "\(bucketIndex + 1)/\(bucketCount)", |
|
| 466 |
+ "fingerprintStrategy": strategy |
|
| 467 |
+ ]) { _, new in new }
|
|
| 133 | 468 |
} |
| 134 | 469 |
|
| 135 | 470 |
private static func buildMetadata(current: TypeCount, previous: TypeCount?) -> [String: String] {
|
@@ -36,6 +36,7 @@ enum DeltaService {
|
||
| 36 | 36 |
if current.isContentAlias, |
| 37 | 37 |
current.contentEquivalentSnapshotID == previous.contentRepresentativeSnapshotID {
|
| 38 | 38 |
delta.typeDeltas = [] |
| 39 |
+ updateListSummary(for: delta, typeDeltas: []) |
|
| 39 | 40 |
context.insert(delta) |
| 40 | 41 |
try context.save() |
| 41 | 42 |
return delta |
@@ -75,11 +76,33 @@ enum DeltaService {
|
||
| 75 | 76 |
} |
| 76 | 77 |
|
| 77 | 78 |
delta.typeDeltas = typeDeltas |
| 79 |
+ updateListSummary(for: delta, typeDeltas: typeDeltas) |
|
| 78 | 80 |
context.insert(delta) |
| 79 | 81 |
try context.save() |
| 80 | 82 |
return delta |
| 81 | 83 |
} |
| 82 | 84 |
|
| 85 |
+ @discardableResult |
|
| 86 |
+ static func rebuildMissingListSummaries(context: ModelContext, maxCount: Int) throws -> Bool {
|
|
| 87 |
+ guard maxCount > 0 else { return false }
|
|
| 88 |
+ |
|
| 89 |
+ let summaryVersion = SnapshotDelta.currentListSummaryVersion |
|
| 90 |
+ var descriptor = FetchDescriptor<SnapshotDelta>( |
|
| 91 |
+ predicate: #Predicate<SnapshotDelta> { $0.listSummaryVersion < summaryVersion }
|
|
| 92 |
+ ) |
|
| 93 |
+ descriptor.fetchLimit = maxCount |
|
| 94 |
+ |
|
| 95 |
+ let deltas = try context.fetch(descriptor) |
|
| 96 |
+ guard !deltas.isEmpty else { return false }
|
|
| 97 |
+ |
|
| 98 |
+ for delta in deltas {
|
|
| 99 |
+ updateListSummary(for: delta, typeDeltas: delta.typeDeltas ?? []) |
|
| 100 |
+ } |
|
| 101 |
+ |
|
| 102 |
+ try context.save() |
|
| 103 |
+ return true |
|
| 104 |
+ } |
|
| 105 |
+ |
|
| 83 | 106 |
// MARK: - Delta merge (for intermediate snapshot deletion) |
| 84 | 107 |
|
| 85 | 108 |
// snapshotBefore and snapshotAfter are the real surrounding snapshots (N-1 and N+1). |
@@ -127,6 +150,7 @@ enum DeltaService {
|
||
| 127 | 150 |
} |
| 128 | 151 |
} |
| 129 | 152 |
merged.typeDeltas = mergedTypeDeltas |
| 153 |
+ updateListSummary(for: merged, typeDeltas: mergedTypeDeltas) |
|
| 130 | 154 |
return merged |
| 131 | 155 |
} |
| 132 | 156 |
|
@@ -197,6 +221,36 @@ enum DeltaService {
|
||
| 197 | 221 |
return td |
| 198 | 222 |
} |
| 199 | 223 |
|
| 224 |
+ private static func updateListSummary(for delta: SnapshotDelta, typeDeltas: [TypeDelta]) {
|
|
| 225 |
+ var absoluteRecordChangeCount = 0 |
|
| 226 |
+ var changedMetricCount = 0 |
|
| 227 |
+ var appearedMetricCount = 0 |
|
| 228 |
+ var disappearedMetricCount = 0 |
|
| 229 |
+ |
|
| 230 |
+ for typeDelta in typeDeltas {
|
|
| 231 |
+ switch typeDelta.transition {
|
|
| 232 |
+ case .unchanged: |
|
| 233 |
+ continue |
|
| 234 |
+ case .changed: |
|
| 235 |
+ changedMetricCount += 1 |
|
| 236 |
+ if typeDelta.qualityBefore == .complete, |
|
| 237 |
+ typeDelta.qualityAfter == .complete {
|
|
| 238 |
+ absoluteRecordChangeCount += abs(typeDelta.countDelta) |
|
| 239 |
+ } |
|
| 240 |
+ case .appeared: |
|
| 241 |
+ appearedMetricCount += 1 |
|
| 242 |
+ case .disappeared: |
|
| 243 |
+ disappearedMetricCount += 1 |
|
| 244 |
+ } |
|
| 245 |
+ } |
|
| 246 |
+ |
|
| 247 |
+ delta.absoluteRecordChangeCount = absoluteRecordChangeCount |
|
| 248 |
+ delta.changedMetricCount = changedMetricCount |
|
| 249 |
+ delta.appearedMetricCount = appearedMetricCount |
|
| 250 |
+ delta.disappearedMetricCount = disappearedMetricCount |
|
| 251 |
+ delta.listSummaryVersion = SnapshotDelta.currentListSummaryVersion |
|
| 252 |
+ } |
|
| 253 |
+ |
|
| 200 | 254 |
private static func historicalBaselinePreviousTypeCount( |
| 201 | 255 |
typeID: String, |
| 202 | 256 |
prev: TypeCount?, |
@@ -133,6 +133,7 @@ final class HealthKitService {
|
||
| 133 | 133 |
} |
| 134 | 134 |
|
| 135 | 135 |
snapshot.snapshotQuality = deriveSnapshotQuality(from: typeCounts) |
| 136 |
+ updateSnapshotSummaryCache(snapshot: snapshot, typeCounts: typeCounts) |
|
| 136 | 137 |
|
| 137 | 138 |
configureSnapshotMetadata( |
| 138 | 139 |
snapshot, |
@@ -171,6 +172,8 @@ final class HealthKitService {
|
||
| 171 | 172 |
return snapshot |
| 172 | 173 |
} |
| 173 | 174 |
|
| 175 |
+ updateSnapshotSummaryCache(snapshot: snapshot, typeCounts: typeCounts) |
|
| 176 |
+ |
|
| 174 | 177 |
configureSnapshotMetadata( |
| 175 | 178 |
snapshot, |
| 176 | 179 |
typeCounts: typeCounts, |
@@ -199,6 +202,8 @@ final class HealthKitService {
|
||
| 199 | 202 |
return try await savePartialSnapshot(snapshot, in: context) |
| 200 | 203 |
} |
| 201 | 204 |
|
| 205 |
+ updateSnapshotSummaryCache(snapshot: snapshot, typeCounts: typeCounts) |
|
| 206 |
+ |
|
| 202 | 207 |
configureSnapshotMetadata( |
| 203 | 208 |
snapshot, |
| 204 | 209 |
typeCounts: typeCounts, |
@@ -245,6 +250,21 @@ final class HealthKitService {
|
||
| 245 | 250 |
try await runPostSavePipeline(snapshot: snapshot, typeCounts: typeCounts, context: context) |
| 246 | 251 |
} |
| 247 | 252 |
|
| 253 |
+ private func updateSnapshotSummaryCache( |
|
| 254 |
+ snapshot: HealthSnapshot, |
|
| 255 |
+ typeCounts: [TypeCount] |
|
| 256 |
+ ) {
|
|
| 257 |
+ snapshot.cachedSummaryVersion = HealthSnapshot.currentCachedSummaryVersion |
|
| 258 |
+ snapshot.cachedTypeCount = typeCounts.count |
|
| 259 |
+ snapshot.cachedRecordCount = typeCounts.reduce(0) { partial, typeCount in
|
|
| 260 |
+ typeCount.count > 0 ? partial + typeCount.count : partial |
|
| 261 |
+ } |
|
| 262 |
+ |
|
| 263 |
+ let datedTypeCounts = typeCounts.filter { !$0.isUnsupported && $0.count > 0 }
|
|
| 264 |
+ snapshot.cachedEarliestRecordDate = datedTypeCounts.compactMap(\.earliestDate).min() |
|
| 265 |
+ snapshot.cachedLatestRecordDate = datedTypeCounts.compactMap(\.latestDate).max() |
|
| 266 |
+ } |
|
| 267 |
+ |
|
| 248 | 268 |
private func configureSnapshotMetadata( |
| 249 | 269 |
_ snapshot: HealthSnapshot, |
| 250 | 270 |
typeCounts: [TypeCount], |
@@ -55,12 +55,12 @@ final class ObserverService {
|
||
| 55 | 55 |
// MARK: - Callback handling |
| 56 | 56 |
|
| 57 | 57 |
private func handleObserverCallback(typeID: String) {
|
| 58 |
- lock.lock() |
|
| 59 |
- let now = Date() |
|
| 60 |
- lastCallbackTimestamp = now |
|
| 61 |
- accumulatedTypeIDs.insert(typeID) |
|
| 62 |
- let alreadyScheduled = debounceTask != nil |
|
| 63 |
- lock.unlock() |
|
| 58 |
+ let alreadyScheduled = lock.withLock {
|
|
| 59 |
+ let now = Date() |
|
| 60 |
+ lastCallbackTimestamp = now |
|
| 61 |
+ accumulatedTypeIDs.insert(typeID) |
|
| 62 |
+ return debounceTask != nil |
|
| 63 |
+ } |
|
| 64 | 64 |
|
| 65 | 65 |
guard !alreadyScheduled else { return }
|
| 66 | 66 |
|
@@ -74,9 +74,9 @@ final class ObserverService {
|
||
| 74 | 74 |
|
| 75 | 75 |
@MainActor |
| 76 | 76 |
private func tryCreateObserverSnapshot() async {
|
| 77 |
- lock.lock() |
|
| 78 |
- debounceTask = nil |
|
| 79 |
- lock.unlock() |
|
| 77 |
+ lock.withLock {
|
|
| 78 |
+ debounceTask = nil |
|
| 79 |
+ } |
|
| 80 | 80 |
|
| 81 | 81 |
guard let container = modelContainer else {
|
| 82 | 82 |
logger.error("ObserverService: no modelContainer — cannot create snapshot")
|
@@ -111,10 +111,10 @@ final class ObserverService {
|
||
| 111 | 111 |
logger.error("ObserverService: failed to create snapshot — \(error)")
|
| 112 | 112 |
} |
| 113 | 113 |
|
| 114 |
- lock.lock() |
|
| 115 |
- accumulatedTypeIDs.removeAll() |
|
| 116 |
- lastCallbackTimestamp = nil |
|
| 117 |
- lock.unlock() |
|
| 114 |
+ lock.withLock {
|
|
| 115 |
+ accumulatedTypeIDs.removeAll() |
|
| 116 |
+ lastCallbackTimestamp = nil |
|
| 117 |
+ } |
|
| 118 | 118 |
} |
| 119 | 119 |
|
| 120 | 120 |
// MARK: - Type classification |
@@ -14,9 +14,8 @@ enum SnapshotLifecycleService {
|
||
| 14 | 14 |
} |
| 15 | 15 |
|
| 16 | 16 |
static func previewDeletion(of snapshot: HealthSnapshot, context: ModelContext) throws -> DeletionPreview {
|
| 17 |
- let allDeltas = try fetchDeltas(context: context) |
|
| 18 |
- let incomingDelta = allDeltas.first { $0.toSnapshotID == snapshot.id }
|
|
| 19 |
- let outgoingDelta = allDeltas.first { $0.fromSnapshotID == snapshot.id }
|
|
| 17 |
+ let incomingDelta = try fetchIncomingDelta(toSnapshotID: snapshot.id, context: context) |
|
| 18 |
+ let outgoingDelta = try fetchOutgoingDelta(fromSnapshotID: snapshot.id, context: context) |
|
| 20 | 19 |
|
| 21 | 20 |
var willBreakChain = false |
| 22 | 21 |
var description = "" |
@@ -62,9 +61,8 @@ enum SnapshotLifecycleService {
|
||
| 62 | 61 |
} |
| 63 | 62 |
|
| 64 | 63 |
static func delete(_ snapshot: HealthSnapshot, context: ModelContext) throws {
|
| 65 |
- let allDeltas = try fetchDeltas(context: context) |
|
| 66 |
- let incomingDelta = allDeltas.first { $0.toSnapshotID == snapshot.id }
|
|
| 67 |
- let outgoingDelta = allDeltas.first { $0.fromSnapshotID == snapshot.id }
|
|
| 64 |
+ let incomingDelta = try fetchIncomingDelta(toSnapshotID: snapshot.id, context: context) |
|
| 65 |
+ let outgoingDelta = try fetchOutgoingDelta(fromSnapshotID: snapshot.id, context: context) |
|
| 68 | 66 |
|
| 69 | 67 |
let deviceID = snapshot.deviceID |
| 70 | 68 |
let version = Bundle.main.appBuildVersion |
@@ -92,7 +90,7 @@ enum SnapshotLifecycleService {
|
||
| 92 | 90 |
for typeCount in nextSnap.typeCounts ?? [] {
|
| 93 | 91 |
typeCount.contentEquivalentTypeCountID = nil |
| 94 | 92 |
} |
| 95 |
- refreshDetailCaches(for: nextSnap, baseline: nil) |
|
| 93 |
+ invalidateDetailCaches(for: nextSnap) |
|
| 96 | 94 |
} |
| 97 | 95 |
context.delete(outgoing) |
| 98 | 96 |
context.delete(snapshot) |
@@ -119,7 +117,7 @@ enum SnapshotLifecycleService {
|
||
| 119 | 117 |
|
| 120 | 118 |
nextSnap.previousSnapshotID = prevSnap.id |
| 121 | 119 |
_ = refreshContentEquivalence(for: nextSnap, baseline: prevSnap) |
| 122 |
- refreshDetailCaches(for: nextSnap, baseline: prevSnap) |
|
| 120 |
+ invalidateDetailCaches(for: nextSnap) |
|
| 123 | 121 |
context.delete(d1) |
| 124 | 122 |
context.delete(d2) |
| 125 | 123 |
context.delete(snapshot) |
@@ -155,9 +153,6 @@ enum SnapshotLifecycleService {
|
||
| 155 | 153 |
"maxTypeCounts": "\(maxTypeCounts)" |
| 156 | 154 |
]) |
| 157 | 155 |
|
| 158 |
- let descriptor = FetchDescriptor<HealthSnapshot>( |
|
| 159 |
- sortBy: [SortDescriptor(\.timestamp, order: .forward)] |
|
| 160 |
- ) |
|
| 161 | 156 |
let snapshotIDs = try context.fetch(FetchDescriptor<HealthSnapshot>()).map { $0.id }
|
| 162 | 157 |
MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.snapshotsFetched", metadata: [
|
| 163 | 158 |
"snapshotCount": "\(snapshotIDs.count)" |
@@ -279,8 +274,18 @@ enum SnapshotLifecycleService {
|
||
| 279 | 274 |
|
| 280 | 275 |
// MARK: - Fetch helpers |
| 281 | 276 |
|
| 282 |
- private static func fetchDeltas(context: ModelContext) throws -> [SnapshotDelta] {
|
|
| 283 |
- try context.fetch(FetchDescriptor<SnapshotDelta>()) |
|
| 277 |
+ private static func fetchIncomingDelta(toSnapshotID snapshotID: UUID, context: ModelContext) throws -> SnapshotDelta? {
|
|
| 278 |
+ let descriptor = FetchDescriptor<SnapshotDelta>( |
|
| 279 |
+ predicate: #Predicate<SnapshotDelta> { $0.toSnapshotID == snapshotID }
|
|
| 280 |
+ ) |
|
| 281 |
+ return try context.fetch(descriptor).first |
|
| 282 |
+ } |
|
| 283 |
+ |
|
| 284 |
+ private static func fetchOutgoingDelta(fromSnapshotID snapshotID: UUID, context: ModelContext) throws -> SnapshotDelta? {
|
|
| 285 |
+ let descriptor = FetchDescriptor<SnapshotDelta>( |
|
| 286 |
+ predicate: #Predicate<SnapshotDelta> { $0.fromSnapshotID == snapshotID }
|
|
| 287 |
+ ) |
|
| 288 |
+ return try context.fetch(descriptor).first |
|
| 284 | 289 |
} |
| 285 | 290 |
|
| 286 | 291 |
private static func fetchSnapshot(id: UUID, context: ModelContext) throws -> HealthSnapshot? {
|
@@ -290,31 +295,9 @@ enum SnapshotLifecycleService {
|
||
| 290 | 295 |
return try context.fetch(descriptor).first |
| 291 | 296 |
} |
| 292 | 297 |
|
| 293 |
- private static func refreshDetailCaches(for snapshot: HealthSnapshot, baseline: HealthSnapshot?) {
|
|
| 294 |
- guard let baseline else {
|
|
| 295 |
- for typeCount in snapshot.typeCounts ?? [] {
|
|
| 296 |
- typeCount.setDetailCache(nil) |
|
| 297 |
- } |
|
| 298 |
- return |
|
| 299 |
- } |
|
| 300 |
- |
|
| 301 |
- let baselineByType = Dictionary( |
|
| 302 |
- uniqueKeysWithValues: (baseline.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
|
|
| 303 |
- ) |
|
| 304 |
- |
|
| 298 |
+ private static func invalidateDetailCaches(for snapshot: HealthSnapshot) {
|
|
| 305 | 299 |
for typeCount in snapshot.typeCounts ?? [] {
|
| 306 |
- if typeCount.isContentAlias {
|
|
| 307 |
- typeCount.setDetailCache(nil) |
|
| 308 |
- continue |
|
| 309 |
- } |
|
| 310 |
- |
|
| 311 |
- typeCount.setDetailCache( |
|
| 312 |
- TypeCountDetailCacheBuilder.build( |
|
| 313 |
- current: typeCount, |
|
| 314 |
- previous: baselineByType[typeCount.typeIdentifier], |
|
| 315 |
- baselineSnapshotID: baseline.id |
|
| 316 |
- ) |
|
| 317 |
- ) |
|
| 300 |
+ typeCount.setDetailCache(nil) |
|
| 318 | 301 |
} |
| 319 | 302 |
} |
| 320 | 303 |
|
@@ -10,7 +10,7 @@ struct MemorySample: Sendable {
|
||
| 10 | 10 |
enum MemoryLog {
|
| 11 | 11 |
static let detailCacheBuildFootprintLimit: UInt64 = 1_500 * 1_024 * 1_024 |
| 12 | 12 |
|
| 13 |
- static func log(_ event: String, metadata: [String: String] = [:]) {
|
|
| 13 |
+ nonisolated static func log(_ event: String, metadata: [String: String] = [:]) {
|
|
| 14 | 14 |
let sample = currentSample() |
| 15 | 15 |
let metadataText = metadata |
| 16 | 16 |
.sorted { $0.key < $1.key }
|
@@ -27,7 +27,7 @@ enum MemoryLog {
|
||
| 27 | 27 |
print(line) |
| 28 | 28 |
} |
| 29 | 29 |
|
| 30 |
- static func startPulse(_ event: String, metadata: [String: String] = [:], intervalSeconds: UInt64 = 1) -> Task<Void, Never> {
|
|
| 30 |
+ nonisolated static func startPulse(_ event: String, metadata: [String: String] = [:], intervalSeconds: UInt64 = 1) -> Task<Void, Never> {
|
|
| 31 | 31 |
Task.detached(priority: .background) {
|
| 32 | 32 |
var previousSample = currentSample() |
| 33 | 33 |
var previousTime = Date() |
@@ -59,23 +59,23 @@ enum MemoryLog {
|
||
| 59 | 59 |
} |
| 60 | 60 |
} |
| 61 | 61 |
|
| 62 |
- static func format(_ bytes: UInt64) -> String {
|
|
| 62 |
+ nonisolated static func format(_ bytes: UInt64) -> String {
|
|
| 63 | 63 |
ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .memory) |
| 64 | 64 |
} |
| 65 | 65 |
|
| 66 |
- static func isFootprintAtOrAbove(_ bytes: UInt64) -> Bool {
|
|
| 66 |
+ nonisolated static func isFootprintAtOrAbove(_ bytes: UInt64) -> Bool {
|
|
| 67 | 67 |
guard let currentSample = currentSample() else { return false }
|
| 68 | 68 |
return currentSample.physicalFootprintBytes >= bytes |
| 69 | 69 |
} |
| 70 | 70 |
|
| 71 |
- private static func signedFormat(_ bytes: Int64) -> String {
|
|
| 71 |
+ nonisolated private static func signedFormat(_ bytes: Int64) -> String {
|
|
| 72 | 72 |
if bytes >= 0 {
|
| 73 | 73 |
return "+\(format(UInt64(bytes)))" |
| 74 | 74 |
} |
| 75 | 75 |
return "-\(format(UInt64(-bytes)))" |
| 76 | 76 |
} |
| 77 | 77 |
|
| 78 |
- private static func currentSample() -> MemorySample? {
|
|
| 78 |
+ nonisolated private static func currentSample() -> MemorySample? {
|
|
| 79 | 79 |
var info = task_vm_info_data_t() |
| 80 | 80 |
var count = mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<integer_t>.size) |
| 81 | 81 |
let result = withUnsafeMutablePointer(to: &info) { pointer in
|
@@ -0,0 +1,168 @@ |
||
| 1 |
+import Foundation |
|
| 2 |
+import SwiftData |
|
| 3 |
+ |
|
| 4 |
+struct TypeCountDetailCacheResolution: Sendable {
|
|
| 5 |
+ let cache: TypeCountDetailCache? |
|
| 6 |
+ let diagnostic: String |
|
| 7 |
+} |
|
| 8 |
+ |
|
| 9 |
+extension TypeCount {
|
|
| 10 |
+ private static let detailCacheResolverVersion = "resolver-v5" |
|
| 11 |
+ |
|
| 12 |
+ @MainActor |
|
| 13 |
+ func ensureRecordArchiveDataIfNeeded() -> Bool {
|
|
| 14 |
+ if count <= 0 {
|
|
| 15 |
+ return true |
|
| 16 |
+ } |
|
| 17 |
+ |
|
| 18 |
+ if let existingArchive = recordArchiveData {
|
|
| 19 |
+ if HealthRecordArchive.isCompact(existingArchive) {
|
|
| 20 |
+ return true |
|
| 21 |
+ } |
|
| 22 |
+ |
|
| 23 |
+ guard let compactArchive = HealthRecordArchive.compactedIfNeeded(existingArchive) else {
|
|
| 24 |
+ return false |
|
| 25 |
+ } |
|
| 26 |
+ recordArchiveData = compactArchive |
|
| 27 |
+ return true |
|
| 28 |
+ } |
|
| 29 |
+ |
|
| 30 |
+ let legacyRecords = records ?? [] |
|
| 31 |
+ guard !legacyRecords.isEmpty else {
|
|
| 32 |
+ return false |
|
| 33 |
+ } |
|
| 34 |
+ |
|
| 35 |
+ let values = legacyRecords.map { record in
|
|
| 36 |
+ HealthRecordValue( |
|
| 37 |
+ typeIdentifier: record.typeIdentifier, |
|
| 38 |
+ sampleUUIDHash: record.sampleUUIDHash, |
|
| 39 |
+ recordFingerprint: record.recordFingerprint, |
|
| 40 |
+ startDate: record.startDate, |
|
| 41 |
+ endDate: record.endDate, |
|
| 42 |
+ displayValue: record.displayValue |
|
| 43 |
+ ) |
|
| 44 |
+ } |
|
| 45 |
+ guard let archive = HealthRecordArchive.encode(values) else {
|
|
| 46 |
+ return false |
|
| 47 |
+ } |
|
| 48 |
+ |
|
| 49 |
+ recordArchiveData = archive |
|
| 50 |
+ records?.removeAll() |
|
| 51 |
+ return true |
|
| 52 |
+ } |
|
| 53 |
+ |
|
| 54 |
+ @MainActor |
|
| 55 |
+ func resolveDetailCache( |
|
| 56 |
+ previous: TypeCount?, |
|
| 57 |
+ baselineSnapshotID: UUID?, |
|
| 58 |
+ context: ModelContext, |
|
| 59 |
+ source: String |
|
| 60 |
+ ) -> TypeCountDetailCache? {
|
|
| 61 |
+ resolveDetailCacheWithDiagnostics( |
|
| 62 |
+ previous: previous, |
|
| 63 |
+ baselineSnapshotID: baselineSnapshotID, |
|
| 64 |
+ context: context, |
|
| 65 |
+ source: source |
|
| 66 |
+ ).cache |
|
| 67 |
+ } |
|
| 68 |
+ |
|
| 69 |
+ @MainActor |
|
| 70 |
+ func resolveDetailCacheWithDiagnostics( |
|
| 71 |
+ previous: TypeCount?, |
|
| 72 |
+ baselineSnapshotID: UUID?, |
|
| 73 |
+ context: ModelContext, |
|
| 74 |
+ source: String |
|
| 75 |
+ ) -> TypeCountDetailCacheResolution {
|
|
| 76 |
+ if let cache = detailCache, |
|
| 77 |
+ cache.matchesBaseline(baselineSnapshotID) {
|
|
| 78 |
+ return TypeCountDetailCacheResolution( |
|
| 79 |
+ cache: cache, |
|
| 80 |
+ diagnostic: detailCacheDiagnostic( |
|
| 81 |
+ previous: previous, |
|
| 82 |
+ baselineSnapshotID: baselineSnapshotID, |
|
| 83 |
+ phase: "cache-hit" |
|
| 84 |
+ ) |
|
| 85 |
+ ) |
|
| 86 |
+ } |
|
| 87 |
+ |
|
| 88 |
+ let currentArchiveWasMissing = count > 0 && recordArchiveData == nil |
|
| 89 |
+ let previousArchiveWasMissing = (previous?.count ?? 0) > 0 && previous?.recordArchiveData == nil |
|
| 90 |
+ let currentArchiveAvailable = ensureRecordArchiveDataIfNeeded() |
|
| 91 |
+ let previousArchiveAvailable = previous?.ensureRecordArchiveDataIfNeeded() ?? true |
|
| 92 |
+ |
|
| 93 |
+ guard currentArchiveAvailable, previousArchiveAvailable else {
|
|
| 94 |
+ let diagnostic = detailCacheDiagnostic( |
|
| 95 |
+ previous: previous, |
|
| 96 |
+ baselineSnapshotID: baselineSnapshotID, |
|
| 97 |
+ phase: "missing-archive" |
|
| 98 |
+ ) |
|
| 99 |
+ MemoryLog.log("\(source).detailCache.resolveUnavailable", metadata: detailCacheMetadata(previous: previous).merging([
|
|
| 100 |
+ "diagnostic": diagnostic |
|
| 101 |
+ ]) { _, new in new })
|
|
| 102 |
+ return TypeCountDetailCacheResolution(cache: nil, diagnostic: diagnostic) |
|
| 103 |
+ } |
|
| 104 |
+ |
|
| 105 |
+ MemoryLog.log("\(source).detailCache.buildBegin", metadata: detailCacheMetadata(previous: previous))
|
|
| 106 |
+ let cache = TypeCountDetailCacheBuilder.build( |
|
| 107 |
+ current: self, |
|
| 108 |
+ previous: previous, |
|
| 109 |
+ baselineSnapshotID: baselineSnapshotID |
|
| 110 |
+ ) |
|
| 111 |
+ let diagnostic = detailCacheDiagnostic( |
|
| 112 |
+ previous: previous, |
|
| 113 |
+ baselineSnapshotID: baselineSnapshotID, |
|
| 114 |
+ phase: cache == nil ? "build-nil" : "built" |
|
| 115 |
+ ) |
|
| 116 |
+ MemoryLog.log("\(source).detailCache.buildEnd", metadata: [
|
|
| 117 |
+ "source": source, |
|
| 118 |
+ "type": typeIdentifier, |
|
| 119 |
+ "cacheBuilt": "\(cache != nil)", |
|
| 120 |
+ "diagnostic": diagnostic |
|
| 121 |
+ ]) |
|
| 122 |
+ |
|
| 123 |
+ if let cache {
|
|
| 124 |
+ setDetailCache(cache) |
|
| 125 |
+ } |
|
| 126 |
+ |
|
| 127 |
+ if cache != nil || |
|
| 128 |
+ (currentArchiveWasMissing && recordArchiveData != nil) || |
|
| 129 |
+ (previousArchiveWasMissing && previous?.recordArchiveData != nil) {
|
|
| 130 |
+ try? context.save() |
|
| 131 |
+ } |
|
| 132 |
+ |
|
| 133 |
+ return TypeCountDetailCacheResolution(cache: cache, diagnostic: diagnostic) |
|
| 134 |
+ } |
|
| 135 |
+ |
|
| 136 |
+ private func detailCacheMetadata(previous: TypeCount?) -> [String: String] {
|
|
| 137 |
+ [ |
|
| 138 |
+ "type": typeIdentifier, |
|
| 139 |
+ "currentCount": "\(count)", |
|
| 140 |
+ "previousCount": "\(previous?.count ?? 0)", |
|
| 141 |
+ "currentArchive": recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil",
|
|
| 142 |
+ "previousArchive": previous?.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil"
|
|
| 143 |
+ ] |
|
| 144 |
+ } |
|
| 145 |
+ |
|
| 146 |
+ private func detailCacheDiagnostic( |
|
| 147 |
+ previous: TypeCount?, |
|
| 148 |
+ baselineSnapshotID: UUID?, |
|
| 149 |
+ phase: String |
|
| 150 |
+ ) -> String {
|
|
| 151 |
+ let baseline = baselineSnapshotID?.uuidString.prefix(6) ?? "none" |
|
| 152 |
+ let currentArchive = archiveDebugLabel(for: self) |
|
| 153 |
+ let previousArchive = archiveDebugLabel(for: previous) |
|
| 154 |
+ return "\(Self.detailCacheResolverVersion) phase=\(phase) base=\(baseline) curr=\(currentArchive) prev=\(previousArchive)" |
|
| 155 |
+ } |
|
| 156 |
+ |
|
| 157 |
+ private func archiveDebugLabel(for typeCount: TypeCount?) -> String {
|
|
| 158 |
+ guard let typeCount else { return "none" }
|
|
| 159 |
+ if let archive = typeCount.recordArchiveData {
|
|
| 160 |
+ let formatSuffix = HealthRecordArchive.isCompact(archive) ? "-c" : "-p" |
|
| 161 |
+ return "\(MemoryLog.format(UInt64(archive.count)))\(formatSuffix)" |
|
| 162 |
+ } |
|
| 163 |
+ if typeCount.count <= 0 {
|
|
| 164 |
+ return "empty" |
|
| 165 |
+ } |
|
| 166 |
+ return "missing" |
|
| 167 |
+ } |
|
| 168 |
+} |
|
@@ -1,4 +1,5 @@ |
||
| 1 | 1 |
import Foundation |
| 2 |
+import SwiftData |
|
| 2 | 3 |
|
| 3 | 4 |
enum BinningStrategy: String, CaseIterable, Equatable {
|
| 4 | 5 |
case day = "Zi" |
@@ -114,17 +115,18 @@ final class DataTypeTemporalDistributionViewModel: Sendable {
|
||
| 114 | 115 |
private var disappearedByDate: [Date: Int] = [:] |
| 115 | 116 |
private var unchangedByDate: [Date: Int] = [:] |
| 116 | 117 |
|
| 117 |
- func load(current: TypeCount?, previous: TypeCount?) async {
|
|
| 118 |
+ func load(current: TypeCount?, previous: TypeCount?, context: ModelContext) async {
|
|
| 118 | 119 |
defer { isLoading = false }
|
| 120 |
+ error = nil |
|
| 121 |
+ hasData = false |
|
| 119 | 122 |
|
| 120 | 123 |
guard let current else {
|
| 121 | 124 |
error = "No current snapshot data" |
| 122 | 125 |
return |
| 123 | 126 |
} |
| 124 | 127 |
|
| 125 |
- guard let cache = current.detailCache, |
|
| 126 |
- cache.matchesBaseline(previous?.snapshot?.id) else {
|
|
| 127 |
- error = "Precomputed detail data is unavailable for this snapshot. Create a new snapshot to generate temporal detail caches." |
|
| 128 |
+ guard let cache = resolveDetailCache(current: current, previous: previous, context: context) else {
|
|
| 129 |
+ error = "Record detail data could not be computed for this snapshot pair." |
|
| 128 | 130 |
return |
| 129 | 131 |
} |
| 130 | 132 |
|
@@ -141,6 +143,19 @@ final class DataTypeTemporalDistributionViewModel: Sendable {
|
||
| 141 | 143 |
await rebuildBinsBackground() |
| 142 | 144 |
} |
| 143 | 145 |
|
| 146 |
+ private func resolveDetailCache( |
|
| 147 |
+ current: TypeCount, |
|
| 148 |
+ previous: TypeCount?, |
|
| 149 |
+ context: ModelContext |
|
| 150 |
+ ) -> TypeCountDetailCache? {
|
|
| 151 |
+ let baselineID = previous?.snapshot?.id |
|
| 152 |
+ guard let cache = current.detailCache, |
|
| 153 |
+ cache.matchesBaseline(baselineID) else {
|
|
| 154 |
+ return nil |
|
| 155 |
+ } |
|
| 156 |
+ return cache |
|
| 157 |
+ } |
|
| 158 |
+ |
|
| 144 | 159 |
private func indexDailyBins(_ bins: [TypeCountDailyChangeBin]) {
|
| 145 | 160 |
addedByDate = Dictionary(uniqueKeysWithValues: bins.map { ($0.dayStart, $0.added) })
|
| 146 | 161 |
disappearedByDate = Dictionary(uniqueKeysWithValues: bins.map { ($0.dayStart, $0.disappeared) })
|
@@ -26,11 +26,34 @@ final class SnapshotsViewModel {
|
||
| 26 | 26 |
var comparisonMode: ComparisonMode = .previous |
| 27 | 27 |
var selectedBaseline: HealthSnapshot? |
| 28 | 28 |
|
| 29 |
+ func baselines(for snapshots: [HealthSnapshot]) -> [UUID: HealthSnapshot] {
|
|
| 30 |
+ let orderedDescending = snapshots.sorted { $0.timestamp > $1.timestamp }
|
|
| 31 |
+ |
|
| 32 |
+ return snapshots.reduce(into: [UUID: HealthSnapshot]()) { partial, snapshot in
|
|
| 33 |
+ partial[snapshot.id] = baseline( |
|
| 34 |
+ for: snapshot, |
|
| 35 |
+ in: snapshots, |
|
| 36 |
+ orderedDescending: orderedDescending |
|
| 37 |
+ ) |
|
| 38 |
+ } |
|
| 39 |
+ } |
|
| 40 |
+ |
|
| 29 | 41 |
func baseline(for snapshot: HealthSnapshot, in snapshots: [HealthSnapshot]) -> HealthSnapshot? {
|
| 30 |
- let sorted = snapshots.sorted { $0.timestamp > $1.timestamp }
|
|
| 42 |
+ baseline( |
|
| 43 |
+ for: snapshot, |
|
| 44 |
+ in: snapshots, |
|
| 45 |
+ orderedDescending: snapshots.sorted { $0.timestamp > $1.timestamp }
|
|
| 46 |
+ ) |
|
| 47 |
+ } |
|
| 48 |
+ |
|
| 49 |
+ private func baseline( |
|
| 50 |
+ for snapshot: HealthSnapshot, |
|
| 51 |
+ in snapshots: [HealthSnapshot], |
|
| 52 |
+ orderedDescending: [HealthSnapshot] |
|
| 53 |
+ ) -> HealthSnapshot? {
|
|
| 31 | 54 |
switch comparisonMode {
|
| 32 | 55 |
case .previous: |
| 33 |
- return sorted.first { $0.timestamp < snapshot.timestamp }
|
|
| 56 |
+ return orderedDescending.first { $0.timestamp < snapshot.timestamp }
|
|
| 34 | 57 |
case .selected: |
| 35 | 58 |
return selectedBaseline |
| 36 | 59 |
case .relativeTime(let interval): |
@@ -6,6 +6,7 @@ struct DataTypeTemporalDistributionView: View {
|
||
| 6 | 6 |
let previous: TypeCount? |
| 7 | 7 |
let displayName: String |
| 8 | 8 |
|
| 9 |
+ @Environment(\.modelContext) private var modelContext |
|
| 9 | 10 |
@State private var viewModel = DataTypeTemporalDistributionViewModel() |
| 10 | 11 |
@State private var isRecomputing = false |
| 11 | 12 |
@State private var isZoomed: Bool = false |
@@ -30,21 +31,17 @@ struct DataTypeTemporalDistributionView: View {
|
||
| 30 | 31 |
} |
| 31 | 32 |
|
| 32 | 33 |
private var timelineSnapshots: [HealthSnapshot] {
|
| 33 |
- allSnapshots.filter { current?.snapshot?.deviceID ?? "" == $0.deviceID }
|
|
| 34 |
+ allSnapshots |
|
| 35 |
+ .filter { current?.snapshot?.deviceID ?? "" == $0.deviceID }
|
|
| 36 |
+ .sorted(by: HealthSnapshot.timelineSort) |
|
| 34 | 37 |
} |
| 35 | 38 |
|
| 36 | 39 |
private var previousSnapshot: HealthSnapshot? {
|
| 37 |
- guard let current = currentSnapshot, |
|
| 38 |
- let idx = timelineSnapshots.firstIndex(where: { $0.id == current.id }),
|
|
| 39 |
- idx > 0 else { return nil }
|
|
| 40 |
- return timelineSnapshots[idx - 1] |
|
| 40 |
+ currentSnapshot?.previousInTimeline(timelineSnapshots) |
|
| 41 | 41 |
} |
| 42 | 42 |
|
| 43 | 43 |
private var nextSnapshot: HealthSnapshot? {
|
| 44 |
- guard let current = currentSnapshot, |
|
| 45 |
- let idx = timelineSnapshots.firstIndex(where: { $0.id == current.id }),
|
|
| 46 |
- idx < timelineSnapshots.count - 1 else { return nil }
|
|
| 47 |
- return timelineSnapshots[idx + 1] |
|
| 44 |
+ currentSnapshot?.nextInTimeline(timelineSnapshots) |
|
| 48 | 45 |
} |
| 49 | 46 |
|
| 50 | 47 |
var body: some View {
|
@@ -83,7 +80,7 @@ struct DataTypeTemporalDistributionView: View {
|
||
| 83 | 80 |
} |
| 84 | 81 |
} |
| 85 | 82 |
.task {
|
| 86 |
- await viewModel.load(current: current, previous: previous) |
|
| 83 |
+ await viewModel.load(current: current, previous: previous, context: modelContext) |
|
| 87 | 84 |
} |
| 88 | 85 |
} |
| 89 | 86 |
|
@@ -1,4 +1,5 @@ |
||
| 1 | 1 |
import SwiftUI |
| 2 |
+import SwiftData |
|
| 2 | 3 |
|
| 3 | 4 |
struct RecordChangeEvolutionChart: View {
|
| 4 | 5 |
let snapshots: [HealthSnapshot] |
@@ -6,8 +7,10 @@ struct RecordChangeEvolutionChart: View {
|
||
| 6 | 7 |
let typeIdentifier: String |
| 7 | 8 |
let displayName: String |
| 8 | 9 |
|
| 10 |
+ @Query private var allDeltas: [SnapshotDelta] |
|
| 11 |
+ |
|
| 9 | 12 |
private var sortedSnapshots: [HealthSnapshot] {
|
| 10 |
- snapshots.sorted { $0.timestamp < $1.timestamp }
|
|
| 13 |
+ snapshots.sorted(by: HealthSnapshot.timelineSort) |
|
| 11 | 14 |
} |
| 12 | 15 |
|
| 13 | 16 |
private var currentIndex: Int? {
|
@@ -21,6 +24,10 @@ struct RecordChangeEvolutionChart: View {
|
||
| 21 | 24 |
return Array(sortedSnapshots[start..<end]) |
| 22 | 25 |
} |
| 23 | 26 |
|
| 27 |
+ private var diffTaskID: String {
|
|
| 28 |
+ ([typeIdentifier, currentSnapshotID.uuidString] + contextSnapshots.map { $0.id.uuidString }).joined(separator: "|")
|
|
| 29 |
+ } |
|
| 30 |
+ |
|
| 24 | 31 |
private var maxCount: Int {
|
| 25 | 32 |
let counts = contextSnapshots.compactMap { snapshot in
|
| 26 | 33 |
snapshot.typeCounts?.first { $0.typeIdentifier == typeIdentifier }?.count
|
@@ -30,9 +37,8 @@ struct RecordChangeEvolutionChart: View {
|
||
| 30 | 37 |
|
| 31 | 38 |
private var maxNegativeCount: Int {
|
| 32 | 39 |
var max = 0 |
| 33 |
- for i in 0..<contextSnapshots.count {
|
|
| 34 |
- let snapshot = contextSnapshots[i] |
|
| 35 |
- let previousSnapshot = i > 0 ? contextSnapshots[i - 1] : nil |
|
| 40 |
+ for snapshot in contextSnapshots {
|
|
| 41 |
+ let previousSnapshot = snapshot.previousInTimeline(sortedSnapshots) |
|
| 36 | 42 |
let diff = recordDiff(current: snapshot, previous: previousSnapshot) |
| 37 | 43 |
max = Swift.max(max, diff.disappeared) |
| 38 | 44 |
} |
@@ -41,19 +47,31 @@ struct RecordChangeEvolutionChart: View {
|
||
| 41 | 47 |
|
| 42 | 48 |
private func recordDiff(current: HealthSnapshot, previous: HealthSnapshot?) -> (added: Int, disappeared: Int) {
|
| 43 | 49 |
guard let previous = previous else { return (0, 0) }
|
| 44 |
- guard let currentType = current.typeCounts? |
|
| 45 |
- .first(where: { $0.typeIdentifier == typeIdentifier }) else {
|
|
| 50 |
+ |
|
| 51 |
+ guard let delta = allDeltas.first(where: {
|
|
| 52 |
+ $0.fromSnapshotID == previous.id && |
|
| 53 |
+ $0.toSnapshotID == current.id |
|
| 54 |
+ }), |
|
| 55 |
+ let typeDelta = delta.typeDeltas?.first(where: { $0.typeIdentifier == typeIdentifier }) else {
|
|
| 46 | 56 |
return (0, 0) |
| 47 | 57 |
} |
| 48 | 58 |
|
| 49 |
- if let previousType = previous.typeCounts?.first(where: { $0.typeIdentifier == typeIdentifier }),
|
|
| 50 |
- currentType.contentEquivalentTypeCountID == previousType.contentRepresentativeTypeCountID {
|
|
| 59 |
+ switch typeDelta.transition {
|
|
| 60 |
+ case .unchanged: |
|
| 51 | 61 |
return (0, 0) |
| 62 |
+ case .changed: |
|
| 63 |
+ if typeDelta.countDelta > 0 {
|
|
| 64 |
+ return (typeDelta.countDelta, 0) |
|
| 65 |
+ } |
|
| 66 |
+ if typeDelta.countDelta < 0 {
|
|
| 67 |
+ return (0, abs(typeDelta.countDelta)) |
|
| 68 |
+ } |
|
| 69 |
+ return (0, 0) |
|
| 70 |
+ case .appeared: |
|
| 71 |
+ return (max(typeDelta.countDelta, 1), 0) |
|
| 72 |
+ case .disappeared: |
|
| 73 |
+ return (0, max(abs(typeDelta.countDelta), 1)) |
|
| 52 | 74 |
} |
| 53 |
- |
|
| 54 |
- guard let cache = currentType.detailCache, |
|
| 55 |
- cache.matchesBaseline(previous.id) else { return (0, 0) }
|
|
| 56 |
- return (cache.addedCount, cache.disappearedCount) |
|
| 57 | 75 |
} |
| 58 | 76 |
|
| 59 | 77 |
var body: some View {
|
@@ -155,7 +173,7 @@ struct RecordChangeEvolutionChart: View {
|
||
| 155 | 173 |
let typeCount = snapshot.typeCounts?.first { $0.typeIdentifier == typeIdentifier }
|
| 156 | 174 |
let count = typeCount?.count ?? 0 |
| 157 | 175 |
let isCurrent = snapshot.id == currentSnapshotID |
| 158 |
- let previousSnapshot = index > 0 ? contextSnapshots[index - 1] : nil |
|
| 176 |
+ let previousSnapshot = snapshot.previousInTimeline(sortedSnapshots) |
|
| 159 | 177 |
let diff = recordDiff(current: snapshot, previous: previousSnapshot) |
| 160 | 178 |
let unchanged = count - diff.added |
| 161 | 179 |
|
@@ -204,6 +222,7 @@ struct RecordChangeEvolutionChart: View {
|
||
| 204 | 222 |
} |
| 205 | 223 |
.frame(maxWidth: .infinity) |
| 206 | 224 |
} |
| 225 |
+ |
|
| 207 | 226 |
} |
| 208 | 227 |
|
| 209 | 228 |
#Preview {
|
@@ -8,12 +8,14 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 8 | 8 |
|
| 9 | 9 |
@Environment(\.modelContext) private var modelContext |
| 10 | 10 |
@Query(sort: \HealthSnapshot.timestamp) private var allSnapshots: [HealthSnapshot] |
| 11 |
+ @Query private var allDeltas: [SnapshotDelta] |
|
| 11 | 12 |
|
| 12 | 13 |
@State private var displayedSnapshot: HealthSnapshot? |
| 13 | 14 |
@State private var diffState: RecordDiffState = .idle |
| 14 | 15 |
@State private var showAddedRecords = false |
| 15 | 16 |
@State private var showDisappearedRecords = false |
| 16 | 17 |
@State private var showTemporalDistribution = false |
| 18 |
+ @State private var detailCacheDiagnostic: String? |
|
| 17 | 19 |
|
| 18 | 20 |
private var currentSnapshot: HealthSnapshot {
|
| 19 | 21 |
displayedSnapshot ?? snapshot |
@@ -26,12 +28,11 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 26 | 28 |
} |
| 27 | 29 |
return candidate.deviceID == currentSnapshot.deviceID |
| 28 | 30 |
} |
| 31 |
+ .sorted(by: HealthSnapshot.timelineSort) |
|
| 29 | 32 |
} |
| 30 | 33 |
|
| 31 | 34 |
private var previousSnapshot: HealthSnapshot? {
|
| 32 |
- guard let currentSnapshotIndex = timelineSnapshots.firstIndex(where: { $0.id == currentSnapshot.id }),
|
|
| 33 |
- currentSnapshotIndex > 0 else { return nil }
|
|
| 34 |
- return timelineSnapshots[currentSnapshotIndex - 1] |
|
| 35 |
+ currentSnapshot.previousInTimeline(timelineSnapshots) |
|
| 35 | 36 |
} |
| 36 | 37 |
|
| 37 | 38 |
private var currentTypeCount: TypeCount? {
|
@@ -48,6 +49,24 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 48 | 49 |
return currentTypeCount.contentEquivalentTypeCountID == previousTypeCount.contentRepresentativeTypeCountID |
| 49 | 50 |
} |
| 50 | 51 |
|
| 52 |
+ private var hasTemporalDistributionCache: Bool {
|
|
| 53 |
+ guard let currentTypeCount, |
|
| 54 |
+ let previousSnapshot else { return false }
|
|
| 55 |
+ return currentTypeCount.detailCache?.matchesBaseline(previousSnapshot.id) == true |
|
| 56 |
+ } |
|
| 57 |
+ |
|
| 58 |
+ private var currentDelta: SnapshotDelta? {
|
|
| 59 |
+ guard let previousSnapshot else { return nil }
|
|
| 60 |
+ return allDeltas.first {
|
|
| 61 |
+ $0.toSnapshotID == currentSnapshot.id && |
|
| 62 |
+ $0.fromSnapshotID == previousSnapshot.id |
|
| 63 |
+ } |
|
| 64 |
+ } |
|
| 65 |
+ |
|
| 66 |
+ private var currentTypeDelta: TypeDelta? {
|
|
| 67 |
+ currentDelta?.typeDeltas?.first { $0.typeIdentifier == typeIdentifier }
|
|
| 68 |
+ } |
|
| 69 |
+ |
|
| 51 | 70 |
private var diffTaskID: String {
|
| 52 | 71 |
[ |
| 53 | 72 |
currentSnapshot.id.uuidString, |
@@ -71,6 +90,25 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 71 | 90 |
countText(for: previousTypeCount) |
| 72 | 91 |
} |
| 73 | 92 |
|
| 93 |
+ private var quickCurrentCountValue: Int {
|
|
| 94 |
+ max(currentTypeCount?.count ?? 0, 0) |
|
| 95 |
+ } |
|
| 96 |
+ |
|
| 97 |
+ private var quickPreviousCountValue: Int {
|
|
| 98 |
+ max(previousTypeCount?.count ?? 0, 0) |
|
| 99 |
+ } |
|
| 100 |
+ |
|
| 101 |
+ private var quickAddedDisappeared: (added: Int, disappeared: Int, exact: Bool) {
|
|
| 102 |
+ if let previousSnapshot, |
|
| 103 |
+ let cache = currentTypeCount?.detailCache, |
|
| 104 |
+ cache.matchesBaseline(previousSnapshot.id) {
|
|
| 105 |
+ return (cache.addedCount, cache.disappearedCount, true) |
|
| 106 |
+ } |
|
| 107 |
+ |
|
| 108 |
+ let net = (currentTypeDelta?.countDelta ?? (quickCurrentCountValue - quickPreviousCountValue)) |
|
| 109 |
+ return (max(net, 0), max(-net, 0), false) |
|
| 110 |
+ } |
|
| 111 |
+ |
|
| 74 | 112 |
var body: some View {
|
| 75 | 113 |
ScrollView {
|
| 76 | 114 |
VStack(spacing: 16) {
|
@@ -194,10 +232,10 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 194 | 232 |
} |
| 195 | 233 |
} |
| 196 | 234 |
|
| 197 |
- @ViewBuilder |
|
| 198 | 235 |
private var recordChangeComparisonSection: some View {
|
| 199 |
- if previousSnapshot != nil {
|
|
| 200 |
- switch diffState {
|
|
| 236 |
+ Group {
|
|
| 237 |
+ if previousSnapshot != nil {
|
|
| 238 |
+ switch diffState {
|
|
| 201 | 239 |
case .loaded(let diff): |
| 202 | 240 |
RecordChangeComparisonCard( |
| 203 | 241 |
displayName: displayName, |
@@ -237,13 +275,55 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 237 | 275 |
) |
| 238 | 276 |
} |
| 239 | 277 |
|
| 278 |
+ case .idle: |
|
| 279 |
+ VStack(alignment: .leading, spacing: 10) {
|
|
| 280 |
+ let quick = quickAddedDisappeared |
|
| 281 |
+ |
|
| 282 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 283 |
+ Text("Quick Counts")
|
|
| 284 |
+ .font(.subheadline.weight(.semibold)) |
|
| 285 |
+ |
|
| 286 |
+ HStack {
|
|
| 287 |
+ quickStat(label: "Current", value: "\(quickCurrentCountValue)") |
|
| 288 |
+ quickStat(label: "Previous", value: "\(quickPreviousCountValue)") |
|
| 289 |
+ } |
|
| 290 |
+ |
|
| 291 |
+ HStack {
|
|
| 292 |
+ quickStat(label: "New", value: "\(quick.added)", color: .healthyGreen) |
|
| 293 |
+ quickStat(label: "Disappeared", value: "\(quick.disappeared)", color: .criticalRed) |
|
| 294 |
+ } |
|
| 295 |
+ |
|
| 296 |
+ if !quick.exact {
|
|
| 297 |
+ Text("New/Disappeared are net values from snapshot delta. Exact split needs deep record analysis.")
|
|
| 298 |
+ .font(.caption2) |
|
| 299 |
+ .foregroundStyle(.secondary) |
|
| 300 |
+ } |
|
| 301 |
+ } |
|
| 302 |
+ .padding(12) |
|
| 303 |
+ .background(Color(.systemBackground).opacity(0.35), in: RoundedRectangle(cornerRadius: 8)) |
|
| 304 |
+ } |
|
| 305 |
+ .padding(12) |
|
| 306 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 307 |
+ .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) |
|
| 308 |
+ |
|
| 240 | 309 |
case .unavailable: |
| 241 |
- Label( |
|
| 242 |
- "Record detail cache is unavailable for this snapshot pair.", |
|
| 243 |
- systemImage: "exclamationmark.triangle.fill" |
|
| 244 |
- ) |
|
| 245 |
- .font(.subheadline) |
|
| 246 |
- .foregroundStyle(Color.warningAmber) |
|
| 310 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 311 |
+ Label( |
|
| 312 |
+ "Record detail cache is unavailable for this snapshot pair.", |
|
| 313 |
+ systemImage: "exclamationmark.triangle.fill" |
|
| 314 |
+ ) |
|
| 315 |
+ .font(.subheadline) |
|
| 316 |
+ .foregroundStyle(Color.warningAmber) |
|
| 317 |
+ |
|
| 318 |
+ #if DEBUG |
|
| 319 |
+ if let detailCacheDiagnostic {
|
|
| 320 |
+ Text(detailCacheDiagnostic) |
|
| 321 |
+ .font(.caption2.monospaced()) |
|
| 322 |
+ .foregroundStyle(.secondary) |
|
| 323 |
+ .textSelection(.enabled) |
|
| 324 |
+ } |
|
| 325 |
+ #endif |
|
| 326 |
+ } |
|
| 247 | 327 |
.padding(12) |
| 248 | 328 |
.frame(maxWidth: .infinity, alignment: .leading) |
| 249 | 329 |
.background(Color.warningAmber.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) |
@@ -256,7 +336,7 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 256 | 336 |
.frame(maxWidth: .infinity, alignment: .leading) |
| 257 | 337 |
.background(Color.warningAmber.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) |
| 258 | 338 |
|
| 259 |
- case .idle, .loading: |
|
| 339 |
+ case .loading: |
|
| 260 | 340 |
HStack(spacing: 8) {
|
| 261 | 341 |
ProgressView() |
| 262 | 342 |
Text("Analyzing record changes...")
|
@@ -266,6 +346,7 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 266 | 346 |
.frame(maxWidth: .infinity, alignment: .leading) |
| 267 | 347 |
.padding(12) |
| 268 | 348 |
.background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) |
| 349 |
+ } |
|
| 269 | 350 |
} |
| 270 | 351 |
} |
| 271 | 352 |
} |
@@ -285,6 +366,19 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 285 | 366 |
@ViewBuilder |
| 286 | 367 |
private var temporalDistributionSection: some View {
|
| 287 | 368 |
if previousSnapshot != nil, currentTypeCount != nil, !isCurrentTypeContentAliasToPrevious {
|
| 369 |
+ if !hasTemporalDistributionCache {
|
|
| 370 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 371 |
+ Label( |
|
| 372 |
+ "Temporal distribution is available only when precomputed cache exists for this snapshot pair.", |
|
| 373 |
+ systemImage: "info.circle" |
|
| 374 |
+ ) |
|
| 375 |
+ .font(.caption) |
|
| 376 |
+ .foregroundStyle(.secondary) |
|
| 377 |
+ } |
|
| 378 |
+ .padding(12) |
|
| 379 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 380 |
+ .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) |
|
| 381 |
+ } else {
|
|
| 288 | 382 |
Button {
|
| 289 | 383 |
showTemporalDistribution = true |
| 290 | 384 |
} label: {
|
@@ -313,6 +407,7 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 313 | 407 |
.background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) |
| 314 | 408 |
} |
| 315 | 409 |
.buttonStyle(.plain) |
| 410 |
+ } |
|
| 316 | 411 |
} |
| 317 | 412 |
} |
| 318 | 413 |
|
@@ -332,14 +427,45 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 332 | 427 |
.background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12)) |
| 333 | 428 |
} |
| 334 | 429 |
|
| 430 |
+ private func typeDeltaSummaryText(_ typeDelta: TypeDelta) -> String {
|
|
| 431 |
+ switch typeDelta.transition {
|
|
| 432 |
+ case .unchanged: |
|
| 433 |
+ return "No metric-level change recorded." |
|
| 434 |
+ case .changed: |
|
| 435 |
+ if typeDelta.countDelta == 0 {
|
|
| 436 |
+ return "Content changed while count stayed the same." |
|
| 437 |
+ } |
|
| 438 |
+ let prefix = typeDelta.countDelta > 0 ? "+" : "" |
|
| 439 |
+ return "Count delta: \(prefix)\(typeDelta.countDelta)." |
|
| 440 |
+ case .appeared: |
|
| 441 |
+ return "Metric appeared in this snapshot." |
|
| 442 |
+ case .disappeared: |
|
| 443 |
+ return "Metric disappeared in this snapshot." |
|
| 444 |
+ } |
|
| 445 |
+ } |
|
| 446 |
+ |
|
| 447 |
+ private func quickStat(label: String, value: String, color: Color = .primary) -> some View {
|
|
| 448 |
+ VStack(alignment: .leading, spacing: 2) {
|
|
| 449 |
+ Text(label) |
|
| 450 |
+ .font(.caption2) |
|
| 451 |
+ .foregroundStyle(.secondary) |
|
| 452 |
+ Text(value) |
|
| 453 |
+ .font(.subheadline.weight(.semibold).monospacedDigit()) |
|
| 454 |
+ .foregroundStyle(color) |
|
| 455 |
+ } |
|
| 456 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 457 |
+ } |
|
| 458 |
+ |
|
| 335 | 459 |
@MainActor |
| 336 | 460 |
private func loadRecordDiff() async {
|
| 337 | 461 |
guard previousSnapshot != nil else {
|
| 462 |
+ detailCacheDiagnostic = nil |
|
| 338 | 463 |
diffState = .loaded(.empty) |
| 339 | 464 |
return |
| 340 | 465 |
} |
| 341 | 466 |
|
| 342 | 467 |
if isCurrentTypeContentAliasToPrevious {
|
| 468 |
+ detailCacheDiagnostic = nil |
|
| 343 | 469 |
diffState = .loaded(.empty) |
| 344 | 470 |
return |
| 345 | 471 |
} |
@@ -348,11 +474,14 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 348 | 474 |
let previousCount = previousTypeCount?.count ?? 0 |
| 349 | 475 |
|
| 350 | 476 |
guard currentCount >= 0, previousCount >= 0 else {
|
| 477 |
+ detailCacheDiagnostic = "counts-invalid current=\(currentCount) previous=\(previousCount)" |
|
| 351 | 478 |
diffState = .unavailable |
| 352 | 479 |
return |
| 353 | 480 |
} |
| 354 | 481 |
|
| 355 |
- guard let cache = currentDetailCache() else {
|
|
| 482 |
+ let resolution = currentDetailCacheResolution() |
|
| 483 |
+ detailCacheDiagnostic = resolution?.diagnostic |
|
| 484 |
+ guard let cache = resolution?.cache else {
|
|
| 356 | 485 |
diffState = .unavailable |
| 357 | 486 |
return |
| 358 | 487 |
} |
@@ -361,14 +490,20 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 361 | 490 |
} |
| 362 | 491 |
|
| 363 | 492 |
@MainActor |
| 364 |
- private func currentDetailCache() -> TypeCountDetailCache? {
|
|
| 493 |
+ private func currentDetailCacheResolution() -> TypeCountDetailCacheResolution? {
|
|
| 365 | 494 |
if isCurrentTypeContentAliasToPrevious {
|
| 366 |
- return nil |
|
| 495 |
+ return TypeCountDetailCacheResolution( |
|
| 496 |
+ cache: nil, |
|
| 497 |
+ diagnostic: "alias-to-previous" |
|
| 498 |
+ ) |
|
| 367 | 499 |
} |
| 368 | 500 |
|
| 369 | 501 |
if let cache = currentTypeCount?.detailCache, |
| 370 | 502 |
cache.matchesBaseline(previousSnapshot?.id) {
|
| 371 |
- return cache |
|
| 503 |
+ return TypeCountDetailCacheResolution( |
|
| 504 |
+ cache: cache, |
|
| 505 |
+ diagnostic: "resolver-v4 phase=cache-hit-view" |
|
| 506 |
+ ) |
|
| 372 | 507 |
} |
| 373 | 508 |
|
| 374 | 509 |
guard let currentTypeCount, |
@@ -376,30 +511,12 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 376 | 511 |
return nil |
| 377 | 512 |
} |
| 378 | 513 |
|
| 379 |
- MemoryLog.log("dataTypeDetail.detailCache.buildBegin", metadata: [
|
|
| 380 |
- "source": "detailView", |
|
| 381 |
- "type": currentTypeCount.typeIdentifier, |
|
| 382 |
- "currentCount": "\(currentTypeCount.count)", |
|
| 383 |
- "previousCount": "\(previousTypeCount?.count ?? 0)", |
|
| 384 |
- "currentArchive": currentTypeCount.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil",
|
|
| 385 |
- "previousArchive": previousTypeCount?.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil",
|
|
| 386 |
- "isAlias": "\(currentTypeCount.isContentAlias)" |
|
| 387 |
- ]) |
|
| 388 |
- let cache = TypeCountDetailCacheBuilder.build( |
|
| 389 |
- current: currentTypeCount, |
|
| 514 |
+ return currentTypeCount.resolveDetailCacheWithDiagnostics( |
|
| 390 | 515 |
previous: previousTypeCount, |
| 391 |
- baselineSnapshotID: previousSnapshot.id |
|
| 516 |
+ baselineSnapshotID: previousSnapshot.id, |
|
| 517 |
+ context: modelContext, |
|
| 518 |
+ source: "dataTypeDetail" |
|
| 392 | 519 |
) |
| 393 |
- MemoryLog.log("dataTypeDetail.detailCache.buildEnd", metadata: [
|
|
| 394 |
- "source": "detailView", |
|
| 395 |
- "type": currentTypeCount.typeIdentifier, |
|
| 396 |
- "cacheBuilt": "\(cache != nil)" |
|
| 397 |
- ]) |
|
| 398 |
- currentTypeCount.setDetailCache(cache) |
|
| 399 |
- if cache != nil {
|
|
| 400 |
- try? modelContext.save() |
|
| 401 |
- } |
|
| 402 |
- return cache |
|
| 403 | 520 |
} |
| 404 | 521 |
} |
| 405 | 522 |
|
@@ -7,49 +7,26 @@ struct SnapshotDetailView: View {
|
||
| 7 | 7 |
let snapshot: HealthSnapshot |
| 8 | 8 |
let baseline: HealthSnapshot? |
| 9 | 9 |
let profile: DeviceProfile? |
| 10 |
- private let contextRadius = 3 |
|
| 11 | 10 |
|
| 12 | 11 |
@Query(sort: \HealthSnapshot.timestamp) private var allSnapshots: [HealthSnapshot] |
| 13 |
- |
|
| 14 |
- private let diffService = SnapshotDiffService.shared |
|
| 15 |
- |
|
| 16 |
- @State private var xAxisMode: EvolutionXAxisMode = .snapshots |
|
| 12 |
+ @Query private var allDeltas: [SnapshotDelta] |
|
| 17 | 13 |
@State private var displayedSnapshot: HealthSnapshot? |
| 18 | 14 |
|
| 19 | 15 |
private var currentSnapshot: HealthSnapshot {
|
| 20 | 16 |
displayedSnapshot ?? snapshot |
| 21 | 17 |
} |
| 22 | 18 |
|
| 23 |
- private var sortedTypeCounts: [TypeCount] {
|
|
| 24 |
- (currentSnapshot.typeCounts ?? []).sorted {
|
|
| 25 |
- $0.displayName.localizedCompare($1.displayName) == .orderedAscending |
|
| 26 |
- } |
|
| 19 |
+ private var currentDelta: SnapshotDelta? {
|
|
| 20 |
+ allDeltas.first { $0.toSnapshotID == currentSnapshot.id }
|
|
| 27 | 21 |
} |
| 28 | 22 |
|
| 29 |
- private var baselineTypeMap: [String: TypeCount] {
|
|
| 30 |
- Dictionary( |
|
| 31 |
- uniqueKeysWithValues: (baseline?.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
|
|
| 32 |
- ) |
|
| 23 |
+ private var currentDeltaSummary: SnapshotDeltaListSummary? {
|
|
| 24 |
+ currentDelta?.listSummary |
|
| 33 | 25 |
} |
| 34 | 26 |
|
| 35 |
- private var totalCount: Int {
|
|
| 36 |
- sortedTypeCounts.reduce(0) { partial, typeCount in
|
|
| 37 |
- typeCount.count > 0 ? partial + typeCount.count : partial |
|
| 38 |
- } |
|
| 39 |
- } |
|
| 40 |
- |
|
| 41 |
- private var snapshotEarliestRecordDate: Date? {
|
|
| 42 |
- sortedTypeCounts |
|
| 43 |
- .filter { !$0.isUnsupported && $0.count > 0 }
|
|
| 44 |
- .compactMap(\.earliestDate) |
|
| 45 |
- .min() |
|
| 46 |
- } |
|
| 47 |
- |
|
| 48 |
- private var snapshotNewestRecordDate: Date? {
|
|
| 49 |
- sortedTypeCounts |
|
| 50 |
- .filter { !$0.isUnsupported && $0.count > 0 }
|
|
| 51 |
- .compactMap(\.latestDate) |
|
| 52 |
- .max() |
|
| 27 |
+ private var allTypeDeltas: [TypeDelta] {
|
|
| 28 |
+ (currentDelta?.typeDeltas ?? []) |
|
| 29 |
+ .sorted { $0.displayName.localizedCompare($1.displayName) == .orderedAscending }
|
|
| 53 | 30 |
} |
| 54 | 31 |
|
| 55 | 32 |
private var deviceDisplayName: String {
|
@@ -66,77 +43,6 @@ struct SnapshotDetailView: View {
|
||
| 66 | 43 |
} |
| 67 | 44 |
} |
| 68 | 45 |
|
| 69 |
- private var timelineContextSnapshots: [HealthSnapshot] {
|
|
| 70 |
- guard let currentIndex = timelineSnapshots.firstIndex(where: { $0.id == currentSnapshot.id }) else {
|
|
| 71 |
- return [currentSnapshot] |
|
| 72 |
- } |
|
| 73 |
- |
|
| 74 |
- let desiredCount = contextRadius * 2 + 1 |
|
| 75 |
- var start = max(0, currentIndex - contextRadius) |
|
| 76 |
- var end = min(timelineSnapshots.count - 1, currentIndex + contextRadius) |
|
| 77 |
- |
|
| 78 |
- let currentCount = end - start + 1 |
|
| 79 |
- if currentCount < desiredCount {
|
|
| 80 |
- let missing = desiredCount - currentCount |
|
| 81 |
- |
|
| 82 |
- let extraBefore = min(start, missing) |
|
| 83 |
- start -= extraBefore |
|
| 84 |
- |
|
| 85 |
- let remaining = missing - extraBefore |
|
| 86 |
- let availableAfter = timelineSnapshots.count - 1 - end |
|
| 87 |
- let extraAfter = min(availableAfter, remaining) |
|
| 88 |
- end += extraAfter |
|
| 89 |
- |
|
| 90 |
- if extraAfter < remaining {
|
|
| 91 |
- let finalRemaining = remaining - extraAfter |
|
| 92 |
- let availableBefore = start |
|
| 93 |
- let finalExtraBefore = min(availableBefore, finalRemaining) |
|
| 94 |
- start -= finalExtraBefore |
|
| 95 |
- } |
|
| 96 |
- } |
|
| 97 |
- |
|
| 98 |
- return Array(timelineSnapshots[start...end]) |
|
| 99 |
- } |
|
| 100 |
- |
|
| 101 |
- private var isTimelineContextTrimmed: Bool {
|
|
| 102 |
- timelineContextSnapshots.count < timelineSnapshots.count |
|
| 103 |
- } |
|
| 104 |
- |
|
| 105 |
- private var timelineSnapshotNumbers: [UUID: Int] {
|
|
| 106 |
- Dictionary( |
|
| 107 |
- uniqueKeysWithValues: timelineSnapshots.enumerated().map { index, snapshot in
|
|
| 108 |
- (snapshot.id, index + 1) |
|
| 109 |
- } |
|
| 110 |
- ) |
|
| 111 |
- } |
|
| 112 |
- |
|
| 113 |
- private var evolutionSeries: [TypeEvolutionSeries] {
|
|
| 114 |
- sortedTypeCounts.compactMap { typeCount in
|
|
| 115 |
- let points = timelineContextSnapshots.compactMap { candidate -> TypeEvolutionPoint? in
|
|
| 116 |
- guard let candidateTypeCount = candidate.typeCounts?.first(where: {
|
|
| 117 |
- $0.typeIdentifier == typeCount.typeIdentifier |
|
| 118 |
- }), |
|
| 119 |
- candidateTypeCount.count >= 0 |
|
| 120 |
- else {
|
|
| 121 |
- return nil |
|
| 122 |
- } |
|
| 123 |
- |
|
| 124 |
- return TypeEvolutionPoint( |
|
| 125 |
- snapshotID: candidate.id, |
|
| 126 |
- timestamp: candidate.timestamp, |
|
| 127 |
- count: candidateTypeCount.count |
|
| 128 |
- ) |
|
| 129 |
- } |
|
| 130 |
- |
|
| 131 |
- guard !points.isEmpty else { return nil }
|
|
| 132 |
- return TypeEvolutionSeries( |
|
| 133 |
- typeIdentifier: typeCount.typeIdentifier, |
|
| 134 |
- displayName: typeCount.displayName, |
|
| 135 |
- points: points |
|
| 136 |
- ) |
|
| 137 |
- } |
|
| 138 |
- } |
|
| 139 |
- |
|
| 140 | 46 |
@State private var showShareSheet = false |
| 141 | 47 |
@State private var pdfExportURL: URL? |
| 142 | 48 |
@State private var isExporting = false |
@@ -255,20 +161,27 @@ struct SnapshotDetailView: View {
|
||
| 255 | 161 |
|
| 256 | 162 |
// Data Range |
| 257 | 163 |
SnapshotDataRangeIndicator( |
| 258 |
- oldestRecordDate: snapshotEarliestRecordDate, |
|
| 259 |
- newestRecordDate: snapshotNewestRecordDate, |
|
| 164 |
+ oldestRecordDate: currentSnapshot.cachedEarliestRecordDate, |
|
| 165 |
+ newestRecordDate: currentSnapshot.cachedLatestRecordDate, |
|
| 260 | 166 |
quality: currentSnapshot.snapshotQuality |
| 261 | 167 |
) |
| 262 | 168 |
|
| 263 | 169 |
// Summary Stats (compact) |
| 264 | 170 |
VStack(spacing: 12) {
|
| 265 |
- HStack(spacing: 16) {
|
|
| 266 |
- statCompact(label: "Types", value: "\(sortedTypeCounts.count)") |
|
| 267 |
- Divider() |
|
| 268 |
- statCompact(label: "Records", value: "\(totalCount)") |
|
| 171 |
+ if currentSnapshot.hasCurrentCachedSummary {
|
|
| 172 |
+ HStack(spacing: 16) {
|
|
| 173 |
+ statCompact(label: "Types", value: "\(currentSnapshot.cachedTypeCount)") |
|
| 174 |
+ Divider() |
|
| 175 |
+ statCompact(label: "Records", value: "\(currentSnapshot.cachedRecordCount)") |
|
| 176 |
+ } |
|
| 177 |
+ .font(.caption) |
|
| 178 |
+ .foregroundStyle(.secondary) |
|
| 179 |
+ } else {
|
|
| 180 |
+ Text("Snapshot summary unavailable")
|
|
| 181 |
+ .font(.caption) |
|
| 182 |
+ .foregroundStyle(.secondary) |
|
| 183 |
+ .frame(maxWidth: .infinity, alignment: .center) |
|
| 269 | 184 |
} |
| 270 |
- .font(.caption) |
|
| 271 |
- .foregroundStyle(.secondary) |
|
| 272 | 185 |
} |
| 273 | 186 |
.padding(12) |
| 274 | 187 |
.background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12)) |
@@ -316,9 +229,10 @@ struct SnapshotDetailView: View {
|
||
| 316 | 229 |
|
| 317 | 230 |
@ViewBuilder |
| 318 | 231 |
private func comparisonSection(baseline: HealthSnapshot) -> some View {
|
| 319 |
- let delta = diffService.totalAbsoluteChange(current: currentSnapshot, baseline: baseline) |
|
| 232 |
+ let delta = currentDeltaSummary?.absoluteRecordChangeCount ?? 0 |
|
| 320 | 233 |
let deltaPercent = computeDeltaPercent(delta: delta, baseline: baseline) |
| 321 |
- let isSignificant = delta > 0 || (deltaPercent > 10) |
|
| 234 |
+ let affectedMetricCount = currentDeltaSummary?.affectedMetricCount ?? 0 |
|
| 235 |
+ let isSignificant = delta > 0 || affectedMetricCount > 0 || (deltaPercent > 10) |
|
| 322 | 236 |
|
| 323 | 237 |
DisclosureGroup {
|
| 324 | 238 |
VStack(alignment: .leading, spacing: 12) {
|
@@ -332,6 +246,18 @@ struct SnapshotDetailView: View {
|
||
| 332 | 246 |
Text(days == 0 ? "Same day" : "\(days) days") |
| 333 | 247 |
.foregroundStyle(.secondary) |
| 334 | 248 |
} |
| 249 |
+ if let summary = currentDeltaSummary {
|
|
| 250 |
+ Divider() |
|
| 251 |
+ DetailRow(label: "Changed Metrics") {
|
|
| 252 |
+ Text("\(summary.affectedMetricCount)")
|
|
| 253 |
+ .foregroundStyle(.secondary) |
|
| 254 |
+ } |
|
| 255 |
+ Divider() |
|
| 256 |
+ DetailRow(label: "Record Changes") {
|
|
| 257 |
+ Text("\(summary.absoluteRecordChangeCount)")
|
|
| 258 |
+ .foregroundStyle(.secondary) |
|
| 259 |
+ } |
|
| 260 |
+ } |
|
| 335 | 261 |
} |
| 336 | 262 |
.padding(.top, 8) |
| 337 | 263 |
} label: {
|
@@ -380,9 +306,7 @@ struct SnapshotDetailView: View {
|
||
| 380 | 306 |
} |
| 381 | 307 |
|
| 382 | 308 |
private func computeDeltaPercent(delta: Int, baseline: HealthSnapshot) -> Double {
|
| 383 |
- let baselineTotal = (baseline.typeCounts ?? []) |
|
| 384 |
- .filter { $0.count > 0 }
|
|
| 385 |
- .reduce(0) { $0 + $1.count }
|
|
| 309 |
+ let baselineTotal = baseline.hasCurrentCachedSummary ? baseline.cachedRecordCount : 0 |
|
| 386 | 310 |
return baselineTotal > 0 ? Double(delta) / Double(baselineTotal) * 100 : 0 |
| 387 | 311 |
} |
| 388 | 312 |
|
@@ -399,66 +323,83 @@ struct SnapshotDetailView: View {
|
||
| 399 | 323 |
|
| 400 | 324 |
private var evolutionSection: some View {
|
| 401 | 325 |
Section("Data Types") {
|
| 402 |
- HStack {
|
|
| 403 |
- Spacer() |
|
| 404 |
- Picker("X-Axis", selection: $xAxisMode) {
|
|
| 405 |
- ForEach(EvolutionXAxisMode.allCases.reversed()) { mode in
|
|
| 406 |
- Text(mode.title).tag(mode) |
|
| 407 |
- } |
|
| 408 |
- } |
|
| 409 |
- .pickerStyle(.segmented) |
|
| 410 |
- Spacer() |
|
| 411 |
- } |
|
| 412 |
- |
|
| 413 |
- if evolutionSeries.isEmpty {
|
|
| 414 |
- if sortedTypeCounts.isEmpty {
|
|
| 415 |
- Text("No tracked data types in this snapshot.")
|
|
| 416 |
- .foregroundStyle(.secondary) |
|
| 417 |
- } else {
|
|
| 418 |
- ForEach(sortedTypeCounts) { typeCount in
|
|
| 419 |
- NavigationLink {
|
|
| 420 |
- DataTypeSnapshotDetailView( |
|
| 421 |
- snapshot: currentSnapshot, |
|
| 422 |
- typeIdentifier: typeCount.typeIdentifier, |
|
| 423 |
- displayName: typeCount.displayName |
|
| 424 |
- ) |
|
| 425 |
- } label: {
|
|
| 426 |
- SnapshotTypeCountRow( |
|
| 427 |
- typeCount: typeCount, |
|
| 428 |
- baselineTypeCount: baselineTypeMap[typeCount.typeIdentifier] |
|
| 429 |
- ) |
|
| 430 |
- } |
|
| 431 |
- } |
|
| 432 |
- } |
|
| 326 |
+ if baseline == nil {
|
|
| 327 |
+ Text("This snapshot starts the chain, so no baseline comparison is available.")
|
|
| 328 |
+ .foregroundStyle(.secondary) |
|
| 329 |
+ } else if currentDelta == nil {
|
|
| 330 |
+ Text("Cached metric summary unavailable for this snapshot.")
|
|
| 331 |
+ .foregroundStyle(.secondary) |
|
| 332 |
+ } else if allTypeDeltas.isEmpty {
|
|
| 333 |
+ Text("No data types are available for this snapshot.")
|
|
| 334 |
+ .foregroundStyle(.secondary) |
|
| 433 | 335 |
} else {
|
| 434 |
- ForEach(evolutionSeries) { series in
|
|
| 336 |
+ ForEach(allTypeDeltas) { typeDelta in
|
|
| 435 | 337 |
NavigationLink {
|
| 436 | 338 |
DataTypeSnapshotDetailView( |
| 437 | 339 |
snapshot: currentSnapshot, |
| 438 |
- typeIdentifier: series.typeIdentifier, |
|
| 439 |
- displayName: series.displayName |
|
| 340 |
+ typeIdentifier: typeDelta.typeIdentifier, |
|
| 341 |
+ displayName: typeDelta.displayName |
|
| 440 | 342 |
) |
| 441 | 343 |
} label: {
|
| 442 |
- TypeEvolutionChart( |
|
| 443 |
- series: series, |
|
| 444 |
- contextSnapshots: timelineContextSnapshots, |
|
| 445 |
- xAxisMode: xAxisMode, |
|
| 446 |
- selectedSnapshotID: currentSnapshot.id, |
|
| 447 |
- selectedTimestamp: currentSnapshot.timestamp, |
|
| 448 |
- snapshotNumbers: timelineSnapshotNumbers, |
|
| 449 |
- baselineTypeCount: baselineTypeMap[series.typeIdentifier] |
|
| 450 |
- ) |
|
| 344 |
+ SnapshotTypeDeltaRow(typeDelta: typeDelta) |
|
| 451 | 345 |
} |
| 452 | 346 |
} |
| 347 |
+ } |
|
| 348 |
+ } |
|
| 349 |
+ } |
|
| 350 |
+} |
|
| 453 | 351 |
|
| 454 |
- if isTimelineContextTrimmed {
|
|
| 455 |
- Text("Charts show only the local window: 3 snapshots before and 3 after the current one.")
|
|
| 456 |
- .font(.caption) |
|
| 457 |
- .foregroundStyle(.secondary) |
|
| 458 |
- } |
|
| 352 |
+private struct SnapshotTypeDeltaRow: View {
|
|
| 353 |
+ let typeDelta: TypeDelta |
|
| 354 |
+ |
|
| 355 |
+ private var deltaLabel: String {
|
|
| 356 |
+ switch typeDelta.transition {
|
|
| 357 |
+ case .changed: |
|
| 358 |
+ if typeDelta.countDelta == 0 {
|
|
| 359 |
+ return "Content changed" |
|
| 459 | 360 |
} |
| 361 |
+ let prefix = typeDelta.countDelta > 0 ? "+" : "" |
|
| 362 |
+ return "\(prefix)\(typeDelta.countDelta) records" |
|
| 363 |
+ case .appeared: |
|
| 364 |
+ return "Appeared" |
|
| 365 |
+ case .disappeared: |
|
| 366 |
+ return "Disappeared" |
|
| 367 |
+ case .unchanged: |
|
| 368 |
+ return "No changes" |
|
| 369 |
+ } |
|
| 370 |
+ } |
|
| 371 |
+ |
|
| 372 |
+ private var deltaColor: Color {
|
|
| 373 |
+ switch typeDelta.transition {
|
|
| 374 |
+ case .disappeared: |
|
| 375 |
+ return .criticalRed |
|
| 376 |
+ case .changed, .appeared: |
|
| 377 |
+ return .warningAmber |
|
| 378 |
+ case .unchanged: |
|
| 379 |
+ return .secondary |
|
| 460 | 380 |
} |
| 461 | 381 |
} |
| 382 |
+ |
|
| 383 |
+ var body: some View {
|
|
| 384 |
+ HStack(spacing: 12) {
|
|
| 385 |
+ VStack(alignment: .leading, spacing: 3) {
|
|
| 386 |
+ Text(typeDelta.displayName) |
|
| 387 |
+ .font(.subheadline) |
|
| 388 |
+ Text(typeDelta.typeIdentifier) |
|
| 389 |
+ .font(.caption2) |
|
| 390 |
+ .foregroundStyle(.secondary) |
|
| 391 |
+ .lineLimit(1) |
|
| 392 |
+ .truncationMode(.middle) |
|
| 393 |
+ } |
|
| 394 |
+ |
|
| 395 |
+ Spacer() |
|
| 396 |
+ |
|
| 397 |
+ Text(deltaLabel) |
|
| 398 |
+ .font(.caption.weight(.semibold)) |
|
| 399 |
+ .foregroundStyle(deltaColor) |
|
| 400 |
+ } |
|
| 401 |
+ .accessibilityElement(children: .combine) |
|
| 402 |
+ } |
|
| 462 | 403 |
} |
| 463 | 404 |
|
| 464 | 405 |
private enum EvolutionXAxisMode: String, CaseIterable, Identifiable {
|
@@ -992,5 +933,5 @@ private struct ShareSheet: UIViewControllerRepresentable {
|
||
| 992 | 933 |
profile: DeviceProfile(deviceID: "preview-device") |
| 993 | 934 |
) |
| 994 | 935 |
} |
| 995 |
- .modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self, DeviceProfile.self], inMemory: true) |
|
| 936 |
+ .modelContainer(for: [HealthSnapshot.self, SnapshotDelta.self, TypeDelta.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self, DeviceProfile.self], inMemory: true) |
|
| 996 | 937 |
} |
@@ -6,6 +6,7 @@ struct SnapshotsView: View {
|
||
| 6 | 6 |
@Environment(\.modelContext) private var modelContext |
| 7 | 7 |
@Environment(AppSettings.self) private var appSettings |
| 8 | 8 |
@Query(sort: \HealthSnapshot.timestamp, order: .reverse) private var allSnapshots: [HealthSnapshot] |
| 9 |
+ @Query private var allDeltas: [SnapshotDelta] |
|
| 9 | 10 |
@Query private var deviceProfiles: [DeviceProfile] |
| 10 | 11 |
@State private var viewModel = SnapshotsViewModel() |
| 11 | 12 |
|
@@ -21,6 +22,22 @@ struct SnapshotsView: View {
|
||
| 21 | 22 |
return allSnapshots.filter { selected.contains($0.deviceID) }
|
| 22 | 23 |
} |
| 23 | 24 |
|
| 25 |
+ private var snapshotItems: [SnapshotListItem] {
|
|
| 26 |
+ let baselines = viewModel.baselines(for: displayedSnapshots) |
|
| 27 |
+ let deltaSummaryBySnapshotID = allDeltas.reduce(into: [UUID: SnapshotDeltaListSummary]()) { partial, delta in
|
|
| 28 |
+ partial[delta.toSnapshotID] = delta.listSummary |
|
| 29 |
+ } |
|
| 30 |
+ |
|
| 31 |
+ return displayedSnapshots.map { snapshot in
|
|
| 32 |
+ SnapshotListItem( |
|
| 33 |
+ snapshot: snapshot, |
|
| 34 |
+ baseline: baselines[snapshot.id] ?? nil, |
|
| 35 |
+ deltaSummary: deltaSummaryBySnapshotID[snapshot.id], |
|
| 36 |
+ showsDeltaSummary: viewModel.comparisonMode == .previous |
|
| 37 |
+ ) |
|
| 38 |
+ } |
|
| 39 |
+ } |
|
| 40 |
+ |
|
| 24 | 41 |
private var knownDevices: [DeviceEntry] {
|
| 25 | 42 |
let currentID = AppSettings.currentDeviceID |
| 26 | 43 |
var ids = Set(allSnapshots.map { $0.deviceID })
|
@@ -57,6 +74,9 @@ struct SnapshotsView: View {
|
||
| 57 | 74 |
} |
| 58 | 75 |
.navigationTitle("Snapshots")
|
| 59 | 76 |
.toolbar { toolbarContent }
|
| 77 |
+ .task(id: allDeltas.count) {
|
|
| 78 |
+ repairDeltaListSummariesIfNeeded() |
|
| 79 |
+ } |
|
| 60 | 80 |
.onChange(of: appSettings.selectedDeviceIDs) {
|
| 61 | 81 |
if let baseline = viewModel.selectedBaseline, |
| 62 | 82 |
!displayedSnapshots.contains(where: { $0.id == baseline.id }) {
|
@@ -70,29 +90,31 @@ struct SnapshotsView: View {
|
||
| 70 | 90 |
// MARK: - List |
| 71 | 91 |
|
| 72 | 92 |
private var snapshotList: some View {
|
| 73 |
- List(displayedSnapshots) { snapshot in
|
|
| 93 |
+ List(snapshotItems) { item in
|
|
| 74 | 94 |
NavigationLink {
|
| 75 | 95 |
SnapshotDetailView( |
| 76 |
- snapshot: snapshot, |
|
| 77 |
- baseline: viewModel.baseline(for: snapshot, in: displayedSnapshots), |
|
| 78 |
- profile: profileMap[snapshot.deviceID] |
|
| 96 |
+ snapshot: item.snapshot, |
|
| 97 |
+ baseline: item.baseline, |
|
| 98 |
+ profile: profileMap[item.snapshot.deviceID] |
|
| 79 | 99 |
) |
| 80 | 100 |
} label: {
|
| 81 | 101 |
SnapshotRow( |
| 82 |
- snapshot: snapshot, |
|
| 83 |
- baseline: viewModel.baseline(for: snapshot, in: displayedSnapshots), |
|
| 84 |
- isSelectedBaseline: viewModel.selectedBaseline?.id == snapshot.id, |
|
| 85 |
- profile: profileMap[snapshot.deviceID] |
|
| 102 |
+ snapshot: item.snapshot, |
|
| 103 |
+ baseline: item.baseline, |
|
| 104 |
+ deltaSummary: item.deltaSummary, |
|
| 105 |
+ showsDeltaSummary: item.showsDeltaSummary, |
|
| 106 |
+ isSelectedBaseline: viewModel.selectedBaseline?.id == item.snapshot.id, |
|
| 107 |
+ profile: profileMap[item.snapshot.deviceID] |
|
| 86 | 108 |
) |
| 87 | 109 |
} |
| 88 | 110 |
.swipeActions(edge: .leading) {
|
| 89 | 111 |
Button {
|
| 90 |
- viewModel.toggleBaseline(snapshot) |
|
| 112 |
+ viewModel.toggleBaseline(item.snapshot) |
|
| 91 | 113 |
viewModel.comparisonMode = .selected |
| 92 | 114 |
} label: {
|
| 93 | 115 |
Label( |
| 94 |
- viewModel.selectedBaseline?.id == snapshot.id ? "Unset Baseline" : "Set as Baseline", |
|
| 95 |
- systemImage: viewModel.selectedBaseline?.id == snapshot.id ? "pin.slash" : "pin" |
|
| 116 |
+ viewModel.selectedBaseline?.id == item.snapshot.id ? "Unset Baseline" : "Set as Baseline", |
|
| 117 |
+ systemImage: viewModel.selectedBaseline?.id == item.snapshot.id ? "pin.slash" : "pin" |
|
| 96 | 118 |
) |
| 97 | 119 |
} |
| 98 | 120 |
.tint(.indigo) |
@@ -100,7 +122,7 @@ struct SnapshotsView: View {
|
||
| 100 | 122 |
.swipeActions(edge: .trailing) {
|
| 101 | 123 |
Button(role: .destructive) {
|
| 102 | 124 |
do {
|
| 103 |
- try SnapshotLifecycleService.delete(snapshot, context: modelContext) |
|
| 125 |
+ try SnapshotLifecycleService.delete(item.snapshot, context: modelContext) |
|
| 104 | 126 |
} catch {
|
| 105 | 127 |
// Failure is surfaced via the navigation stack; no silent discard |
| 106 | 128 |
} |
@@ -161,6 +183,23 @@ struct SnapshotsView: View {
|
||
| 161 | 183 |
.tint(isMulti ? .orange : .accentColor) |
| 162 | 184 |
.accessibilityLabel("Select devices – \(selected.count) selected")
|
| 163 | 185 |
} |
| 186 |
+ |
|
| 187 |
+ private func repairDeltaListSummariesIfNeeded() {
|
|
| 188 |
+ do {
|
|
| 189 |
+ _ = try DeltaService.rebuildMissingListSummaries(context: modelContext, maxCount: 64) |
|
| 190 |
+ } catch {
|
|
| 191 |
+ // Keep the list responsive even if summary repair fails. |
|
| 192 |
+ } |
|
| 193 |
+ } |
|
| 194 |
+} |
|
| 195 |
+ |
|
| 196 |
+private struct SnapshotListItem: Identifiable {
|
|
| 197 |
+ let snapshot: HealthSnapshot |
|
| 198 |
+ let baseline: HealthSnapshot? |
|
| 199 |
+ let deltaSummary: SnapshotDeltaListSummary? |
|
| 200 |
+ let showsDeltaSummary: Bool |
|
| 201 |
+ |
|
| 202 |
+ var id: UUID { snapshot.id }
|
|
| 164 | 203 |
} |
| 165 | 204 |
|
| 166 | 205 |
// MARK: - Row |
@@ -168,10 +207,11 @@ struct SnapshotsView: View {
|
||
| 168 | 207 |
private struct SnapshotRow: View {
|
| 169 | 208 |
let snapshot: HealthSnapshot |
| 170 | 209 |
let baseline: HealthSnapshot? |
| 210 |
+ let deltaSummary: SnapshotDeltaListSummary? |
|
| 211 |
+ let showsDeltaSummary: Bool |
|
| 171 | 212 |
let isSelectedBaseline: Bool |
| 172 | 213 |
let profile: DeviceProfile? |
| 173 | 214 |
|
| 174 |
- private let diffService = SnapshotDiffService.shared |
|
| 175 | 215 |
private static let dateFormatter: DateFormatter = {
|
| 176 | 216 |
let f = DateFormatter() |
| 177 | 217 |
f.dateStyle = .medium |
@@ -188,12 +228,44 @@ private struct SnapshotRow: View {
|
||
| 188 | 228 |
DeviceColor(rawValue: profile?.colorTag ?? "")?.color ?? .neutralGray |
| 189 | 229 |
} |
| 190 | 230 |
|
| 191 |
- private var metricCount: Int {
|
|
| 192 |
- snapshot.typeCounts?.count ?? 0 |
|
| 231 |
+ private var metricCountLabel: String? {
|
|
| 232 |
+ guard snapshot.hasCurrentCachedSummary else { return nil }
|
|
| 233 |
+ return snapshot.cachedTypeCount == 1 ? "1 metric" : "\(snapshot.cachedTypeCount) metrics" |
|
| 234 |
+ } |
|
| 235 |
+ |
|
| 236 |
+ private var deltaSummaryText: String? {
|
|
| 237 |
+ guard let deltaSummary else { return nil }
|
|
| 238 |
+ |
|
| 239 |
+ var parts: [String] = [] |
|
| 240 |
+ |
|
| 241 |
+ if deltaSummary.absoluteRecordChangeCount > 0 {
|
|
| 242 |
+ parts.append("\(deltaSummary.absoluteRecordChangeCount) record change\(deltaSummary.absoluteRecordChangeCount == 1 ? "" : "s")")
|
|
| 243 |
+ } |
|
| 244 |
+ |
|
| 245 |
+ if deltaSummary.changedMetricCount > 0 {
|
|
| 246 |
+ parts.append("\(deltaSummary.changedMetricCount) metric change\(deltaSummary.changedMetricCount == 1 ? "" : "s")")
|
|
| 247 |
+ } |
|
| 248 |
+ |
|
| 249 |
+ if deltaSummary.appearedMetricCount > 0 {
|
|
| 250 |
+ parts.append("\(deltaSummary.appearedMetricCount) metric appeared")
|
|
| 251 |
+ } |
|
| 252 |
+ |
|
| 253 |
+ if deltaSummary.disappearedMetricCount > 0 {
|
|
| 254 |
+ parts.append("\(deltaSummary.disappearedMetricCount) metric disappeared")
|
|
| 255 |
+ } |
|
| 256 |
+ |
|
| 257 |
+ if parts.isEmpty {
|
|
| 258 |
+ return "No changes" |
|
| 259 |
+ } |
|
| 260 |
+ |
|
| 261 |
+ return parts.joined(separator: " • ") |
|
| 193 | 262 |
} |
| 194 | 263 |
|
| 195 |
- private var metricCountLabel: String {
|
|
| 196 |
- metricCount == 1 ? "1 metric" : "\(metricCount) metrics" |
|
| 264 |
+ private var deltaSummaryColor: Color {
|
|
| 265 |
+ guard let deltaSummary else { return .secondary }
|
|
| 266 |
+ if deltaSummary.disappearedMetricCount > 0 { return Color.criticalRed }
|
|
| 267 |
+ if deltaSummary.hasChanges { return Color.warningAmber }
|
|
| 268 |
+ return Color.healthyGreen |
|
| 197 | 269 |
} |
| 198 | 270 |
|
| 199 | 271 |
private var hasOSVersionChange: Bool {
|
@@ -230,9 +302,11 @@ private struct SnapshotRow: View {
|
||
| 230 | 302 |
Text(deviceDisplayName) |
| 231 | 303 |
.font(.caption) |
| 232 | 304 |
.foregroundStyle(.secondary) |
| 233 |
- Label(metricCountLabel, systemImage: "list.bullet.rectangle") |
|
| 234 |
- .font(.caption) |
|
| 235 |
- .foregroundStyle(.secondary) |
|
| 305 |
+ if let metricCountLabel {
|
|
| 306 |
+ Label(metricCountLabel, systemImage: "list.bullet.rectangle") |
|
| 307 |
+ .font(.caption) |
|
| 308 |
+ .foregroundStyle(.secondary) |
|
| 309 |
+ } |
|
| 236 | 310 |
if hasOSVersionChange {
|
| 237 | 311 |
Label("OS \(snapshot.osVersion)", systemImage: "gearshape.fill")
|
| 238 | 312 |
.font(.caption) |
@@ -250,14 +324,16 @@ private struct SnapshotRow: View {
|
||
| 250 | 324 |
.foregroundStyle(Color.warningAmber) |
| 251 | 325 |
} |
| 252 | 326 |
|
| 253 |
- if let baseline {
|
|
| 254 |
- let delta = diffService.totalAbsoluteChange(current: snapshot, baseline: baseline) |
|
| 327 |
+ if showsDeltaSummary, |
|
| 328 |
+ let deltaSummaryText {
|
|
| 255 | 329 |
HStack(spacing: 4) {
|
| 256 |
- Image(systemName: "arrow.triangle.2.circlepath") |
|
| 257 |
- Text(delta == 0 ? "No changes" : "\(delta) record changes") |
|
| 330 |
+ Image(systemName: deltaSummary == nil || deltaSummary?.hasChanges == false |
|
| 331 |
+ ? "checkmark.circle" |
|
| 332 |
+ : "arrow.triangle.2.circlepath") |
|
| 333 |
+ Text(deltaSummaryText) |
|
| 258 | 334 |
} |
| 259 | 335 |
.font(.caption) |
| 260 |
- .foregroundStyle(delta == 0 ? Color.healthyGreen : Color.warningAmber) |
|
| 336 |
+ .foregroundStyle(deltaSummaryColor) |
|
| 261 | 337 |
} |
| 262 | 338 |
} |
| 263 | 339 |
.padding(.vertical, 2) |
@@ -294,6 +370,6 @@ private struct SnapshotRow: View {
|
||
| 294 | 370 |
|
| 295 | 371 |
#Preview {
|
| 296 | 372 |
SnapshotsView() |
| 297 |
- .modelContainer(for: [HealthSnapshot.self, TypeCount.self, HealthRecord.self, TypeDistributionBin.self, DeviceProfile.self], inMemory: true) |
|
| 373 |
+ .modelContainer(for: [HealthSnapshot.self, SnapshotDelta.self, TypeDelta.self, TypeCount.self, HealthRecord.self, TypeDistributionBin.self, DeviceProfile.self], inMemory: true) |
|
| 298 | 374 |
.environment(AppSettings()) |
| 299 | 375 |
} |