// // CalendarDataSource.swift // iris // // Created by Codex. // import EventKit import Foundation struct CalendarDataSourceConfig: Sendable { var lookaheadSec: Int = 2 * 60 * 60 var soonWindowSec: Int = 30 * 60 var maxCandidates: Int = 3 var includeAllDay: Bool = false var includeDeclined: Bool = false init() {} } final class CalendarDataSource { struct Event: Sendable, Equatable { let id: String let title: String let startAt: Int let endAt: Int let isAllDay: Bool let location: String? } struct Data: Sendable, Equatable { let events: [Event] } struct Snapshot: Sendable, Equatable { let data: Data let diagnostics: [String: String] } enum CalendarError: Error, LocalizedError, Sendable, Equatable { case accessNotGranted(diagnostics: [String: String]) var errorDescription: String? { switch self { case .accessNotGranted: return "Calendar access not granted." } } var diagnostics: [String: String] { switch self { case .accessNotGranted(let diagnostics): return diagnostics } } } private let store: EKEventStore private let config: CalendarDataSourceConfig init(store: EKEventStore = EKEventStore(), config: CalendarDataSourceConfig = .init()) { self.store = store self.config = config } func dataWithDiagnostics(now: Int) async throws -> Snapshot { var diagnostics: [String: String] = [ "now": String(now), "lookahead_sec": String(config.lookaheadSec), "soon_window_sec": String(config.soonWindowSec), ] let nowDate = Date(timeIntervalSince1970: TimeInterval(now)) let endDate = nowDate.addingTimeInterval(TimeInterval(config.lookaheadSec)) let auth = calendarAuthorizationStatusString() diagnostics["auth"] = auth let accessGranted = await ensureCalendarAccess() diagnostics["access_granted"] = accessGranted ? "true" : "false" guard accessGranted else { throw CalendarError.accessNotGranted(diagnostics: diagnostics) } let predicate = store.predicateForEvents(withStart: nowDate, end: endDate, calendars: nil) let events = store.events(matching: predicate) diagnostics["events_matched"] = String(events.count) let filtered = events .filter { shouldInclude(event: $0) } .sorted { $0.startDate < $1.startDate } diagnostics["events_filtered"] = String(filtered.count) let outputEvents = buildEvents(from: filtered, nowDate: nowDate) diagnostics["events_output"] = String(outputEvents.count) return Snapshot(data: Data(events: outputEvents), diagnostics: diagnostics) } private func shouldInclude(event: EKEvent) -> Bool { if event.isAllDay, !config.includeAllDay { return false } if !config.includeDeclined { // `EKEvent.participants` is optional; safest check is `eventStatus` and organizer availability. // Many calendars won’t provide participants; keep it simple for Phase 1. if event.status == .canceled { return false } } return true } private func buildEvents(from events: [EKEvent], nowDate: Date) -> [Event] { var results: [Event] = [] results.reserveCapacity(min(config.maxCandidates, events.count)) for event in events { if results.count >= config.maxCandidates { break } guard let start = event.startDate, let end = event.endDate else { continue } let isOngoing = start <= nowDate && end > nowDate let startsInSec = Int(start.timeIntervalSince(nowDate)) if !isOngoing, startsInSec > config.soonWindowSec { continue } let id = "cal:\(event.eventIdentifier ?? UUID().uuidString):\(Int(start.timeIntervalSince1970))" let title = (event.title ?? "Event").trimmingCharacters(in: .whitespacesAndNewlines) results.append( Event( id: id, title: title.isEmpty ? "Event" : title, startAt: Int(start.timeIntervalSince1970), endAt: Int(end.timeIntervalSince1970), isAllDay: event.isAllDay, location: event.location ) ) } return results } private func calendarAuthorizationStatusString() -> String { if #available(iOS 17.0, *) { return String(describing: EKEventStore.authorizationStatus(for: .event)) } return String(describing: EKEventStore.authorizationStatus(for: .event)) } private func ensureCalendarAccess() async -> Bool { let status = EKEventStore.authorizationStatus(for: .event) if #available(iOS 17.0, *) { if status == .fullAccess { return true } if status == .writeOnly { return false } } switch status { case .notDetermined: return await requestAccess() case .denied, .restricted: return false case .authorized: return true @unknown default: return false } } private func requestAccess() async -> Bool { await MainActor.run { // Ensure the permission prompt is allowed to present. } return await withCheckedContinuation { continuation in if #available(iOS 17.0, *) { store.requestFullAccessToEvents { granted, _ in continuation.resume(returning: granted) } } else { store.requestAccess(to: .event) { granted, _ in continuation.resume(returning: granted) } } } } }