HealthProbe / HealthProbe / Views / Snapshots / SnapshotArchiveDetailView.swift
1 contributor
305 lines | 10.739kb
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: []
        )
    }
}