HealthProbe / HealthProbe / Utilities / SnapshotFetchProgress.swift
1 contributor
255 lines | 8.619kb
import Foundation

struct HealthKitAPICallResult: Sendable, Equatable {
    enum Status: String, Sendable {
        case complete
        case failed
        case timeout
        case unknown
        case unauthorized
        case unsupported
    }

    let queryType: String
    let status: Status
    let elapsedSeconds: TimeInterval
    let resultValue: String?
    let errorCode: String?
    let errorDomain: String?
    let errorDescription: String?
    let failureKind: String?
    let cancellationReason: String?

    init(
        queryType: String,
        status: Status,
        elapsedSeconds: TimeInterval,
        resultValue: String?,
        errorCode: String? = nil,
        errorDomain: String? = nil,
        errorDescription: String? = nil,
        failureKind: String? = nil,
        cancellationReason: String? = nil
    ) {
        self.queryType = queryType
        self.status = status
        self.elapsedSeconds = elapsedSeconds
        self.resultValue = resultValue
        self.errorCode = errorCode
        self.errorDomain = errorDomain
        self.errorDescription = errorDescription
        self.failureKind = failureKind
        self.cancellationReason = cancellationReason
    }

    var statusDescription: String {
        switch status {
        case .complete: return "complete"
        case .failed: return "failed"
        case .timeout: return "timeout"
        case .unknown: return "unknown"
        case .unauthorized: return "unauthorized"
        case .unsupported: return "unsupported"
        }
    }
}

@Observable
final class SnapshotFetchProgress {
    struct YearlyCountProgress: Identifiable, Sendable, Equatable {
        var id: Int { year }
        let year: Int
        let count: Int
        let isApproximate: Bool
    }

    struct TypeProgress: Identifiable, Sendable, Equatable {
        enum FetchStatus: Sendable, Equatable {
            case pending
            case fetching
            case complete
            case failed(String)

            var icon: String {
                switch self {
                case .pending: return "circle"
                case .fetching: return "arrow.triangle.2.circlepath"
                case .complete: return "checkmark.circle.fill"
                case .failed: return "xmark.circle.fill"
                }
            }
        }

        let id: String
        let displayName: String
        var status: FetchStatus = .pending
        var quality: String = SnapshotQuality.loading.rawValue
        var recordCount: Int = 0
        var isUnsupported: Bool = false
        var authorizationStatus: String = "unknown"
        var earliestDate: Date?
        var latestDate: Date?
        var yearlyCounts: [YearlyCountProgress] = []
        var apiCallDetails: [HealthKitAPICallResult] = []
        var timeoutConfiguredSeconds: TimeInterval = 0
        var totalElapsedSeconds: TimeInterval = 0
        var timeoutMode: String = "default"
        var lastSuccessfulElapsed: TimeInterval = 0
        var learnedTimeout: TimeInterval = 0
        var suggestedRetryTimeout: TimeInterval = 0
        var timeoutCount: Int = 0
        var successCount: Int = 0
        var blockProgress: String = ""
    }

    let totalTypeCount: Int
    var types: [TypeProgress] = []
    var perTypeTimeoutSeconds: TimeInterval = 0
    var maxConcurrentTypeFetches: Int = 0
    var adaptiveTimeoutsEnabled: Bool = false
    var previousSnapshotID: UUID?
    var isChainStart: Bool?
    var snapshotChecksum: String = ""
    var monitoredTypeSetHash: String = ""
    var monitoredRegistryVersion: Int?
    private let displayNamesByID: [String: String]

    var visibleTypes: [TypeProgress] { types }
    var completedCount: Int { types.filter { $0.status == .complete }.count }
    var failedCount: Int {
        types.filter {
            if case .failed = $0.status { return true }
            return false
        }.count
    }
    var totalRecords: Int {
        types.reduce(0) { $0 + max($1.recordCount, 0) }
    }

    init(monitoredTypes: [(id: String, displayName: String)]) {
        self.totalTypeCount = monitoredTypes.count
        self.displayNamesByID = Dictionary(uniqueKeysWithValues: monitoredTypes.map { ($0.id, $0.displayName) })
    }

