Showing 12 changed files with 606 additions and 381 deletions
+59 -59
HealthProbe.xcodeproj/project.pbxproj
@@ -6,11 +6,6 @@
6 6
 	objectVersion = 77;
7 7
 	objects = {
8 8
 
9
-/* Begin PBXFileReference section */
10
-		439832792FA4933E003C0182 /* HealthProbe.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HealthProbe.app; sourceTree = BUILT_PRODUCTS_DIR; };
11
-		43A100012FA5000000000001 /* HealthProbeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HealthProbeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
12
-/* End PBXFileReference section */
13
-
14 9
 /* Begin PBXContainerItemProxy section */
15 10
 		43A1000B2FA5000000000001 /* PBXContainerItemProxy */ = {
16 11
 			isa = PBXContainerItemProxy;
@@ -21,6 +16,11 @@
21 16
 		};
22 17
 /* End PBXContainerItemProxy section */
23 18
 
19
+/* Begin PBXFileReference section */
20
+		439832792FA4933E003C0182 /* HealthProbe.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HealthProbe.app; sourceTree = BUILT_PRODUCTS_DIR; };
21
+		43A100012FA5000000000001 /* HealthProbeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HealthProbeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
22
+/* End PBXFileReference section */
23
+
24 24
 /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
25 25
 		439832862FA4933F003C0182 /* Exceptions for "HealthProbe" folder in "HealthProbe" target */ = {
26 26
 			isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
@@ -309,60 +309,6 @@
309 309
 			};
310 310
 			name = Release;
311 311
 		};
312
-		43A100072FA5000000000001 /* Debug */ = {
313
-			isa = XCBuildConfiguration;
314
-			buildSettings = {
315
-				BUNDLE_LOADER = "$(TEST_HOST)";
316
-				CODE_SIGN_STYLE = Automatic;
317
-				CURRENT_PROJECT_VERSION = 1;
318
-				DEVELOPMENT_TEAM = 9K2U3V9GZF;
319
-				GENERATE_INFOPLIST_FILE = YES;
320
-				IPHONEOS_DEPLOYMENT_TARGET = 26.4;
321
-				LD_RUNPATH_SEARCH_PATHS = (
322
-					"$(inherited)",
323
-					"@executable_path/Frameworks",
324
-					"@loader_path/Frameworks",
325
-				);
326
-				MARKETING_VERSION = 1.0;
327
-				PRODUCT_BUNDLE_IDENTIFIER = ro.xdev.HealthProbeTests;
328
-				PRODUCT_NAME = "$(TARGET_NAME)";
329
-				SDKROOT = iphoneos;
330
-				SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
331
-				SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
332
-				SWIFT_VERSION = 5.0;
333
-				TARGETED_DEVICE_FAMILY = 1;
334
-				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/HealthProbe.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/HealthProbe";
335
-				TEST_TARGET_NAME = HealthProbe;
336
-			};
337
-			name = Debug;
338
-		};
339
-		43A100082FA5000000000001 /* Release */ = {
340
-			isa = XCBuildConfiguration;
341
-			buildSettings = {
342
-				BUNDLE_LOADER = "$(TEST_HOST)";
343
-				CODE_SIGN_STYLE = Automatic;
344
-				CURRENT_PROJECT_VERSION = 1;
345
-				DEVELOPMENT_TEAM = 9K2U3V9GZF;
346
-				GENERATE_INFOPLIST_FILE = YES;
347
-				IPHONEOS_DEPLOYMENT_TARGET = 26.4;
348
-				LD_RUNPATH_SEARCH_PATHS = (
349
-					"$(inherited)",
350
-					"@executable_path/Frameworks",
351
-					"@loader_path/Frameworks",
352
-				);
353
-				MARKETING_VERSION = 1.0;
354
-				PRODUCT_BUNDLE_IDENTIFIER = ro.xdev.HealthProbeTests;
355
-				PRODUCT_NAME = "$(TARGET_NAME)";
356
-				SDKROOT = iphoneos;
357
-				SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
358
-				SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
359
-				SWIFT_VERSION = 5.0;
360
-				TARGETED_DEVICE_FAMILY = 1;
361
-				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/HealthProbe.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/HealthProbe";
362
-				TEST_TARGET_NAME = HealthProbe;
363
-			};
364
-			name = Release;
365
-		};
366 312
 		4398328A2FA4933F003C0182 /* Debug */ = {
367 313
 			isa = XCBuildConfiguration;
368 314
 			buildSettings = {
@@ -486,6 +432,60 @@
486 432
 			};
487 433
 			name = Release;
488 434
 		};
435
+		43A100072FA5000000000001 /* Debug */ = {
436
+			isa = XCBuildConfiguration;
437
+			buildSettings = {
438
+				BUNDLE_LOADER = "$(TEST_HOST)";
439
+				CODE_SIGN_STYLE = Automatic;
440
+				CURRENT_PROJECT_VERSION = 1;
441
+				DEVELOPMENT_TEAM = 9K2U3V9GZF;
442
+				GENERATE_INFOPLIST_FILE = YES;
443
+				IPHONEOS_DEPLOYMENT_TARGET = 26.4;
444
+				LD_RUNPATH_SEARCH_PATHS = (
445
+					"$(inherited)",
446
+					"@executable_path/Frameworks",
447
+					"@loader_path/Frameworks",
448
+				);
449
+				MARKETING_VERSION = 1.0;
450
+				PRODUCT_BUNDLE_IDENTIFIER = ro.xdev.HealthProbeTests;
451
+				PRODUCT_NAME = "$(TARGET_NAME)";
452
+				SDKROOT = iphoneos;
453
+				SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
454
+				SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
455
+				SWIFT_VERSION = 5.0;
456
+				TARGETED_DEVICE_FAMILY = 1;
457
+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/HealthProbe.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/HealthProbe";
458
+				TEST_TARGET_NAME = HealthProbe;
459
+			};
460
+			name = Debug;
461
+		};
462
+		43A100082FA5000000000001 /* Release */ = {
463
+			isa = XCBuildConfiguration;
464
+			buildSettings = {
465
+				BUNDLE_LOADER = "$(TEST_HOST)";
466
+				CODE_SIGN_STYLE = Automatic;
467
+				CURRENT_PROJECT_VERSION = 1;
468
+				DEVELOPMENT_TEAM = 9K2U3V9GZF;
469
+				GENERATE_INFOPLIST_FILE = YES;
470
+				IPHONEOS_DEPLOYMENT_TARGET = 26.4;
471
+				LD_RUNPATH_SEARCH_PATHS = (
472
+					"$(inherited)",
473
+					"@executable_path/Frameworks",
474
+					"@loader_path/Frameworks",
475
+				);
476
+				MARKETING_VERSION = 1.0;
477
+				PRODUCT_BUNDLE_IDENTIFIER = ro.xdev.HealthProbeTests;
478
+				PRODUCT_NAME = "$(TARGET_NAME)";
479
+				SDKROOT = iphoneos;
480
+				SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
481
+				SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
482
+				SWIFT_VERSION = 5.0;
483
+				TARGETED_DEVICE_FAMILY = 1;
484
+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/HealthProbe.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/HealthProbe";
485
+				TEST_TARGET_NAME = HealthProbe;
486
+			};
487
+			name = Release;
488
+		};
489 489
 /* End XCBuildConfiguration section */
