// WizardController.swift — owns the window + all state (focused week, the // events, the auth token). Drives: // • the sign-in screen (when there's no shared AC token / on a 401), // • the rolling upcoming agenda, // • the add/edit/delete editor sheet for aesthetical events, // • a "Connect a Google calendar" action. import AppKit final class WizardController: NSWindowController, NSWindowDelegate, WeekViewDelegate, AgendaViewDelegate { // ── state ──────────────────────────────────────────────────────── private var api: CalAPI private let auth = Auth() // How far forward the "no day selected" agenda + the menu-bar countdown // badge look. A rolling window from the start of today. static let upcomingDays = 14 // Live watch on the shared ~/.ac-token (suite-wide broadcast). Active for // the whole app lifetime so a sign-in/out in any AC app updates us live. private var sessionWatch: UUID? // The Sunday that begins the focused week. private var weekStart = WeekView.startOfWeek(for: Date()) private var aestheticalEvents: [CalEvent] = [] private var feedEvents: [FeedEvent] = [] // The rolling upcoming set (both sources, merged) that feeds the agenda list // and the menu-bar countdown badge. Independent of the focused day/week. private var upcomingAesthetical: [CalEvent] = [] private var upcomingFeed: [FeedEvent] = [] // Periodic reload so the badge + agenda stay honest even when the window is // closed / we're parked in day mode. private var upcomingTimer: Timer? // The last merged+capped upcoming set, kept so the badge can re-pick the next // appointment against the wall clock between network reloads. private var cachedUpcoming: [LaidEvent] = [] // Fast, network-free tick that re-selects the next appointment so the badge // rolls off a meeting the moment it starts — it doesn't wait for the 5-minute // reload (which may be asleep or failing), which used to strand the wand on // "now" for hours after a meeting had actually ended. private var badgeTicker: Timer? // No timed event is treated as "ongoing" past this many seconds — a cap so a // missing/garbled end time can't keep an appointment alive in the agenda (and // masking the ones behind it) indefinitely. static let maxTimedDuration: TimeInterval = 12 * 3600 // Legacy day-focus broadcast. The agenda-only UI always publishes nil. var onFocusedDayChanged: ((Date?) -> Void)? // Broadcast the start of the next upcoming appointment (nil = none ahead) so // the menu bar can paint a countdown badge on the wand. Wired by AppDelegate. var onNextEventChanged: ((Date?, String?) -> Void)? // ── UI ─────────────────────────────────────────────────────────── private var backdrop: BackdropView! // Legacy day-view surfaces. They remain typed here while the old grid code // is retired, but are no longer installed in the window hierarchy. private var dayGlow: DayGlowView! private var dayDots: DayDotsView! private var weekView: WeekView! // The ordered "no day selected" agenda list (rolling upcoming events), // shown in place of the grid whenever we're not focused on a single day. private var agendaScroll: NSScrollView! private var agendaView: AgendaView! private var titleLabel: NSTextField! private var statusLabel: NSTextField! private var addButton: NSButton! // "+ Event" — the only toolbar button. private var upcomingButton: NSButton! // Vestigial: there's no mode-toggle button in the window anymore (mode is // driven by launch flags / CLI). Live code still harmlessly sets its title. private var modeButton: NSButton! // MARK: - Deprecated toolbar buttons // Paging, "today", refresh, and calendars were removed from the window; // their equivalents live in the wizard / ac-login CLI. Kept for reference // alongside the matching deprecated actions further down. @available(*, deprecated, message: "Removed from window UI — paging lives in the CLI.") private var prevButton: NSButton! @available(*, deprecated, message: "Removed from window UI — paging lives in the CLI.") private var nextButton: NSButton! @available(*, deprecated, message: "Removed from window UI — use the menu bar 'Go to Today'.") private var todayButton: NSButton! @available(*, deprecated, message: "Removed from window UI — refresh is automatic / CLI.") private var refreshButton: NSButton! @available(*, deprecated, message: "Removed from window UI — calendar setup lives in the CLI.") private var connectButton: NSButton! // Kept at seven while the old day-grid implementation is retired. The // visible surface is always the rolling agenda. private var dayCount: Int = 7 // Auth overlay (shown when unauthenticated). private var authOverlay: NSView? private var authStatusLabel: NSTextField? // Calendars panel (subscription link + connectors), live-updated by async loads. private var subLinkField: NSTextField? private var connectorsBox: NSView? private weak var calendarsWindow: NSWindow? private var editor: EventEditor? // ── lifecycle ──────────────────────────────────────────────────── init() { let auth = Auth() self.api = CalAPI(token: auth.currentToken()) let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 840, height: 560), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false) window.title = "DateWizard" window.minSize = NSSize(width: 640, height: 420) window.center() super.init(window: window) window.delegate = self setupUI() } required init?(coder: NSCoder) { fatalError() } // Called by the AppDelegate after the window shows. Ensures auth, // then loads the current week. func start() { // Live suite-wide session watch: any sign-in/out (this app, another // wizard, or the AC Electron tray) updates us without a restart. if sessionWatch == nil { sessionWatch = auth.startWatching { [weak self] in self?.handleSessionChange() } } if let token = auth.currentToken() { api.setToken(token) hideAuthScreen() refresh() } else { showAuthScreen() } startUpcomingTimer() } // Reload the rolling upcoming set every few minutes so the agenda and the // menu-bar countdown badge stay current even when the window is closed. // (MenuBarDays owns the per-minute countdown tick; this refreshes the data // behind it — new/changed events, and the roll to the next appointment.) private func startUpcomingTimer() { guard upcomingTimer == nil else { return } let timer = Timer(timeInterval: 300, repeats: true) { [weak self] _ in self?.loadUpcoming() } RunLoop.main.add(timer, forMode: .common) upcomingTimer = timer // Cheap local re-selection every 20s: advances the badge to the next // meeting as soon as the current one starts, with no network round-trip. guard badgeTicker == nil else { return } let tick = Timer(timeInterval: 20, repeats: true) { [weak self] _ in self?.publishNextEvent() } RunLoop.main.add(tick, forMode: .common) badgeTicker = tick } // Reacts to a change in the shared ~/.ac-token (broadcast). private func handleSessionChange() { let who = auth.handle ?? "—" NSLog("[ac-session] broadcast: token=\(auth.currentToken() != nil ? "present" : "none") handle=\(who)") if let token = auth.currentToken() { api.setToken(token) if authOverlay != nil { authStatusLabel?.stringValue = auth.handle.map { "Signed in as \($0)." } ?? "Signed in." hideAuthScreen() } refresh() } else { // Signed out / expired elsewhere — drop to the sign-in screen. api.setToken(nil) showAuthScreen() } } // ── layout ─────────────────────────────────────────────────────── private func setupUI() { guard let cv = window?.contentView else { return } cv.wantsLayer = true // Living video backdrop (Seedance loop of the mascot illy), drawn // behind everything at low opacity. Sits at the back of the view // stack; the week grid and toolbar render in front of it. backdrop = BackdropView(frame: cv.bounds) backdrop.autoresizingMask = [.width, .height] cv.addSubview(backdrop) let pad: CGFloat = 12 let topH: CGFloat = 40 // Toolbar row (top): title · + Event. Week/day paging, // refresh, "today", and calendar management all live in the // ac-login / wizard CLI — the window stays deliberately minimal. titleLabel = NSTextField(labelWithString: weekRangeTitle()) titleLabel.font = .systemFont(ofSize: 17, weight: .bold) titleLabel.frame = NSRect(x: pad, y: 560 - topH + 4, width: 245, height: 28) titleLabel.autoresizingMask = [.maxXMargin, .minYMargin] cv.addSubview(titleLabel) // + Event, far right. addButton = NSButton(title: "+ Event", target: self, action: #selector(addEventAction)) addButton.bezelStyle = .rounded addButton.font = .systemFont(ofSize: 12) addButton.autoresizingMask = [.minXMargin, .minYMargin] addButton.frame = NSRect(x: 840 - pad - 78, y: 560 - topH + 6, width: 78, height: 26) cv.addSubview(addButton) let bodyRect = NSRect(x: pad, y: pad + 22, width: 840 - pad * 2, height: 560 - topH - pad * 2 - 22) // The sole calendar surface: a rolling appointments agenda. Transparent // so the living backdrop shows through. agendaScroll = NSScrollView(frame: bodyRect) agendaScroll.autoresizingMask = [.width, .height] agendaScroll.drawsBackground = false agendaScroll.hasVerticalScroller = true agendaScroll.hasHorizontalScroller = false agendaScroll.autohidesScrollers = true agendaScroll.verticalScrollElasticity = .allowed agendaView = AgendaView(frame: NSRect(x: 0, y: 0, width: bodyRect.width, height: bodyRect.height)) agendaView.delegate = self agendaScroll.documentView = agendaView agendaScroll.isHidden = false cv.addSubview(agendaScroll) // Status line (bottom). statusLabel = NSTextField(labelWithString: "") statusLabel.font = .systemFont(ofSize: 11) statusLabel.textColor = .secondaryLabelColor statusLabel.frame = NSRect(x: pad, y: pad - 2, width: 840 - pad * 2, height: 18) statusLabel.autoresizingMask = [.width, .maxYMargin] statusLabel.lineBreakMode = .byTruncatingTail cv.addSubview(statusLabel) updateDayTheme() } // Single source of truth for the day-color theme: washes the pane, lights // Agenda-only theme: no day is focused, so legacy day accents stay clear. private func updateDayTheme() { dayGlow?.color = nil dayDots?.todayIndex = nil dayDots?.selectedIndex = nil titleLabel?.textColor = .labelColor weekView?.isHidden = true agendaScroll?.isHidden = false upcomingButton?.isHidden = true onFocusedDayChanged?(nil) } // A dot in the in-window strip was clicked: focus that weekday within the // currently displayed week, switching to day mode. private func focusDayInCurrentWeek(_ idx: Int) { let base = WeekView.startOfWeek(for: weekStart) let date = Calendar.current.date(byAdding: .day, value: idx, to: base) ?? weekStart dayCount = 1 weekStart = Calendar.current.startOfDay(for: date) modeButton?.title = "Week" weekView.dayCount = 1 weekView.weekStart = weekStart titleLabel.stringValue = weekRangeTitle() updateDayTheme() refresh() } private func weekRangeTitle() -> String { let cal = Calendar.current if dayCount == 1 { // Day mode: "Today / Tomorrow / Yesterday · Tue, Jun 16 2026". let fmt = DateFormatter(); fmt.dateFormat = "EEE, MMM d yyyy" let dayDelta = cal.dateComponents([.day], from: cal.startOfDay(for: Date()), to: weekStart).day ?? 0 let word: String switch dayDelta { case 0: word = "Today · " case 1: word = "Tomorrow · " case -1: word = "Yesterday · " default: word = "" } return "\(word)\(fmt.string(from: weekStart))" } // No day selected → the rolling agenda. return "Upcoming" } // "Today" now opens the same rolling agenda; there is no secondary day UI. func revealToday() { showWindow(nil) NSApp.activate(ignoringOtherApps: true) showUpcomingAction() } // Compatibility entry point for older callers: day requests land on the // agenda rather than navigating to the retired hour grid. func revealDay(_ date: Date) { showWindow(nil) NSApp.activate(ignoringOtherApps: true) showUpcomingAction() } // Show the window on the rolling "Upcoming" agenda with no day focused — // backs a click on the menu-bar wand. You land on what's coming up next, // not whatever single day happened to be showing last. func revealUpcoming() { showWindow(nil) NSApp.activate(ignoringOtherApps: true) showUpcomingAction() } // Menu-bar wand left-click: toggle. If the window is already up and focused, // a second tap hides it (matching the "click the icon to dismiss" reflex). // Otherwise reveal it — this also covers the buried-window case, where the // tap brings the wizard forward instead of dismissing it. func toggleUpcoming() { if let w = window, w.isVisible, NSApp.isActive, w.isKeyWindow { w.orderOut(nil) } else { revealUpcoming() } } // ── paging ───────────────────────────────────────────────────────── // DEPRECATED: week paging was removed from the window UI (CLI / launch // flags handle it). Kept for reference; shiftWeek below backs these. @available(*, deprecated, message: "Removed from window UI — paging lives in the CLI.") @objc private func prevWeek() { shiftWeek(by: -dayCount) } @available(*, deprecated, message: "Removed from window UI — paging lives in the CLI.") @objc private func nextWeek() { shiftWeek(by: dayCount) } @objc private func goToday() { showUpcomingAction() } // Leave a single-day view and return to the rolling agenda ("no day // selected"). Backs the "‹ Upcoming" toolbar button. @objc private func showUpcomingAction() { dayCount = 7 weekStart = WeekView.startOfWeek(for: Date()) modeButton?.title = "Day" weekView?.dayCount = dayCount weekView?.weekStart = weekStart titleLabel.stringValue = weekRangeTitle() updateDayTheme() refresh() } // DEPRECATED: the Day⇄Week toggle button was removed from the window; // mode is set via launch flags / CLI. Keeps the currently-focused day in // view across the switch. Kept for reference. @available(*, deprecated, message: "Removed from window UI — mode is set via CLI / launch flags.") @objc private func toggleModeAction() { let cal = Calendar.current // The day to keep focused: today if it's in the current span, else the // span's first visible day. let now = Date() let focus = (weekStart <= now && now < weekBounds().to) ? now : weekStart if dayCount == 1 { dayCount = 7 weekStart = WeekView.startOfWeek(for: focus) } else { dayCount = 1 weekStart = cal.startOfDay(for: focus) } modeButton.title = (dayCount == 1) ? "Week" : "Day" weekView.dayCount = dayCount weekView.weekStart = weekStart titleLabel.stringValue = weekRangeTitle() updateDayTheme() refresh() } // Compatibility shim for old --today/--tomorrow/--day launchers. Day mode // is retired, so all of them now open the rolling agenda. @available(*, deprecated, message: "DateWizard is agenda-only; day mode is retired.") func setDayMode(_ date: Date) { dayCount = 7 weekStart = WeekView.startOfWeek(for: Date()) titleLabel?.stringValue = weekRangeTitle() updateDayTheme() } // DEPRECATED backing helper for prevWeek/nextWeek (both removed from UI). private func shiftWeek(by days: Int) { weekStart = Calendar.current.date(byAdding: .day, value: days, to: weekStart) ?? weekStart weekView.weekStart = weekStart titleLabel.stringValue = weekRangeTitle() updateDayTheme() refresh() } // DEPRECATED: the Refresh button was removed; refresh is automatic / CLI. @available(*, deprecated, message: "Removed from window UI — refresh is automatic / CLI.") @objc private func refreshAction() { refresh() } // The visible window's UTC bounds [weekStart, weekStart+7d). private func weekBounds() -> (from: Date, to: Date) { let to = Calendar.current.date(byAdding: .day, value: dayCount, to: weekStart) ?? weekStart return (weekStart, to) } // ── data load ──────────────────────────────────────────────────── // Two loads, kept separate: // • loadUpcoming() — the rolling agenda + the menu-bar countdown badge. // Runs on every refresh (and a background timer) so the badge stays // honest even when the window is closed or parked in day mode. // • loadDayGrid() — the hour grid for the focused single day. Only when // a day is actually selected. func refresh() { guard let token = auth.currentToken() else { showAuthScreen(); return } api.setToken(token) loadUpcoming() } // Rolling window [start of today, +upcomingDays). Feeds the agenda list and // the next-appointment badge; independent of the focused day/week. private func loadUpcoming() { guard let token = auth.currentToken() else { return } api.setToken(token) let cal = Calendar.current let from = cal.startOfDay(for: Date()) let to = cal.date(byAdding: .day, value: Self.upcomingDays, to: from) ?? from api.fetchEvents(from: from, to: to) { [weak self] result in guard let self else { return } switch result { case .success(let events): self.upcomingAesthetical = events self.rebuildUpcoming() case .failure(let err): if case .unauthorized = err { self.handleUnauthorized() } } } api.fetchFeedEvents(from: from, to: to) { [weak self] result in guard let self else { return } if case .success(let events) = result { self.upcomingFeed = events self.rebuildUpcoming() } // Feed errors are non-fatal — keep the aesthetical agenda. } } // Merge + filter + sort the upcoming sources into the agenda list, and // publish the next appointment's start for the menu-bar countdown badge. private func rebuildUpcoming() { let now = Date() var laid: [LaidEvent] = [] for e in upcomingAesthetical { let allDay = e.allDay ?? false guard let s = Self.parseBoundary(e.start, allDay: allDay), let en = Self.parseBoundary(e.end, allDay: allDay) else { continue } laid.append(LaidEvent( kind: .aesthetical, uid: e.uid, title: e.title, start: s, end: Self.cappedEnd(start: s, end: en, allDay: allDay), allDay: allDay, note: e.note ?? "", visibility: e.visibility ?? "private", sourceLabel: nil, color: nil)) } for e in upcomingFeed { let allDay = e.allDay ?? false guard let s = Self.parseBoundary(e.start, allDay: allDay) else { continue } let en = e.end.flatMap { Self.parseBoundary($0, allDay: allDay) } ?? s.addingTimeInterval(1800) laid.append(LaidEvent( kind: .feed, uid: e.uid, title: e.title, start: s, end: Self.cappedEnd(start: s, end: en, allDay: allDay), allDay: allDay, note: e.note ?? "", visibility: "private", sourceLabel: e.label, color: Self.color(from: e.color))) } // Collapse duplicates (same event via import + feed) before filtering, // then keep still-relevant events (ongoing or ahead), ordered by start. let upcoming = Self.deduped(laid).filter { $0.end >= now }.sorted { $0.start < $1.start } cachedUpcoming = upcoming agendaView?.events = upcoming if dayCount != 1 { let count = upcoming.count statusLabel.stringValue = count == 0 ? "Nothing coming up in the next \(Self.upcomingDays) days." : "\(count) upcoming event\(count == 1 ? "" : "s") · next \(Self.upcomingDays) days" } publishNextEvent() } // Clamp an event's end so a missing or garbled end time can't leave it // "ongoing" forever. All-day items keep their span; timed items are capped at // maxTimedDuration past their start. private static func cappedEnd(start: Date, end: Date, allDay: Bool) -> Date { if allDay { return max(end, start) } let ceiling = start.addingTimeInterval(maxTimedDuration) return min(max(end, start), ceiling) } // Pick the next appointment for the menu-bar badge from the cached set and // hand it to the wand. Next = soonest timed event that hasn't started yet; // all-day items are skipped so the badge always counts down to a clock time. // Runs on the local ticker too, so the badge advances the instant a meeting // starts — it never lingers on "now" waiting for the network reload. private func publishNextEvent() { let now = Date() let next = cachedUpcoming.first { !$0.allDay && $0.start > now } onNextEventChanged?(next?.start, next?.title) } private func loadDayGrid() { guard let token = auth.currentToken() else { showAuthScreen(); return } api.setToken(token) let (from, to) = weekBounds() statusLabel.stringValue = "Loading…" api.fetchEvents(from: from, to: to) { [weak self] result in guard let self else { return } switch result { case .success(let events): self.aestheticalEvents = events self.rebuildLaidEvents() self.statusLabel.stringValue = "\(events.count) aesthetical event\(events.count == 1 ? "" : "s")" // Now layer the read-only feed overlay. self.loadFeedEvents() case .failure(let err): if case .unauthorized = err { self.handleUnauthorized() } else { self.statusLabel.stringValue = err.localizedDescription } } } } private func loadFeedEvents() { let (from, to) = weekBounds() api.fetchFeedEvents(from: from, to: to) { [weak self] result in guard let self else { return } switch result { case .success(let events): self.feedEvents = events self.rebuildLaidEvents() if !events.isEmpty { let base = self.aestheticalEvents.count self.statusLabel.stringValue = "\(base) aesthetical · \(events.count) from connected calendars" } case .failure(let err): if case .unauthorized = err { self.handleUnauthorized() } // Feed errors are non-fatal — keep showing aesthetical events. } } } // Merge the two sources into the laid-out events the WeekView draws. private func rebuildLaidEvents() { var laid: [LaidEvent] = [] for e in aestheticalEvents { let allDay = e.allDay ?? false guard let s = Self.parseBoundary(e.start, allDay: allDay), let en = Self.parseBoundary(e.end, allDay: allDay) else { continue } laid.append(LaidEvent( kind: .aesthetical, uid: e.uid, title: e.title, start: s, end: en, allDay: allDay, note: e.note ?? "", visibility: e.visibility ?? "private", sourceLabel: nil, color: nil)) } for e in feedEvents { let allDay = e.allDay ?? false guard let s = Self.parseBoundary(e.start, allDay: allDay) else { continue } let en = e.end.flatMap { Self.parseBoundary($0, allDay: allDay) } ?? s.addingTimeInterval(1800) laid.append(LaidEvent( kind: .feed, uid: e.uid, title: e.title, start: s, end: en, allDay: allDay, note: e.note ?? "", visibility: "private", sourceLabel: e.label, color: Self.color(from: e.color))) } weekView.events = Self.deduped(laid) } // Collapse events that represent the same underlying item — e.g. a Google // event that is both imported into AesthetiCal (uid "ics:") and // shown live via a connected feed (uid ""), or the same event // arriving from two feeds. Callers append editable aesthetical events before // feed events, so keeping the first occurrence prefers the editable copy. private static func deduped(_ events: [LaidEvent]) -> [LaidEvent] { var seen = Set() var out: [LaidEvent] = [] out.reserveCapacity(events.count) for e in events { if let key = dedupKey(e), !seen.insert(key).inserted { continue } out.append(e) } return out } // Normalized identity for duplicate detection. Strips the "ics:" import // prefix so an imported event and its feed twin share a key. Returns nil // when there's no stable id to match on (those events are always kept). private static func dedupKey(_ e: LaidEvent) -> String? { var uid = e.uid.trimmingCharacters(in: .whitespacesAndNewlines) if uid.hasPrefix("ics:") { uid = String(uid.dropFirst(4)) } return uid.isEmpty ? nil : uid } // ── editor ─────────────────────────────────────────────────────── @objc private func addEventAction() { // Default the new event to the next whole hour today (or in the // focused week if it isn't the current week). let cal = Calendar.current let now = Date() let base = (weekStart <= now && now < weekBounds().to) ? now : weekStart var comps = cal.dateComponents([.year, .month, .day, .hour], from: base) comps.hour = (comps.hour ?? 9) + 1 comps.minute = 0 let start = cal.date(from: comps) ?? base presentEditor(uid: nil, title: "", start: start, end: start.addingTimeInterval(3600), note: "", visibility: "private") } private func presentEditor(uid: String?, title: String, start: Date, end: Date, note: String, visibility: String) { guard let window else { return } let ed = EventEditor(uid: uid, title: title, start: start, end: end, note: note, visibility: visibility) { [weak self] result in self?.handleEditorResult(result) } self.editor = ed ed.present(on: window) } private func handleEditorResult(_ result: EventEditorResult) { switch result { case .cancel: break case .save(let uid, let title, let start, let end, let note, let visibility): let startISO = CalAPI.iso(start) let endISO = CalAPI.iso(end) statusLabel.stringValue = "Saving…" let done: (Result) -> Void = { [weak self] r in guard let self else { return } switch r { case .success: self.refresh() case .failure(let err): if case .unauthorized = err { self.handleUnauthorized() } else { self.statusLabel.stringValue = err.localizedDescription } } } if let uid { api.updateEvent(uid: uid, title: title, start: startISO, end: endISO, allDay: false, note: note, visibility: visibility, completion: done) } else { api.createEvent(title: title, start: startISO, end: endISO, allDay: false, note: note, visibility: visibility, completion: done) } case .delete(let uid): statusLabel.stringValue = "Deleting…" api.deleteEvent(uid: uid) { [weak self] r in guard let self else { return } switch r { case .success: self.refresh() case .failure(let err): if case .unauthorized = err { self.handleUnauthorized() } else { self.statusLabel.stringValue = err.localizedDescription } } } } } // MARK: - Deprecated calendars UI // ── calendars panel: subscription link + connectors ────────────── // DEPRECATED: the "Calendars…" button was removed from the window; // subscription links and Google-calendar connectors are managed via the // CLI now. This panel and its helpers (loadSubLink/copySubLink/ // rotateSubLink/reloadConnectors/removeConnector/connectFeedAction below) // are kept for reference and reachable only from here. @available(*, deprecated, message: "Removed from window UI — manage calendars via the CLI.") @objc private func calendarsPanelAction() { guard let window else { return } let W: CGFloat = 470 let container = NSView(frame: NSRect(x: 0, y: 0, width: W, height: 248)) let linkTitle = NSTextField(labelWithString: "Your AesthetiCal subscription link") linkTitle.font = .systemFont(ofSize: 12, weight: .semibold) linkTitle.frame = NSRect(x: 0, y: 226, width: W, height: 18) container.addSubview(linkTitle) let hint = NSTextField(labelWithString: "Subscribe in Apple/Google Calendar. Full calendar (private incl.) — keep it secret.") hint.font = .systemFont(ofSize: 10); hint.textColor = .secondaryLabelColor hint.frame = NSRect(x: 0, y: 210, width: W, height: 14) container.addSubview(hint) let field = NSTextField(frame: NSRect(x: 0, y: 182, width: 352, height: 22)) field.isEditable = false; field.isSelectable = true field.font = .monospacedSystemFont(ofSize: 10, weight: .regular) field.stringValue = "Loading…" container.addSubview(field) self.subLinkField = field let copyBtn = NSButton(title: "Copy", target: self, action: #selector(copySubLink)) copyBtn.bezelStyle = .rounded copyBtn.frame = NSRect(x: 356, y: 181, width: 48, height: 24) container.addSubview(copyBtn) let rotateBtn = NSButton(title: "Rotate", target: self, action: #selector(rotateSubLink)) rotateBtn.bezelStyle = .rounded rotateBtn.frame = NSRect(x: 406, y: 181, width: 60, height: 24) container.addSubview(rotateBtn) let sep = NSBox(frame: NSRect(x: 0, y: 166, width: W, height: 1)) sep.boxType = .separator container.addSubview(sep) let connTitle = NSTextField(labelWithString: "Connected calendars") connTitle.font = .systemFont(ofSize: 12, weight: .semibold) connTitle.frame = NSRect(x: 0, y: 142, width: 300, height: 18) container.addSubview(connTitle) let box = NSView(frame: NSRect(x: 0, y: 0, width: W, height: 134)) container.addSubview(box) self.connectorsBox = box let alert = NSAlert() alert.messageText = "Calendars" alert.accessoryView = container alert.addButton(withTitle: "Done") alert.addButton(withTitle: "Connect a Calendar…") alert.beginSheetModal(for: window) { [weak self] resp in guard let self else { return } self.subLinkField = nil self.connectorsBox = nil // "Connect a Calendar…" — present its sheet now that this one is gone. if resp == .alertSecondButtonReturn { self.connectFeedAction() } } loadSubLink() reloadConnectors() } private func loadSubLink() { api.fetchSubscriptionLink { [weak self] r in guard let self else { return } switch r { case .success(let link): self.subLinkField?.stringValue = link.url ?? "(no link — handle not set)" case .failure(let e): if case .unauthorized = e { self.handleUnauthorized() } self.subLinkField?.stringValue = "Error: \(e.localizedDescription)" } } } @objc private func copySubLink() { guard let s = subLinkField?.stringValue, s.hasPrefix("http") else { return } NSPasteboard.general.clearContents() NSPasteboard.general.setString(s, forType: .string) statusLabel.stringValue = "Subscription link copied." } @objc private func rotateSubLink() { subLinkField?.stringValue = "Rotating…" api.rotateSubscriptionLink { [weak self] r in guard let self else { return } switch r { case .success(let link): self.subLinkField?.stringValue = link.url ?? "(no link)" self.statusLabel.stringValue = "Subscription link rotated — old links revoked." case .failure(let e): if case .unauthorized = e { self.handleUnauthorized() } self.subLinkField?.stringValue = "Error: \(e.localizedDescription)" } } } private func reloadConnectors() { api.fetchFeeds { [weak self] r in guard let self, let box = self.connectorsBox else { return } box.subviews.forEach { $0.removeFromSuperview() } switch r { case .success(let feeds): if feeds.isEmpty { let l = NSTextField(labelWithString: "No calendars connected yet — use “Connect a Calendar…”.") l.font = .systemFont(ofSize: 11); l.textColor = .secondaryLabelColor l.frame = NSRect(x: 0, y: box.bounds.height - 22, width: box.bounds.width, height: 18) box.addSubview(l) return } var y = box.bounds.height - 26 for feed in feeds { let name = NSTextField(labelWithString: feed.label ?? "Calendar") name.font = .systemFont(ofSize: 11) name.frame = NSRect(x: 0, y: y + 3, width: 340, height: 18) box.addSubview(name) let rm = NSButton(title: "Remove", target: self, action: #selector(self.removeConnector(_:))) rm.bezelStyle = .rounded rm.frame = NSRect(x: box.bounds.width - 84, y: y, width: 84, height: 24) rm.identifier = NSUserInterfaceItemIdentifier(feed.id) box.addSubview(rm) y -= 30 } case .failure(let e): if case .unauthorized = e { self.handleUnauthorized() } } } } @objc private func removeConnector(_ sender: NSButton) { guard let id = sender.identifier?.rawValue else { return } sender.isEnabled = false api.removeFeed(id: id) { [weak self] r in guard let self else { return } switch r { case .success: self.reloadConnectors() self.refresh() case .failure(let e): sender.isEnabled = true if case .unauthorized = e { self.handleUnauthorized() } } } } // ── connect a Google calendar ──────────────────────────────────── @objc private func connectFeedAction() { guard let window else { return } let alert = NSAlert() alert.messageText = "Connect a Google calendar" alert.informativeText = "Paste the Google \"secret address in iCal format\" URL and a label.\nGoogle Calendar → Settings → your calendar → Integrate calendar → Secret address in iCal format." alert.addButton(withTitle: "Connect") alert.addButton(withTitle: "Cancel") let stack = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 54)) let urlField = NSTextField(frame: NSRect(x: 0, y: 28, width: 320, height: 22)) urlField.placeholderString = "https://calendar.google.com/calendar/ical/.../basic.ics" let labelField = NSTextField(frame: NSRect(x: 0, y: 0, width: 320, height: 22)) labelField.placeholderString = "Label (e.g. Work)" stack.addSubview(urlField) stack.addSubview(labelField) alert.accessoryView = stack alert.beginSheetModal(for: window) { [weak self] resp in guard let self, resp == .alertFirstButtonReturn else { return } let url = urlField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) let label = labelField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) guard !url.isEmpty else { return } self.statusLabel.stringValue = "Connecting calendar…" self.api.connectFeed(url: url, label: label.isEmpty ? "Calendar" : label) { r in switch r { case .success(let feed): self.statusLabel.stringValue = "Connected \(feed.label ?? "calendar")." self.refresh() case .failure(let err): if case .unauthorized = err { self.handleUnauthorized() } else { self.statusLabel.stringValue = err.localizedDescription } } } } } // ── WeekViewDelegate ───────────────────────────────────────────── func weekView(_ view: WeekView, didSelectEditable event: LaidEvent) { presentEditor(uid: event.uid, title: event.title, start: event.start, end: event.end, note: event.note, visibility: event.visibility) } func weekView(_ view: WeekView, didSelectReadOnly event: LaidEvent) { presentReadOnlyDetails(event) } private func presentReadOnlyDetails(_ event: LaidEvent) { // Read-only details — no edit. let alert = NSAlert() alert.messageText = event.title let fmt = DateFormatter() fmt.dateStyle = .medium; fmt.timeStyle = .short var info = "\(fmt.string(from: event.start)) – \(fmt.string(from: event.end))" if let src = event.sourceLabel { info += "\n\nSource: \(src) (read-only)" } if !event.note.isEmpty { info += "\n\n\(event.note)" } alert.informativeText = info alert.addButton(withTitle: "OK") if let window { alert.beginSheetModal(for: window) { _ in } } else { alert.runModal() } } // ── AgendaViewDelegate ─────────────────────────────────────────── // Same routing as the grid: aesthetical → editor, feed → read-only details. func agendaView(_ view: AgendaView, didSelectEditable event: LaidEvent) { presentEditor(uid: event.uid, title: event.title, start: event.start, end: event.end, note: event.note, visibility: event.visibility) } func agendaView(_ view: AgendaView, didSelectReadOnly event: LaidEvent) { presentReadOnlyDetails(event) } func weekView(_ view: WeekView, didRequestNewEventAt day: Date, hour: Int) { let cal = Calendar.current var comps = cal.dateComponents([.year, .month, .day], from: day) comps.hour = max(0, min(23, hour)) comps.minute = 0 let start = cal.date(from: comps) ?? day presentEditor(uid: nil, title: "", start: start, end: start.addingTimeInterval(3600), note: "", visibility: "private") } // ── sign-in screen ─────────────────────────────────────────────── private func handleUnauthorized() { // Token missing/expired — drop it and ask the user to sign in. api.setToken(nil) showAuthScreen() } private func showAuthScreen() { guard let cv = window?.contentView else { return } if authOverlay != nil { return } let overlay = NSView(frame: cv.bounds) overlay.wantsLayer = true // Translucent scrim so the living mascot backdrop shows through // behind the sign-in prompt. overlay.layer?.backgroundColor = NSColor.windowBackgroundColor .withAlphaComponent(0.62).cgColor overlay.autoresizingMask = [.width, .height] let title = NSTextField(labelWithString: "Sign in to Aesthetic Computer") title.font = .systemFont(ofSize: 18, weight: .semibold) title.alignment = .center title.frame = NSRect(x: 0, y: cv.bounds.height - 150, width: cv.bounds.width, height: 28) title.autoresizingMask = [.width, .minYMargin] overlay.addSubview(title) let instr = NSTextField(labelWithString: "Sign in with your browser — no terminal needed") instr.alignment = .center instr.font = .systemFont(ofSize: 13) instr.textColor = .secondaryLabelColor instr.frame = NSRect(x: 0, y: cv.bounds.height - 190, width: cv.bounds.width, height: 22) instr.autoresizingMask = [.width, .minYMargin] overlay.addSubview(instr) let signInBtn = NSButton(title: "Sign in", target: self, action: #selector(signInAction)) signInBtn.bezelStyle = .rounded signInBtn.keyEquivalent = "\r" signInBtn.frame = NSRect(x: cv.bounds.width / 2 - 110, y: cv.bounds.height - 240, width: 220, height: 30) signInBtn.autoresizingMask = [.minXMargin, .maxXMargin, .minYMargin] overlay.addSubview(signInBtn) let cliBtn = NSButton(title: "Use ac-login CLI instead", target: self, action: #selector(runAcLoginAction)) cliBtn.bezelStyle = .inline cliBtn.isBordered = false cliBtn.contentTintColor = .tertiaryLabelColor cliBtn.font = .systemFont(ofSize: 11) cliBtn.frame = NSRect(x: cv.bounds.width / 2 - 110, y: cv.bounds.height - 274, width: 220, height: 22) cliBtn.autoresizingMask = [.minXMargin, .maxXMargin, .minYMargin] overlay.addSubview(cliBtn) let status = NSTextField(labelWithString: "") status.alignment = .center status.font = .systemFont(ofSize: 12) status.textColor = .tertiaryLabelColor status.frame = NSRect(x: 0, y: cv.bounds.height - 320, width: cv.bounds.width, height: 20) status.autoresizingMask = [.width, .minYMargin] overlay.addSubview(status) self.authStatusLabel = status cv.addSubview(overlay) self.authOverlay = overlay // No polling — the live session watch (installed in start()) proceeds // automatically the instant ~/.ac-token appears. } private func hideAuthScreen() { authOverlay?.removeFromSuperview() authOverlay = nil authStatusLabel = nil } @objc private func signInAction() { authStatusLabel?.stringValue = "Opening browser… finish signing in there." auth.signIn { [weak self] result in guard let self else { return } switch result { case .success: // The session file-watch (start()) will proceed automatically; // call directly too so we don't wait on the debounce. self.handleSessionChange() case .failure(let error): self.authStatusLabel?.stringValue = error.localizedDescription } } } @objc private func runAcLoginAction() { auth.runAcLogin() authStatusLabel?.stringValue = "Finish signing in your terminal — waiting for ~/.ac-token…" } // ── parsing helpers ────────────────────────────────────────────── private static let isoFractional: ISO8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return f }() private static let isoPlain: ISO8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime] return f }() // Date-only ("2026-07-16"), read in UTC — how some all-day events arrive. private static let isoDateOnly: DateFormatter = { let f = DateFormatter() f.calendar = Calendar(identifier: .gregorian) f.locale = Locale(identifier: "en_US_POSIX") f.timeZone = TimeZone(identifier: "UTC") f.dateFormat = "yyyy-MM-dd" return f }() // A UTC calendar for reading the calendar date out of an all-day instant. private static let utcCalendar: Calendar = { var c = Calendar(identifier: .gregorian) c.timeZone = TimeZone(identifier: "UTC")! return c }() static func parseISO(_ s: String) -> Date? { isoFractional.date(from: s) ?? isoPlain.date(from: s) ?? isoDateOnly.date(from: s) } // Parse an event boundary (start/end). Timed events keep their exact // instant; all-day events are *floating dates* — they arrive as UTC midnight // (e.g. "2026-07-16T00:00:00Z", or a bare "2026-07-16"), and converting that // instant into a zone behind UTC would push it into the previous evening, // rendering the event a full day early. So for all-day items we take the // calendar Y/M/D (read in UTC) and rebuild it as local start-of-day, keeping // July 16 on July 16 in every timezone. static func parseBoundary(_ s: String, allDay: Bool) -> Date? { guard let d = parseISO(s) else { return nil } guard allDay else { return d } let ymd = utcCalendar.dateComponents([.year, .month, .day], from: d) return Calendar.current.date(from: ymd) ?? Calendar.current.startOfDay(for: d) } // Parse a CSS-ish color string ("#rrggbb", "#rgb", or a few named // colors) into an NSColor. Falls back to a stable gray. static func color(from str: String?) -> NSColor { guard let raw = str?.trimmingCharacters(in: .whitespaces), !raw.isEmpty else { return .systemGray } let named: [String: NSColor] = [ "red": .systemRed, "orange": .systemOrange, "yellow": .systemYellow, "green": .systemGreen, "blue": .systemBlue, "purple": .systemPurple, "pink": .systemPink, "teal": .systemTeal, "gray": .systemGray, "grey": .systemGray, "brown": .systemBrown, "indigo": .systemIndigo, ] if let c = named[raw.lowercased()] { return c } var hex = raw if hex.hasPrefix("#") { hex.removeFirst() } if hex.count == 3 { hex = hex.map { "\($0)\($0)" }.joined() } guard hex.count == 6, let v = UInt32(hex, radix: 16) else { return .systemGray } let r = CGFloat((v >> 16) & 0xff) / 255.0 let g = CGFloat((v >> 8) & 0xff) / 255.0 let b = CGFloat(v & 0xff) / 255.0 return NSColor(calibratedRed: r, green: g, blue: b, alpha: 1) } // ── window lifecycle ───────────────────────────────────────────── // Always-on app: hide the window instead of closing it, so the shared // session watch (and live event state) survives. The menu bar strip // keeps running; clicking it re-shows the window. Real teardown happens // on app terminate. func windowShouldClose(_ sender: NSWindow) -> Bool { window?.orderOut(nil) return false } func windowWillClose(_ notification: Notification) { if let id = sessionWatch { auth.stopWatching(id); sessionWatch = nil } } }