@@ -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/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 | 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, operation logging, ContentView preview, Settings data maintenance, and legacy anomaly/count-drop review have moved outside SwiftData or been removed. 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 |
-| UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker. Dashboard status reads archive/cache observation rows and shows cache health, with SwiftData retained only for capture/review actions; Snapshots timeline reads archive/cache rows when available and no longer queries `SnapshotDelta` for list summaries; snapshot detail summaries/type rows and Data Types list prefer Core Data cache rows when archive observation ids exist, with Data Types diff rows no longer falling back to SwiftData `TypeCount` traversal; data type detail reads Core Data type/diff summaries and uses SQLite `diffRecords` for paged drill-down; record-change evolution and temporal distribution screens now receive DTO rows/cache input instead of querying SwiftData directly; 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 | |
|
| 31 |
+| UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker. Dashboard status reads archive/cache observation rows and shows cache health, with SwiftData retained only for capture/review actions; Snapshots timeline reads archive/cache rows when available and no longer queries `SnapshotDelta` for list summaries; snapshot detail summaries/type rows require Core Data cache rows and no longer fall back to `SnapshotDelta`/`TypeDelta`; Data Types list rows no longer fall back to SwiftData `TypeCount` traversal; data type detail reads Core Data type/diff summaries and uses SQLite `diffRecords` for paged drill-down; record-change evolution and temporal distribution screens now receive DTO rows/cache input instead of querying SwiftData directly; 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 | SQL diff summaries, paged diff records, aggregate comparisons, and consolidation-evidence labels exist; legacy anomaly/count-drop review has been removed from active flows | Continue moving remaining SwiftData fallback detail paths to archive/cache DTOs | |
| 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 | |
| 34 | 34 |
| Legacy device support | Simplified detail UI mode is implemented for small/accessibility layouts and as a Settings toggle | Remove SwiftData dependency and validate lower deployment targets | |
@@ -226,7 +226,9 @@ Checklist: |
||
| 226 | 226 |
- [x] Observation timeline rows read Core Data cache when available and no |
| 227 | 227 |
longer query `SnapshotDelta` list summaries, while retaining SwiftData handles |
| 228 | 228 |
for detail navigation during transition. |
| 229 |
-- [x] Observation detail uses cached summary/type rows plus SQLite diff summaries when archive observation ids exist. |
|
| 229 |
+- [x] Observation detail uses cached summary/type rows plus SQLite diff |
|
| 230 |
+ summaries and no longer falls back to legacy `SnapshotDelta`/`TypeDelta` |
|
| 231 |
+ rows. |
|
| 230 | 232 |
- [x] Data Types list rows use Core Data cached counts plus SQLite `diffSummary` and no longer fall back to SwiftData `TypeCount` traversal. |
| 231 | 233 |
- [x] Data type detail uses SQLite `diffSummary` when archive observation ids exist. |
| 232 | 234 |
- [x] Data type new/missing drill-down pages through SQLite `diffRecords` when archive observation ids exist. |
@@ -123,6 +123,11 @@ The following SwiftData dependencies were removed from active flows: |
||
| 123 | 123 |
small observation contexts and builds rows from Core Data cache + SQLite |
| 124 | 124 |
archive diff APIs. It no longer falls back to `SnapshotDiffService.diff(...)` |
| 125 | 125 |
over SwiftData `TypeCount` relationships. |
| 126 |
+- `HealthProbe/Views/Snapshots/SnapshotDetailView.swift` no longer queries |
|
| 127 |
+ `SnapshotDelta`/`TypeDelta` or carries the old SwiftData type-delta/chart |
|
| 128 |
+ fallback. Snapshot detail type rows now require archive/cache summaries; the |
|
| 129 |
+ temporary SwiftData dependency is limited to snapshot navigation, metadata, |
|
| 130 |
+ and PDF export handles. |
|
| 126 | 131 |
- `HealthProbe/Models/AnomalyRecord.swift`, |
| 127 | 132 |
`HealthProbe/Models/AnomalyType.swift`, and |
| 128 | 133 |
`HealthProbe/Services/AnomalyDetector.swift` were deleted. The app no longer |
@@ -1,4 +1,3 @@ |
||
| 1 |
-import Charts |
|
| 2 | 1 |
import SwiftUI |
| 3 | 2 |
import SwiftData |
| 4 | 3 |
import UIKit |
@@ -9,7 +8,6 @@ struct SnapshotDetailView: View {
|
||
| 9 | 8 |
let profile: LocalDeviceProfile? |
| 10 | 9 |
|
| 11 | 10 |
@Query(sort: \HealthSnapshot.timestamp) private var allSnapshots: [HealthSnapshot] |
| 12 |
- @Query private var allDeltas: [SnapshotDelta] |
|
| 13 | 11 |
@State private var displayedSnapshot: HealthSnapshot? |
| 14 | 12 |
@State private var archiveTypeRows: [SnapshotArchiveTypeRow]? |
| 15 | 13 |
@State private var archiveTypeError: String? |
@@ -18,19 +16,6 @@ struct SnapshotDetailView: View {
|
||
| 18 | 16 |
displayedSnapshot ?? snapshot |
| 19 | 17 |
} |
| 20 | 18 |
|
| 21 |
- private var currentDelta: SnapshotDelta? {
|
|
| 22 |
- allDeltas.first { $0.toSnapshotID == currentSnapshot.id }
|
|
| 23 |
- } |
|
| 24 |
- |
|
| 25 |
- private var currentDeltaSummary: SnapshotDeltaListSummary? {
|
|
| 26 |
- currentDelta?.listSummary |
|
| 27 |
- } |
|
| 28 |
- |
|
| 29 |
- private var allTypeDeltas: [TypeDelta] {
|
|
| 30 |
- (currentDelta?.typeDeltas ?? []) |
|
| 31 |
- .sorted { $0.displayName.localizedCompare($1.displayName) == .orderedAscending }
|
|
| 32 |
- } |
|
| 33 |
- |
|
| 34 | 19 |
private var archiveReloadID: String {
|
| 35 | 20 |
[ |
| 36 | 21 |
currentSnapshot.id.uuidString, |
@@ -342,9 +327,9 @@ struct SnapshotDetailView: View {
|
||
| 342 | 327 |
|
| 343 | 328 |
@ViewBuilder |
| 344 | 329 |
private func comparisonSection(baseline: HealthSnapshot) -> some View {
|
| 345 |
- let delta = archiveRecordChangeCount ?? currentDeltaSummary?.absoluteRecordChangeCount ?? 0 |
|
| 330 |
+ let delta = archiveRecordChangeCount ?? 0 |
|
| 346 | 331 |
let deltaPercent = computeDeltaPercent(delta: delta, baseline: baseline) |
| 347 |
- let affectedMetricCount = archiveAffectedMetricCount ?? currentDeltaSummary?.affectedMetricCount ?? 0 |
|
| 332 |
+ let affectedMetricCount = archiveAffectedMetricCount ?? 0 |
|
| 348 | 333 |
let isSignificant = delta > 0 || affectedMetricCount > 0 || (deltaPercent > 10) |
| 349 | 334 |
|
| 350 | 335 |
DisclosureGroup {
|
@@ -359,7 +344,7 @@ struct SnapshotDetailView: View {
|
||
| 359 | 344 |
Text(days == 0 ? "Same day" : "\(days) days") |
| 360 | 345 |
.foregroundStyle(.secondary) |
| 361 | 346 |
} |
| 362 |
- if archiveTypeRows != nil || currentDeltaSummary != nil {
|
|
| 347 |
+ if archiveTypeRows != nil {
|
|
| 363 | 348 |
Divider() |
| 364 | 349 |
DetailRow(label: "Changed Metrics") {
|
| 365 | 350 |
Text("\(affectedMetricCount)")
|
@@ -461,24 +446,9 @@ struct SnapshotDetailView: View {
|
||
| 461 | 446 |
} else if baseline == nil {
|
| 462 | 447 |
Text("This snapshot starts the chain, so no baseline comparison is available.")
|
| 463 | 448 |
.foregroundStyle(.secondary) |
| 464 |
- } else if currentDelta == nil {
|
|
| 449 |
+ } else {
|
|
| 465 | 450 |
Text("Cached metric summary unavailable for this snapshot.")
|
| 466 | 451 |
.foregroundStyle(.secondary) |
| 467 |
- } else if allTypeDeltas.isEmpty {
|
|
| 468 |
- Text("No data types are available for this snapshot.")
|
|
| 469 |
- .foregroundStyle(.secondary) |
|
| 470 |
- } else {
|
|
| 471 |
- ForEach(allTypeDeltas) { typeDelta in
|
|
| 472 |
- NavigationLink {
|
|
| 473 |
- DataTypeSnapshotDetailView( |
|
| 474 |
- snapshot: currentSnapshot, |
|
| 475 |
- typeIdentifier: typeDelta.typeIdentifier, |
|
| 476 |
- displayName: typeDelta.displayName |
|
| 477 |
- ) |
|
| 478 |
- } label: {
|
|
| 479 |
- SnapshotTypeDeltaRow(typeDelta: typeDelta) |
|
| 480 |
- } |
|
| 481 |
- } |
|
| 482 | 452 |
} |
| 483 | 453 |
} |
| 484 | 454 |
} |
@@ -571,430 +541,6 @@ private struct SnapshotArchiveTypeRowView: View {
|
||
| 571 | 541 |
} |
| 572 | 542 |
} |
| 573 | 543 |
|
| 574 |
-private struct SnapshotTypeDeltaRow: View {
|
|
| 575 |
- let typeDelta: TypeDelta |
|
| 576 |
- |
|
| 577 |
- private var deltaLabel: String {
|
|
| 578 |
- switch typeDelta.transition {
|
|
| 579 |
- case .changed: |
|
| 580 |
- if typeDelta.countDelta == 0 {
|
|
| 581 |
- return "Content changed" |
|
| 582 |
- } |
|
| 583 |
- let prefix = typeDelta.countDelta > 0 ? "+" : "" |
|
| 584 |
- return "\(prefix)\(typeDelta.countDelta) records" |
|
| 585 |
- case .appeared: |
|
| 586 |
- return "New" |
|
| 587 |
- case .disappeared: |
|
| 588 |
- return "Missing" |
|
| 589 |
- case .unchanged: |
|
| 590 |
- return "No changes" |
|
| 591 |
- } |
|
| 592 |
- } |
|
| 593 |
- |
|
| 594 |
- private var deltaColor: Color {
|
|
| 595 |
- switch typeDelta.transition {
|
|
| 596 |
- case .disappeared: |
|
| 597 |
- return .criticalRed |
|
| 598 |
- case .changed, .appeared: |
|
| 599 |
- return .warningAmber |
|
| 600 |
- case .unchanged: |
|
| 601 |
- return .secondary |
|
| 602 |
- } |
|
| 603 |
- } |
|
| 604 |
- |
|
| 605 |
- var body: some View {
|
|
| 606 |
- HStack(spacing: 12) {
|
|
| 607 |
- VStack(alignment: .leading, spacing: 3) {
|
|
| 608 |
- Text(typeDelta.displayName) |
|
| 609 |
- .font(.subheadline) |
|
| 610 |
- Text(typeDelta.typeIdentifier) |
|
| 611 |
- .font(.caption2) |
|
| 612 |
- .foregroundStyle(.secondary) |
|
| 613 |
- .lineLimit(1) |
|
| 614 |
- .truncationMode(.middle) |
|
| 615 |
- } |
|
| 616 |
- |
|
| 617 |
- Spacer() |
|
| 618 |
- |
|
| 619 |
- Text(deltaLabel) |
|
| 620 |
- .font(.caption.weight(.semibold)) |
|
| 621 |
- .foregroundStyle(deltaColor) |
|
| 622 |
- } |
|
| 623 |
- .accessibilityElement(children: .combine) |
|
| 624 |
- } |
|
| 625 |
-} |
|
| 626 |
- |
|
| 627 |
-private enum EvolutionXAxisMode: String, CaseIterable, Identifiable {
|
|
| 628 |
- case time |
|
| 629 |
- case snapshots |
|
| 630 |
- |
|
| 631 |
- var id: String { rawValue }
|
|
| 632 |
- |
|
| 633 |
- var title: String {
|
|
| 634 |
- switch self {
|
|
| 635 |
- case .time: |
|
| 636 |
- return "Time" |
|
| 637 |
- case .snapshots: |
|
| 638 |
- return "Snapshots" |
|
| 639 |
- } |
|
| 640 |
- } |
|
| 641 |
-} |
|
| 642 |
- |
|
| 643 |
-private struct TypeEvolutionSeries: Identifiable {
|
|
| 644 |
- let typeIdentifier: String |
|
| 645 |
- let displayName: String |
|
| 646 |
- let points: [TypeEvolutionPoint] |
|
| 647 |
- |
|
| 648 |
- var id: String { typeIdentifier }
|
|
| 649 |
- |
|
| 650 |
- var latestPoint: TypeEvolutionPoint? {
|
|
| 651 |
- points.max { $0.timestamp < $1.timestamp }
|
|
| 652 |
- } |
|
| 653 |
- |
|
| 654 |
- var selectedOrLatestPoint: TypeEvolutionPoint? {
|
|
| 655 |
- points.last |
|
| 656 |
- } |
|
| 657 |
- |
|
| 658 |
- var yDomain: ClosedRange<Double> {
|
|
| 659 |
- let counts = points.map(\.count) |
|
| 660 |
- guard let minCount = counts.min(), let maxCount = counts.max() else {
|
|
| 661 |
- return 0...1 |
|
| 662 |
- } |
|
| 663 |
- |
|
| 664 |
- if minCount == maxCount {
|
|
| 665 |
- let lower = max(0, minCount - 1) |
|
| 666 |
- return Double(lower)...Double(maxCount + 1) |
|
| 667 |
- } |
|
| 668 |
- |
|
| 669 |
- return Double(max(0, minCount))...Double(maxCount) |
|
| 670 |
- } |
|
| 671 |
-} |
|
| 672 |
- |
|
| 673 |
-private struct TypeEvolutionPoint: Identifiable {
|
|
| 674 |
- let snapshotID: UUID |
|
| 675 |
- let timestamp: Date |
|
| 676 |
- let count: Int |
|
| 677 |
- |
|
| 678 |
- var id: UUID { snapshotID }
|
|
| 679 |
-} |
|
| 680 |
- |
|
| 681 |
-private struct TypeEvolutionChart: View {
|
|
| 682 |
- let series: TypeEvolutionSeries |
|
| 683 |
- let contextSnapshots: [HealthSnapshot] |
|
| 684 |
- let xAxisMode: EvolutionXAxisMode |
|
| 685 |
- let selectedSnapshotID: UUID |
|
| 686 |
- let selectedTimestamp: Date |
|
| 687 |
- let snapshotNumbers: [UUID: Int] |
|
| 688 |
- let baselineTypeCount: TypeCount? |
|
| 689 |
- |
|
| 690 |
- private struct SnapshotAxisPoint: Identifiable {
|
|
| 691 |
- let snapshotID: UUID |
|
| 692 |
- let contextIndex: Int |
|
| 693 |
- let timestamp: Date |
|
| 694 |
- let count: Int |
|
| 695 |
- |
|
| 696 |
- var id: UUID { snapshotID }
|
|
| 697 |
- } |
|
| 698 |
- |
|
| 699 |
- private var selectedPoint: TypeEvolutionPoint? {
|
|
| 700 |
- series.points.first { $0.snapshotID == selectedSnapshotID }
|
|
| 701 |
- } |
|
| 702 |
- |
|
| 703 |
- private var isMissingInSelectedSnapshot: Bool {
|
|
| 704 |
- selectedPoint == nil |
|
| 705 |
- } |
|
| 706 |
- |
|
| 707 |
- private var previousPoint: TypeEvolutionPoint? {
|
|
| 708 |
- guard let selectedIndex = series.points.firstIndex(where: { $0.snapshotID == selectedSnapshotID }),
|
|
| 709 |
- selectedIndex > 0 else { return nil }
|
|
| 710 |
- return series.points[selectedIndex - 1] |
|
| 711 |
- } |
|
| 712 |
- |
|
| 713 |
- private var delta: Int? {
|
|
| 714 |
- guard let selected = selectedPoint, |
|
| 715 |
- let previous = previousPoint, |
|
| 716 |
- selected.count >= 0, |
|
| 717 |
- previous.count >= 0 else { return nil }
|
|
| 718 |
- return selected.count - previous.count |
|
| 719 |
- } |
|
| 720 |
- |
|
| 721 |
- private var isSignificantChange: Bool {
|
|
| 722 |
- guard let d = delta, let prev = previousPoint?.count, prev > 0 else { return false }
|
|
| 723 |
- let percentChange = abs(Double(d)) / Double(prev) * 100 |
|
| 724 |
- return percentChange > 10 || d > 0 |
|
| 725 |
- } |
|
| 726 |
- |
|
| 727 |
- private var contextPointCountLabel: String {
|
|
| 728 |
- "\(series.points.count)/\(contextSnapshots.count) snapshots with data" |
|
| 729 |
- } |
|
| 730 |
- |
|
| 731 |
- private var contextAxisPoints: [SnapshotAxisPoint] {
|
|
| 732 |
- contextSnapshots.enumerated().compactMap { index, snapshot in
|
|
| 733 |
- guard let candidateTypeCount = snapshot.typeCounts?.first(where: {
|
|
| 734 |
- $0.typeIdentifier == series.typeIdentifier |
|
| 735 |
- }), candidateTypeCount.count >= 0 else {
|
|
| 736 |
- return nil |
|
| 737 |
- } |
|
| 738 |
- |
|
| 739 |
- return SnapshotAxisPoint( |
|
| 740 |
- snapshotID: snapshot.id, |
|
| 741 |
- contextIndex: index, |
|
| 742 |
- timestamp: snapshot.timestamp, |
|
| 743 |
- count: candidateTypeCount.count |
|
| 744 |
- ) |
|
| 745 |
- } |
|
| 746 |
- } |
|
| 747 |
- |
|
| 748 |
- private var contextAxisGroups: [[SnapshotAxisPoint]] {
|
|
| 749 |
- guard !contextAxisPoints.isEmpty else { return [] }
|
|
| 750 |
- |
|
| 751 |
- var groups: [[SnapshotAxisPoint]] = [] |
|
| 752 |
- var currentGroup: [SnapshotAxisPoint] = [contextAxisPoints[0]] |
|
| 753 |
- |
|
| 754 |
- for point in contextAxisPoints.dropFirst() {
|
|
| 755 |
- if let previous = currentGroup.last, point.contextIndex == previous.contextIndex + 1 {
|
|
| 756 |
- currentGroup.append(point) |
|
| 757 |
- } else {
|
|
| 758 |
- groups.append(currentGroup) |
|
| 759 |
- currentGroup = [point] |
|
| 760 |
- } |
|
| 761 |
- } |
|
| 762 |
- |
|
| 763 |
- groups.append(currentGroup) |
|
| 764 |
- return groups |
|
| 765 |
- } |
|
| 766 |
- |
|
| 767 |
- private var selectedContextIndex: Int? {
|
|
| 768 |
- contextSnapshots.firstIndex { $0.id == selectedSnapshotID }
|
|
| 769 |
- } |
|
| 770 |
- |
|
| 771 |
- private var snapshotAxisValues: [Int] {
|
|
| 772 |
- Array(contextSnapshots.indices) |
|
| 773 |
- } |
|
| 774 |
- |
|
| 775 |
- private func snapshotAxisLabel(for index: Int) -> String {
|
|
| 776 |
- guard contextSnapshots.indices.contains(index) else { return "\(index + 1)" }
|
|
| 777 |
- let snapshotID = contextSnapshots[index].id |
|
| 778 |
- return "\(snapshotNumbers[snapshotID] ?? index + 1)" |
|
| 779 |
- } |
|
| 780 |
- |
|
| 781 |
- private var snapshotAxisDomain: ClosedRange<Int> {
|
|
| 782 |
- guard let first = snapshotAxisValues.first, let last = snapshotAxisValues.last else {
|
|
| 783 |
- return 0...0 |
|
| 784 |
- } |
|
| 785 |
- return first...last |
|
| 786 |
- } |
|
| 787 |
- |
|
| 788 |
- @ViewBuilder |
|
| 789 |
- private var chartContent: some View {
|
|
| 790 |
- switch xAxisMode {
|
|
| 791 |
- case .time: |
|
| 792 |
- timeChart |
|
| 793 |
- case .snapshots: |
|
| 794 |
- snapshotChart |
|
| 795 |
- } |
|
| 796 |
- } |
|
| 797 |
- |
|
| 798 |
- private var timeChart: some View {
|
|
| 799 |
- Chart {
|
|
| 800 |
- ForEach(contextSnapshots, id: \.id) { item in
|
|
| 801 |
- RuleMark(x: .value("Timeline", item.timestamp))
|
|
| 802 |
- .foregroundStyle(Color.secondary.opacity(0.10)) |
|
| 803 |
- } |
|
| 804 |
- |
|
| 805 |
- RuleMark(x: .value("Selected Snapshot", selectedTimestamp))
|
|
| 806 |
- .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3])) |
|
| 807 |
- .foregroundStyle(Color.secondary.opacity(0.55)) |
|
| 808 |
- |
|
| 809 |
- ForEach(series.points) { point in
|
|
| 810 |
- LineMark( |
|
| 811 |
- x: .value("Date", point.timestamp),
|
|
| 812 |
- y: .value("Records", point.count)
|
|
| 813 |
- ) |
|
| 814 |
- .interpolationMethod(.linear) |
|
| 815 |
- |
|
| 816 |
- PointMark( |
|
| 817 |
- x: .value("Date", point.timestamp),
|
|
| 818 |
- y: .value("Records", point.count)
|
|
| 819 |
- ) |
|
| 820 |
- .symbolSize(24) |
|
| 821 |
- |
|
| 822 |
- if point.snapshotID == selectedSnapshotID {
|
|
| 823 |
- PointMark( |
|
| 824 |
- x: .value("Selected Date", point.timestamp),
|
|
| 825 |
- y: .value("Selected Records", point.count)
|
|
| 826 |
- ) |
|
| 827 |
- .symbolSize(64) |
|
| 828 |
- } |
|
| 829 |
- } |
|
| 830 |
- } |
|
| 831 |
- } |
|
| 832 |
- |
|
| 833 |
- private var snapshotChart: some View {
|
|
| 834 |
- Chart {
|
|
| 835 |
- ForEach(contextSnapshots.indices, id: \.self) { index in
|
|
| 836 |
- RuleMark(x: .value("Snapshot", index))
|
|
| 837 |
- .foregroundStyle(Color.secondary.opacity(0.10)) |
|
| 838 |
- } |
|
| 839 |
- |
|
| 840 |
- if let selectedContextIndex {
|
|
| 841 |
- RuleMark(x: .value("Selected Snapshot", selectedContextIndex))
|
|
| 842 |
- .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3])) |
|
| 843 |
- .foregroundStyle(Color.secondary.opacity(0.55)) |
|
| 844 |
- } |
|
| 845 |
- |
|
| 846 |
- ForEach(contextAxisGroups.indices, id: \.self) { groupIndex in
|
|
| 847 |
- let group = contextAxisGroups[groupIndex] |
|
| 848 |
- |
|
| 849 |
- ForEach(group) { point in
|
|
| 850 |
- LineMark( |
|
| 851 |
- x: .value("Snapshot", point.contextIndex),
|
|
| 852 |
- y: .value("Records", point.count)
|
|
| 853 |
- ) |
|
| 854 |
- .interpolationMethod(.linear) |
|
| 855 |
- |
|
| 856 |
- PointMark( |
|
| 857 |
- x: .value("Snapshot", point.contextIndex),
|
|
| 858 |
- y: .value("Records", point.count)
|
|
| 859 |
- ) |
|
| 860 |
- .symbolSize(24) |
|
| 861 |
- |
|
| 862 |
- if point.snapshotID == selectedSnapshotID {
|
|
| 863 |
- PointMark( |
|
| 864 |
- x: .value("Selected Snapshot", point.contextIndex),
|
|
| 865 |
- y: .value("Selected Records", point.count)
|
|
| 866 |
- ) |
|
| 867 |
- .symbolSize(64) |
|
| 868 |
- } |
|
| 869 |
- } |
|
| 870 |
- } |
|
| 871 |
- } |
|
| 872 |
- .chartXAxis {
|
|
| 873 |
- AxisMarks(values: snapshotAxisValues) { value in
|
|
| 874 |
- AxisGridLine() |
|
| 875 |
- AxisTick() |
|
| 876 |
- if let rawIndex = value.as(Int.self) {
|
|
| 877 |
- AxisValueLabel(snapshotAxisLabel(for: rawIndex)) |
|
| 878 |
- } |
|
| 879 |
- } |
|
| 880 |
- } |
|
| 881 |
- .chartXScale(domain: snapshotAxisDomain) |
|
| 882 |
- } |
|
| 883 |
- |
|
| 884 |
- var body: some View {
|
|
| 885 |
- VStack(alignment: .leading, spacing: 8) {
|
|
| 886 |
- HStack(alignment: .firstTextBaseline) {
|
|
| 887 |
- Text(series.displayName) |
|
| 888 |
- .font(.subheadline.weight(.semibold)) |
|
| 889 |
- Spacer() |
|
| 890 |
- VStack(alignment: .trailing, spacing: 4) {
|
|
| 891 |
- if let selectedPoint {
|
|
| 892 |
- Text("\(selectedPoint.count)")
|
|
| 893 |
- .font(.subheadline.monospacedDigit()) |
|
| 894 |
- .foregroundStyle(.secondary) |
|
| 895 |
- } |
|
| 896 |
- if isSignificantChange, let delta {
|
|
| 897 |
- SeverityBadge(delta: delta) |
|
| 898 |
- } |
|
| 899 |
- } |
|
| 900 |
- } |
|
| 901 |
- |
|
| 902 |
- chartContent |
|
| 903 |
- .chartYScale(domain: series.yDomain) |
|
| 904 |
- .chartXAxis {
|
|
| 905 |
- switch xAxisMode {
|
|
| 906 |
- case .time: |
|
| 907 |
- AxisMarks(values: .automatic(desiredCount: 3)) |
|
| 908 |
- case .snapshots: |
|
| 909 |
- AxisMarks(values: snapshotAxisValues) { value in
|
|
| 910 |
- AxisGridLine() |
|
| 911 |
- AxisTick() |
|
| 912 |
- if let rawIndex = value.as(Int.self) {
|
|
| 913 |
- AxisValueLabel(snapshotAxisLabel(for: rawIndex)) |
|
| 914 |
- } |
|
| 915 |
- } |
|
| 916 |
- } |
|
| 917 |
- } |
|
| 918 |
- .chartYAxis {
|
|
| 919 |
- AxisMarks(position: .leading, values: .automatic(desiredCount: 3)) |
|
| 920 |
- } |
|
| 921 |
- .frame(height: 120) |
|
| 922 |
- .foregroundStyle(Color.accentColor) |
|
| 923 |
- |
|
| 924 |
- if isMissingInSelectedSnapshot {
|
|
| 925 |
- Text("Datatype missing in this snapshot")
|
|
| 926 |
- .font(.caption2) |
|
| 927 |
- .foregroundStyle(Color.warningAmber) |
|
| 928 |
- } else if series.points.count == 1 {
|
|
| 929 |
- Text("Only one measurement")
|
|
| 930 |
- .font(.caption2) |
|
| 931 |
- .foregroundStyle(.secondary) |
|
| 932 |
- } |
|
| 933 |
- |
|
| 934 |
- Text(contextPointCountLabel) |
|
| 935 |
- .font(.caption2) |
|
| 936 |
- .foregroundStyle(.secondary) |
|
| 937 |
- } |
|
| 938 |
- .padding(.vertical, 4) |
|
| 939 |
- .accessibilityElement(children: .combine) |
|
| 940 |
- } |
|
| 941 |
-} |
|
| 942 |
- |
|
| 943 |
-private struct SnapshotTypeCountRow: View {
|
|
| 944 |
- let typeCount: TypeCount |
|
| 945 |
- let baselineTypeCount: TypeCount? |
|
| 946 |
- |
|
| 947 |
- private var countText: String {
|
|
| 948 |
- if typeCount.isUnsupported { return "Unsupported" }
|
|
| 949 |
- if typeCount.count == -1 { return "Unavailable" }
|
|
| 950 |
- return "\(typeCount.count)" |
|
| 951 |
- } |
|
| 952 |
- |
|
| 953 |
- private var countColor: Color {
|
|
| 954 |
- if typeCount.isUnsupported { return .secondary }
|
|
| 955 |
- if typeCount.count == -1 { return Color.criticalRed }
|
|
| 956 |
- if typeCount.quality != SnapshotQuality.complete { return Color.warningAmber }
|
|
| 957 |
- return Color.primary |
|
| 958 |
- } |
|
| 959 |
- |
|
| 960 |
- private var delta: Int? {
|
|
| 961 |
- guard let b = baselineTypeCount, |
|
| 962 |
- typeCount.count >= 0, |
|
| 963 |
- b.count >= 0 else { return nil }
|
|
| 964 |
- return typeCount.count - b.count |
|
| 965 |
- } |
|
| 966 |
- |
|
| 967 |
- private var isSignificantChange: Bool {
|
|
| 968 |
- guard let d = delta, let b = baselineTypeCount?.count, b > 0 else { return false }
|
|
| 969 |
- let percentChange = abs(Double(d)) / Double(b) * 100 |
|
| 970 |
- return percentChange > 10 || d > 0 |
|
| 971 |
- } |
|
| 972 |
- |
|
| 973 |
- var body: some View {
|
|
| 974 |
- HStack(spacing: 12) {
|
|
| 975 |
- VStack(alignment: .leading, spacing: 3) {
|
|
| 976 |
- Text(typeCount.displayName) |
|
| 977 |
- .font(.subheadline) |
|
| 978 |
- Text(typeCount.typeIdentifier) |
|
| 979 |
- .font(.caption2) |
|
| 980 |
- .foregroundStyle(.secondary) |
|
| 981 |
- .lineLimit(1) |
|
| 982 |
- .truncationMode(.middle) |
|
| 983 |
- } |
|
| 984 |
- Spacer() |
|
| 985 |
- VStack(alignment: .trailing, spacing: 4) {
|
|
| 986 |
- Text(countText) |
|
| 987 |
- .font(.subheadline.monospacedDigit()) |
|
| 988 |
- .foregroundStyle(countColor) |
|
| 989 |
- if isSignificantChange, let delta {
|
|
| 990 |
- SeverityBadge(delta: delta) |
|
| 991 |
- } |
|
| 992 |
- } |
|
| 993 |
- } |
|
| 994 |
- .accessibilityElement(children: .combine) |
|
| 995 |
- } |
|
| 996 |
-} |
|
| 997 |
- |
|
| 998 | 544 |
private struct SnapshotDataRangeIndicator: View {
|
| 999 | 545 |
let oldestRecordDate: Date? |
| 1000 | 546 |
let newestRecordDate: Date? |
@@ -1155,5 +701,5 @@ private struct ShareSheet: UIViewControllerRepresentable {
|
||
| 1155 | 701 |
profile: LocalDeviceProfile(deviceID: "preview-device") |
| 1156 | 702 |
) |
| 1157 | 703 |
} |
| 1158 |
- .modelContainer(for: [HealthSnapshot.self, SnapshotDelta.self, TypeDelta.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self], inMemory: true) |
|
| 704 |
+ .modelContainer(for: [HealthSnapshot.self], inMemory: true) |
|
| 1159 | 705 |
} |