1 contributor
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
}
}