    func updateConfiguration(
        perTypeTimeoutSeconds: TimeInterval,
        maxConcurrentTypeFetches: Int,
        adaptiveTimeoutsEnabled: Bool
    ) {
        self.perTypeTimeoutSeconds = perTypeTimeoutSeconds
        self.maxConcurrentTypeFetches = maxConcurrentTypeFetches
        self.adaptiveTimeoutsEnabled = adaptiveTimeoutsEnabled
    }

    func updateChainContext(
        previousSnapshotID: UUID?,
        isChainStart: Bool,
        snapshotChecksum: String,
        monitoredTypeSetHash: String,
        monitoredRegistryVersion: Int
    ) {
        self.previousSnapshotID = previousSnapshotID
        self.isChainStart = isChainStart
        self.snapshotChecksum = snapshotChecksum
        self.monitoredTypeSetHash = monitoredTypeSetHash
        self.monitoredRegistryVersion = monitoredRegistryVersion
    }

    func updateStatus(_ id: String, status: TypeProgress.FetchStatus, recordCount: Int? = nil) {
        let index = visibleTypeIndex(for: id)
        types[index].status = status
        switch status {
        case .complete, .failed, .pending:
            types[index].blockProgress = ""
        case .fetching:
            break
        }
        if let recordCount {
            types[index].recordCount = recordCount
        }
    }

    func updateDetails(
        _ id: String,
        quality: String,
        recordCount: Int,
        isUnsupported: Bool,
        authorizationStatus: String,
        earliestDate: Date?,
        latestDate: Date?,
        yearlyCounts: [YearlyCountProgress],
        apiCallDetails: [HealthKitAPICallResult],
        timeoutConfiguredSeconds: TimeInterval,
        totalElapsedSeconds: TimeInterval,
        timeoutMode: String,
        lastSuccessfulElapsed: TimeInterval,
        learnedTimeout: TimeInterval,
        suggestedRetryTimeout: TimeInterval,
        timeoutCount: Int,
        successCount: Int
    ) {
        let index = visibleTypeIndex(for: id)
        types[index].quality = quality
        types[index].recordCount = recordCount
        types[index].isUnsupported = isUnsupported
        types[index].authorizationStatus = authorizationStatus
        types[index].earliestDate = earliestDate
        types[index].latestDate = latestDate
        types[index].yearlyCounts = yearlyCounts
        types[index].apiCallDetails = apiCallDetails
        types[index].timeoutConfiguredSeconds = timeoutConfiguredSeconds
        types[index].totalElapsedSeconds = totalElapsedSeconds
        types[index].timeoutMode = timeoutMode
        types[index].lastSuccessfulElapsed = lastSuccessfulElapsed
        types[index].learnedTimeout = learnedTimeout
        types[index].suggestedRetryTimeout = suggestedRetryTimeout
        types[index].timeoutCount = timeoutCount
        types[index].successCount = successCount
    }

    func updateTimeoutProfile(
        _ id: String,
        timeoutMode: String,
        lastSuccessfulElapsed: TimeInterval,
        learnedTimeout: TimeInterval,
        suggestedRetryTimeout: TimeInterval,
        timeoutCount: Int,
        successCount: Int
    ) {
        let index = visibleTypeIndex(for: id)
        types[index].timeoutMode = timeoutMode
        types[index].lastSuccessfulElapsed = lastSuccessfulElapsed
        types[index].learnedTimeout = learnedTimeout
        types[index].suggestedRetryTimeout = suggestedRetryTimeout
        types[index].timeoutCount = timeoutCount
        types[index].successCount = successCount
    }

    func updateBlockProgress(_ id: String, detail: String, recordCount: Int? = nil) {
        let index = visibleTypeIndex(for: id)
        types[index].blockProgress = detail
        if let recordCount {
            types[index].recordCount = recordCount
        }
    }

    func markUnavailable(_ id: String) {
        let index = visibleTypeIndex(for: id)
        types[index].status = .failed("Not authorized")
        types[index].quality = SnapshotQuality.unauthorized.rawValue
        types[index].recordCount = -1
        types[index].authorizationStatus = "unavailable"
    }

    private func visibleTypeIndex(for id: String) -> Int {
        if let index = types.firstIndex(where: { $0.id == id }) {
            return index
        }

        types.insert(
            TypeProgress(id: id, displayName: displayNamesByID[id] ?? id),
            at: 0
        )
        return 0
    }
}