490 490
 
491 491
 /* Begin XCConfigurationList section */
+1 -1
HealthProbe.xcodeproj/xcshareddata/xcschemes/HealthProbe.xcscheme
@@ -1,6 +1,6 @@
1 1
 <?xml version="1.0" encoding="UTF-8"?>
2 2
 <Scheme
3
-   LastUpgradeVersion = "2640"
3
+   LastUpgradeVersion = "2650"
4 4
    version = "1.7">
5 5
    <BuildAction
6 6
       parallelizeBuildables = "YES"
+5 -0
HealthProbe.xcodeproj/xcuserdata/bogdan.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -9,6 +9,11 @@
9 9
 			<key>orderHint</key>
10 10
 			<integer>0</integer>
11 11
 		</dict>
12
+		<key>HealthProbeTests.xcscheme_^#shared#^_</key>
13
+		<dict>
14
+			<key>orderHint</key>
15
+			<integer>1</integer>
16
+		</dict>
12 17
 	</dict>
13 18
 	<key>SuppressBuildableAutocreation</key>
14 19
 	<dict>
+1 -1
HealthProbe/Doc/04-project/IMPLEMENTATION_STATUS.md
@@ -28,7 +28,7 @@ There are no real deployments, only test installations. Existing prototype datab
28 28
 | SQLite archive | Archive v2 schema, 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 | Start Core Data cache work |
29 29
 | Core Data cache | Initial programmatic Core Data model and full-cache rebuild service are in place for observation rows, type summaries, daily aggregates, diff summaries, export manifest rows, and archive health | Wire cache reads into UI-facing view models and add targeted partial invalidation |
30 30
 | SwiftData cache | Exists | Treat as disposable prototype data; reset/ignore during v2 transition |
31
-| UI | Prototype exists | Reframe screens around observations, diffs, export, archive status |
31
+| UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker, and record-change detail uses a separate preview/paged list view with archive-value enrichment and scoped export action | Reframe remaining screens around observations, diffs, export, archive status |
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 |
+0 -32
HealthProbe/Utilities/AppSettings.swift
@@ -1,10 +1,8 @@
1 1
 import Foundation
2
-import UIKit
3 2
 
4 3
 @Observable
5 4
 final class AppSettings {
6 5
     private static let selectedTypeIDsKey   = "hp_selectedTypeIDs"
7
-    private static let selectedDeviceIDsKey = "hp_selectedDeviceIDs"
8 6
     static let adaptiveTimeoutsEnabledKey = "hp_adaptiveTimeoutsEnabled"
9 7
     static let typeDetailCacheBackfillVersionKey = "hp_typeDetailCacheBackfillVersion"
10 8
     static let currentTypeDetailCacheBackfillVersion = 2
@@ -13,10 +11,6 @@ final class AppSettings {
13 11
         didSet { persistTypes() }
14 12
     }
15 13
 
16
-    var selectedDeviceIDs: Set<String> {
17
-        didSet { persistDevices() }
18
-    }
19
-
20 14
     var adaptiveTimeoutsEnabled: Bool {
21 15
         didSet {
22 16
             UserDefaults.standard.set(adaptiveTimeoutsEnabled, forKey: Self.adaptiveTimeoutsEnabledKey)
@@ -35,27 +29,11 @@ final class AppSettings {
35 29
             selectedTypeIDs = Set(HealthKitService.allTypes.filter { $0.isEnabledByDefault }.map { $0.id })
36 30
         }
37 31
 
38
-        if let data = UserDefaults.standard.data(forKey: Self.selectedDeviceIDsKey),
39
-           let ids  = try? JSONDecoder().decode([String].self, from: data) {
40
-            let storedIDs = Set(ids)
41
-            let oldCurrentID = UIDevice.current.identifierForVendor?.uuidString
42
-            if let oldCurrentID, storedIDs == [oldCurrentID] {
43
-                selectedDeviceIDs = [Self.currentDeviceID]
44
-            } else {
45
-                selectedDeviceIDs = storedIDs
46
-            }
47
-        } else {
48
-            let currentID = Self.currentDeviceID
49
-            selectedDeviceIDs = currentID.isEmpty ? [] : [currentID]
50
-        }
51
-
52 32
         if UserDefaults.standard.object(forKey: Self.adaptiveTimeoutsEnabledKey) == nil {
53 33
             adaptiveTimeoutsEnabled = true
54 34
         } else {
55 35
             adaptiveTimeoutsEnabled = UserDefaults.standard.bool(forKey: Self.adaptiveTimeoutsEnabledKey)
56 36
         }
57
-
58
-        persistDevices()
59 37
     }
60 38
 
61 39
     func isEnabled(_ type: MonitoredType) -> Bool { selectedTypeIDs.contains(type.id) }
@@ -65,18 +43,8 @@ final class AppSettings {
65 43
         else { selectedTypeIDs.insert(type.id) }
66 44
     }
67 45
 
68
-    func toggleDevice(_ id: String) {
69
-        if selectedDeviceIDs.contains(id) { selectedDeviceIDs.remove(id) }
70
-        else { selectedDeviceIDs.insert(id) }
71
-    }
72
-
73 46
     private func persistTypes() {
74 47
         UserDefaults.standard.set(try? JSONEncoder().encode(Array(selectedTypeIDs)),
75 48
                                   forKey: Self.selectedTypeIDsKey)
76 49
     }
77
-
78
-    private func persistDevices() {
79
-        UserDefaults.standard.set(try? JSONEncoder().encode(Array(selectedDeviceIDs)),
80
-                                  forKey: Self.selectedDeviceIDsKey)
81
-    }
82 50
 }
+0 -7
HealthProbe/Utilities/DesignSystem.swift
@@ -26,13 +26,6 @@ enum DeviceColor: String, CaseIterable, Identifiable {
26 26
     }
27 27
 }
28 28
 
