@@ -28,7 +28,7 @@ There are no real deployments, only test installations. Existing prototype datab |
||
| 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/health rows, and Dashboard archive-cache status wiring are in place | Move Snapshots/Data Types to cache DTOs and add targeted partial invalidation | |
| 30 | 30 |
| SwiftData cache | Exists; test builds now reset legacy prototype UI/archive/cache stores once for archive v2 so old SwiftData-only snapshots are not treated as backed-up observations | Treat as disposable prototype data; reset/ignore during v2 transition | |
| 31 |
-| UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker. Dashboard shows archive/cache health; Snapshots timeline, snapshot detail summaries/type rows, and Data Types list prefer Core Data cache rows when archive observation ids exist; data type detail/drill-down uses SQLite `diffSummary`/`diffRecords`, with SwiftData detail cache as transition fallback | Finish remaining detail charts/export previews on paged SQLite DTOs | |
|
| 31 |
+| UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker. Dashboard status prefers archive/cache observation rows and shows cache health; Snapshots timeline, snapshot detail summaries/type rows, and Data Types list prefer Core Data cache rows when archive observation ids exist; data type detail/drill-down uses SQLite `diffSummary`/`diffRecords`, with SwiftData detail cache as transition fallback | Finish remaining detail charts/export previews on paged SQLite DTOs | |
|
| 32 | 32 |
| Diff/change explanation | Prototype/legacy anomaly logic exists | Move heavy diffing into SQLite and use neutral change classifications | |
| 33 | 33 |
| Export | Prototype scoped JSON export exists | Add recovery-compatible manifests and streaming/paged export | |
| 34 | 34 |
| Legacy device support | Not implemented | Remove SwiftData dependency and simplify heavy views for low-memory devices | |
@@ -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 SwiftData prototype models for navigation handles, some charts, and export/PDF paths. |
|
| 52 |
+- Current UI/cache layers still depend on SwiftData prototype models for capture review actions, navigation handles, some charts, and export/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 |
- Existing implementation may decode or cache too much data for low-end devices. |
@@ -221,7 +221,7 @@ Acceptance: |
||
| 221 | 221 |
|
| 222 | 222 |
Checklist: |
| 223 | 223 |
- [ ] Replace direct SwiftData `@Query` dependencies for target screens. |
| 224 |
-- [ ] Dashboard reads Core Data cache. |
|
| 224 |
+- [x] Dashboard status reads Core Data cached observation rows and cache health, with SwiftData retained for capture review actions. |
|
| 225 | 225 |
- [x] Observation timeline rows read Core Data cache when available, while retaining SwiftData handles for detail navigation during transition. |
| 226 | 226 |
- [x] Observation detail uses cached summary/type rows plus SQLite diff summaries when archive observation ids exist. |
| 227 | 227 |
- [x] Data Types list rows prefer Core Data cached counts plus SQLite `diffSummary` when archive observation ids exist. |
@@ -24,6 +24,7 @@ final class DashboardViewModel {
|
||
| 24 | 24 |
var completedSnapshotRetryOfSnapshotID: UUID? = nil |
| 25 | 25 |
var ambiguousDisappearedMetrics: [AmbiguousDisappearedMetric] = [] |
| 26 | 26 |
var latestArchiveObservation: CachedArchiveObservationRow? |
| 27 |
+ var archiveObservationRows: [CachedArchiveObservationRow] = [] |
|
| 27 | 28 |
var archiveHealthStatus: CachedArchiveHealthStatus? |
| 28 | 29 |
var archiveCacheError: String? |
| 29 | 30 |
|
@@ -357,10 +358,12 @@ final class DashboardViewModel {
|
||
| 357 | 358 |
func loadArchiveCacheStatus() {
|
| 358 | 359 |
do {
|
| 359 | 360 |
let cache = try CoreDataArchiveCacheStore() |
| 360 |
- latestArchiveObservation = try cache.latestObservationRow() |
|
| 361 |
+ archiveObservationRows = try cache.observationRows(limit: 2) |
|
| 362 |
+ latestArchiveObservation = archiveObservationRows.first |
|
| 361 | 363 |
archiveHealthStatus = try cache.latestArchiveHealthStatus() |
| 362 | 364 |
archiveCacheError = nil |
| 363 | 365 |
} catch {
|
| 366 |
+ archiveObservationRows = [] |
|
| 364 | 367 |
latestArchiveObservation = nil |
| 365 | 368 |
archiveHealthStatus = nil |
| 366 | 369 |
archiveCacheError = error.localizedDescription |
@@ -463,10 +466,13 @@ final class DashboardViewModel {
|
||
| 463 | 466 |
do {
|
| 464 | 467 |
let cache = try CoreDataArchiveCacheStore() |
| 465 | 468 |
_ = try cache.rebuild(fromArchiveAt: SQLiteHealthArchiveStore.defaultDatabaseURL) |
| 466 |
- latestArchiveObservation = try cache.latestObservationRow() |
|
| 469 |
+ archiveObservationRows = try cache.observationRows(limit: 2) |
|
| 470 |
+ latestArchiveObservation = archiveObservationRows.first |
|
| 467 | 471 |
archiveHealthStatus = try cache.latestArchiveHealthStatus() |
| 468 | 472 |
archiveCacheError = nil |
| 469 | 473 |
} catch {
|
| 474 |
+ archiveObservationRows = [] |
|
| 475 |
+ latestArchiveObservation = nil |
|
| 470 | 476 |
archiveCacheError = error.localizedDescription |
| 471 | 477 |
} |
| 472 | 478 |
} |
@@ -27,14 +27,21 @@ struct DashboardView: View {
|
||
| 27 | 27 |
|
| 28 | 28 |
private var latest: HealthSnapshot? { snapshots.first }
|
| 29 | 29 |
private var previous: HealthSnapshot? { snapshots.dropFirst().first }
|
| 30 |
+ private var latestArchiveObservation: CachedArchiveObservationRow? {
|
|
| 31 |
+ viewModel.latestArchiveObservation |
|
| 32 |
+ } |
|
| 33 |
+ private var previousArchiveObservation: CachedArchiveObservationRow? {
|
|
| 34 |
+ guard viewModel.archiveObservationRows.count > 1 else { return nil }
|
|
| 35 |
+ return viewModel.archiveObservationRows[1] |
|
| 36 |
+ } |
|
| 30 | 37 |
private var currentDeviceProfile: DeviceProfile? {
|
| 31 | 38 |
deviceProfiles.first { $0.deviceID == AppSettings.currentDeviceID }
|
| 32 | 39 |
} |
| 33 | 40 |
|
| 34 | 41 |
private var currentDeviceDisplayName: String {
|
| 35 | 42 |
if let name = currentDeviceProfile?.name, !name.isEmpty { return name }
|
| 36 |
- guard let latest, !latest.deviceName.isEmpty else { return "Unknown device" }
|
|
| 37 |
- return latest.deviceName |
|
| 43 |
+ if let latest, !latest.deviceName.isEmpty { return latest.deviceName }
|
|
| 44 |
+ return "Local device" |
|
| 38 | 45 |
} |
| 39 | 46 |
|
| 40 | 47 |
private var latestUnavailableMetricCount: Int {
|
@@ -42,6 +49,16 @@ struct DashboardView: View {
|
||
| 42 | 49 |
return (latest.typeCounts ?? []).filter { $0.quality == .unauthorized }.count
|
| 43 | 50 |
} |
| 44 | 51 |
|
| 52 |
+ private var latestArchiveChangeCount: Int? {
|
|
| 53 |
+ guard let latestArchiveObservation, |
|
| 54 |
+ previousArchiveObservation != nil else {
|
|
| 55 |
+ return nil |
|
| 56 |
+ } |
|
| 57 |
+ return latestArchiveObservation.appearedCount |
|
| 58 |
+ + latestArchiveObservation.disappearedCount |
|
| 59 |
+ + latestArchiveObservation.representationChangedCount |
|
| 60 |
+ } |
|
| 61 |
+ |
|
| 45 | 62 |
var body: some View {
|
| 46 | 63 |
NavigationStack {
|
| 47 | 64 |
List {
|
@@ -1125,13 +1142,22 @@ struct DashboardView: View {
|
||
| 1125 | 1142 |
|
| 1126 | 1143 |
private var statusSection: some View {
|
| 1127 | 1144 |
Section("Status") {
|
| 1128 |
- if let latest {
|
|
| 1145 |
+ if let latestArchiveObservation {
|
|
| 1129 | 1146 |
InfoRow(label: "Last Snapshot") {
|
| 1130 |
- Text(latest.timestamp, style: .relative) |
|
| 1147 |
+ Text(latestArchiveObservation.observedAt, style: .relative) |
|
| 1131 | 1148 |
.foregroundStyle(.secondary) |
| 1132 | 1149 |
} |
| 1133 |
- InfoRow(label: "Device") {
|
|
| 1134 |
- Text(currentDeviceDisplayName) |
|
| 1150 |
+ InfoRow(label: "Records") {
|
|
| 1151 |
+ Text("\(latestArchiveObservation.visibleRecordCount)")
|
|
| 1152 |
+ .foregroundStyle(.secondary) |
|
| 1153 |
+ } |
|
| 1154 |
+ InfoRow(label: "Types") {
|
|
| 1155 |
+ Text("\(latestArchiveObservation.trackedTypeCount)")
|
|
| 1156 |
+ .foregroundStyle(.secondary) |
|
| 1157 |
+ } |
|
| 1158 |
+ } else if let latest {
|
|
| 1159 |
+ InfoRow(label: "Last Snapshot") {
|
|
| 1160 |
+ Text(latest.timestamp, style: .relative) |
|
| 1135 | 1161 |
.foregroundStyle(.secondary) |
| 1136 | 1162 |
} |
| 1137 | 1163 |
if latest.snapshotQuality != SnapshotQuality.complete {
|
@@ -1150,18 +1176,21 @@ struct DashboardView: View {
|
||
| 1150 | 1176 |
.foregroundStyle(.secondary) |
| 1151 | 1177 |
} |
| 1152 | 1178 |
|
| 1179 |
+ if latest != nil || latestArchiveObservation != nil {
|
|
| 1180 |
+ InfoRow(label: "Device") {
|
|
| 1181 |
+ Text(currentDeviceDisplayName) |
|
| 1182 |
+ .foregroundStyle(.secondary) |
|
| 1183 |
+ } |
|
| 1184 |
+ } |
|
| 1185 |
+ |
|
| 1153 | 1186 |
InfoRow(label: "Monitored Types") {
|
| 1154 | 1187 |
Text("\(appSettings.selectedTypeIDs.count)")
|
| 1155 | 1188 |
.foregroundStyle(.secondary) |
| 1156 | 1189 |
} |
| 1157 | 1190 |
|
| 1158 |
- if let archiveObservation = viewModel.latestArchiveObservation {
|
|
| 1159 |
- InfoRow(label: "Archive Observation") {
|
|
| 1160 |
- Text(archiveObservation.observedAt, style: .relative) |
|
| 1161 |
- .foregroundStyle(.secondary) |
|
| 1162 |
- } |
|
| 1163 |
- InfoRow(label: "Archive Records") {
|
|
| 1164 |
- Text("\(archiveObservation.visibleRecordCount)")
|
|
| 1191 |
+ if let archiveObservation = latestArchiveObservation {
|
|
| 1192 |
+ InfoRow(label: "Observation ID") {
|
|
| 1193 |
+ Text("\(archiveObservation.observationID)")
|
|
| 1165 | 1194 |
.foregroundStyle(.secondary) |
| 1166 | 1195 |
} |
| 1167 | 1196 |
} |
@@ -1189,7 +1218,12 @@ struct DashboardView: View {
|
||
| 1189 | 1218 |
.foregroundStyle(.secondary) |
| 1190 | 1219 |
} |
| 1191 | 1220 |
|
| 1192 |
- if let latest, let previous {
|
|
| 1221 |
+ if let archiveDelta = latestArchiveChangeCount {
|
|
| 1222 |
+ InfoRow(label: "Changes vs Previous") {
|
|
| 1223 |
+ Text(archiveDelta == 0 ? "None" : "\(archiveDelta) records") |
|
| 1224 |
+ .foregroundStyle(archiveDelta == 0 ? Color.healthyGreen : Color.warningAmber) |
|
| 1225 |
+ } |
|
| 1226 |
+ } else if let latest, let previous {
|
|
| 1193 | 1227 |
let delta = viewModel.totalChanges(latest: latest, previous: previous) |
| 1194 | 1228 |
InfoRow(label: "Changes vs Previous") {
|
| 1195 | 1229 |
Text(delta == 0 ? "None" : "\(delta) records") |