oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1import Combine
2import Foundation
3import RastrilloApple
4
5@MainActor public final class CalendarModel: ObservableObject {
6 @Published public var date = Date()
7 @Published public var selectedCalendar: Int64?
8 @Published public private(set) var calendars: [TeamCalendar] = []
9 @Published public private(set) var events: [CalendarEvent] = []
10 @Published public private(set) var zone = "UTC"
11 @Published public private(set) var loading = false
12 @Published public var error = ""
13 /// True once a load has succeeded in this session. A later failure
14 /// then keeps what is on screen and reports the error beside it: a
15 /// network blip must not blank a calendar that was showing a second
16 /// ago. Only a session that has never loaded shows the error alone.
17 @Published public private(set) var hasLoaded = false
18 @Published public private(set) var notice = ""
19 private var weekStart = 1
20 private var defaultCalendar: Int64 = 0
21 private var generation = UUID()
22 private var active = true
23 @Published var quickText = ""
24 @Published var quickSavedNotice = ""
25 @Published private(set) var quickSaving = false
26 private let fixedZone: String?
27 public static var preferredZone: String {
28 let override = UserDefaults.standard.string(forKey: "calendar.timeZoneOverride") ?? ""
29 return TimeZone(identifier: override)?.identifier ?? TimeZone.current.identifier
30 }
31 public init(zone: String? = nil, date: Date = Date()) {
32 fixedZone = zone; self.zone = zone ?? Self.preferredZone; self.date = date
33 }
34 public var calendar: Calendar {
35 var c = Calendar(identifier: .gregorian)
36 c.timeZone = TimeZone(identifier: zone) ?? .gmt
37 c.firstWeekday = weekStart % 7 + 1
38 return c
39 }
40 public var weekdayLabels: [String] {
41 let symbols = calendar.shortStandaloneWeekdaySymbols
42 return (0..<7).map { symbols[(calendar.firstWeekday - 1 + $0) % 7] }
43 }
44 public var monthStart: Date { calendar.date(from: calendar.dateComponents([.year, .month], from: date)) ?? date }
45 public var days: [Date] {
46 let first = monthStart
47 let offset = (calendar.component(.weekday, from: first) - calendar.firstWeekday + 7) % 7
48 let start = calendar.date(byAdding: .day, value: -offset, to: first) ?? first
49 return (0..<42).compactMap { calendar.date(byAdding: .day, value: $0, to: start) }
50 }
51 public func clear() {
52 active = false; quickText = ""; quickSavedNotice = ""; quickSaving = false
53 generation = UUID(); calendars = []; events = []; hasLoaded = false; notice = ""; error = ""; loading = false
54 selectedCalendar = nil; date = Date(); zone = fixedZone ?? Self.preferredZone
55 }
56 public func dayEvents(_ day: Date) -> [CalendarEvent] {
57 events.filter { (selectedCalendar == nil || $0.calendar_id == selectedCalendar) && $0.occurs(on: day, calendar: calendar) }
58 }
59 public func load(_ client: NativeHTTP) async {
60 guard active else { return }
61 zone = fixedZone ?? Self.preferredZone
62 let token = UUID(); generation = token
63 loading = true; error = ""
64 defer { if generation == token { loading = false } }
65 do {
66 let session: CalendarSession = try await client.request("/api/native/session")
67 guard generation == token else { return }
68 weekStart = session.week_start; calendars = session.calendars; defaultCalendar = session.default_calendar_id
69 let from = format(days.first ?? monthStart, allDay: true)
70 let to = format(calendar.date(byAdding: .day, value: 1, to: days.last ?? monthStart)!, allDay: true)
71 let response: CalendarEvents = try await client.request("/api/native/events?from=\(from)&to=\(to)&zone=\(zone.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? zone)")
72 guard generation == token else { return }
73 events = response.events
74 hasLoaded = true
75 } catch {
76 // Keep the last good calendars and events: they are stale, not
77 // wrong, and the error says so beside them.
78 if generation == token { self.error = error.localizedDescription }
79 }
80 }
81 public func newEvent() -> CalendarEvent {
82 var event = CalendarEvent()
83 event.calendar_id = calendars.first(where: { $0.id == selectedCalendar && $0.writable })?.id ?? defaultCalendar
84 event.tz = zone
85 let day = calendar.startOfDay(for: date)
86 let start = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: day) ?? day
87 event.start_local = format(start, allDay: false)
88 event.end_local = format(start.addingTimeInterval(3600), allDay: false)
89 return event
90 }
91 public func save(_ event: CalendarEvent, client: NativeHTTP) async throws {
92 guard active else { throw CancellationError() }
93 let saved: SavedEvent = try await client.request(event.id == 0 ? "/api/native/events" : "/api/native/events/\(event.id)", method: "POST", body: JSONEncoder().encode(event))
94 guard active else { throw CancellationError() }
95 await load(client)
96 guard active else { throw CancellationError() }
97 notice = saved.warning ?? ""
98 }
99 func saveQuick(_ preview: QuickPreview, client: NativeHTTP) async throws {
100 guard active, !quickSaving else { throw CancellationError() }
101 quickSaving = true
102 defer { if active { quickSaving = false } }
103 let result: SavedEvent = try await client.request("/api/native/quick", method: "POST", body: JSONEncoder().encode(preview.draft))
104 guard active else { throw CancellationError() }
105 quickText = ""
106 quickSavedNotice = result.warning ?? "Added \(preview.draft.title)."
107 notice = quickSavedNotice
108 if let date = parse(preview.draft.start_local, allDay: preview.draft.all_day, zone: preview.draft.tz) { self.date = date }
109 await load(client)
110 }
111 public func format(_ date: Date, allDay: Bool, zone: String? = nil) -> String {
112 let f = DateFormatter(); f.locale = Locale(identifier: "en_US_POSIX")
113 f.timeZone = TimeZone(identifier: zone ?? self.zone) ?? .gmt
114 f.dateFormat = allDay ? "yyyy-MM-dd" : "yyyy-MM-dd'T'HH:mm"
115 return f.string(from: date)
116 }
117 public func display(_ date: Date, template: String, zone: String? = nil) -> String {
118 let f = DateFormatter()
119 f.locale = .current
120 f.timeZone = TimeZone(identifier: zone ?? self.zone) ?? .gmt
121 f.setLocalizedDateFormatFromTemplate(template)
122 return f.string(from: date)
123 }
124 public func parse(_ value: String, allDay: Bool, zone: String) -> Date? {
125 let f = DateFormatter(); f.locale = Locale(identifier: "en_US_POSIX")
126 f.timeZone = TimeZone(identifier: zone) ?? .gmt
127 f.dateFormat = allDay ? "yyyy-MM-dd" : "yyyy-MM-dd'T'HH:mm"
128 return f.date(from: value)
129 }
130}
131