import Foundation enum GoogleCalendarClientError: Error { case invalidResponse case httpStatus(Int, String) case decodeFailed(String) } final class GoogleCalendarClient { struct Options: Sendable { var daysAhead: Int var maxResults: Int var includeNonMeetEvents: Bool init(daysAhead: Int = 180, maxResults: Int = 200, includeNonMeetEvents: Bool = true) { self.daysAhead = daysAhead self.maxResults = maxResults self.includeNonMeetEvents = includeNonMeetEvents } } private let session: URLSession init(session: URLSession = .shared) { self.session = session } func fetchUpcomingMeetings(accessToken: String) async throws -> [ScheduledMeeting] { try await fetchUpcomingMeetings(accessToken: accessToken, options: Options()) } func fetchUpcomingMeetings(accessToken: String, options: Options) async throws -> [ScheduledMeeting] { let now = Date() let end = Calendar.current.date(byAdding: .day, value: max(1, options.daysAhead), to: now) ?? now.addingTimeInterval(180 * 24 * 60 * 60) let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] let totalLimit = max(1, options.maxResults) let pageSize = min(250, totalLimit) var nextPageToken: String? var meetings: [ScheduledMeeting] = [] repeat { var components = URLComponents(string: "https://www.googleapis.com/calendar/v3/calendars/primary/events")! var queryItems = [ URLQueryItem(name: "timeMin", value: formatter.string(from: now)), URLQueryItem(name: "timeMax", value: formatter.string(from: end)), URLQueryItem(name: "singleEvents", value: "true"), URLQueryItem(name: "orderBy", value: "startTime"), URLQueryItem(name: "maxResults", value: String(pageSize)), URLQueryItem(name: "conferenceDataVersion", value: "1") ] if let nextPageToken { queryItems.append(URLQueryItem(name: "pageToken", value: nextPageToken)) } components.queryItems = queryItems var request = URLRequest(url: components.url!) request.httpMethod = "GET" request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") let (data, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { throw GoogleCalendarClientError.invalidResponse } guard (200..<300).contains(http.statusCode) else { let body = String(data: data, encoding: .utf8) ?? "" throw GoogleCalendarClientError.httpStatus(http.statusCode, body) } let decoded: EventsList do { decoded = try JSONDecoder().decode(EventsList.self, from: data) } catch { let raw = String(data: data, encoding: .utf8) ?? "" throw GoogleCalendarClientError.decodeFailed(raw) } let pageMeetings: [ScheduledMeeting] = decoded.items.compactMap { item in let title = item.summary?.trimmingCharacters(in: .whitespacesAndNewlines) let subtitle = item.organizer?.displayName ?? item.organizer?.email guard let start = item.start?.resolvedDate, let end = item.end?.resolvedDate else { return nil } let isAllDay = item.start?.date != nil let meetURL: URL? = { if let hangout = item.hangoutLink, let u = URL(string: hangout) { return u } let entry = item.conferenceData?.entryPoints?.first(where: { $0.entryPointType == "video" && ($0.uri?.contains("meet.google.com") ?? false) }) if let uri = entry?.uri, let u = URL(string: uri) { return u } if options.includeNonMeetEvents, let htmlLink = item.htmlLink, let u = URL(string: htmlLink) { return u } return nil }() if meetURL == nil, options.includeNonMeetEvents == false { return nil } guard let meetURL else { return nil } return ScheduledMeeting( id: item.id ?? UUID().uuidString, title: (title?.isEmpty == false) ? title! : "Untitled meeting", subtitle: subtitle, startDate: start, endDate: end, meetURL: meetURL, isAllDay: isAllDay ) } meetings.append(contentsOf: pageMeetings) nextPageToken = decoded.nextPageToken } while nextPageToken != nil && meetings.count < totalLimit if meetings.count > totalLimit { meetings = Array(meetings.prefix(totalLimit)) } return meetings } } extension GoogleCalendarClientError: LocalizedError { var errorDescription: String? { switch self { case .invalidResponse: return "Google Calendar returned an invalid response." case let .httpStatus(status, body): return "Google Calendar API error (\(status)): \(body)" case let .decodeFailed(raw): return "Failed to parse Google Calendar events: \(raw)" } } } // MARK: - Calendar API models private struct EventsList: Decodable { let items: [EventItem] let nextPageToken: String? } private struct EventItem: Decodable { let id: String? let summary: String? let hangoutLink: String? let htmlLink: String? let organizer: Organizer? let start: EventDateTime? let end: EventDateTime? let conferenceData: ConferenceData? } private struct Organizer: Decodable { let displayName: String? let email: String? } private struct EventDateTime: Decodable { let dateTime: String? let date: String? let timeZone: String? var resolvedDate: Date? { if let dateTime, let parsed = Self.parseDateTime(dateTime, timeZone: timeZone) { return parsed } if let date, let parsed = DateFormatter.googleAllDay.date(from: date) { return parsed } return nil } private static func parseDateTime(_ raw: String, timeZone: String?) -> Date? { if let dt = ISO8601DateFormatter.fractional.date(from: raw) ?? ISO8601DateFormatter.nonFractional.date(from: raw) { return dt } // Some Calendar payloads provide dateTime without explicit zone and separate timeZone field. let formatter = DateFormatter() formatter.calendar = Calendar(identifier: .gregorian) formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" if let timeZone, let tz = TimeZone(identifier: timeZone) { formatter.timeZone = tz } else { formatter.timeZone = TimeZone.current } return formatter.date(from: raw) } } private struct ConferenceData: Decodable { let entryPoints: [EntryPoint]? } private struct EntryPoint: Decodable { let entryPointType: String? let uri: String? } private extension ISO8601DateFormatter { static let fractional: ISO8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return f }() static let nonFractional: ISO8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime] return f }() } private extension DateFormatter { static let googleAllDay: DateFormatter = { let f = DateFormatter() f.calendar = Calendar(identifier: .gregorian) f.locale = Locale(identifier: "en_US_POSIX") f.timeZone = TimeZone(secondsFromGMT: 0) f.dateFormat = "yyyy-MM-dd" return f }() }