Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
53 kB · 1132 lines
Swift
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133// 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 week view (paging, refresh),// • 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
// Broadcast the focused day (nil in week mode) so the menu bar strip can // light the same dot. Wired by the AppDelegate. 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! // Inner radial glow framing the window in the focused day's color (only // in single-day mode). A subtle edge highlight, not a full wash. private var dayGlow: DayGlowView! // In-window twin of the menu bar strip: seven ROYGBIV day dots. 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. // "‹ Upcoming" — jumps back from a single day to the agenda list. Hidden // while the agenda is already showing. 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!
// Display span: 7 = week (Sun→Sat), 1 = a single day. 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 · day-dots · + 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)
// Day-dots, to the right of the title — the in-window twin of the menu // bar strip. Click a dot to jump to that day of the week. dayDots = DayDotsView(frame: NSRect(x: pad + 252, y: 560 - topH + 7, width: 190, height: 26)) dayDots.autoresizingMask = [.minYMargin] dayDots.onSelect = { [weak self] idx in self?.focusDayInCurrentWeek(idx) } cv.addSubview(dayDots)
// + 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)
// "‹ Upcoming" — return to the agenda list from a single-day view. Sits // just left of + Event; hidden while the agenda is already showing. upcomingButton = NSButton(title: "‹ Upcoming", target: self, action: #selector(showUpcomingAction)) upcomingButton.bezelStyle = .rounded upcomingButton.font = .systemFont(ofSize: 12) upcomingButton.autoresizingMask = [.minXMargin, .minYMargin] upcomingButton.frame = NSRect(x: 840 - pad - 78 - 8 - 96, y: 560 - topH + 6, width: 96, height: 26) upcomingButton.isHidden = true cv.addSubview(upcomingButton)
let bodyRect = NSRect(x: pad, y: pad + 22, width: 840 - pad * 2, height: 560 - topH - pad * 2 - 22)
// Week/day grid (used for the single-day view). weekView = WeekView(frame: bodyRect) weekView.autoresizingMask = [.width, .height] weekView.weekStart = weekStart weekView.dayCount = dayCount weekView.delegate = self weekView.isHidden = (dayCount != 1) cv.addSubview(weekView)
// Agenda list (the "no day selected" face), in a scroll view over the // same body rect. 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 = (dayCount == 1) 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)
// Inner radial glow, added last so it frames everything (click-through). dayGlow = DayGlowView(frame: cv.bounds) dayGlow.autoresizingMask = [.width, .height] cv.addSubview(dayGlow)
updateDayTheme() }
// Single source of truth for the day-color theme: washes the pane, lights // the in-window dots, colors the title, and tells the menu bar which day // is focused. In week mode there's no single focus (the week shows all // seven), so the wash clears and the title returns to neutral. private func updateDayTheme() { let dayMode = (dayCount == 1) let dayColor = DayPalette.color(for: weekStart)
// Frame the window in the day color (edge glow), not a full wash. dayGlow?.color = dayMode ? dayColor : nil
// Only mark today while a single day is in focus. On the "Upcoming" // agenda nothing is selected, and DayStrip promotes today to the lit // "selected" look when there's no selection — which read as a stray // highlighted day (e.g. "F") on a screen that isn't about any one day. dayDots?.todayIndex = dayMode ? DayPalette.index(for: Date()) : nil dayDots?.selectedIndex = dayMode ? DayPalette.index(for: weekStart) : nil
titleLabel?.textColor = dayMode ? dayColor : .labelColor
// Grid for a single day, agenda list for "no day selected". weekView?.isHidden = !dayMode agendaScroll?.isHidden = dayMode upcomingButton?.isHidden = !dayMode
onFocusedDayChanged?(dayMode ? weekStart : 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" }
// Show the window and jump to today (menu bar "Go to Today"). func revealToday() { showWindow(nil) NSApp.activate(ignoringOtherApps: true) goToday() }
// Show the window focused on a single day (menu bar day-circle tap). func revealDay(_ date: Date) { showWindow(nil) NSApp.activate(ignoringOtherApps: true) setDayMode(date) refresh() }
// 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() }
// ── 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() { weekStart = (dayCount == 1) ? Calendar.current.startOfDay(for: Date()) : WeekView.startOfWeek(for: Date()) weekView.weekStart = weekStart titleLabel.stringValue = weekRangeTitle() updateDayTheme() refresh() }
// 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() }
// Pre-set a single-day view BEFORE start() (used by the --tomorrow / --day // launch flag). Does NOT refresh — start() does the single initial load, so // we don't race a stale week fetch against the day fetch. func setDayMode(_ date: Date) { dayCount = 1 weekStart = Calendar.current.startOfDay(for: date) modeButton?.title = "Week" weekView?.dayCount = dayCount weekView?.weekStart = weekStart 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() if dayCount == 1 { loadDayGrid() } }
// 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:<sourceUID>") and // shown live via a connected feed (uid "<sourceUID>"), 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<String>() 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<CalEvent, CalAPIError>) -> 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) { // 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) { weekView(weekView, didSelectReadOnly: 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 } }}