// // 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 CandidatesResult: Sendable { let candidates: [Candidate] let error: String? let diagnostics: [String: String] } private let store: EKEventStore private let config: CalendarDataSourceConfig init(store: EKEventStore = EKEventStore(), config: CalendarDataSourceConfig = .init()) { self.store = store self.config = config } func candidatesWithDiagnostics(now: Int) async -> CandidatesResult { 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 { return CandidatesResult(candidates: [], error: "Calendar access not granted.", 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 candidates = buildCandidates(from: filtered, now: now, nowDate: nowDate) diagnostics["candidates"] = String(candidates.count) return CandidatesResult(candidates: candidates, error: nil, 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 buildCandidates(from events: [EKEvent], now: Int, nowDate: Date) -> [Candidate] { var results: [Candidate] = [] 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 title = (isOngoing ? "Now: \(event.title ?? "Event")" : event.title ?? "Upcoming") .truncated(maxLength: TextConstraints.titleMax) let subtitle = subtitleText(event: event, nowDate: nowDate) .truncated(maxLength: TextConstraints.subtitleMax) let confidence: Double = isOngoing ? 0.9 : 0.7 let ttl = ttlSec(end: end, nowDate: nowDate) let id = "cal:\(event.eventIdentifier ?? UUID().uuidString):\(Int(start.timeIntervalSince1970))" results.append( Candidate( id: id, type: .info, title: title, subtitle: subtitle, confidence: confidence, createdAt: now, ttlSec: ttl, metadata: [ "source": "eventkit", "calendar": event.calendar.title, "start": String(Int(start.timeIntervalSince1970)), "end": String(Int(end.timeIntervalSince1970)), "all_day": event.isAllDay ? "true" : "false", "location": event.location ?? "", ] ) ) } return results } private func ttlSec(end: Date, nowDate: Date) -> Int { let ttl = Int(end.timeIntervalSince(nowDate)) // Keep the candidate alive until it ends, but cap at 2h and floor at 60s. return min(max(ttl, 60), 2 * 60 * 60) } private func subtitleText(event: EKEvent, nowDate: Date) -> String { guard let start = event.startDate, let end = event.endDate else { return "" } let isOngoing = start <= nowDate && end > nowDate if isOngoing { let remainingMin = max(0, Int(floor(end.timeIntervalSince(nowDate) / 60.0))) if let loc = event.location, !loc.isEmpty { return "\(loc) • \(remainingMin)m left" } return "\(remainingMin)m left" } let minutes = max(0, Int(floor(start.timeIntervalSince(nowDate) / 60.0))) if let loc = event.location, !loc.isEmpty { return "In \(minutes)m • \(loc)" } return "In \(minutes)m" } 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) switch status { case .authorized: return true case .denied, .restricted: return false case .notDetermined: return await requestAccess() @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) } } } } }