@@ -27,7 +27,7 @@ There are no real deployments, only test installations. Existing prototype datab |
||
| 27 | 27 |
| HealthKit capture | Capture now opens one archive observation per user-visible snapshot and attaches HealthKit pages, deleted-object evidence, and type verification to that observation id before finishing it | Continue moving UI/cache reads to archive-backed observation ids | |
| 28 | 28 |
| SQLite archive | Archive v2 schema, snapshot-level observation grouping, differential write path, v2 verification/delete bookkeeping, daily aggregate rebuilds, integrity report, v2 record reads, SQL diff/count/aggregate/provenance/consolidation-evidence APIs, large synthetic diff pagination coverage, formal timing/memory metrics, and XCTest coverage are in place; the legacy `archive_samples` mirror has been removed | Move Snapshots/Data Types from SwiftData previews to archive/cache DTOs | |
| 29 | 29 |
| Core Data cache | Initial programmatic Core Data model, full-cache rebuild service, read DTOs for observation/type/diff/health rows, and Dashboard archive-cache status wiring are in place | Move remaining export/report paths to cache DTOs and add targeted partial invalidation | |
| 30 |
-| SwiftData cache | Exists; test builds now reset legacy prototype UI/archive/cache stores once for archive v2 so old SwiftData-only snapshots are not treated as backed-up observations. Remaining SwiftData imports are inventoried in [`SwiftData-Retirement-Inventory.md`](SwiftData-Retirement-Inventory.md) | Treat as disposable prototype data; replace Settings/local rows, capture review actions, and navigation handles before removing `ModelContainer` | |
|
| 30 |
+| SwiftData cache | Exists; test builds now reset legacy prototype UI/archive/cache stores once for archive v2 so old SwiftData-only snapshots are not treated as backed-up observations. Metric timeout calibration has moved to a local Codable store outside `ModelContainer`. Remaining SwiftData imports are inventoried in [`SwiftData-Retirement-Inventory.md`](SwiftData-Retirement-Inventory.md) | Treat as disposable prototype data; replace remaining Settings/local rows, capture review actions, and navigation handles before removing `ModelContainer` | |
|
| 31 | 31 |
| UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker. Dashboard status prefers archive/cache observation rows and shows cache health; Snapshots timeline, snapshot detail summaries/type rows, and Data Types list prefer Core Data cache rows when archive observation ids exist; data type detail reads Core Data type/diff summaries and uses SQLite `diffRecords` for paged drill-down; export preview reads the archive export API before showing/exporting JSON; simplified detail mode replaces heavy charts with summary rows on small/accessibility layouts or when enabled in Settings; visible change labels now use neutral new/missing/change-review language, with SwiftData detail cache as transition fallback | Remove remaining SwiftData navigation handles | |
| 32 | 32 |
| Diff/change explanation | Prototype/legacy anomaly logic exists | Move heavy diffing into SQLite and use neutral change classifications | |
| 33 | 33 |
| Export | SQLite export preview, paged JSON writing, SHA256 manifest hashing, and `export_manifests` rows are in place for selected records and observation diffs | Fill remaining recovery-compatible envelope metadata, CSV export, relationship preservation, and reproducibility checks | |
@@ -49,7 +49,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m |
||
| 49 | 49 |
- SwiftData currently blocks iOS 15-era device support. |
| 50 | 50 |
- Existing `Anomaly*` model/service names are legacy language. |
| 51 | 51 |
- Some screens still imply snapshot-count monitoring rather than Time Machine inspection. |
| 52 |
-- Current UI/cache layers still depend on 28 SwiftData-backed files for launch container, local Settings rows, capture review actions, navigation handles, some charts, and PDF paths. |
|
| 52 |
+- Current UI/cache layers still depend on 27 SwiftData-backed files for launch container, remaining local Settings rows, capture review actions, navigation handles, some charts, and PDF paths. |
|
| 53 | 53 |
- Snapshots timeline, snapshot detail summary/type rows, Data Types list rows, and record drill-down are archive/cache-backed for new archive v2 observations when cache rows exist, but navigation still uses SwiftData snapshot handles during the transition. |
| 54 | 54 |
- Legacy SwiftData-only snapshots can show diffs without archive-backed values; they are now reset for archive v2 test installs rather than migrated. |
| 55 | 55 |
- Capture strategy and some legacy SwiftData transition paths may still decode or cache too much data for low-end devices. |
@@ -246,7 +246,10 @@ Acceptance: |
||
| 246 | 246 |
|
| 247 | 247 |
Checklist: |
| 248 | 248 |
- [x] Identify all remaining SwiftData imports. |
| 249 |
-- [ ] Replace SwiftData models used by active flows. |
|
| 249 |
+- [ ] Replace SwiftData models used by active flows. Metric timeout calibration |
|
| 250 |
+ has been moved to a local Codable store and removed from `ModelContainer`; |
|
| 251 |
+ `DeviceProfile`, `OperationLog`, and SwiftData snapshot/navigation handles |
|
| 252 |
+ remain. |
|
| 250 | 253 |
- [ ] Remove/disable `ModelContainer` as required for target builds. |
| 251 | 254 |
- [x] Add prototype-store ignore/delete/reset path for test installs. |
| 252 | 255 |
- [ ] Verify no old-store compatibility layer remains in active flows. |
@@ -9,7 +9,7 @@ local settings stored outside SwiftData where needed. |
||
| 9 | 9 |
|
| 10 | 10 |
## Current Count |
| 11 | 11 |
|
| 12 |
-After removing unused imports from pure legacy services, 28 app files still have |
|
| 12 |
+After moving timeout calibration out of SwiftData, 27 app files still have |
|
| 13 | 13 |
SwiftData imports. |
| 14 | 14 |
|
| 15 | 15 |
## Launch Container |
@@ -20,8 +20,8 @@ These files keep SwiftData required at app launch: |
||
| 20 | 20 |
- `HealthProbe/ContentView.swift` |
| 21 | 21 |
|
| 22 | 22 |
Retirement path: |
| 23 |
-- move local settings models (`DeviceProfile`, `MetricTimeoutProfile`, |
|
| 24 |
- `OperationLog`) to non-SwiftData storage or Core Data cache/local store; |
|
| 23 |
+- move remaining local settings models (`DeviceProfile`, `OperationLog`) to |
|
| 24 |
+ non-SwiftData storage or Core Data cache/local store; |
|
| 25 | 25 |
- replace prototype snapshot model dependencies in tab roots; |
| 26 | 26 |
- remove `.modelContainer(...)` once no active view needs `@Query` or |
| 27 | 27 |
`ModelContext`. |
@@ -35,7 +35,6 @@ block: |
||
| 35 | 35 |
- `HealthProbe/Models/DeviceProfile.swift` |
| 36 | 36 |
- `HealthProbe/Models/HealthRecord.swift` |
| 37 | 37 |
- `HealthProbe/Models/HealthSnapshot.swift` |
| 38 |
-- `HealthProbe/Models/MetricTimeoutProfile.swift` |
|
| 39 | 38 |
- `HealthProbe/Models/OperationLog.swift` |
| 40 | 39 |
- `HealthProbe/Models/SnapshotDelta.swift` |
| 41 | 40 |
- `HealthProbe/Models/TypeCount.swift` |
@@ -48,8 +47,8 @@ Retirement path: |
||
| 48 | 47 |
`YearlyCount`, `TypeDistributionBin`, and `HealthRecord` active reads with |
| 49 | 48 |
archive/cache DTOs; |
| 50 | 49 |
- replace `AnomalyRecord` flows with neutral change/diff DTOs; |
| 51 |
-- move `DeviceProfile`, `MetricTimeoutProfile`, and `OperationLog` to a local |
|
| 52 |
- non-SwiftData store before removing the launch container. |
|
| 50 |
+- move `DeviceProfile` and `OperationLog` to a local non-SwiftData store before |
|
| 51 |
+ removing the launch container. |
|
| 53 | 52 |
|
| 54 | 53 |
## Capture And Maintenance Services |
| 55 | 54 |
|
@@ -64,7 +63,7 @@ These services still write/read legacy SwiftData transition models: |
||
| 64 | 63 |
Retirement path: |
| 65 | 64 |
- make capture persist archive observations first and expose only bridge ids |
| 66 | 65 |
while transition UI still exists; |
| 67 |
-- move timeout learning and operation logging out of SwiftData; |
|
| 66 |
+- move operation logging out of SwiftData; |
|
| 68 | 67 |
- delete legacy record repair once old SwiftData stores are no longer opened; |
| 69 | 68 |
- remove snapshot deletion/repair logic after archive/cache navigation replaces |
| 70 | 69 |
prototype snapshots. |
@@ -90,22 +89,26 @@ Retirement path: |
||
| 90 | 89 |
queries plus archive ids; |
| 91 | 90 |
- replace detail navigation parameters from SwiftData models to observation/type |
| 92 | 91 |
DTOs; |
| 93 |
-- move Settings device profile and timeout profile rows to local non-SwiftData |
|
| 94 |
- storage; |
|
| 92 |
+- move Settings device profile rows to local non-SwiftData storage; |
|
| 95 | 93 |
- keep paged record drill-down and export paths on archive APIs. |
| 96 | 94 |
|
| 97 | 95 |
## Removed During This Pass |
| 98 | 96 |
|
| 99 |
-The following files no longer import SwiftData because they only use already |
|
| 100 |
-declared app model types and do not need SwiftData APIs directly: |
|
| 97 |
+The following SwiftData dependencies were removed from active flows: |
|
| 101 | 98 |
|
| 102 | 99 |
- `HealthProbe/Services/AnomalyDetector.swift` |
| 103 | 100 |
- `HealthProbe/Services/IntegrityService.swift` |
| 101 |
+- `HealthProbe/Models/MetricTimeoutProfile.swift` was deleted. |
|
| 102 |
+- Metric timeout learning/calibration now uses |
|
| 103 |
+ `HealthProbe/Utilities/LocalMetricTimeoutProfile.swift`, a Codable local |
|
| 104 |
+ store outside `ModelContainer`. |
|
| 105 |
+- `SettingsView`, `DashboardView`, and `HealthKitService` read/write timeout |
|
| 106 |
+ calibration through the local store. |
|
| 104 | 107 |
|
| 105 | 108 |
## Next Recommended Slices |
| 106 | 109 |
|
| 107 |
-1. Replace Settings local rows (`DeviceProfile`, `MetricTimeoutProfile`) with a |
|
| 108 |
- small non-SwiftData local store so Settings no longer requires `@Query`. |
|
| 110 |
+1. Replace Settings local rows (`DeviceProfile`) with a small non-SwiftData |
|
| 111 |
+ local store so Settings no longer requires `@Query` for local configuration. |
|
| 109 | 112 |
2. Replace `ContentView` preview/container dependency after tab roots stop using |
| 110 | 113 |
`@Query`. |
| 111 | 114 |
3. Move `DashboardView` capture review actions away from `ModelContext`. |
@@ -33,7 +33,7 @@ struct HealthProbeApp: App {
|
||
| 33 | 33 |
let fullSchema = Schema([ |
| 34 | 34 |
HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self, |
| 35 | 35 |
SnapshotDelta.self, TypeDelta.self, AnomalyRecord.self, |
| 36 |
- OperationLog.self, DeviceProfile.self, MetricTimeoutProfile.self, |
|
| 36 |
+ OperationLog.self, DeviceProfile.self, |
|
| 37 | 37 |
]) |
| 38 | 38 |
|
| 39 | 39 |
let appSupportURL = URL.applicationSupportDirectory |
@@ -44,7 +44,7 @@ struct HealthProbeApp: App {
|
||
| 44 | 44 |
HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self, |
| 45 | 45 |
SnapshotDelta.self, TypeDelta.self, AnomalyRecord.self, |
| 46 | 46 |
]) |
| 47 |
- let localModels = Schema([OperationLog.self, DeviceProfile.self, MetricTimeoutProfile.self]) |
|
| 47 |
+ let localModels = Schema([OperationLog.self, DeviceProfile.self]) |
|
| 48 | 48 |
|
| 49 | 49 |
let uiCacheConfig = ModelConfiguration( |
| 50 | 50 |
"ui-cache", |
@@ -1,140 +0,0 @@ |
||
| 1 |
-import Foundation |
|
| 2 |
-import SwiftData |
|
| 3 |
- |
|
| 4 |
-@Model final class MetricTimeoutProfile {
|
|
| 5 |
- static let defaultInitialTimeout: TimeInterval = 15 |
|
| 6 |
- static let maximumTimeout: TimeInterval = 120 |
|
| 7 |
- private static let sampleLimit = 30 |
|
| 8 |
- private static let p95MinimumSampleCount = 5 |
|
| 9 |
- |
|
| 10 |
- var id: UUID = UUID() |
|
| 11 |
- var metricIdentifier: String = "" |
|
| 12 |
- var displayName: String = "" |
|
| 13 |
- var lastSuccessfulElapsed: TimeInterval = 0 |
|
| 14 |
- var averageSuccessfulElapsed: TimeInterval = 0 |
|
| 15 |
- var p95SuccessfulElapsed: TimeInterval = 0 |
|
| 16 |
- var lastTimeoutElapsed: TimeInterval = 0 |
|
| 17 |
- var timeoutCount: Int = 0 |
|
| 18 |
- var successCount: Int = 0 |
|
| 19 |
- var lastUpdatedAt: Date = Date.distantPast |
|
| 20 |
- var currentTimeout: TimeInterval = MetricTimeoutProfile.defaultInitialTimeout |
|
| 21 |
- var isManuallyOverridden: Bool = false |
|
| 22 |
- var manualTimeoutValue: TimeInterval = MetricTimeoutProfile.defaultInitialTimeout |
|
| 23 |
- var successfulElapsedSamplesJSON: String = "[]" |
|
| 24 |
- |
|
| 25 |
- init(metricIdentifier: String, displayName: String) {
|
|
| 26 |
- self.id = UUID() |
|
| 27 |
- self.metricIdentifier = metricIdentifier |
|
| 28 |
- self.displayName = displayName |
|
| 29 |
- self.currentTimeout = Self.defaultInitialTimeout |
|
| 30 |
- self.manualTimeoutValue = Self.defaultInitialTimeout |
|
| 31 |
- } |
|
| 32 |
-} |
|
| 33 |
- |
|
| 34 |
-extension MetricTimeoutProfile {
|
|
| 35 |
- var effectiveTimeout: TimeInterval {
|
|
| 36 |
- clamp(isManuallyOverridden ? manualTimeoutValue : currentTimeout) |
|
| 37 |
- } |
|
| 38 |
- |
|
| 39 |
- var timeoutMode: String {
|
|
| 40 |
- if isManuallyOverridden { return "manual" }
|
|
| 41 |
- return successCount == 0 && timeoutCount == 0 ? "default" : "adaptive" |
|
| 42 |
- } |
|
| 43 |
- |
|
| 44 |
- var hasP95: Bool {
|
|
| 45 |
- successSamples.count >= Self.p95MinimumSampleCount |
|
| 46 |
- } |
|
| 47 |
- |
|
| 48 |
- var p95SuccessfulElapsedIfAvailable: TimeInterval? {
|
|
| 49 |
- hasP95 ? p95SuccessfulElapsed : nil |
|
| 50 |
- } |
|
| 51 |
- |
|
| 52 |
- var suggestedRetryTimeout: TimeInterval {
|
|
| 53 |
- clamp(max(effectiveTimeout * 2, lastSuccessfulElapsed * 2, Self.defaultInitialTimeout)) |
|
| 54 |
- } |
|
| 55 |
- |
|
| 56 |
- func recordSuccess(elapsed: TimeInterval, displayName: String) {
|
|
| 57 |
- self.displayName = displayName |
|
| 58 |
- lastSuccessfulElapsed = elapsed |
|
| 59 |
- successCount += 1 |
|
| 60 |
- lastUpdatedAt = Date() |
|
| 61 |
- |
|
| 62 |
- var samples = successSamples |
|
| 63 |
- samples.append(elapsed) |
|
| 64 |
- if samples.count > Self.sampleLimit {
|
|
| 65 |
- samples.removeFirst(samples.count - Self.sampleLimit) |
|
| 66 |
- } |
|
| 67 |
- successSamples = samples |
|
| 68 |
- |
|
| 69 |
- averageSuccessfulElapsed = samples.reduce(0, +) / Double(samples.count) |
|
| 70 |
- p95SuccessfulElapsed = Self.percentile95(samples) |
|
| 71 |
- currentTimeout = automaticTimeout(from: samples) |
|
| 72 |
- } |
|
| 73 |
- |
|
| 74 |
- func recordTimeout(elapsed: TimeInterval, displayName: String) {
|
|
| 75 |
- self.displayName = displayName |
|
| 76 |
- lastTimeoutElapsed = elapsed |
|
| 77 |
- timeoutCount += 1 |
|
| 78 |
- lastUpdatedAt = Date() |
|
| 79 |
- |
|
| 80 |
- guard !isManuallyOverridden else { return }
|
|
| 81 |
- let progressive = max(currentTimeout * 1.5, elapsed * 2, Self.defaultInitialTimeout) |
|
| 82 |
- currentTimeout = clamp(progressive) |
|
| 83 |
- } |
|
| 84 |
- |
|
| 85 |
- func resetLearning(displayName: String? = nil) {
|
|
| 86 |
- if let displayName { self.displayName = displayName }
|
|
| 87 |
- lastSuccessfulElapsed = 0 |
|
| 88 |
- averageSuccessfulElapsed = 0 |
|
| 89 |
- p95SuccessfulElapsed = 0 |
|
| 90 |
- lastTimeoutElapsed = 0 |
|
| 91 |
- timeoutCount = 0 |
|
| 92 |
- successCount = 0 |
|
| 93 |
- lastUpdatedAt = Date() |
|
| 94 |
- currentTimeout = Self.defaultInitialTimeout |
|
| 95 |
- isManuallyOverridden = false |
|
| 96 |
- manualTimeoutValue = Self.defaultInitialTimeout |
|
| 97 |
- successfulElapsedSamplesJSON = "[]" |
|
| 98 |
- } |
|
| 99 |
- |
|
| 100 |
- func setManualTimeout(_ value: TimeInterval) {
|
|
| 101 |
- manualTimeoutValue = clamp(value) |
|
| 102 |
- isManuallyOverridden = true |
|
| 103 |
- lastUpdatedAt = Date() |
|
| 104 |
- } |
|
| 105 |
- |
|
| 106 |
- func returnToAutomatic() {
|
|
| 107 |
- isManuallyOverridden = false |
|
| 108 |
- lastUpdatedAt = Date() |
|
| 109 |
- } |
|
| 110 |
- |
|
| 111 |
- private var successSamples: [TimeInterval] {
|
|
| 112 |
- get {
|
|
| 113 |
- guard let data = successfulElapsedSamplesJSON.data(using: .utf8), |
|
| 114 |
- let values = try? JSONDecoder().decode([TimeInterval].self, from: data) else {
|
|
| 115 |
- return [] |
|
| 116 |
- } |
|
| 117 |
- return values |
|
| 118 |
- } |
|
| 119 |
- set {
|
|
| 120 |
- successfulElapsedSamplesJSON = (try? String(data: JSONEncoder().encode(newValue), encoding: .utf8)) ?? "[]" |
|
| 121 |
- } |
|
| 122 |
- } |
|
| 123 |
- |
|
| 124 |
- private func automaticTimeout(from samples: [TimeInterval]) -> TimeInterval {
|
|
| 125 |
- let basis = samples.count >= Self.p95MinimumSampleCount ? Self.percentile95(samples) : samples.max() ?? 0 |
|
| 126 |
- return clamp(max(Self.defaultInitialTimeout, basis * 2, currentTimeout * 0.9)) |
|
| 127 |
- } |
|
| 128 |
- |
|
| 129 |
- private func clamp(_ value: TimeInterval) -> TimeInterval {
|
|
| 130 |
- min(max(value, Self.defaultInitialTimeout), Self.maximumTimeout) |
|
| 131 |
- } |
|
| 132 |
- |
|
| 133 |
- private static func percentile95(_ samples: [TimeInterval]) -> TimeInterval {
|
|
| 134 |
- guard !samples.isEmpty else { return 0 }
|
|
| 135 |
- let sorted = samples.sorted() |
|
| 136 |
- let rawIndex = Int(ceil(Double(sorted.count) * 0.95)) - 1 |
|
| 137 |
- let index = min(max(rawIndex, 0), sorted.count - 1) |
|
| 138 |
- return sorted[index] |
|
| 139 |
- } |
|
| 140 |
-} |
|
@@ -46,8 +46,8 @@ final class HealthKitService {
|
||
| 46 | 46 |
|
| 47 | 47 |
static let allTypes: [MonitoredType] = buildAllTypes() |
| 48 | 48 |
|
| 49 |
- static let defaultInitialTimeoutSeconds: TimeInterval = MetricTimeoutProfile.defaultInitialTimeout |
|
| 50 |
- static let maximumTimeoutSeconds: TimeInterval = MetricTimeoutProfile.maximumTimeout |
|
| 49 |
+ static let defaultInitialTimeoutSeconds: TimeInterval = LocalMetricTimeoutProfile.defaultInitialTimeout |
|
| 50 |
+ static let maximumTimeoutSeconds: TimeInterval = LocalMetricTimeoutProfile.maximumTimeout |
|
| 51 | 51 |
static let fullHistoryImportTimeoutSeconds: TimeInterval = 30 * 60 |
| 52 | 52 |
// Prevents 3N simultaneous HK queries from exhausting resources at N=20 types. |
| 53 | 53 |
static let maxConcurrentTypeFetches = 6 |
@@ -611,7 +611,7 @@ final class HealthKitService {
|
||
| 611 | 611 |
typeCounts.reserveCapacity(active.count) |
| 612 | 612 |
|
| 613 | 613 |
for monitoredType in active {
|
| 614 |
- let profile = timeoutProfile(for: monitoredType, context: context) |
|
| 614 |
+ var profile = timeoutProfile(for: monitoredType) |
|
| 615 | 615 |
let timeout = timeoutForFetch( |
| 616 | 616 |
profile: profile, |
| 617 | 617 |
adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled, |
@@ -625,7 +625,8 @@ final class HealthKitService {
|
||
| 625 | 625 |
archiveObservationID: archiveObservationID, |
| 626 | 626 |
progress: progress |
| 627 | 627 |
) |
| 628 |
- updateTimeoutProfile(profile, with: result, monitoredType: monitoredType) |
|
| 628 |
+ updateTimeoutProfile(&profile, with: result, monitoredType: monitoredType) |
|
| 629 |
+ LocalMetricTimeoutProfileStore.save(profile) |
|
| 629 | 630 |
result.applyTimeoutProfile(profile) |
| 630 | 631 |
progress?.updateTimeoutProfile(from: profile, for: monitoredType.id) |
| 631 | 632 |
let typeCount = result.makeTypeCount() |
@@ -637,7 +638,7 @@ final class HealthKitService {
|
||
| 637 | 638 |
|
| 638 | 639 |
private func fetchTypeCountData( |
| 639 | 640 |
for monitoredType: MonitoredType, |
| 640 |
- timeoutProfile: MetricTimeoutProfile, |
|
| 641 |
+ timeoutProfile: LocalMetricTimeoutProfile, |
|
| 641 | 642 |
timeoutSeconds: TimeInterval, |
| 642 | 643 |
previousTypeCount: TypeCount?, |
| 643 | 644 |
archiveObservationID: Int64, |
@@ -1475,24 +1476,12 @@ final class HealthKitService {
|
||
| 1475 | 1476 |
|
| 1476 | 1477 |
// MARK: - Timeout profiles |
| 1477 | 1478 |
|
| 1478 |
- private func timeoutProfile(for monitoredType: MonitoredType, context: ModelContext) -> MetricTimeoutProfile {
|
|
| 1479 |
- let identifier = monitoredType.id |
|
| 1480 |
- var descriptor = FetchDescriptor<MetricTimeoutProfile>( |
|
| 1481 |
- predicate: #Predicate<MetricTimeoutProfile> { $0.metricIdentifier == identifier }
|
|
| 1482 |
- ) |
|
| 1483 |
- descriptor.fetchLimit = 1 |
|
| 1484 |
- if let existing = try? context.fetch(descriptor).first {
|
|
| 1485 |
- existing.displayName = monitoredType.displayName |
|
| 1486 |
- return existing |
|
| 1487 |
- } |
|
| 1488 |
- |
|
| 1489 |
- let profile = MetricTimeoutProfile(metricIdentifier: monitoredType.id, displayName: monitoredType.displayName) |
|
| 1490 |
- context.insert(profile) |
|
| 1491 |
- return profile |
|
| 1479 |
+ private func timeoutProfile(for monitoredType: MonitoredType) -> LocalMetricTimeoutProfile {
|
|
| 1480 |
+ LocalMetricTimeoutProfileStore.profile(for: monitoredType) |
|
| 1492 | 1481 |
} |
| 1493 | 1482 |
|
| 1494 | 1483 |
private func timeoutForFetch( |
| 1495 |
- profile: MetricTimeoutProfile, |
|
| 1484 |
+ profile: LocalMetricTimeoutProfile, |
|
| 1496 | 1485 |
adaptiveTimeoutsEnabled: Bool, |
| 1497 | 1486 |
timeoutMultiplier: Double |
| 1498 | 1487 |
) -> TimeInterval {
|
@@ -1501,7 +1490,7 @@ final class HealthKitService {
|
||
| 1501 | 1490 |
} |
| 1502 | 1491 |
|
| 1503 | 1492 |
private func updateTimeoutProfile( |
| 1504 |
- _ profile: MetricTimeoutProfile, |
|
| 1493 |
+ _ profile: inout LocalMetricTimeoutProfile, |
|
| 1505 | 1494 |
with result: TypeCountFetchResult, |
| 1506 | 1495 |
monitoredType: MonitoredType |
| 1507 | 1496 |
) {
|
@@ -2083,7 +2072,7 @@ private struct TypeCountFetchResult: Sendable {
|
||
| 2083 | 2072 |
var timeoutCount: Int = 0 |
| 2084 | 2073 |
var successCount: Int = 0 |
| 2085 | 2074 |
|
| 2086 |
- mutating func applyTimeoutProfile(_ profile: MetricTimeoutProfile) {
|
|
| 2075 |
+ mutating func applyTimeoutProfile(_ profile: LocalMetricTimeoutProfile) {
|
|
| 2087 | 2076 |
timeoutMode = profile.timeoutMode |
| 2088 | 2077 |
lastSuccessfulElapsed = profile.lastSuccessfulElapsed |
| 2089 | 2078 |
learnedTimeout = profile.effectiveTimeout |
@@ -2175,7 +2164,7 @@ private extension SnapshotFetchProgress {
|
||
| 2175 | 2164 |
) |
| 2176 | 2165 |
} |
| 2177 | 2166 |
|
| 2178 |
- func updateTimeoutProfile(from profile: MetricTimeoutProfile, for typeID: String) {
|
|
| 2167 |
+ func updateTimeoutProfile(from profile: LocalMetricTimeoutProfile, for typeID: String) {
|
|
| 2179 | 2168 |
updateTimeoutProfile( |
| 2180 | 2169 |
typeID, |
| 2181 | 2170 |
timeoutMode: profile.timeoutMode, |
@@ -0,0 +1,169 @@ |
||
| 1 |
+import Foundation |
|
| 2 |
+ |
|
| 3 |
+struct LocalMetricTimeoutProfile: Codable, Equatable, Identifiable, Sendable {
|
|
| 4 |
+ static let defaultInitialTimeout: TimeInterval = 15 |
|
| 5 |
+ static let maximumTimeout: TimeInterval = 120 |
|
| 6 |
+ private static let sampleLimit = 30 |
|
| 7 |
+ private static let p95MinimumSampleCount = 5 |
|
| 8 |
+ |
|
| 9 |
+ var id: String { metricIdentifier }
|
|
| 10 |
+ var metricIdentifier: String |
|
| 11 |
+ var displayName: String |
|
| 12 |
+ var lastSuccessfulElapsed: TimeInterval = 0 |
|
| 13 |
+ var averageSuccessfulElapsed: TimeInterval = 0 |
|
| 14 |
+ var p95SuccessfulElapsed: TimeInterval = 0 |
|
| 15 |
+ var lastTimeoutElapsed: TimeInterval = 0 |
|
| 16 |
+ var timeoutCount: Int = 0 |
|
| 17 |
+ var successCount: Int = 0 |
|
| 18 |
+ var lastUpdatedAt: Date = Date.distantPast |
|
| 19 |
+ var currentTimeout: TimeInterval = LocalMetricTimeoutProfile.defaultInitialTimeout |
|
| 20 |
+ var isManuallyOverridden: Bool = false |
|
| 21 |
+ var manualTimeoutValue: TimeInterval = LocalMetricTimeoutProfile.defaultInitialTimeout |
|
| 22 |
+ var successfulElapsedSamples: [TimeInterval] = [] |
|
| 23 |
+ |
|
| 24 |
+ init(metricIdentifier: String, displayName: String) {
|
|
| 25 |
+ self.metricIdentifier = metricIdentifier |
|
| 26 |
+ self.displayName = displayName |
|
| 27 |
+ } |
|
| 28 |
+ |
|
| 29 |
+ var effectiveTimeout: TimeInterval {
|
|
| 30 |
+ clamp(isManuallyOverridden ? manualTimeoutValue : currentTimeout) |
|
| 31 |
+ } |
|
| 32 |
+ |
|
| 33 |
+ var timeoutMode: String {
|
|
| 34 |
+ if isManuallyOverridden { return "manual" }
|
|
| 35 |
+ return successCount == 0 && timeoutCount == 0 ? "default" : "adaptive" |
|
| 36 |
+ } |
|
| 37 |
+ |
|
| 38 |
+ var p95SuccessfulElapsedIfAvailable: TimeInterval? {
|
|
| 39 |
+ successfulElapsedSamples.count >= Self.p95MinimumSampleCount ? p95SuccessfulElapsed : nil |
|
| 40 |
+ } |
|
| 41 |
+ |
|
| 42 |
+ var suggestedRetryTimeout: TimeInterval {
|
|
| 43 |
+ clamp(max(effectiveTimeout * 2, lastSuccessfulElapsed * 2, Self.defaultInitialTimeout)) |
|
| 44 |
+ } |
|
| 45 |
+ |
|
| 46 |
+ mutating func recordSuccess(elapsed: TimeInterval, displayName: String) {
|
|
| 47 |
+ self.displayName = displayName |
|
| 48 |
+ lastSuccessfulElapsed = elapsed |
|
| 49 |
+ successCount += 1 |
|
| 50 |
+ lastUpdatedAt = Date() |
|
| 51 |
+ |
|
| 52 |
+ successfulElapsedSamples.append(elapsed) |
|
| 53 |
+ if successfulElapsedSamples.count > Self.sampleLimit {
|
|
| 54 |
+ successfulElapsedSamples.removeFirst(successfulElapsedSamples.count - Self.sampleLimit) |
|
| 55 |
+ } |
|
| 56 |
+ |
|
| 57 |
+ averageSuccessfulElapsed = successfulElapsedSamples.reduce(0, +) / Double(successfulElapsedSamples.count) |
|
| 58 |
+ p95SuccessfulElapsed = Self.percentile95(successfulElapsedSamples) |
|
| 59 |
+ currentTimeout = automaticTimeout(from: successfulElapsedSamples) |
|
| 60 |
+ } |
|
| 61 |
+ |
|
| 62 |
+ mutating func recordTimeout(elapsed: TimeInterval, displayName: String) {
|
|
| 63 |
+ self.displayName = displayName |
|
| 64 |
+ lastTimeoutElapsed = elapsed |
|
| 65 |
+ timeoutCount += 1 |
|
| 66 |
+ lastUpdatedAt = Date() |
|
| 67 |
+ |
|
| 68 |
+ guard !isManuallyOverridden else { return }
|
|
| 69 |
+ let progressive = max(currentTimeout * 1.5, elapsed * 2, Self.defaultInitialTimeout) |
|
| 70 |
+ currentTimeout = clamp(progressive) |
|
| 71 |
+ } |
|
| 72 |
+ |
|
| 73 |
+ mutating func resetLearning(displayName: String? = nil) {
|
|
| 74 |
+ if let displayName { self.displayName = displayName }
|
|
| 75 |
+ lastSuccessfulElapsed = 0 |
|
| 76 |
+ averageSuccessfulElapsed = 0 |
|
| 77 |
+ p95SuccessfulElapsed = 0 |
|
| 78 |
+ lastTimeoutElapsed = 0 |
|
| 79 |
+ timeoutCount = 0 |
|
| 80 |
+ successCount = 0 |
|
| 81 |
+ lastUpdatedAt = Date() |
|
| 82 |
+ currentTimeout = Self.defaultInitialTimeout |
|
| 83 |
+ isManuallyOverridden = false |
|
| 84 |
+ manualTimeoutValue = Self.defaultInitialTimeout |
|
| 85 |
+ successfulElapsedSamples = [] |
|
| 86 |
+ } |
|
| 87 |
+ |
|
| 88 |
+ mutating func setManualTimeout(_ value: TimeInterval) {
|
|
| 89 |
+ manualTimeoutValue = clamp(value) |
|
| 90 |
+ isManuallyOverridden = true |
|
| 91 |
+ lastUpdatedAt = Date() |
|
| 92 |
+ } |
|
| 93 |
+ |
|
| 94 |
+ mutating func returnToAutomatic() {
|
|
| 95 |
+ isManuallyOverridden = false |
|
| 96 |
+ lastUpdatedAt = Date() |
|
| 97 |
+ } |
|
| 98 |
+ |
|
| 99 |
+ private func automaticTimeout(from samples: [TimeInterval]) -> TimeInterval {
|
|
| 100 |
+ let basis = samples.count >= Self.p95MinimumSampleCount ? Self.percentile95(samples) : samples.max() ?? 0 |
|
| 101 |
+ return clamp(max(Self.defaultInitialTimeout, basis * 2, currentTimeout * 0.9)) |
|
| 102 |
+ } |
|
| 103 |
+ |
|
| 104 |
+ private func clamp(_ value: TimeInterval) -> TimeInterval {
|
|
| 105 |
+ min(max(value, Self.defaultInitialTimeout), Self.maximumTimeout) |
|
| 106 |
+ } |
|
| 107 |
+ |
|
| 108 |
+ private static func percentile95(_ samples: [TimeInterval]) -> TimeInterval {
|
|
| 109 |
+ guard !samples.isEmpty else { return 0 }
|
|
| 110 |
+ let sorted = samples.sorted() |
|
| 111 |
+ let rawIndex = Int(ceil(Double(sorted.count) * 0.95)) - 1 |
|
| 112 |
+ let index = min(max(rawIndex, 0), sorted.count - 1) |
|
| 113 |
+ return sorted[index] |
|
| 114 |
+ } |
|
| 115 |
+} |
|
| 116 |
+ |
|
| 117 |
+enum LocalMetricTimeoutProfileStore {
|
|
| 118 |
+ private static let key = "hp_localMetricTimeoutProfiles" |
|
| 119 |
+ |
|
| 120 |
+ static func allProfiles(for monitoredTypes: [MonitoredType] = HealthKitService.allTypes) -> [LocalMetricTimeoutProfile] {
|
|
| 121 |
+ let existing = Dictionary(uniqueKeysWithValues: load().map { ($0.metricIdentifier, $0) })
|
|
| 122 |
+ return monitoredTypes.map { type in
|
|
| 123 |
+ var profile = existing[type.id] ?? LocalMetricTimeoutProfile(metricIdentifier: type.id, displayName: type.displayName) |
|
| 124 |
+ profile.displayName = type.displayName |
|
| 125 |
+ return profile |
|
| 126 |
+ } |
|
| 127 |
+ .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
|
|
| 128 |
+ } |
|
| 129 |
+ |
|
| 130 |
+ static func profile(for monitoredType: MonitoredType) -> LocalMetricTimeoutProfile {
|
|
| 131 |
+ allProfiles(for: [monitoredType]).first ?? LocalMetricTimeoutProfile( |
|
| 132 |
+ metricIdentifier: monitoredType.id, |
|
| 133 |
+ displayName: monitoredType.displayName |
|
| 134 |
+ ) |
|
| 135 |
+ } |
|
| 136 |
+ |
|
| 137 |
+ static func save(_ profile: LocalMetricTimeoutProfile) {
|
|
| 138 |
+ var profiles = load() |
|
| 139 |
+ if let index = profiles.firstIndex(where: { $0.metricIdentifier == profile.metricIdentifier }) {
|
|
| 140 |
+ profiles[index] = profile |
|
| 141 |
+ } else {
|
|
| 142 |
+ profiles.append(profile) |
|
| 143 |
+ } |
|
| 144 |
+ save(profiles) |
|
| 145 |
+ } |
|
| 146 |
+ |
|
| 147 |
+ static func resetAll(monitoredTypes: [MonitoredType] = HealthKitService.allTypes) {
|
|
| 148 |
+ let profiles = monitoredTypes.map { type in
|
|
| 149 |
+ LocalMetricTimeoutProfile(metricIdentifier: type.id, displayName: type.displayName) |
|
| 150 |
+ } |
|
| 151 |
+ save(profiles) |
|
| 152 |
+ } |
|
| 153 |
+ |
|
| 154 |
+ static func removeAll() {
|
|
| 155 |
+ UserDefaults.standard.removeObject(forKey: key) |
|
| 156 |
+ } |
|
| 157 |
+ |
|
| 158 |
+ private static func load() -> [LocalMetricTimeoutProfile] {
|
|
| 159 |
+ guard let data = UserDefaults.standard.data(forKey: key) else { return [] }
|
|
| 160 |
+ return (try? JSONDecoder().decode([LocalMetricTimeoutProfile].self, from: data)) ?? [] |
|
| 161 |
+ } |
|
| 162 |
+ |
|
| 163 |
+ private static func save(_ profiles: [LocalMetricTimeoutProfile]) {
|
|
| 164 |
+ let sorted = profiles.sorted {
|
|
| 165 |
+ $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending |
|
| 166 |
+ } |
|
| 167 |
+ UserDefaults.standard.set(try? JSONEncoder().encode(sorted), forKey: key) |
|
| 168 |
+ } |
|
| 169 |
+} |
|
@@ -373,8 +373,8 @@ struct DashboardView: View {
|
||
| 373 | 373 |
lines.append("")
|
| 374 | 374 |
lines.append("CONFIGURATION")
|
| 375 | 375 |
lines.append("adaptiveTimeoutsEnabled: \(progress.adaptiveTimeoutsEnabled ? "true" : "false")")
|
| 376 |
- lines.append("defaultInitialTimeout: \(formatDuration(MetricTimeoutProfile.defaultInitialTimeout))")
|
|
| 377 |
- lines.append("maximumTimeout: \(formatDuration(MetricTimeoutProfile.maximumTimeout))")
|
|
| 376 |
+ lines.append("defaultInitialTimeout: \(formatDuration(LocalMetricTimeoutProfile.defaultInitialTimeout))")
|
|
| 377 |
+ lines.append("maximumTimeout: \(formatDuration(LocalMetricTimeoutProfile.maximumTimeout))")
|
|
| 378 | 378 |
lines.append("maxConcurrentTypeFetches: \(progress.maxConcurrentTypeFetches)")
|
| 379 | 379 |
lines.append("")
|
| 380 | 380 |
lines.append("CHAIN/INTEGRITY CONTEXT")
|
@@ -732,7 +732,7 @@ struct DashboardView: View {
|
||
| 732 | 732 |
Text("Observed needed: ~\(formatDuration(type.learnedTimeout))")
|
| 733 | 733 |
.font(.caption).foregroundStyle(.secondary) |
| 734 | 734 |
} |
| 735 |
- Text("Suggested retry: \(type.suggestedRetryTimeout > 0 ? formatDuration(type.suggestedRetryTimeout) : formatDuration(MetricTimeoutProfile.maximumTimeout))")
|
|
| 735 |
+ Text("Suggested retry: \(type.suggestedRetryTimeout > 0 ? formatDuration(type.suggestedRetryTimeout) : formatDuration(LocalMetricTimeoutProfile.maximumTimeout))")
|
|
| 736 | 736 |
.font(.caption).foregroundStyle(.secondary) |
| 737 | 737 |
} |
| 738 | 738 |
} |
@@ -8,11 +8,11 @@ struct SettingsView: View {
|
||
| 8 | 8 |
@Environment(AppSettings.self) private var appSettings |
| 9 | 9 |
@Query private var snapshots: [HealthSnapshot] |
| 10 | 10 |
@Query private var deviceProfiles: [DeviceProfile] |
| 11 |
- @Query(sort: \MetricTimeoutProfile.displayName) private var timeoutProfiles: [MetricTimeoutProfile] |
|
| 12 | 11 |
@AppStorage("checkFrequencyHours") private var checkFrequencyHours: Int = 6
|
| 13 | 12 |
@State private var showDeleteConfirm = false |
| 14 | 13 |
@State private var showRepairLegacyRecordsConfirm = false |
| 15 | 14 |
@State private var dataMaintenanceMessage: String? |
| 15 |
+ @State private var timeoutProfiles: [LocalMetricTimeoutProfile] = [] |
|
| 16 | 16 |
|
| 17 | 17 |
private var currentDeviceID: String {
|
| 18 | 18 |
AppSettings.currentDeviceID |
@@ -36,7 +36,7 @@ struct SettingsView: View {
|
||
| 36 | 36 |
.navigationTitle("Settings")
|
| 37 | 37 |
.onAppear {
|
| 38 | 38 |
ensureCurrentDeviceProfile() |
| 39 |
- ensureTimeoutProfiles() |
|
| 39 |
+ loadTimeoutProfiles() |
|
| 40 | 40 |
} |
| 41 | 41 |
.confirmationDialog( |
| 42 | 42 |
"Delete All Audit Data", |
@@ -139,17 +139,23 @@ struct SettingsView: View {
|
||
| 139 | 139 |
)) |
| 140 | 140 |
|
| 141 | 141 |
InfoRow(label: "Default Initial Timeout") {
|
| 142 |
- Text(formatDuration(MetricTimeoutProfile.defaultInitialTimeout)) |
|
| 142 |
+ Text(formatDuration(LocalMetricTimeoutProfile.defaultInitialTimeout)) |
|
| 143 | 143 |
.foregroundStyle(.secondary) |
| 144 | 144 |
} |
| 145 | 145 |
|
| 146 | 146 |
InfoRow(label: "Maximum Timeout") {
|
| 147 |
- Text(formatDuration(MetricTimeoutProfile.maximumTimeout)) |
|
| 147 |
+ Text(formatDuration(LocalMetricTimeoutProfile.maximumTimeout)) |
|
| 148 | 148 |
.foregroundStyle(.secondary) |
| 149 | 149 |
} |
| 150 | 150 |
|
| 151 | 151 |
ForEach(timeoutProfiles) { profile in
|
| 152 |
- TimeoutProfileRow(profile: profile) |
|
| 152 |
+ TimeoutProfileRow( |
|
| 153 |
+ profile: profile, |
|
| 154 |
+ onManualTimeoutChanged: { setManualTimeout(profile.metricIdentifier, value: $0) },
|
|
| 155 |
+ onSetManual: { setManualTimeout(profile.metricIdentifier, value: profile.effectiveTimeout) },
|
|
| 156 |
+ onAutomatic: { returnTimeoutProfileToAutomatic(profile.metricIdentifier) },
|
|
| 157 |
+ onReset: { resetTimeoutProfile(profile.metricIdentifier) }
|
|
| 158 |
+ ) |
|
| 153 | 159 |
} |
| 154 | 160 |
|
| 155 | 161 |
Button(role: .destructive) {
|
@@ -220,19 +226,43 @@ struct SettingsView: View {
|
||
| 220 | 226 |
try? modelContext.save() |
| 221 | 227 |
} |
| 222 | 228 |
|
| 223 |
- private func ensureTimeoutProfiles() {
|
|
| 224 |
- let existingIDs = Set(timeoutProfiles.map(\.metricIdentifier)) |
|
| 225 |
- for type in HealthKitService.allTypes where !existingIDs.contains(type.id) {
|
|
| 226 |
- modelContext.insert(MetricTimeoutProfile(metricIdentifier: type.id, displayName: type.displayName)) |
|
| 227 |
- } |
|
| 228 |
- try? modelContext.save() |
|
| 229 |
+ private func loadTimeoutProfiles() {
|
|
| 230 |
+ timeoutProfiles = LocalMetricTimeoutProfileStore.allProfiles() |
|
| 229 | 231 |
} |
| 230 | 232 |
|
| 231 | 233 |
private func resetAllTimeoutProfiles() {
|
| 232 |
- for profile in timeoutProfiles {
|
|
| 234 |
+ LocalMetricTimeoutProfileStore.resetAll() |
|
| 235 |
+ loadTimeoutProfiles() |
|
| 236 |
+ } |
|
| 237 |
+ |
|
| 238 |
+ private func setManualTimeout(_ metricIdentifier: String, value: TimeInterval) {
|
|
| 239 |
+ updateTimeoutProfile(metricIdentifier) { profile in
|
|
| 240 |
+ profile.setManualTimeout(value) |
|
| 241 |
+ } |
|
| 242 |
+ } |
|
| 243 |
+ |
|
| 244 |
+ private func returnTimeoutProfileToAutomatic(_ metricIdentifier: String) {
|
|
| 245 |
+ updateTimeoutProfile(metricIdentifier) { profile in
|
|
| 246 |
+ profile.returnToAutomatic() |
|
| 247 |
+ } |
|
| 248 |
+ } |
|
| 249 |
+ |
|
| 250 |
+ private func resetTimeoutProfile(_ metricIdentifier: String) {
|
|
| 251 |
+ updateTimeoutProfile(metricIdentifier) { profile in
|
|
| 233 | 252 |
profile.resetLearning() |
| 234 | 253 |
} |
| 235 |
- try? modelContext.save() |
|
| 254 |
+ } |
|
| 255 |
+ |
|
| 256 |
+ private func updateTimeoutProfile( |
|
| 257 |
+ _ metricIdentifier: String, |
|
| 258 |
+ mutate: (inout LocalMetricTimeoutProfile) -> Void |
|
| 259 |
+ ) {
|
|
| 260 |
+ guard let index = timeoutProfiles.firstIndex(where: { $0.metricIdentifier == metricIdentifier }) else {
|
|
| 261 |
+ return |
|
| 262 |
+ } |
|
| 263 |
+ mutate(&timeoutProfiles[index]) |
|
| 264 |
+ LocalMetricTimeoutProfileStore.save(timeoutProfiles[index]) |
|
| 265 |
+ loadTimeoutProfiles() |
|
| 236 | 266 |
} |
| 237 | 267 |
|
| 238 | 268 |
private func deleteAllData() {
|
@@ -278,7 +308,11 @@ private struct TypeToggleRow: View {
|
||
| 278 | 308 |
} |
| 279 | 309 |
|
| 280 | 310 |
private struct TimeoutProfileRow: View {
|
| 281 |
- @Bindable var profile: MetricTimeoutProfile |
|
| 311 |
+ let profile: LocalMetricTimeoutProfile |
|
| 312 |
+ let onManualTimeoutChanged: (TimeInterval) -> Void |
|
| 313 |
+ let onSetManual: () -> Void |
|
| 314 |
+ let onAutomatic: () -> Void |
|
| 315 |
+ let onReset: () -> Void |
|
| 282 | 316 |
|
| 283 | 317 |
var body: some View {
|
| 284 | 318 |
VStack(alignment: .leading, spacing: 8) {
|
@@ -327,24 +361,24 @@ private struct TimeoutProfileRow: View {
|
||
| 327 | 361 |
if profile.isManuallyOverridden {
|
| 328 | 362 |
Stepper( |
| 329 | 363 |
"Manual \(formatDuration(profile.manualTimeoutValue))", |
| 330 |
- value: $profile.manualTimeoutValue, |
|
| 331 |
- in: MetricTimeoutProfile.defaultInitialTimeout...MetricTimeoutProfile.maximumTimeout, |
|
| 364 |
+ value: Binding( |
|
| 365 |
+ get: { profile.manualTimeoutValue },
|
|
| 366 |
+ set: onManualTimeoutChanged |
|
| 367 |
+ ), |
|
| 368 |
+ in: LocalMetricTimeoutProfile.defaultInitialTimeout...LocalMetricTimeoutProfile.maximumTimeout, |
|
| 332 | 369 |
step: 5 |
| 333 | 370 |
) |
| 334 | 371 |
.font(.caption) |
| 335 |
- .onChange(of: profile.manualTimeoutValue) { _, newValue in
|
|
| 336 |
- profile.setManualTimeout(newValue) |
|
| 337 |
- } |
|
| 338 | 372 |
} |
| 339 | 373 |
|
| 340 | 374 |
HStack {
|
| 341 | 375 |
Button("Set Manual") {
|
| 342 |
- profile.setManualTimeout(profile.effectiveTimeout) |
|
| 376 |
+ onSetManual() |
|
| 343 | 377 |
} |
| 344 | 378 |
.buttonStyle(.borderless) |
| 345 | 379 |
|
| 346 | 380 |
Button("Automatic") {
|
| 347 |
- profile.returnToAutomatic() |
|
| 381 |
+ onAutomatic() |
|
| 348 | 382 |
} |
| 349 | 383 |
.buttonStyle(.borderless) |
| 350 | 384 |
.disabled(!profile.isManuallyOverridden) |
@@ -352,7 +386,7 @@ private struct TimeoutProfileRow: View {
|
||
| 352 | 386 |
Spacer() |
| 353 | 387 |
|
| 354 | 388 |
Button("Reset", role: .destructive) {
|
| 355 |
- profile.resetLearning() |
|
| 389 |
+ onReset() |
|
| 356 | 390 |
} |
| 357 | 391 |
.buttonStyle(.borderless) |
| 358 | 392 |
} |
@@ -386,6 +420,6 @@ private func formatDuration(_ seconds: TimeInterval) -> String {
|
||
| 386 | 420 |
|
| 387 | 421 |
#Preview {
|
| 388 | 422 |
SettingsView() |
| 389 |
- .modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self, DeviceProfile.self, MetricTimeoutProfile.self], inMemory: true) |
|
| 423 |
+ .modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self, DeviceProfile.self], inMemory: true) |
|
| 390 | 424 |
.environment(AppSettings()) |
| 391 | 425 |
} |
@@ -0,0 +1,39 @@ |
||
| 1 |
+import XCTest |
|
| 2 |
+@testable import HealthProbe |
|
| 3 |
+ |
|
| 4 |
+final class LocalMetricTimeoutProfileTests: XCTestCase {
|
|
| 5 |
+ override func tearDown() {
|
|
| 6 |
+ LocalMetricTimeoutProfileStore.removeAll() |
|
| 7 |
+ super.tearDown() |
|
| 8 |
+ } |
|
| 9 |
+ |
|
| 10 |
+ func testRecordsSuccessAndPersistsProfile() {
|
|
| 11 |
+ var profile = LocalMetricTimeoutProfile(metricIdentifier: "step_count", displayName: "Step Count") |
|
| 12 |
+ |
|
| 13 |
+ profile.recordSuccess(elapsed: 12, displayName: "Step Count") |
|
| 14 |
+ LocalMetricTimeoutProfileStore.save(profile) |
|
| 15 |
+ |
|
| 16 |
+ let loaded = LocalMetricTimeoutProfileStore.profile(for: MonitoredType( |
|
| 17 |
+ id: "step_count", |
|
| 18 |
+ displayName: "Step Count", |
|
| 19 |
+ category: .activity, |
|
| 20 |
+ isEnabledByDefault: true, |
|
| 21 |
+ objectType: nil |
|
| 22 |
+ )) |
|
| 23 |
+ XCTAssertEqual(loaded.successCount, 1) |
|
| 24 |
+ XCTAssertEqual(loaded.lastSuccessfulElapsed, 12) |
|
| 25 |
+ XCTAssertEqual(loaded.timeoutMode, "adaptive") |
|
| 26 |
+ } |
|
| 27 |
+ |
|
| 28 |
+ func testManualTimeoutIsClampedAndResettable() {
|
|
| 29 |
+ var profile = LocalMetricTimeoutProfile(metricIdentifier: "heart_rate", displayName: "Heart Rate") |
|
| 30 |
+ |
|
| 31 |
+ profile.setManualTimeout(1_000) |
|
| 32 |
+ XCTAssertEqual(profile.effectiveTimeout, LocalMetricTimeoutProfile.maximumTimeout) |
|
| 33 |
+ XCTAssertEqual(profile.timeoutMode, "manual") |
|
| 34 |
+ |
|
| 35 |
+ profile.resetLearning() |
|
| 36 |
+ XCTAssertEqual(profile.effectiveTimeout, LocalMetricTimeoutProfile.defaultInitialTimeout) |
|
| 37 |
+ XCTAssertEqual(profile.timeoutMode, "default") |
|
| 38 |
+ } |
|
| 39 |
+} |
|