HealthProbe / HealthProbe / ViewModels / DataTypeTemporalDistributionViewModel.swift
1 contributor
253 lines | 8.624kb
import Foundation
import SwiftData

enum BinningStrategy: String, CaseIterable, Equatable {
    case day = "Zi"
    case week = "Săptămână"
    case month = "Lună"
    case year = "An"

    nonisolated var calendar: Calendar {
        var cal = Calendar.current
        cal.timeZone = TimeZone(abbreviation: "UTC") ?? TimeZone.current
        return cal
    }

    nonisolated func bins(from startDate: Date, to endDate: Date) -> [Date] {
        var result: [Date] = []
        var current = startDate

        switch self {
        case .day:
            while current <= endDate {
                result.append(current)
                current = calendar.date(byAdding: .hour, value: 1, to: current) ?? endDate.addingTimeInterval(1)
            }
        case .week:
            while current <= endDate {
                result.append(current)
                current = calendar.date(byAdding: .day, value: 1, to: current) ?? endDate.addingTimeInterval(1)
            }
        case .month:
            while current <= endDate {
                result.append(current)
                current = calendar.date(byAdding: .day, value: 1, to: current) ?? endDate.addingTimeInterval(1)
            }
        case .year:
            while current <= endDate {
                result.append(current)
                current = calendar.date(byAdding: .month, value: 1, to: current) ?? endDate.addingTimeInterval(1)
            }
        }

        return result
    }

    nonisolated func label(for date: Date) -> String {
        switch self {
        case .day:
            return date.formatted(.dateTime.hour().minute())
        case .week:
            return date.formatted(.dateTime.month().day())
        case .month:
            return date.formatted(.dateTime.month().day())
        case .year:
            return date.formatted(.dateTime.month().year())
        }
    }

    nonisolated func contains(_ date: Date, in binStart: Date, binEnd: Date) -> Bool {
        date >= binStart && date < binEnd
    }

    nonisolated func dateRange(containing date: Date) -> (start: Date, end: Date) {
        let cal = calendar

        switch self {
        case .day:
            let dayStart = cal.startOfDay(for: date)
            let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart.addingTimeInterval(86400)
            return (dayStart, dayEnd)

        case .week:
            var weekStart = cal.startOfDay(for: date)
            while cal.component(.weekday, from: weekStart) != 2 {
                weekStart = cal.date(byAdding: .day, value: -1, to: weekStart) ?? weekStart
            }
            let weekEnd = cal.date(byAdding: .day, value: 7, to: weekStart) ?? weekStart.addingTimeInterval(604800)
            return (weekStart, weekEnd)

        case .month:
            let monthStart = cal.date(from: cal.dateComponents([.year, .month], from: date)) ?? date
            let monthEnd = cal.date(byAdding: .month, value: 1, to: monthStart) ?? monthStart.addingTimeInterval(2592000)
            return (monthStart, monthEnd)

        case .year:
            let yearStart = cal.date(from: cal.dateComponents([.year], from: date)) ?? date
            let yearEnd = cal.date(byAdding: .year, value: 1, to: yearStart) ?? yearStart.addingTimeInterval(31536000)
            return (yearStart, yearEnd)
        }
    }
}

@MainActor
@Observable
final class DataTypeTemporalDistributionViewModel: Sendable {
    private(set) var allBins: [TemporalBin] = []
    private(set) var hasData: Bool = false
    private(set) var isLoading: Bool = true
    private(set) var error: String? = nil
    private(set) var dateRange: (start: Date, end: Date)? = nil
    var displayedDateRange: (start: Date, end: Date)? = nil

    var binningStrategy: BinningStrategy = .month {
        didSet {
            if hasData {
                adjustDisplayRangeForStrategy()
                Task {
                    await rebuildBinsBackground()
                }
            }
        }
    }

    private var addedByDate: [Date: Int] = [:]
    private var disappearedByDate: [Date: Int] = [:]
    private var unchangedByDate: [Date: Int] = [:]

