1 contributor
import SwiftUI
struct SnapshotArchiveDetailView: View {
let row: CachedArchiveObservationRow
let baseline: CachedArchiveObservationRow?
let timelineRows: [CachedArchiveObservationRow]
@State private var typeRows: [SnapshotArchiveTypeSummaryRow] = []
@State private var loadError: String?
private var timelineContexts: [DataTypeSnapshotContext] {
timelineRows
.sorted { $0.observedAt > $1.observedAt }
.map {
DataTypeSnapshotContext(
observationID: $0.observationID,
observedAt: $0.observedAt
)
}
}
private var currentContext: DataTypeSnapshotContext {
DataTypeSnapshotContext(
observationID: row.observationID,
observedAt: row.observedAt
)
}
private var baselineContext: DataTypeSnapshotContext? {
baseline.map {
DataTypeSnapshotContext(
observationID: $0.observationID,
observedAt: $0.observedAt
)
}
}
private var dataRange: (earliest: Date?, latest: Date?) {
(
typeRows.compactMap(\.earliestStartDate).min(),
typeRows.compactMap(\.latestEndDate).max()
)
}
private var taskID: String {
"\(baseline?.observationID ?? -1)|\(row.observationID)"
}
var body: some View {
List {
summarySection
typeSection
}
.navigationTitle("Snapshot")
.navigationBarTitleDisplayMode(.inline)
.task(id: taskID) {
await loadTypeRows()
}
}
private var summarySection: some View {
Section {
DataTypeRangeIndicator(
earliestDate: dataRange.earliest,
latestDate: dataRange.latest,
quality: .complete
)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
SnapshotArchiveSummaryRow(label: "Metrics", value: "\(row.trackedTypeCount)")
SnapshotArchiveSummaryRow(label: "Records", value: "\(row.visibleRecordCount)")
if let baseline {
SnapshotArchiveSummaryRow(
label: "Baseline",
value: baseline.observedAt.formatted(.dateTime.month().day().hour().minute())
)
SnapshotArchiveSummaryRow(
label: "Record Changes",
value: "\(row.appearedCount + row.disappearedCount + row.representationChangedCount)"
)
}
if let loadError {
Label(loadError, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(Color.warningAmber)
}
}
}
private var typeSection: some View {
Section("Data Types") {
if typeRows.isEmpty {
Text("No data types are available for this observation.")
.foregroundStyle(.secondary)
} else {
ForEach(typeRows) { typeRow in
if let baselineContext {
NavigationLink {
DataTypeArchiveDetailView(
current: currentContext,
baseline: baselineContext,
timeline: timelineContexts,
typeIdentifier: typeRow.typeIdentifier,
displayName: typeRow.displayName,
initialDiff: typeRow.typeDiff
)
} label: {
SnapshotArchiveTypeSummaryRowView(row: typeRow, hasBaseline: true)
}
} else {
SnapshotArchiveTypeSummaryRowView(row: typeRow, hasBaseline: false)
}
}
}
}
}
@MainActor
private func loadTypeRows() async {
do {
let cache = try CoreDataArchiveCacheStore()
let currentSummaries = try cache.typeSummaries(observationID: row.observationID)
let previousSummaries: [CachedArchiveTypeSummary]
if let baseline {
previousSummaries = try cache.typeSummaries(observationID: baseline.observationID)
} else {
previousSummaries = []
}
let currentByType = Dictionary(uniqueKeysWithValues: currentSummaries.map { ($0.sampleTypeIdentifier, $0) })
let previousByType = Dictionary(uniqueKeysWithValues: previousSummaries.map { ($0.sampleTypeIdentifier, $0) })
let typeIdentifiers = Set(currentByType.keys).union(previousByType.keys)
var rows: [SnapshotArchiveTypeSummaryRow] = []
rows.reserveCapacity(typeIdentifiers.count)
for typeIdentifier in typeIdentifiers {
let summary = currentByType[typeIdentifier]
let previousSummary = previousByType[typeIdentifier]
let diffSummary: CachedArchiveDiffSummary?
if let baseline {
diffSummary = try cache.diffSummary(
fromObservationID: baseline.observationID,
toObservationID: row.observationID,
sampleTypeIdentifier: typeIdentifier
)
} else {
diffSummary = nil
}
rows.append(SnapshotArchiveTypeSummaryRow(
typeIdentifier: typeIdentifier,
displayName: summary?.displayName ?? previousSummary?.displayName ?? typeIdentifier,
currentCount: summary?.visibleRecordCount ?? 0,
previousCount: previousSummary?.visibleRecordCount,
appearedCount: diffSummary?.appearedCount ?? summary?.appearedCount ?? 0,
disappearedCount: diffSummary?.disappearedCount ?? summary?.disappearedCount ?? 0,
representationChangedCount: diffSummary?.representationChangedCount ?? summary?.representationChangedCount ?? 0,
earliestStartDate: summary?.earliestStartDate ?? previousSummary?.earliestStartDate,
latestEndDate: summary?.latestEndDate ?? previousSummary?.latestEndDate
))
}
typeRows = rows.sorted {
$0.displayName.localizedCompare($1.displayName) == .orderedAscending
}
loadError = nil
} catch {
typeRows = []
loadError = error.localizedDescription
}
}
}
private struct SnapshotArchiveSummaryRow: View {
let label: String
let value: String
var body: some View {
HStack {
Text(label)
Spacer()
Text(value)
.foregroundStyle(.secondary)
.monospacedDigit()
}
}
}
private struct SnapshotArchiveTypeSummaryRow: Identifiable {
let typeIdentifier: String
let displayName: String
let currentCount: Int
let previousCount: Int?
let appearedCount: Int
let disappearedCount: Int
let representationChangedCount: Int
let earliestStartDate: Date?
let latestEndDate: Date?
var id: String { typeIdentifier }
var currentDelta: Int {
guard let previousCount else { return currentCount }
return currentCount - previousCount
}
var recordChangeCount: Int {
appearedCount + disappearedCount + representationChangedCount
}
var hasChanges: Bool {
currentDelta != 0 || recordChangeCount > 0
}
var typeDiff: TypeDiff {
TypeDiff(
id: typeIdentifier,
typeIdentifier: typeIdentifier,
displayName: displayName,
currentCount: currentCount,
previousCount: previousCount ?? 0,
previousTracked: previousCount != nil,
appearedCount: appearedCount,
disappearedCount: disappearedCount,
representationChangedCount: representationChangedCount
)
}
}
private struct SnapshotArchiveTypeSummaryRowView: View {
let row: SnapshotArchiveTypeSummaryRow
let hasBaseline: Bool
private var changeLabel: String {
guard hasBaseline else { return "Stored" }
if row.disappearedCount > 0 { return "\(row.disappearedCount) missing" }
if row.appearedCount > 0 { return "\(row.appearedCount) new" }
if row.representationChangedCount > 0 { return "\(row.representationChangedCount) changed" }
if row.currentDelta != 0 {
let prefix = row.currentDelta > 0 ? "+" : ""
return "\(prefix)\(row.currentDelta) records"
}
return "No changes"
}
private var changeColor: Color {
guard hasBaseline else { return .secondary }
if row.disappearedCount > 0 { return .criticalRed }
if row.hasChanges { return .warningAmber }
return .secondary
}
var body: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 3) {
Text(row.displayName)
.font(.subheadline)
Text(row.typeIdentifier)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer()
VStack(alignment: .trailing, spacing: 4) {
Text("\(row.currentCount)")
.font(.subheadline.monospacedDigit())
.foregroundStyle(.primary)
Text(changeLabel)
.font(.caption.weight(.semibold))
.foregroundStyle(changeColor)
}
}
.accessibilityElement(children: .combine)
}
}
#Preview {
NavigationStack {
SnapshotArchiveDetailView(
row: CachedArchiveObservationRow(
observationID: 2,
observedAt: .now,
status: "completed",
triggerReason: "manual",
timeZoneIdentifier: nil,
trackedTypeCount: 12,
visibleRecordCount: 2000,
appearedCount: 40,
disappearedCount: 10,
representationChangedCount: 3,
archiveSchemaVersion: 2,
cacheSchemaVersion: 1,
computedAt: .now
),
baseline: nil,
timelineRows: []
)
}
}