import Combine import Foundation import RastrilloApple @MainActor public final class CalendarModel: ObservableObject { @Published public var date = Date() @Published public var selectedCalendar: Int64? @Published public private(set) var calendars: [TeamCalendar] = [] @Published public private(set) var events: [CalendarEvent] = [] @Published public private(set) var zone = "UTC" @Published public private(set) var loading = false @Published public var error = "" /// True once a load has succeeded in this session. A later failure /// then keeps what is on screen and reports the error beside it: a /// network blip must not blank a calendar that was showing a second /// ago. Only a session that has never loaded shows the error alone. @Published public private(set) var hasLoaded = false @Published public private(set) var notice = "" private var weekStart = 1 private var defaultCalendar: Int64 = 0 private var generation = UUID() private var active = true @Published var quickText = "" @Published var quickSavedNotice = "" @Published private(set) var quickSaving = false private let fixedZone: String? public static var preferredZone: String { let override = UserDefaults.standard.string(forKey: "calendar.timeZoneOverride") ?? "" return TimeZone(identifier: override)?.identifier ?? TimeZone.current.identifier } public init(zone: String? = nil, date: Date = Date()) { fixedZone = zone; self.zone = zone ?? Self.preferredZone; self.date = date } public var calendar: Calendar { var c = Calendar(identifier: .gregorian) c.timeZone = TimeZone(identifier: zone) ?? .gmt c.firstWeekday = weekStart % 7 + 1 return c } public var weekdayLabels: [String] { let symbols = calendar.shortStandaloneWeekdaySymbols return (0..<7).map { symbols[(calendar.firstWeekday - 1 + $0) % 7] } } public var monthStart: Date { calendar.date(from: calendar.dateComponents([.year, .month], from: date)) ?? date } public var days: [Date] { let first = monthStart let offset = (calendar.component(.weekday, from: first) - calendar.firstWeekday + 7) % 7 let start = calendar.date(byAdding: .day, value: -offset, to: first) ?? first return (0..<42).compactMap { calendar.date(byAdding: .day, value: $0, to: start) } } public func clear() { active = false; quickText = ""; quickSavedNotice = ""; quickSaving = false generation = UUID(); calendars = []; events = []; hasLoaded = false; notice = ""; error = ""; loading = false selectedCalendar = nil; date = Date(); zone = fixedZone ?? Self.preferredZone } public func dayEvents(_ day: Date) -> [CalendarEvent] { events.filter { (selectedCalendar == nil || $0.calendar_id == selectedCalendar) && $0.occurs(on: day, calendar: calendar) } } public func load(_ client: NativeHTTP) async { guard active else { return } zone = fixedZone ?? Self.preferredZone let token = UUID(); generation = token loading = true; error = "" defer { if generation == token { loading = false } } do { let session: CalendarSession = try await client.request("/api/native/session") guard generation == token else { return } weekStart = session.week_start; calendars = session.calendars; defaultCalendar = session.default_calendar_id let from = format(days.first ?? monthStart, allDay: true) let to = format(calendar.date(byAdding: .day, value: 1, to: days.last ?? monthStart)!, allDay: true) let response: CalendarEvents = try await client.request("/api/native/events?from=\(from)&to=\(to)&zone=\(zone.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? zone)") guard generation == token else { return } events = response.events hasLoaded = true } catch { // Keep the last good calendars and events: they are stale, not // wrong, and the error says so beside them. if generation == token { self.error = error.localizedDescription } } } public func newEvent() -> CalendarEvent { var event = CalendarEvent() event.calendar_id = calendars.first(where: { $0.id == selectedCalendar && $0.writable })?.id ?? defaultCalendar event.tz = zone let day = calendar.startOfDay(for: date) let start = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: day) ?? day event.start_local = format(start, allDay: false) event.end_local = format(start.addingTimeInterval(3600), allDay: false) return event } public func save(_ event: CalendarEvent, client: NativeHTTP) async throws { guard active else { throw CancellationError() } let saved: SavedEvent = try await client.request(event.id == 0 ? "/api/native/events" : "/api/native/events/\(event.id)", method: "POST", body: JSONEncoder().encode(event)) guard active else { throw CancellationError() } await load(client) guard active else { throw CancellationError() } notice = saved.warning ?? "" } func saveQuick(_ preview: QuickPreview, client: NativeHTTP) async throws { guard active, !quickSaving else { throw CancellationError() } quickSaving = true defer { if active { quickSaving = false } } let result: SavedEvent = try await client.request("/api/native/quick", method: "POST", body: JSONEncoder().encode(preview.draft)) guard active else { throw CancellationError() } quickText = "" quickSavedNotice = result.warning ?? "Added \(preview.draft.title)." notice = quickSavedNotice if let date = parse(preview.draft.start_local, allDay: preview.draft.all_day, zone: preview.draft.tz) { self.date = date } await load(client) } public func format(_ date: Date, allDay: Bool, zone: String? = nil) -> String { let f = DateFormatter(); f.locale = Locale(identifier: "en_US_POSIX") f.timeZone = TimeZone(identifier: zone ?? self.zone) ?? .gmt f.dateFormat = allDay ? "yyyy-MM-dd" : "yyyy-MM-dd'T'HH:mm" return f.string(from: date) } public func display(_ date: Date, template: String, zone: String? = nil) -> String { let f = DateFormatter() f.locale = .current f.timeZone = TimeZone(identifier: zone ?? self.zone) ?? .gmt f.setLocalizedDateFormatFromTemplate(template) return f.string(from: date) } public func parse(_ value: String, allDay: Bool, zone: String) -> Date? { let f = DateFormatter(); f.locale = Locale(identifier: "en_US_POSIX") f.timeZone = TimeZone(identifier: zone) ?? .gmt f.dateFormat = allDay ? "yyyy-MM-dd" : "yyyy-MM-dd'T'HH:mm" return f.date(from: value) } }