    func load(current: TypeCount?, previous: TypeCount?, context: ModelContext) async {
        defer { isLoading = false }
        error = nil
        hasData = false

        guard let current else {
            error = "No current snapshot data"
            return
        }

        guard let cache = resolveDetailCache(current: current, previous: previous, context: context) else {
            error = "Record detail data could not be computed for this snapshot pair."
            return
        }

        guard let minDate = cache.earliestRecordDate,
              let maxDate = cache.latestRecordDate else {
            error = "Cannot determine date range"
            return
        }

        dateRange = (minDate, maxDate)
        displayedDateRange = (minDate, maxDate)

        indexDailyBins(cache.dailyChangeBins)
        await rebuildBinsBackground()
    }

    private func resolveDetailCache(
        current: TypeCount,
        previous: TypeCount?,
        context: ModelContext
    ) -> TypeCountDetailCache? {
        let baselineID = previous?.snapshot?.id
        guard let cache = current.detailCache,
              cache.matchesBaseline(baselineID) else {
            return nil
        }
        return cache
    }

    private func indexDailyBins(_ bins: [TypeCountDailyChangeBin]) {
        addedByDate = Dictionary(uniqueKeysWithValues: bins.map { ($0.dayStart, $0.added) })
        disappearedByDate = Dictionary(uniqueKeysWithValues: bins.map { ($0.dayStart, $0.disappeared) })
        unchangedByDate = Dictionary(uniqueKeysWithValues: bins.map { ($0.dayStart, $0.unchanged) })
    }

    private func adjustDisplayRangeForStrategy() {
        guard let dateRange else { return }

        displayedDateRange = dateRange
    }

    func rebuildBinsBackground() async {
        guard let displayedDateRange else { return }

        let addedByDate = self.addedByDate
        let disappearedByDate = self.disappearedByDate
        let unchangedByDate = self.unchangedByDate
        let strategy = binningStrategy

        let result = await Task.detached(priority: .userInitiated) {
            Self.computeBins(
                dateRange: displayedDateRange,
                binningStrategy: strategy,
                addedByDate: addedByDate,
                disappearedByDate: disappearedByDate,
                unchangedByDate: unchangedByDate
            )
        }.value

        self.allBins = result
        self.hasData = !result.isEmpty
    }

    nonisolated private static func computeBins(
        dateRange: (start: Date, end: Date),
        binningStrategy: BinningStrategy,
        addedByDate: [Date: Int],
        disappearedByDate: [Date: Int],
        unchangedByDate: [Date: Int]
    ) -> [TemporalBin] {
        let binDates = binningStrategy.bins(from: dateRange.start, to: dateRange.end)
        let calendar = Calendar.current
        var bins: [TemporalBin] = []

        for (idx, binStart) in binDates.enumerated() {
            let binEnd = binDates[safe: idx + 1] ?? dateRange.end.addingTimeInterval(86400)

            var added = 0
            var disappeared = 0
            var unchanged = 0

            var dayCheck = calendar.startOfDay(for: binStart)
            while dayCheck < binEnd {
                added += addedByDate[dayCheck] ?? 0
                disappeared += disappearedByDate[dayCheck] ?? 0
                unchanged += unchangedByDate[dayCheck] ?? 0

                dayCheck = calendar.date(byAdding: .day, value: 1, to: dayCheck) ?? binEnd
            }

            if added > 0 || disappeared > 0 || unchanged > 0 {
                bins.append(
                    TemporalBin(
                        date: binStart,
                        dateRange: (binStart, binEnd),
                        added: added,
                        disappeared: disappeared,
                        unchanged: unchanged
                    )
                )
            }
        }

        return bins
    }
}

struct TemporalBin: Identifiable, Sendable {
    var id: String { date.ISO8601Format() }
    var date: Date
    var dateRange: (start: Date, end: Date)
    var added: Int = 0
    var disappeared: Int = 0
    var unchanged: Int = 0

    var total: Int { added + disappeared + unchanged }
    var hasData: Bool { total > 0 }
}

extension Array {
    nonisolated fileprivate subscript(safe index: Index) -> Element? {
        indices.contains(index) ? self[index] : nil
    }
}