Showing 8 changed files with 105 additions and 81 deletions
+2 -2
HealthProbe/Doc/04-project/IMPLEMENTATION_STATUS.md
@@ -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. Metric timeout calibration and local device profile settings have moved to Codable stores outside `ModelContainer`. Remaining SwiftData imports are inventoried in [`SwiftData-Retirement-Inventory.md`](SwiftData-Retirement-Inventory.md) | Treat as disposable prototype data; replace remaining operation/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, local device profile settings, and operation logging have moved to Codable stores outside `ModelContainer`. Remaining SwiftData imports are inventoried in [`SwiftData-Retirement-Inventory.md`](SwiftData-Retirement-Inventory.md) | Treat as disposable prototype data; replace 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 26 SwiftData-backed files for launch container, remaining operation/local rows, capture review actions, navigation handles, some charts, and PDF paths.
52
+- Current UI/cache layers still depend on 25 SwiftData-backed files for launch container, 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.
+2 -2
HealthProbe/Doc/04-project/Refactoring-Plan.md
@@ -247,8 +247,8 @@ Acceptance:
247 247
 Checklist:
248 248
 - [x] Identify all remaining SwiftData imports.
249 249
 - [ ] Replace SwiftData models used by active flows. Metric timeout calibration
250
-  and local device profile settings have been moved to local Codable stores and
251
-  removed from `ModelContainer`; `OperationLog` and SwiftData
250
+  local device profile settings, and operation logging have been moved to local
251
+  Codable stores and removed from `ModelContainer`; SwiftData
252 252
   snapshot/navigation handles remain.
253 253
 - [ ] Remove/disable `ModelContainer` as required for target builds.
254 254
 - [x] Add prototype-store ignore/delete/reset path for test installs.
+11 -13
HealthProbe/Doc/04-project/SwiftData-Retirement-Inventory.md
@@ -9,8 +9,8 @@ local settings stored outside SwiftData where needed.
9 9
 
10 10
 ## Current Count
11 11
 
12
-After moving timeout calibration and local device profile settings out of
13
-SwiftData, 26 app files still have SwiftData imports.
12
+After moving timeout calibration, local device profile settings, and operation
13
+logging out of SwiftData, 25 app files still have SwiftData imports.
14 14
 
15 15
 ## Launch Container
16 16
 
@@ -20,8 +20,6 @@ These files keep SwiftData required at app launch:
20 20
 - `HealthProbe/ContentView.swift`
21 21
 
22 22
 Retirement path:
23
-- move remaining local settings model (`OperationLog`) to non-SwiftData storage
24
-  or Core Data cache/local store;
25 23
 - replace prototype snapshot model dependencies in tab roots;
26 24
 - remove `.modelContainer(...)` once no active view needs `@Query` or
27 25
   `ModelContext`.
@@ -34,7 +32,6 @@ block:
34 32
 - `HealthProbe/Models/AnomalyRecord.swift`
35 33
 - `HealthProbe/Models/HealthRecord.swift`
36 34
 - `HealthProbe/Models/HealthSnapshot.swift`
37
-- `HealthProbe/Models/OperationLog.swift`
38 35
 - `HealthProbe/Models/SnapshotDelta.swift`
39 36
 - `HealthProbe/Models/TypeCount.swift`
40 37
 - `HealthProbe/Models/TypeDelta.swift`
@@ -46,8 +43,7 @@ Retirement path:
46 43
   `YearlyCount`, `TypeDistributionBin`, and `HealthRecord` active reads with
47 44
   archive/cache DTOs;
48 45
 - replace `AnomalyRecord` flows with neutral change/diff DTOs;
49
-- move `OperationLog` to a local non-SwiftData store before removing the launch
50
-  container.
46
+- retire active reads/writes before removing the launch container.
51 47
 
52 48
 ## Capture And Maintenance Services
53 49
 
@@ -88,7 +84,7 @@ Retirement path:
88 84
   queries plus archive ids;
89 85
 - replace detail navigation parameters from SwiftData models to observation/type
90 86
   DTOs;
91
-- remove remaining local-only SwiftData rows;
87
+- remove remaining snapshot/cache SwiftData rows from active flows;
92 88
 - keep paged record drill-down and export paths on archive APIs.
93 89
 
94 90
 ## Removed During This Pass
@@ -107,12 +103,14 @@ The following SwiftData dependencies were removed from active flows:
107 103
 - Device display name/color settings now use
108 104
   `HealthProbe/Utilities/LocalDeviceProfile.swift`, a Codable local store used
109 105
   by Settings, Dashboard, Snapshots, and legacy PDF export.
106
+- `HealthProbe/Models/OperationLog.swift` was deleted.
107
+- Snapshot deletion logging now uses
108
+  `HealthProbe/Utilities/LocalOperationLog.swift`, a bounded Codable local log
109
+  outside `ModelContainer`.
110 110
 
111 111
 ## Next Recommended Slices
112 112
 