29
-struct DeviceEntry: Identifiable {
30
-    let id: String          // deviceID; "" = unidentified
31
-    let displayName: String
32
-    let color: Color
33
-    let isCurrent: Bool
34
-}
35
-
36 29
 struct SeverityBadge: View {
37 30
     let delta: Int
38 31
     var dimmed: Bool = false
+143 -0
HealthProbe/ViewModels/DataTypeRecordListViewModel.swift
@@ -0,0 +1,143 @@
1
+import Foundation
2
+import Observation
3
+
4
+enum RecordListMode {
5
+    case disappeared(typeIdentifier: String)
6
+    case added(typeIdentifier: String, afterDate: Date, beforeDate: Date)
7
+
8
+    var typeIdentifier: String {
9
+        switch self {
10
+        case .disappeared(let typeID):
11
+            return typeID
12
+        case .added(let typeID, _, _):
13
+            return typeID
14
+        }
15
+    }
16
+}
17
+
18
+enum ExportState: Equatable {
19
+    case idle
20
+    case loading
21
+    case ready(URL)
22
+    case failed(String)
23
+}
24
+
25
+@MainActor
26
+@Observable
27
+final class DataTypeRecordListViewModel {
28
+    let typeIdentifier: String
29
+    let displayName: String
30
+    let totalCount: Int
31
+    let mode: RecordListMode
32
+    let previewRecords: [HealthRecordValue]
33
+
34
+    var displayRecords: [HealthRecordValue] = []
35
+    var isLoadingMore = false
36
+    var hasMore: Bool { displayRecords.count < min(totalCount, previewRecords.count) }
37
+    var exportState: ExportState = .idle
38
+
39
+    private var pageIndex = 0
40
+    private let pageSize = 50
41
+    private let archiveStore: HealthArchiveStore
42
+
43
+    init(
44
+        typeIdentifier: String,
45
+        displayName: String,
46
+        totalCount: Int,
47
+        mode: RecordListMode,
48
+        previewRecords: [HealthRecordValue] = [],
49
+        archiveStore: HealthArchiveStore
50
+    ) {
51
+        self.typeIdentifier = typeIdentifier
52
+        self.displayName = displayName
53
+        self.totalCount = totalCount
54
+        self.mode = mode
55
+        self.previewRecords = previewRecords
56
+        self.archiveStore = archiveStore
57
+    }
58
+
59
+    func loadFirstPage() async {
60
+        guard displayRecords.isEmpty else { return }
61
+        pageIndex = 0
62
+        await loadNextPage()
63
+    }
64
+
65
+    func loadNextPage() async {
66
+        guard !isLoadingMore, hasMore else { return }
67
+        isLoadingMore = true
68
+        defer { isLoadingMore = false }
69
+
70
+        let batchStart = pageIndex
71
+        let batchEnd = min(pageIndex + pageSize, previewRecords.count)
72
+
73
+        guard batchStart < previewRecords.count else {
74
+            return
75
+        }
76
+
77
+        let batch = Array(previewRecords[batchStart..<batchEnd])
78
+
79
+        // Enrich batch with actual values from archive store
80
+        var enrichedBatch = batch
81
+        do {
82
+            let fingerprints = Set(batch.map { $0.recordFingerprint })
83
+            let request = HealthArchiveRecordRequest(
84
+                sampleTypeIdentifier: typeIdentifier,
85
+                fingerprints: fingerprints,
86
+                limit: nil
87
+            )
88
+            let archivedRecords = try await archiveStore.records(for: request)
89
+
90
+            let archiveByFingerprint = Dictionary(grouping: archivedRecords, by: { $0.strictFingerprint })
91
+            enrichedBatch = batch.map { cached in
92
+                guard let archived = archiveByFingerprint[cached.recordFingerprint]?.first,
93
+                      cached.displayValue == nil else { return cached }
94
+                return HealthRecordValue(
95
+                    typeIdentifier: cached.typeIdentifier,
96
+                    sampleUUIDHash: cached.sampleUUIDHash,
97
+                    recordFingerprint: cached.recordFingerprint,
98
+                    startDate: cached.startDate,
99
+                    endDate: cached.endDate,
100
+                    displayValue: archived.displayValue
101
+                )
102
+            }
103
+        } catch {
104
+            // Cached preview records remain usable when the archive cannot enrich display values.
105
+        }
106
+
107
+        displayRecords.append(contentsOf: enrichedBatch)
108
+        pageIndex = batchEnd
109
+    }
110
+
111
+    func exportAllRecords() async {
112
+        exportState = .loading
113
+
114
+        do {
115
+            let reportRequest = buildExportRequest()
116
+            let exportURL = try await archiveStore.exportReport(reportRequest)
117
+            exportState = .ready(exportURL)
118
+        } catch {
119
+            exportState = .failed(error.localizedDescription)
120
+        }
121
+    }
122
+
123
+
124
+    private func buildExportRequest() -> HealthArchiveReportRequest {
125
+        switch mode {
126
+        case .disappeared(let typeID):
127
+            return HealthArchiveReportRequest(
128
+                reportID: UUID(),
129
+                title: "Disappeared Records - \(displayName)",
130
+                typeIdentifierFilter: typeID,
131
+                disappearedOnly: true
132
+            )
133
+        case .added(let typeID, let afterDate, let beforeDate):
134
+            return HealthArchiveReportRequest(
135
+                reportID: UUID(),
136
+                title: "Added Records - \(displayName)",
137
+                typeIdentifierFilter: typeID,
138
+                firstSeenAfter: afterDate,
139
+                firstSeenBefore: beforeDate
140
+            )
141
+        }
142
+    }
143
+}
+9 -58
HealthProbe/Views/DataTypes/DataTypesView.swift
@@ -2,41 +2,23 @@ import SwiftUI
2 2
 import SwiftData
3 3
 
4 4
 struct DataTypesView: View {
5
-    @Environment(AppSettings.self) private var appSettings
6 5
     @Query(sort: \HealthSnapshot.timestamp, order: .reverse) private var allSnapshots: [HealthSnapshot]
7
-    @Query private var deviceProfiles: [DeviceProfile]
8 6
     @State private var viewModel = DataTypesViewModel()
9 7
 
10
-    private var profileMap: [String: DeviceProfile] {
11
-        Dictionary(uniqueKeysWithValues: deviceProfiles.compactMap {
12
-            $0.deviceID.isEmpty ? nil : ($0.deviceID, $0)
13
-        })
14
-    }
15
-
16 8
     private var displayedSnapshots: [HealthSnapshot] {
17
-        let selected = appSettings.selectedDeviceIDs
18
-        guard !selected.isEmpty else { return [] }
19
-        return allSnapshots.filter { selected.contains($0.deviceID) }
9
+        guard let deviceID = localDeviceID else { return [] }
10
+        return allSnapshots.filter { $0.deviceID == deviceID }
20 11
     }
21 12
 
22 13
     private var latest: HealthSnapshot? { displayedSnapshots.first }
23 14
 
24
-    private var knownDevices: [DeviceEntry] {
15
+    private var localDeviceID: String? {
25 16
         let currentID = AppSettings.currentDeviceID
26
-        var ids = Set(allSnapshots.map { $0.deviceID })
27
-        if !currentID.isEmpty { ids.insert(currentID) }
28
-        return ids.map { id in
29
-            let profile = profileMap[id]
30
-            let name: String
31
-            if let n = profile?.name, !n.isEmpty { name = n }
32
-            else if id.isEmpty { name = "Unidentified" }
33
-            else { name = "Unknown Device" }
34
-            let color = DeviceColor(rawValue: profile?.colorTag ?? "")?.color ?? .neutralGray
35
-            return DeviceEntry(id: id, displayName: name, color: color, isCurrent: id == currentID)
36
-        }.sorted {
37
-            if $0.isCurrent != $1.isCurrent { return $0.isCurrent }
38
-            return $0.displayName < $1.displayName
17
+        if allSnapshots.contains(where: { $0.deviceID == currentID }) {
18
+            return currentID
39 19
         }
20
+
21
+        return allSnapshots.first?.deviceID
40 22
     }
41 23
 
42 24
     var body: some View {
@@ -45,10 +27,8 @@ struct DataTypesView: View {
45 27
                 if displayedSnapshots.count < 2 {
46 28
                     EmptyStateView(
47 29
                         icon: "waveform.path.ecg",
48
-                        title: appSettings.selectedDeviceIDs.isEmpty ? "No Devices Selected" : "Not Enough Data",
49
-                        message: appSettings.selectedDeviceIDs.isEmpty
50
-                            ? "Select at least one device using the filter above."
51
-                            : "Create at least two snapshots to compare data types."
30
+                        title: "Not Enough Data",
31
+                        message: "Create at least two local snapshots to compare data types."
52 32
                     )
53 33
                 } else {
54 34
                     typeList
@@ -106,9 +86,6 @@ struct DataTypesView: View {
106 86
 
107 87
     @ToolbarContentBuilder
108 88
     private var filterPicker: some ToolbarContent {
109
-        ToolbarItem(placement: .topBarLeading) {
110
-            devicePickerMenu
111
-        }
112 89
         ToolbarItem(placement: .navigationBarTrailing) {
113 90
             Menu {
114 91
                 Picker("Filter", selection: $viewModel.filter) {
@@ -122,32 +99,6 @@ struct DataTypesView: View {
122 99
             }
123 100
         }
124 101
     }
125
-
126
-    private var devicePickerMenu: some View {
127
-        let selected = appSettings.selectedDeviceIDs
128
-        let isMulti  = selected.count > 1
129
-        return Menu {
130
-            ForEach(knownDevices) { entry in
131
-                Button {
132
-                    appSettings.toggleDevice(entry.id)
133
-                } label: {
134
-                    Label {
135
-                        Text(entry.isCurrent
136
-                             ? "\(entry.displayName) (This Device)"
137
-                             : entry.displayName)
138
-                    } icon: {
139
-                        Image(systemName: selected.contains(entry.id)
140
-                              ? "checkmark.circle.fill" : "circle.fill")
141
-                            .foregroundStyle(entry.color)
142
-                    }
143
-                }
144
-            }
145
-        } label: {
146
-            Image(systemName: "iphone")
147
-        }
148
-        .tint(isMulti ? .orange : .accentColor)
149
-        .accessibilityLabel("Select devices – \(selected.count) selected")
150
-    }
151 102
 }
152 103
 
153 104
 // MARK: - Row
+153 -52
HealthProbe/Views/DataTypes/RecordChangeEvolutionChart.swift
@@ -35,42 +35,65 @@ struct RecordChangeEvolutionChart: View {
35 35
         return counts.max() ?? 1
36 36
     }
37 37
 
38
-    private var maxNegativeCount: Int {
39
-        var max = 0
40
-        for snapshot in contextSnapshots {
38
+    private var chartPoints: [ChartPoint] {
39
+        contextSnapshots.map { snapshot in
40
+            let typeCount = snapshot.typeCounts?.first { $0.typeIdentifier == typeIdentifier }
41 41
             let previousSnapshot = snapshot.previousInTimeline(sortedSnapshots)
42
-            let diff = recordDiff(current: snapshot, previous: previousSnapshot)
43
-            max = Swift.max(max, diff.disappeared)
42
+            return ChartPoint(
43
+                snapshot: snapshot,
44
+                count: max(typeCount?.count ?? 0, 0),
45
+                diff: recordDiff(current: snapshot, previous: previousSnapshot)
46
+            )
44 47
         }
45
-        return max
46 48
     }
47 49
 
48
-    private func recordDiff(current: HealthSnapshot, previous: HealthSnapshot?) -> (added: Int, disappeared: Int) {
49
-        guard let previous = previous else { return (0, 0) }
50
+    private var maxChangeCount: Int {
51
+        let maxChange = chartPoints
52
+            .map { max($0.diff.added, $0.diff.disappeared) }
53
+            .max() ?? 0
54
+        return max(maxChange, 1)
55
+    }
56
+
57
+    private var hasAnyRecordChanges: Bool {
58
+        chartPoints.contains { $0.diff.added > 0 || $0.diff.disappeared > 0 }
59
+    }
60
+
61
+    private func recordDiff(current: HealthSnapshot, previous: HealthSnapshot?) -> RecordChangeDiff {
62
+        guard let previous = previous else { return RecordChangeDiff() }
63
+
64
+        if let typeCount = current.typeCounts?.first(where: { $0.typeIdentifier == typeIdentifier }),
65
+           let cache = typeCount.detailCache,
66
+           cache.matchesBaseline(previous.id) {
67
+            return RecordChangeDiff(
68
+                added: cache.addedCount,
69
+                disappeared: cache.disappearedCount,
70
+                isExact: true
71
+            )
72
+        }
50 73
 
51 74
         guard let delta = allDeltas.first(where: {
52 75
             $0.fromSnapshotID == previous.id &&
53 76
             $0.toSnapshotID == current.id
54 77
         }),
55 78
         let typeDelta = delta.typeDeltas?.first(where: { $0.typeIdentifier == typeIdentifier }) else {
56
-            return (0, 0)
79
+            return RecordChangeDiff()
57 80
         }
58 81
 
59 82
         switch typeDelta.transition {
60 83
         case .unchanged:
61
-            return (0, 0)
84
+            return RecordChangeDiff()
62 85
         case .changed:
63 86
             if typeDelta.countDelta > 0 {
64
-                return (typeDelta.countDelta, 0)
87
+                return RecordChangeDiff(added: typeDelta.countDelta)
65 88
             }
66 89
             if typeDelta.countDelta < 0 {
67
-                return (0, abs(typeDelta.countDelta))
90
+                return RecordChangeDiff(disappeared: abs(typeDelta.countDelta))
68 91
             }
69
-            return (0, 0)
92
+            return RecordChangeDiff()
70 93
         case .appeared:
71
-            return (max(typeDelta.countDelta, 1), 0)
94
+            return RecordChangeDiff(added: max(typeDelta.countDelta, 1))
72 95
         case .disappeared:
73
-            return (0, max(abs(typeDelta.countDelta), 1))
96
+            return RecordChangeDiff(disappeared: max(abs(typeDelta.countDelta), 1))
74 97
         }
75 98
     }
76 99
 
@@ -113,6 +136,10 @@ struct RecordChangeEvolutionChart: View {
113 136
 
114 137
             evolutionChart
115 138
 
139
+            if hasAnyRecordChanges {
140
+                changeLegend
141
+            }
142
+
116 143
             HStack(spacing: 12) {
117 144
                 VStack(alignment: .leading, spacing: 4) {
118 145
                     Text("Min")
@@ -159,30 +186,21 @@ struct RecordChangeEvolutionChart: View {
159 186
     @ViewBuilder
160 187
     private var evolutionChart: some View {
161 188
         HStack(alignment: .bottom, spacing: 4) {
162
-            ForEach(Array(contextSnapshots.enumerated()), id: \.element.id) { idx, snapshot in
163
-                chartBar(at: idx, snapshot: snapshot)
189
+            ForEach(Array(chartPoints.enumerated()), id: \.element.snapshot.id) { idx, point in
190
+                chartBar(at: idx, point: point)
164 191
             }
165 192
         }
166
-        .frame(height: 100)
193
+        .frame(height: 132)
167 194
         .padding(12)
168 195
         .background(Color(.systemBackground).opacity(0.3), in: RoundedRectangle(cornerRadius: 8))
169 196
     }
170 197
 
171 198
     @ViewBuilder
172
-    private func chartBar(at index: Int, snapshot: HealthSnapshot) -> some View {
173
-        let typeCount = snapshot.typeCounts?.first { $0.typeIdentifier == typeIdentifier }
174
-        let count = typeCount?.count ?? 0
175
-        let isCurrent = snapshot.id == currentSnapshotID
176
-        let previousSnapshot = snapshot.previousInTimeline(sortedSnapshots)
177
-        let diff = recordDiff(current: snapshot, previous: previousSnapshot)
178
-        let unchanged = count - diff.added
179
-
180
-        let barTotalRange = CGFloat(diff.disappeared + count)
181
-        let redHeight = barTotalRange > 0 ? CGFloat(diff.disappeared) / barTotalRange * 80 : 0
182
-        let blueHeight = barTotalRange > 0 ? CGFloat(unchanged) / barTotalRange * 80 : 0
183
-        let greenHeight = barTotalRange > 0 ? CGFloat(diff.added) / barTotalRange * 80 : 0
184
-
185
-        let minVisibleHeight: CGFloat = 3
199
+    private func chartBar(at index: Int, point: ChartPoint) -> some View {
200
+        let isCurrent = point.snapshot.id == currentSnapshotID
201
+        let totalBarHeight = CGFloat(point.count) / CGFloat(max(maxCount, 1)) * 54
202
+        let addedHeight = changeHeight(for: point.diff.added)
203
+        let disappearedHeight = changeHeight(for: point.diff.disappeared)
186 204
 
187 205
         VStack(spacing: 0) {
188 206
             if isCurrent {
@@ -192,29 +210,37 @@ struct RecordChangeEvolutionChart: View {
192 210
                     .padding(.bottom, 4)
193 211
             }
194 212
 
195
-            VStack(spacing: 0) {
196
-                if diff.added > 0 {
197
-                    RoundedRectangle(cornerRadius: 1)
198
-                        .fill(Color.healthyGreen)
199
-                        .frame(height: max(minVisibleHeight, greenHeight))
200
-                }
201
-
202
-                if unchanged > 0 {
203
-                    RoundedRectangle(cornerRadius: 1)
204
-                        .fill(Color.neutralGray.opacity(0.6))
205
-                        .frame(height: max(1, blueHeight))
206
-                }
207
-
208
-                if diff.disappeared > 0 {
209
-                    RoundedRectangle(cornerRadius: 1)
210
-                        .fill(Color.criticalRed)
211
-                        .frame(height: max(minVisibleHeight, redHeight))
213
+            VStack(spacing: 3) {
214
+                changeSegment(
215
+                    count: point.diff.added,
216
+                    height: addedHeight,
217
+                    color: Color.healthyGreen,
218
+                    pinsToBottom: true
219
+                )
220
+
221
+                RoundedRectangle(cornerRadius: 2)
222
+                    .fill(Color.neutralGray.opacity(0.6))
223
+                    .frame(height: max(4, totalBarHeight))
224
+                    .frame(maxWidth: .infinity)
225
+
226
+                changeSegment(
227
+                    count: point.diff.disappeared,
228
+                    height: disappearedHeight,
229
+                    color: Color.criticalRed,
230
+                    pinsToBottom: false
231
+                )
232
+            }
233
+            .frame(height: 96, alignment: .bottom)
234
+            .overlay(alignment: .bottom) {
235
+                if point.diff.isFallback, point.diff.disappeared > 0 || point.diff.added > 0 {
236
+                    Image(systemName: "tilde")
237
+                        .font(.system(size: 9, weight: .bold))
238
+                        .foregroundStyle(.secondary)
239
+                        .offset(y: 12)
212 240
                 }
213 241
             }
214
-            .frame(height: 80)
215
-            .frame(maxWidth: .infinity)
216 242
 
217
-            Text(snapshot.timestamp.formatted(.dateTime.month().day()))
243
+            Text(point.snapshot.timestamp.formatted(.dateTime.month().day()))
218 244
                 .font(.caption2)
219 245
                 .foregroundStyle(.secondary)
220 246
                 .lineLimit(1)
@@ -223,6 +249,81 @@ struct RecordChangeEvolutionChart: View {
223 249
         .frame(maxWidth: .infinity)
224 250
     }
225 251
 
252
+    private func changeHeight(for count: Int) -> CGFloat {
253
+        guard count > 0 else { return 0 }
254
+        let scaled = CGFloat(count) / CGFloat(maxChangeCount) * 18
255
+        return max(4, scaled)
256
+    }
257
+
258
+    @ViewBuilder
259
+    private func changeSegment(
260
+        count: Int,
261
+        height: CGFloat,
262
+        color: Color,
263
+        pinsToBottom: Bool
264
+    ) -> some View {
265
+        VStack {
266
+            if pinsToBottom {
267
+                Spacer(minLength: 0)
268
+            }
269
+
270
+            if count > 0 {
271
+                RoundedRectangle(cornerRadius: 2)
272
+                    .fill(color)
273
+                    .frame(height: height)
274
+                    .frame(maxWidth: .infinity)
275
+                    .accessibilityLabel("\(count) records")
276
+            } else {
277
+                Color.clear.frame(height: 0)
278
+            }
279
+
280
+            if !pinsToBottom {
281
+                Spacer(minLength: 0)
282
+            }
283
+        }
284
+        .frame(height: 18)
285
+    }
286
+
287
+    private var changeLegend: some View {
288
+        HStack(spacing: 10) {
289
+            legendItem(color: Color.healthyGreen, label: "Added")
290
+            legendItem(color: Color.criticalRed, label: "Disappeared")
291
+            Spacer(minLength: 0)
292
+        }
293
+        .font(.caption2)
294
+        .foregroundStyle(.secondary)
295
+    }
296
+
297
+    private func legendItem(color: Color, label: String) -> some View {
298
+        HStack(spacing: 4) {
299
+            RoundedRectangle(cornerRadius: 2)
300
+                .fill(color)
301
+                .frame(width: 12, height: 4)
302
+            Text(label)
303
+        }
304
+    }
305
+}
306
+
307
+private struct ChartPoint {
308
+    let snapshot: HealthSnapshot
309
+    let count: Int
310
+    let diff: RecordChangeDiff
311
+}
312
+
313
+private struct RecordChangeDiff {
314
+    let added: Int
315
+    let disappeared: Int
316
+    let isExact: Bool
317
+
318
+    init(added: Int = 0, disappeared: Int = 0, isExact: Bool = false) {
319
+        self.added = added
320
+        self.disappeared = disappeared
321
+        self.isExact = isExact
322
+    }
323
+
324
+    var isFallback: Bool {
325
+        !isExact
326
+    }
226 327
 }
227 328
 
228 329
 #Preview {
+214 -0
HealthProbe/Views/Snapshots/DataTypeRecordListView.swift
@@ -0,0 +1,214 @@
1
+import SwiftUI
2
+import UIKit
3
+
4
+struct DataTypeRecordListView: View {
5
+    let title: String
6
+    let displayName: String
7
+    let totalCount: Int
8
+    let tint: Color
9
+
10
+    @State private var viewModel: DataTypeRecordListViewModel?
11
+
12
+    private let recordListMode: RecordListMode
13
+    private let previewRecords: [HealthRecordValue]
14
+
15
+    init(
16
+        title: String,
17
+        displayName: String,
18
+        totalCount: Int,
19
+        mode: RecordListMode,
20
+        previewRecords: [HealthRecordValue] = [],
21
+        tint: Color
22
+    ) {
23
+        self.title = title
24
+        self.displayName = displayName
25
+        self.totalCount = totalCount
26
+        self.tint = tint
27
+        self.recordListMode = mode
28
+        self.previewRecords = previewRecords
29
+    }
30
+
31
+
32
+    private var isTruncated: Bool {
33
+        guard let viewModel else { return totalCount > 0 }
34
+        return viewModel.displayRecords.count < totalCount
35
+    }
36
+
37
+    var body: some View {
38
+        Group {
39
+            if let viewModel = viewModel {
40
+                List {
41
+                    Section {
42
+                        DataTypeDetailRow(label: "Data Type") {
43
+                            Text(displayName)
44
+                                .foregroundStyle(.secondary)
45
+                                .lineLimit(1)
46
+                        }
47
+                        DataTypeDetailRow(label: "Records") {
48
+                            Text("\(totalCount)")
49
+                                .foregroundStyle(tint)
50
+                                .monospacedDigit()
51
+                        }
52
+                        if isTruncated {
53
+                            HStack(spacing: 4) {
54
+                                Image(systemName: "exclamationmark.circle.fill")
55
+                                    .font(.caption)
56
+                                    .foregroundStyle(Color.warningAmber)
57
+                                Text("Showing \(viewModel.displayRecords.count) of \(totalCount)")
58
+                                    .font(.caption)
59
+                                    .foregroundStyle(.secondary)
60
+                            }
61
+                            .padding(.vertical, 4)
62
+                        }
63
+                        Text("HealthProbe stores timestamps, fingerprints, and metadata. Export includes complete record details.")
64
+                            .font(.caption)
65
+                            .foregroundStyle(.secondary)
66
+                    }
67
+
68
+                    if viewModel.displayRecords.isEmpty {
69
+                        Section(title) {
70
+                            Text("No records.")
71
+                                .foregroundStyle(.secondary)
72
+                        }
73
+                    } else {
74
+                        Section(title) {
75
+                            ForEach(viewModel.displayRecords) { record in
76
+                                HealthRecordValueRow(record: record, tint: tint)
77
+                                    .onAppear {
78
+                                        if record.id == viewModel.displayRecords.last?.id {
79
+                                            Task { await viewModel.loadNextPage() }
80
+                                        }
81
+                                    }
82
+                            }
83
+
84
+                            if viewModel.isLoadingMore {
85
+                                HStack(spacing: 8) {
86
+                                    ProgressView()
87
+                                        .scaleEffect(0.8)
88
+                                    Text("Loading more...")
89
+                                        .font(.caption)
90
+                                        .foregroundStyle(.secondary)
91
+                                }
92
+                                .frame(maxWidth: .infinity, alignment: .center)
93
+                                .padding(.vertical, 8)
94
+                            }
95
+                        }
96
+                    }
97
+                }
98
+                .navigationTitle(title)
99
+                .navigationBarTitleDisplayMode(.inline)
100
+                .toolbar {
101
+                    ToolbarItem(placement: .topBarTrailing) {
102
+                        Menu {
103
+                            Button(action: { Task { await viewModel.exportAllRecords() } }) {
104
+                                Label("Export as JSON", systemImage: "square.and.arrow.up")
105
+                            }
106
+                        } label: {
107
+                            Image(systemName: "ellipsis.circle")
108
+                                .foregroundStyle(.primary)
109
+                        }
110
+                    }
111
+                }
112
+                .onChange(of: viewModel.exportState) { _, newState in
113
+                    if case .ready(let url) = newState {
114
+                        shareExportURL(url)
115
+                    }
116
+                }
117
+            } else {
118
+                ProgressView()
119
+                    .navigationTitle(title)
120
+                    .navigationBarTitleDisplayMode(.inline)
121
+            }
122
+        }
123
+        .task {
124
+            guard viewModel == nil else { return }
125
+            let vm = DataTypeRecordListViewModel(
126
+                typeIdentifier: recordListMode.typeIdentifier,
127
+                displayName: displayName,
128
+                totalCount: totalCount,
129
+                mode: recordListMode,
130
+                previewRecords: previewRecords,
131
+                archiveStore: SQLiteHealthArchiveStore.shared
132
+            )
133
+            viewModel = vm
134
+            await vm.loadFirstPage()
135
+        }
136
+    }
137
+
138
+    private func shareExportURL(_ url: URL) {
139
+        let activity = UIActivityViewController(activityItems: [url], applicationActivities: nil)
140
+        if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
141
+            scene.windows.first?.rootViewController?.present(activity, animated: true)
142
+        }
143
+    }
144
+}
145
+
146
+private struct HealthRecordValueRow: View {
147
+    let record: HealthRecordValue
148
+    let tint: Color
149
+
150
+    var body: some View {
151
+        let recordDate = record.startDate.formatted(.dateTime.year().month().day())
152
+        let startTime = record.startDate.formatted(.dateTime.hour().minute())
153
+        let endTime = record.endDate.formatted(.dateTime.hour().minute())
154
+        let timeRange = (startTime == endTime) ? startTime : "\(startTime)–\(endTime)"
155
+
156
+        VStack(alignment: .leading, spacing: 6) {
157
+            HStack(spacing: 12) {
158
+                VStack(alignment: .leading, spacing: 2) {
159
+                    Text("\(recordDate) • \(timeRange)")
160
+                        .font(.subheadline.weight(.semibold))
161
+                        .foregroundStyle(.primary)
162
+
163
+                    if let displayValue = record.displayValue, !displayValue.isEmpty {
164
+                        Text(displayValue)
165
+                            .font(.caption)
166
+                            .foregroundStyle(.secondary)
167
+                    } else {
168
+                        Text("No value stored")
169
+                            .font(.caption)
170
+                            .foregroundStyle(.secondary)
171
+                    }
172
+                }
173
+
174
+                Spacer()
175
+
176
+                Image(systemName: "xmark.circle.fill")
177
+                    .font(.caption)
178
+                    .foregroundStyle(Color.criticalRed)
179
+            }
180
+
181
+            Text(record.recordFingerprint.suffix(8))
182
+                .font(.caption.monospaced())
183
+                .foregroundStyle(.tertiary)
184
+                .lineLimit(1)
185
+        }
186
+        .padding(.vertical, 2)
187
+        .accessibilityElement(children: .combine)
188
+    }
189
+}
190
+
191
+private struct DataTypeDetailRow<Content: View>: View {
192
+    let label: String
193
+    @ViewBuilder let content: () -> Content
194
+
195
+    var body: some View {
196
+        HStack {
197
+            Text(label)
198
+            Spacer()
199
+            content()
200
+        }
201
+    }
202
+}
203
+
204
+#Preview {
205
+    NavigationStack {
206
+        DataTypeRecordListView(
207
+            title: "Disappeared Records",
208
+            displayName: "Step Count",
209
+            totalCount: 1500,
210
+            mode: .disappeared(typeIdentifier: "HKQuantityTypeIdentifierStepCount"),
211
+            tint: .criticalRed
212
+        )
213
+    }
214
+}
+12 -111
HealthProbe/Views/Snapshots/DataTypeSnapshotDetailView.swift
@@ -257,20 +257,24 @@ struct DataTypeSnapshotDetailView: View {
257 257
                     }
258 258
                 )
259 259
                 .navigationDestination(isPresented: $showAddedRecords) {
260
-                    DataTypeRecordListView(
261
-                        title: "Added Records",
262
-                        displayName: displayName,
263
-                        records: diff.addedRecords,
264
-                        totalCount: diff.addedCount,
265
-                        tint: Color.healthyGreen
266
-                    )
260
+                    if let previous = previousSnapshot {
261
+                        DataTypeRecordListView(
262
+                            title: "Added Records",
263
+                            displayName: displayName,
264
+                            totalCount: diff.addedCount,
265
+                            mode: .added(typeIdentifier: typeIdentifier, afterDate: previous.timestamp, beforeDate: currentSnapshot.timestamp),
266
+                            previewRecords: diff.addedRecords,
267
+                            tint: Color.healthyGreen
268
+                        )
269
+                    }
267 270
                 }
268 271
                 .navigationDestination(isPresented: $showDisappearedRecords) {
269 272
                     DataTypeRecordListView(
270 273
                         title: "Disappeared Records",
271 274
                         displayName: displayName,
272
-                        records: diff.disappearedRecords,
273 275
                         totalCount: diff.disappearedCount,
276
+                        mode: .disappeared(typeIdentifier: typeIdentifier),
277
+                        previewRecords: diff.disappearedRecords,
274 278
                         tint: Color.criticalRed
275 279
                     )
276 280
                 }
@@ -520,57 +524,6 @@ struct DataTypeSnapshotDetailView: View {
520 524
     }
521 525
 }
522 526
 
523
-private struct DataTypeRecordListView: View {
524
-    let title: String
525
-    let displayName: String
526
-    let records: [HealthRecordValue]
527
-    let totalCount: Int
528
-    let tint: Color
529
-    private var showsValueDisclaimer: Bool {
530
-        records.contains { ($0.displayValue?.isEmpty ?? true) }
531
-    }
532
-
533
-    var body: some View {
534
-        List {
535
-            Section {
536
-                DataTypeDetailRow(label: "Data Type") {
537
-                    Text(displayName)
538
-                        .foregroundStyle(.secondary)
539
-                        .lineLimit(1)
540
-                }
541
-                DataTypeDetailRow(label: "Records") {
542
-                    Text("\(totalCount)")
543
-                        .foregroundStyle(tint)
544
-                        .monospacedDigit()
545
-                }
546
-                if totalCount > records.count {
547
-                    Text("Showing newest \(records.count) records.")
548
-                        .font(.caption)
549
-                        .foregroundStyle(.secondary)
550
-                }
551
-                if showsValueDisclaimer {
552
-                    Text("For privacy, HealthProbe stores only timestamps and a record fingerprint here (not the raw HealthKit value).")
553
-                        .font(.caption)
554
-                        .foregroundStyle(.secondary)
555
-                }
556
-            }
557
-
558
-            Section(title) {
559
-                if records.isEmpty {
560
-                    Text("No records.")
561
-                        .foregroundStyle(.secondary)
562
-                } else {
563
-                    ForEach(records) { record in
564
-                        DataTypeRecordRow(record: record, tint: tint)
565
-                    }
566
-                }
567
-            }
568
-        }
569
-        .navigationTitle(title)
570
-        .navigationBarTitleDisplayMode(.inline)
571
-    }
572
-}
573
-
574 527
 private enum RecordDiffState: Equatable {
575 528
     case idle
576 529
     case loading
@@ -617,58 +570,6 @@ private struct DataTypeRecordDiff: Equatable, Sendable {
617 570
     }
618 571
 }
619 572
 
620
-private struct DataTypeRecordRow: View {
621
-    let record: HealthRecordValue
622
-    let tint: Color
623
-
624
-    var body: some View {
625
-        let recordDate = record.startDate.formatted(.dateTime.year().month().day())
626
-        let startTime = record.startDate.formatted(.dateTime.hour().minute())
627
-        let endTime = record.endDate.formatted(.dateTime.hour().minute())
628
-        let timeRange = (startTime == endTime) ? startTime : "\(startTime)–\(endTime)"
629
-
630
-        HStack(spacing: 12) {
631
-            VStack(alignment: .leading, spacing: 2) {
632
-                if let displayValue = record.displayValue, !displayValue.isEmpty {
633
-                    Text(displayValue)
634
-                        .font(.subheadline)
635
-                        .foregroundStyle(.primary)
636
-                } else {
637
-                    Text("No value stored")
638
-                        .font(.subheadline)
639
-                        .foregroundStyle(.secondary)
640
-                }
641
-                Text("\(recordDate) • \(timeRange)")
642
-                    .font(.caption)
643
-                    .foregroundStyle(.secondary)
644
-            }
645
-
646
-            Spacer()
647
-            if startTime != endTime {
648
-                Text(endTime)
649
-                    .font(.caption)
650
-                    .foregroundStyle(tint)
651
-                    .accessibilityLabel("End \(endTime)")
652
-            }
653
-        }
654
-        .padding(.vertical, 2)
655
-        .accessibilityElement(children: .combine)
656
-    }
657
-}
658
-
659
-private struct DataTypeDetailRow<Content: View>: View {
660
-    let label: String
661
-    @ViewBuilder let content: () -> Content
662
-
663
-    var body: some View {
664
-        HStack {
665
-            Text(label)
666
-            Spacer()
667
-            content()
668
-        }
669
-    }
670
-}
671
-
672 573
 #Preview {
673 574
     NavigationStack {
674 575
         DataTypeSnapshotDetailView(
+9 -60
HealthProbe/Views/Snapshots/SnapshotsView.swift
@@ -1,10 +1,8 @@
1 1
 import SwiftUI
2 2
 import SwiftData
3
-import UIKit
4 3
 
5 4
 struct SnapshotsView: View {
6 5
     @Environment(\.modelContext) private var modelContext
7
-    @Environment(AppSettings.self) private var appSettings
8 6
     @Query(sort: \HealthSnapshot.timestamp, order: .reverse) private var allSnapshots: [HealthSnapshot]
9 7
     @Query private var allDeltas: [SnapshotDelta]
10 8
     @Query private var deviceProfiles: [DeviceProfile]
@@ -17,9 +15,8 @@ struct SnapshotsView: View {
17 15
     }
18 16
 
19 17
     private var displayedSnapshots: [HealthSnapshot] {
20
-        let selected = appSettings.selectedDeviceIDs
21
-        guard !selected.isEmpty else { return [] }
22
-        return allSnapshots.filter { selected.contains($0.deviceID) }
18
+        guard let deviceID = localDeviceID else { return [] }
19
+        return allSnapshots.filter { $0.deviceID == deviceID }
23 20
     }
24 21
 
25 22
     private var snapshotItems: [SnapshotListItem] {
@@ -38,23 +35,13 @@ struct SnapshotsView: View {
38 35
         }
39 36
     }
40 37
 
41
-    private var knownDevices: [DeviceEntry] {
38
+    private var localDeviceID: String? {
42 39
         let currentID = AppSettings.currentDeviceID
43
-        var ids = Set(allSnapshots.map { $0.deviceID })
44
-        ids.insert(currentID)
45
-        return ids.map { id in
46
-            let profile = profileMap[id]
47
-            let name: String
48
-            if let n = profile?.name, !n.isEmpty { name = n }
49
-            else if id.isEmpty { name = "Unidentified" }
50
-            else { name = "Unknown Device" }
51
-            let color = DeviceColor(rawValue: profile?.colorTag ?? "")?.color ?? .neutralGray
52
-            return DeviceEntry(id: id, displayName: name, color: color, isCurrent: id == currentID)
53
-        }
54
-        .sorted {
55
-            if $0.isCurrent != $1.isCurrent { return $0.isCurrent }
56
-            return $0.displayName < $1.displayName
40
+        if allSnapshots.contains(where: { $0.deviceID == currentID }) {
41
+            return currentID
57 42
         }
43
+
44
+        return allSnapshots.first?.deviceID
58 45
     }
59 46
 
60 47
     var body: some View {
@@ -63,10 +50,8 @@ struct SnapshotsView: View {
63 50
                 if displayedSnapshots.isEmpty {
64 51
                     EmptyStateView(
65 52
                         icon: "clock.arrow.circlepath",
66
-                        title: appSettings.selectedDeviceIDs.isEmpty ? "No Devices Selected" : "No Snapshots",
67
-                        message: appSettings.selectedDeviceIDs.isEmpty
68
-                            ? "Select at least one device using the filter above."
69
-                            : "Use the Dashboard to create your first snapshot."
53
+                        title: "No Snapshots",
54
+                        message: "Use the Dashboard to create your first local snapshot."
70 55
                     )
71 56
                 } else {
72 57
                     snapshotList
@@ -77,13 +62,6 @@ struct SnapshotsView: View {
77 62
             .task(id: allDeltas.count) {
78 63
                 repairDeltaListSummariesIfNeeded()
79 64
             }
80
-            .onChange(of: appSettings.selectedDeviceIDs) {
81
-                if let baseline = viewModel.selectedBaseline,
82
-                   !displayedSnapshots.contains(where: { $0.id == baseline.id }) {
83
-                    viewModel.selectedBaseline = nil
84
-                    viewModel.comparisonMode = .previous
85
-                }
86
-            }
87 65
         }
88 66
     }
89 67
 
@@ -137,9 +115,6 @@ struct SnapshotsView: View {
137 115
 
138 116
     @ToolbarContentBuilder
139 117
     private var toolbarContent: some ToolbarContent {
140
-        ToolbarItem(placement: .topBarLeading) {
141
-            devicePickerMenu
142
-        }
143 118
         ToolbarItem(placement: .navigationBarTrailing) {
144 119
             Menu {
145 120
                 Picker("Compare Against", selection: $viewModel.comparisonMode) {
@@ -158,32 +133,6 @@ struct SnapshotsView: View {
158 133
         }
159 134
     }
160 135
 
161
-    private var devicePickerMenu: some View {
162
-        let selected = appSettings.selectedDeviceIDs
163
-        let isMulti  = selected.count > 1
164
-        return Menu {
165
-            ForEach(knownDevices) { entry in
166
-                Button {
167
-                    appSettings.toggleDevice(entry.id)
168
-                } label: {
169
-                    Label {
170
-                        Text(entry.isCurrent
171
-                             ? "\(entry.displayName) (This Device)"
172
-                             : entry.displayName)
173
-                    } icon: {
174
-                        Image(systemName: selected.contains(entry.id)
175
-                              ? "checkmark.circle.fill" : "circle.fill")
176
-                            .foregroundStyle(entry.color)
177
-                    }
178
-                }
179
-            }
180
-        } label: {
181
-            Image(systemName: "iphone")
182
-        }
183
-        .tint(isMulti ? .orange : .accentColor)
184
-        .accessibilityLabel("Select devices – \(selected.count) selected")
185
-    }
186
-
187 136
     private func repairDeltaListSummariesIfNeeded() {
188 137
         do {
189 138
             _ = try DeltaService.rebuildMissingListSummaries(context: modelContext, maxCount: 64)