Showing 1 changed files with 307 additions and 36 deletions
+307 -36
HealthProbe/Views/Snapshots/SnapshotDetailView.swift
@@ -7,11 +7,14 @@ struct SnapshotDetailView: View {
7 7
     let snapshot: HealthSnapshot
8 8
     let baseline: HealthSnapshot?
9 9
     let profile: DeviceProfile?
10
+    private let contextRadius = 3
10 11
 
11 12
     @Query(sort: \HealthSnapshot.timestamp) private var allSnapshots: [HealthSnapshot]
12 13
 
13 14
     private let diffService = SnapshotDiffService.shared
14 15
 
16
+    @State private var xAxisMode: EvolutionXAxisMode = .time
17
+
15 18
     private var sortedTypeCounts: [TypeCount] {
16 19
         (snapshot.typeCounts ?? []).sorted {
17 20
             $0.displayName.localizedCompare($1.displayName) == .orderedAscending
@@ -44,9 +47,45 @@ struct SnapshotDetailView: View {
44 47
         }
45 48
     }
46 49
 
50
+    private var timelineContextSnapshots: [HealthSnapshot] {
51
+        guard let currentIndex = timelineSnapshots.firstIndex(where: { $0.id == snapshot.id }) else {
52
+            return [snapshot]
53
+        }
54
+
55
+        let desiredCount = contextRadius * 2 + 1
56
+        var start = max(0, currentIndex - contextRadius)
57
+        var end = min(timelineSnapshots.count - 1, currentIndex + contextRadius)
58
+
59
+        let currentCount = end - start + 1
60
+        if currentCount < desiredCount {
61
+            let missing = desiredCount - currentCount
62
+
63
+            let extraBefore = min(start, missing)
64
+            start -= extraBefore
65
+
66
+            let remaining = missing - extraBefore
67
+            let availableAfter = timelineSnapshots.count - 1 - end
68
+            let extraAfter = min(availableAfter, remaining)
69
+            end += extraAfter
70
+
71
+            if extraAfter < remaining {
72
+                let finalRemaining = remaining - extraAfter
73
+                let availableBefore = start
74
+                let finalExtraBefore = min(availableBefore, finalRemaining)
75
+                start -= finalExtraBefore
76
+            }
77
+        }
78
+
79
+        return Array(timelineSnapshots[start...end])
80
+    }
81
+
82
+    private var isTimelineContextTrimmed: Bool {
83
+        timelineContextSnapshots.count < timelineSnapshots.count
84
+    }
85
+
47 86
     private var evolutionSeries: [TypeEvolutionSeries] {
48 87
         sortedTypeCounts.compactMap { typeCount in
49
-            let points = timelineSnapshots.compactMap { candidate -> TypeEvolutionPoint? in
88
+            let points = timelineContextSnapshots.compactMap { candidate -> TypeEvolutionPoint? in
50 89
                 guard let candidateTypeCount = candidate.typeCounts?.first(where: {
51 90
                     $0.typeIdentifier == typeCount.typeIdentifier
52 91
                 }),
@@ -83,7 +122,6 @@ struct SnapshotDetailView: View {
83 122
                 comparisonSection(baseline)
84 123
             }
85 124
             evolutionSection
86
-            typeCountsSection
87 125
         }
88 126
         .navigationTitle("Snapshot")
89 127
         .navigationBarTitleDisplayMode(.inline)
@@ -177,34 +215,66 @@ struct SnapshotDetailView: View {
177 215
     }
178 216
 
179 217
     private var evolutionSection: some View {
180
-        Section("Evolution") {
181
-            if evolutionSeries.isEmpty {
182
-                Text("No chartable data for this snapshot.")
218
+        Section("Data Types") {
219
+            HStack {
220
+                Text("X-Axis")
183 221
                     .foregroundStyle(.secondary)
222
+                Spacer()
223
+                Picker("X-Axis", selection: $xAxisMode) {
224
+                    ForEach(EvolutionXAxisMode.allCases) { mode in
225
+                        Text(mode.title).tag(mode)
226
+                    }
227
+                }
228
+                .pickerStyle(.segmented)
229
+                .frame(maxWidth: 220)
230
+            }
231
+
232
+            if evolutionSeries.isEmpty {
233
+                if sortedTypeCounts.isEmpty {
234
+                    Text("No tracked data types in this snapshot.")
235
+                        .foregroundStyle(.secondary)
236
+                } else {
237
+                    ForEach(sortedTypeCounts) { typeCount in
238
+                        SnapshotTypeCountRow(
239
+                            typeCount: typeCount,
240
+                            baselineTypeCount: baselineTypeMap[typeCount.typeIdentifier]
241
+                        )
242
+                    }
243
+                }
184 244
             } else {
185 245
                 ForEach(evolutionSeries) { series in
186 246
                     TypeEvolutionChart(
187 247
                         series: series,
188
-                        selectedSnapshotID: snapshot.id
248
+                        contextSnapshots: timelineContextSnapshots,
249
+                        xAxisMode: xAxisMode,
250
+                        selectedSnapshotID: snapshot.id,
251
+                        selectedTimestamp: snapshot.timestamp,
252
+                        baselineTypeCount: baselineTypeMap[series.typeIdentifier]
189 253
                     )
190 254
                 }
255
+
256
+                if isTimelineContextTrimmed {
257
+                    Text("Charts show only the local window: 3 snapshots before and 3 after the current one.")
258
+                        .font(.caption)
259
+                        .foregroundStyle(.secondary)
260
+                }
191 261
             }
192 262
         }
193 263
     }
264
+}
194 265
 
195
-    private var typeCountsSection: some View {
196
-        Section("Data Types") {
197
-            if sortedTypeCounts.isEmpty {
198
-                Text("No tracked data types in this snapshot.")
199
-                .foregroundStyle(.secondary)
200
-            } else {
201
-                ForEach(sortedTypeCounts) { typeCount in
202
-                    SnapshotTypeCountRow(
203
-                        typeCount: typeCount,
204
-                        baselineTypeCount: baselineTypeMap[typeCount.typeIdentifier]
205
-                    )
206
-                }
207
-            }
266
+private enum EvolutionXAxisMode: String, CaseIterable, Identifiable {
267
+    case time
268
+    case snapshots
269
+
270
+    var id: String { rawValue }
271
+
272
+    var title: String {
273
+        switch self {
274
+        case .time:
275
+            return "Time"
276
+        case .snapshots:
277
+            return "Snapshots"
208 278
         }
209 279
     }
210 280
 }
@@ -249,45 +319,238 @@ private struct TypeEvolutionPoint: Identifiable {
249 319
 
250 320
 private struct TypeEvolutionChart: View {
251 321
     let series: TypeEvolutionSeries
322
+    let contextSnapshots: [HealthSnapshot]
323
+    let xAxisMode: EvolutionXAxisMode
252 324
     let selectedSnapshotID: UUID
325
+    let selectedTimestamp: Date
326
+    let baselineTypeCount: TypeCount?
327
+
328
+    private struct SnapshotAxisPoint: Identifiable {
329
+        let snapshotID: UUID
330
+        let contextIndex: Int
331
+        let timestamp: Date
332
+        let count: Int
333
+
334
+        var id: UUID { snapshotID }
335
+    }
253 336
 
254 337
     private var selectedPoint: TypeEvolutionPoint? {
255 338
         series.points.first { $0.snapshotID == selectedSnapshotID }
256 339
     }
257 340
 
258
-    var body: some View {
259
-        VStack(alignment: .leading, spacing: 8) {
260
-            HStack(alignment: .firstTextBaseline) {
261
-                Text(series.displayName)
262
-                    .font(.subheadline.weight(.semibold))
263
-                Spacer()
264
-                if let selectedPoint {
265
-                    Text("\(selectedPoint.count)")
266
-                        .font(.subheadline.monospacedDigit())
267
-                        .foregroundStyle(.secondary)
341
+    private var isMissingInSelectedSnapshot: Bool {
342
+        selectedPoint == nil
343
+    }
344
+
345
+    private var previousPoint: TypeEvolutionPoint? {
346
+        guard let selectedIndex = series.points.firstIndex(where: { $0.snapshotID == selectedSnapshotID }),
347
+              selectedIndex > 0 else { return nil }
348
+        return series.points[selectedIndex - 1]
349
+    }
350
+
351
+    private var delta: Int? {
352
+        guard let selected = selectedPoint,
353
+              let previous = previousPoint,
354
+              selected.count >= 0,
355
+              previous.count >= 0 else { return nil }
356
+        return selected.count - previous.count
357
+    }
358
+
359
+    private var contextPointCountLabel: String {
360
+        "\(series.points.count)/\(contextSnapshots.count) snapshots with data"
361
+    }
362
+
363
+    private var contextAxisPoints: [SnapshotAxisPoint] {
364
+        contextSnapshots.enumerated().compactMap { index, snapshot in
365
+            guard let candidateTypeCount = snapshot.typeCounts?.first(where: {
366
+                $0.typeIdentifier == series.typeIdentifier
367
+            }), candidateTypeCount.count >= 0 else {
368
+                return nil
369
+            }
370
+
371
+            return SnapshotAxisPoint(
372
+                snapshotID: snapshot.id,
373
+                contextIndex: index,
374
+                timestamp: snapshot.timestamp,
375
+                count: candidateTypeCount.count
376
+            )
377
+        }
378
+    }
379
+
380
+    private var contextAxisGroups: [[SnapshotAxisPoint]] {
381
+        guard !contextAxisPoints.isEmpty else { return [] }
382
+
383
+        var groups: [[SnapshotAxisPoint]] = []
384
+        var currentGroup: [SnapshotAxisPoint] = [contextAxisPoints[0]]
385
+
386
+        for point in contextAxisPoints.dropFirst() {
387
+            if let previous = currentGroup.last, point.contextIndex == previous.contextIndex + 1 {
388
+                currentGroup.append(point)
389
+            } else {
390
+                groups.append(currentGroup)
391
+                currentGroup = [point]
392
+            }
393
+        }
394
+
395
+        groups.append(currentGroup)
396
+        return groups
397
+    }
398
+
399
+    private var selectedContextIndex: Int? {
400
+        contextSnapshots.firstIndex { $0.id == selectedSnapshotID }
401
+    }
402
+
403
+    private var snapshotAxisValues: [Int] {
404
+        Array(contextSnapshots.indices)
405
+    }
406
+
407
+    private func snapshotAxisLabel(for index: Int) -> String {
408
+        guard let selectedContextIndex else {
409
+            return "S\(index + 1)"
410
+        }
411
+
412
+        let offset = index - selectedContextIndex
413
+        if offset == 0 { return "Current" }
414
+        if offset > 0 { return "S+\(offset)" }
415
+        return "S\(offset)"
416
+    }
417
+
418
+    private var snapshotAxisDomain: ClosedRange<Int> {
419
+        guard let first = snapshotAxisValues.first, let last = snapshotAxisValues.last else {
420
+            return 0...0
421
+        }
422
+        return first...last
423
+    }
424
+
425
+    @ViewBuilder
426
+    private var chartContent: some View {
427
+        switch xAxisMode {
428
+        case .time:
429
+            timeChart
430
+        case .snapshots:
431
+            snapshotChart
432
+        }
433
+    }
434
+
435
+    private var timeChart: some View {
436
+        Chart {
437
+            ForEach(contextSnapshots, id: \.id) { item in
438
+                RuleMark(x: .value("Timeline", item.timestamp))
439
+                    .foregroundStyle(Color.secondary.opacity(0.10))
440
+            }
441
+
442
+            RuleMark(x: .value("Selected Snapshot", selectedTimestamp))
443
+                .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3]))
444
+                .foregroundStyle(Color.secondary.opacity(0.55))
445
+
446
+            ForEach(series.points) { point in
447
+                LineMark(
448
+                    x: .value("Date", point.timestamp),
449
+                    y: .value("Records", point.count)
450
+                )
451
+                .interpolationMethod(.linear)
452
+
453
+                PointMark(
454
+                    x: .value("Date", point.timestamp),
455
+                    y: .value("Records", point.count)
456
+                )
457
+                .symbolSize(24)
458
+
459
+                if point.snapshotID == selectedSnapshotID {
460
+                    PointMark(
461
+                        x: .value("Selected Date", point.timestamp),
462
+                        y: .value("Selected Records", point.count)
463
+                    )
464
+                    .symbolSize(64)
268 465
                 }
269 466
             }
467
+        }
468
+    }
469
+
470
+    private var snapshotChart: some View {
471
+        Chart {
472
+            ForEach(contextSnapshots.indices, id: \.self) { index in
473
+                RuleMark(x: .value("Snapshot", index))
474
+                    .foregroundStyle(Color.secondary.opacity(0.10))
475
+            }
476
+
477
+            if let selectedContextIndex {
478
+                RuleMark(x: .value("Selected Snapshot", selectedContextIndex))
479
+                    .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3]))
480
+                    .foregroundStyle(Color.secondary.opacity(0.55))
481
+            }
482
+
483
+            ForEach(contextAxisGroups.indices, id: \.self) { groupIndex in
484
+                let group = contextAxisGroups[groupIndex]
270 485
 
271
-            Chart {
272
-                ForEach(series.points) { point in
486
+                ForEach(group) { point in
273 487
                     LineMark(
274
-                        x: .value("Date", point.timestamp),
488
+                        x: .value("Snapshot", point.contextIndex),
489
+                        y: .value("Records", point.count)
490
+                    )
491
+                    .interpolationMethod(.linear)
492
+
493
+                    PointMark(
494
+                        x: .value("Snapshot", point.contextIndex),
275 495
                         y: .value("Records", point.count)
276 496
                     )
277
-                    .interpolationMethod(.catmullRom)
497
+                    .symbolSize(24)
278 498
 
279 499
                     if point.snapshotID == selectedSnapshotID {
280 500
                         PointMark(
281
-                            x: .value("Selected Date", point.timestamp),
501
+                            x: .value("Selected Snapshot", point.contextIndex),
282 502
                             y: .value("Selected Records", point.count)
283 503
                         )
284 504
                         .symbolSize(64)
285 505
                     }
286 506
                 }
287 507
             }
508
+        }
509
+        .chartXAxis {
510
+            AxisMarks(values: snapshotAxisValues) { value in
511
+                AxisGridLine()
512
+                AxisTick()
513
+                if let rawIndex = value.as(Int.self) {
514
+                    AxisValueLabel(snapshotAxisLabel(for: rawIndex))
515
+                }
516
+            }
517
+        }
518
+        .chartXScale(domain: snapshotAxisDomain)
519
+    }
520
+
521
+    var body: some View {
522
+        VStack(alignment: .leading, spacing: 8) {
523
+            HStack(alignment: .firstTextBaseline) {
524
+                Text(series.displayName)
525
+                    .font(.subheadline.weight(.semibold))
526
+                Spacer()
527
+                VStack(alignment: .trailing, spacing: 4) {
528
+                    if let selectedPoint {
529
+                        Text("\(selectedPoint.count)")
530
+                            .font(.subheadline.monospacedDigit())
531
+                            .foregroundStyle(.secondary)
532
+                    }
533
+                    if let delta {
534
+                        SeverityBadge(delta: delta)
535
+                    }
536
+                }
537
+            }
538
+
539
+            chartContent
288 540
             .chartYScale(domain: series.yDomain)
289 541
             .chartXAxis {
290
-                AxisMarks(values: .automatic(desiredCount: 3))
542
+                switch xAxisMode {
543
+                case .time:
544
+                    AxisMarks(values: .automatic(desiredCount: 3))
545
+                case .snapshots:
546
+                    AxisMarks(values: snapshotAxisValues) { value in
547
+                        AxisGridLine()
548
+                        AxisTick()
549
+                        if let rawIndex = value.as(Int.self) {
550
+                            AxisValueLabel(snapshotAxisLabel(for: rawIndex))
551
+                        }
552
+                    }
553
+                }
291 554
             }
292 555
             .chartYAxis {
293 556
                 AxisMarks(position: .leading, values: .automatic(desiredCount: 3))
@@ -295,11 +558,19 @@ private struct TypeEvolutionChart: View {
295 558
             .frame(height: 120)
296 559
             .foregroundStyle(Color.accentColor)
297 560
 
298
-            if series.points.count == 1 {
561
+            if isMissingInSelectedSnapshot {
562
+                Text("Datatype missing in this snapshot")
563
+                    .font(.caption2)
564
+                    .foregroundStyle(Color.warningAmber)
565
+            } else if series.points.count == 1 {
299 566
                 Text("Only one measurement")
300 567
                     .font(.caption2)
301 568
                     .foregroundStyle(.secondary)
302 569
             }
570
+
571
+            Text(contextPointCountLabel)
572
+                .font(.caption2)
573
+                .foregroundStyle(.secondary)
303 574
         }
304 575
         .padding(.vertical, 4)
305 576
         .accessibilityElement(children: .combine)