113
-1. Move `OperationLog` away from SwiftData, or delete it if no active reporting
114
-   flow still needs it.
115
-2. Replace `ContentView` preview/container dependency after tab roots stop using
113
+1. Replace `ContentView` preview/container dependency after tab roots stop using
116 114
    `@Query`.
117
-3. Move `DashboardView` capture review actions away from `ModelContext`.
118
-4. Replace Snapshots/Data Types navigation handles with archive/cache DTOs.
115
+2. Move `DashboardView` capture review actions away from `ModelContext`.
116
+3. Replace Snapshots/Data Types navigation handles with archive/cache DTOs.
+2 -14
HealthProbe/HealthProbeApp.swift
@@ -21,10 +21,6 @@ struct HealthProbeApp: App {
21 21
         .modelContainer(sharedModelContainer)
22 22
     }
23 23
 
24
-    // Two local ModelConfiguration instances:
25
-    //   uiCacheConfig - derived audit/index data for UI
26
-    //   localConfig   - local-only settings and operation metadata
27
-    //
28 24
     // SwiftData is not the forensic source of truth. Complete HealthKit samples
29 25
     // belong in the local archive store; these rows must remain rebuildable.
30 26
     private static func createModelContainer() throws -> ModelContainer {
@@ -33,7 +29,6 @@ struct HealthProbeApp: App {
33 29
         let fullSchema = Schema([
34 30
             HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self,
35 31
             SnapshotDelta.self, TypeDelta.self, AnomalyRecord.self,
36
-            OperationLog.self,
37 32
         ])
38 33
 
39 34
         let appSupportURL = URL.applicationSupportDirectory
@@ -44,7 +39,6 @@ struct HealthProbeApp: App {
44 39
             HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self,
45 40
             SnapshotDelta.self, TypeDelta.self, AnomalyRecord.self,
46 41
         ])
47
-        let localModels = Schema([OperationLog.self])
48 42
 
49 43
         let uiCacheConfig = ModelConfiguration(
50 44
             "ui-cache",
@@ -52,15 +46,9 @@ struct HealthProbeApp: App {
52 46
             url: uiCacheStoreURL,
53 47
             cloudKitDatabase: .none
54 48
         )
55
-        let localConfig = ModelConfiguration(
56
-            "local",
57
-            schema: localModels,
58
-            url: appSupportURL.appending(path: "HealthProbeLocal.store"),
59
-            cloudKitDatabase: .none
60
-        )
61 49
 
62 50
         do {
63
-            return try ModelContainer(for: fullSchema, configurations: [uiCacheConfig, localConfig])
51
+            return try ModelContainer(for: fullSchema, configurations: [uiCacheConfig])
64 52
         } catch {
65 53
             let candidates: [URL] = [
66 54
                 uiCacheStoreURL,
@@ -74,7 +62,7 @@ struct HealthProbeApp: App {
74 62
                 appSupportURL.appending(path: "HealthProbeLocal.store.wal"),
75 63
             ]
76 64
             for url in candidates { try? FileManager.default.removeItem(at: url) }
77
-            return try ModelContainer(for: fullSchema, configurations: [uiCacheConfig, localConfig])
65
+            return try ModelContainer(for: fullSchema, configurations: [uiCacheConfig])
78 66
         }
79 67
     }
80 68
 }
+0 -27
HealthProbe/Models/OperationLog.swift
@@ -1,27 +0,0 @@
1
-import Foundation
2
-import SwiftData
3
-
4
-@Model final class OperationLog {
5
-    var id: UUID = UUID()
6
-    var timestamp: Date = Date.now
7
-    var operationType: String = ""
8
-    var affectedSnapshotIDsJSON: String = "[]"
9
-    var summary: String = ""
10
-    var operationDeviceID: String = ""
11
-    var operationAppBuildVersion: String = ""
12
-
13
-    init(operationType: String, summary: String, deviceID: String, appBuildVersion: String) {
14
-        self.id = UUID()
15
-        self.operationType = operationType
16
-        self.summary = summary
17
-        self.operationDeviceID = deviceID
18
-        self.operationAppBuildVersion = appBuildVersion
19
-    }
20
-}
21
-
22
-extension OperationLog {
23
-    var affectedSnapshotIDs: [String] {
24
-        get { (try? JSONDecoder().decode([String].self, from: Data(affectedSnapshotIDsJSON.utf8))) ?? [] }
25
-        set { affectedSnapshotIDsJSON = (try? String(data: JSONEncoder().encode(newValue), encoding: .utf8)) ?? "[]" }
26
-    }
27
-}
+4 -23
HealthProbe/Services/SnapshotLifecycleService.swift
@@ -67,16 +67,14 @@ enum SnapshotLifecycleService {
67 67
         let deviceID = snapshot.deviceID
68 68
         let version = Bundle.main.appBuildVersion
69 69
 
70
-        // Build operation log before making changes
71
-        let log = OperationLog(
70
+        let log = LocalOperationLog(
72 71
             operationType: "delete",
73 72
             summary: buildSummary(snapshot: snapshot, incoming: incomingDelta, outgoing: outgoingDelta),
74 73
             deviceID: deviceID,
75 74
             appBuildVersion: version
76 75
         )
77
-        log.affectedSnapshotIDs = [snapshot.id.uuidString]
78
-        let logID = log.id
79
-        context.insert(log)
76
+        var completedLog = log
77
+        completedLog.affectedSnapshotIDs = [snapshot.id.uuidString]
80 78
 
81 79
         if incomingDelta == nil && outgoingDelta == nil {
82 80
             // Standalone snapshot — just delete
@@ -123,25 +121,8 @@ enum SnapshotLifecycleService {
123 121
             context.delete(snapshot)
124 122
         }
125 123
 
126
-        // Atomic save: log + destructive changes in same save call
127 124
         try context.save()
128
-
129
-        // Post-save OperationLog verification
130
-        let verifyDescriptor = FetchDescriptor<OperationLog>(
131
-            predicate: #Predicate<OperationLog> { $0.id == logID }
132
-        )
133
-        if (try? context.fetch(verifyDescriptor).first) == nil {
134
-            logger.critical("OperationLog not found after save — attempting recovery re-insert")
135
-            let recovery = OperationLog(
136
-                operationType: log.operationType,
137
-                summary: log.summary,
138
-                deviceID: log.operationDeviceID,
139
-                appBuildVersion: log.operationAppBuildVersion
140
-            )
141
-            recovery.affectedSnapshotIDsJSON = log.affectedSnapshotIDsJSON
142
-            context.insert(recovery)
143
-            try? context.save()
144
-        }
125
+        LocalOperationLogStore.append(completedLog)
145 126
     }
146 127
 
147 128
     static func rebuildMissingDetailCaches(
+49 -0
HealthProbe/Utilities/LocalOperationLog.swift
@@ -0,0 +1,49 @@
1
+import Foundation
2
+
3
+struct LocalOperationLog: Codable, Equatable, Identifiable, Sendable {
4
+    var id: UUID = UUID()
5
+    var timestamp: Date = Date.now
6
+    var operationType: String
7
+    var affectedSnapshotIDs: [String] = []
8
+    var summary: String
9
+    var operationDeviceID: String
10
+    var operationAppBuildVersion: String
11
+
12
+    init(operationType: String, summary: String, deviceID: String, appBuildVersion: String) {
13
+        self.operationType = operationType
14
+        self.summary = summary
15
+        self.operationDeviceID = deviceID
16
+        self.operationAppBuildVersion = appBuildVersion
17
+    }
18
+}
19
+
20
+enum LocalOperationLogStore {
21
+    private static let key = "hp_localOperationLogs"
22
+    private static let maximumEntries = 200
23
+
24
+    static func allLogs() -> [LocalOperationLog] {
25
+        load().sorted { $0.timestamp > $1.timestamp }
26
+    }
27
+
28
+    static func append(_ log: LocalOperationLog) {
29
+        var logs = load()
30
+        logs.append(log)
31
+        if logs.count > maximumEntries {
32
+            logs = Array(logs.suffix(maximumEntries))
33
+        }
34
+        save(logs)
35
+    }
36
+
37
+    static func removeAll() {
38
+        UserDefaults.standard.removeObject(forKey: key)
39
+    }
40
+
41
+    private static func load() -> [LocalOperationLog] {
42
+        guard let data = UserDefaults.standard.data(forKey: key) else { return [] }
43
+        return (try? JSONDecoder().decode([LocalOperationLog].self, from: data)) ?? []
44
+    }
45
+
46
+    private static func save(_ logs: [LocalOperationLog]) {
47
+        UserDefaults.standard.set(try? JSONEncoder().encode(logs), forKey: key)
48
+    }
49
+}
+35 -0
HealthProbeTests/LocalOperationLogTests.swift
@@ -0,0 +1,35 @@
1
+import XCTest
2
+@testable import HealthProbe
3
+
4
+final class LocalOperationLogTests: XCTestCase {
5
+    override func tearDown() {
6
+        LocalOperationLogStore.removeAll()
7
+        super.tearDown()
8
+    }
9
+
10
+    func testAppendsLogsNewestFirst() {
11
+        var older = LocalOperationLog(
12
+            operationType: "delete",
13
+            summary: "Older",
14
+            deviceID: "device-a",
15
+            appBuildVersion: "1"
16
+        )
17
+        older.timestamp = Date(timeIntervalSince1970: 1)
18
+
19
+        var newer = LocalOperationLog(
20
+            operationType: "delete",
21
+            summary: "Newer",
22
+            deviceID: "device-a",
23
+            appBuildVersion: "1"
24
+        )
25
+        newer.timestamp = Date(timeIntervalSince1970: 2)
26
+        newer.affectedSnapshotIDs = ["snapshot-a"]
27
+
28
+        LocalOperationLogStore.append(older)
29
+        LocalOperationLogStore.append(newer)
30
+
31
+        let logs = LocalOperationLogStore.allLogs()
32
+        XCTAssertEqual(logs.map(\.summary), ["Newer", "Older"])
33
+        XCTAssertEqual(logs.first?.affectedSnapshotIDs, ["snapshot-a"])
34
+    }
35
+}