Quellcode durchsuchen

Build custom Calendar page with date scheduling flow.

Replace the Calendar placeholder with a month grid UI, add day-level meeting counts and scheduling popovers, and create Google Calendar events with Meet links while refreshing calendar data after saves.

Made-with: Cursor
huzaifahayat12 vor 1 Woche
Ursprung
Commit
1167c17384

+ 1 - 1
meetings_app/Auth/GoogleOAuthService.swift

@@ -43,7 +43,7 @@ final class GoogleOAuthService: NSObject {
43 43
         "openid",
44 44
         "email",
45 45
         "profile",
46
-        "https://www.googleapis.com/auth/calendar.readonly"
46
+        "https://www.googleapis.com/auth/calendar.events"
47 47
     ]
48 48
 
49 49
     private let tokenStore = KeychainTokenStore()

+ 102 - 0
meetings_app/Google/GoogleCalendarClient.swift

@@ -113,6 +113,82 @@ final class GoogleCalendarClient {
113 113
         }
114 114
         return meetings
115 115
     }
116
+
117
+    func createEvent(accessToken: String,
118
+                     title: String,
119
+                     description: String?,
120
+                     start: Date,
121
+                     end: Date,
122
+                     timeZone: TimeZone = .current) async throws -> ScheduledMeeting {
123
+        var components = URLComponents(string: "https://www.googleapis.com/calendar/v3/calendars/primary/events")!
124
+        components.queryItems = [
125
+            URLQueryItem(name: "conferenceDataVersion", value: "1")
126
+        ]
127
+
128
+        let requestID = UUID().uuidString
129
+        let body = CreateEventRequest(
130
+            summary: title,
131
+            description: description,
132
+            start: CreateEventRequest.EventDateTime(dateTime: ISO8601DateFormatter.fractional.string(from: start), timeZone: timeZone.identifier),
133
+            end: CreateEventRequest.EventDateTime(dateTime: ISO8601DateFormatter.fractional.string(from: end), timeZone: timeZone.identifier),
134
+            conferenceData: CreateEventRequest.ConferenceData(
135
+                createRequest: CreateEventRequest.CreateRequest(
136
+                    requestId: requestID,
137
+                    conferenceSolutionKey: CreateEventRequest.ConferenceSolutionKey(type: "hangoutsMeet")
138
+                )
139
+            )
140
+        )
141
+
142
+        var request = URLRequest(url: components.url!)
143
+        request.httpMethod = "POST"
144
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
145
+        request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
146
+        request.httpBody = try JSONEncoder().encode(body)
147
+
148
+        let (data, response) = try await session.data(for: request)
149
+        guard let http = response as? HTTPURLResponse else { throw GoogleCalendarClientError.invalidResponse }
150
+        guard (200..<300).contains(http.statusCode) else {
151
+            let raw = String(data: data, encoding: .utf8) ?? "<no body>"
152
+            throw GoogleCalendarClientError.httpStatus(http.statusCode, raw)
153
+        }
154
+
155
+        let created: EventItem
156
+        do {
157
+            created = try JSONDecoder().decode(EventItem.self, from: data)
158
+        } catch {
159
+            let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
160
+            throw GoogleCalendarClientError.decodeFailed(raw)
161
+        }
162
+
163
+        guard let startDate = created.start?.resolvedDate,
164
+              let endDate = created.end?.resolvedDate else {
165
+            throw GoogleCalendarClientError.decodeFailed("Created event missing start/end.")
166
+        }
167
+
168
+        let isAllDay = created.start?.date != nil
169
+        let meetURL: URL? = {
170
+            if let hangout = created.hangoutLink, let u = URL(string: hangout) { return u }
171
+            let entry = created.conferenceData?.entryPoints?.first(where: { $0.entryPointType == "video" && ($0.uri?.contains("meet.google.com") ?? false) })
172
+            if let uri = entry?.uri, let u = URL(string: uri) { return u }
173
+            if let htmlLink = created.htmlLink, let u = URL(string: htmlLink) { return u }
174
+            return nil
175
+        }()
176
+        guard let meetURL else {
177
+            throw GoogleCalendarClientError.decodeFailed("Created event missing Meet/HTML link.")
178
+        }
179
+
180
+        let cleanedTitle = created.summary?.trimmingCharacters(in: .whitespacesAndNewlines)
181
+        let subtitle = created.organizer?.displayName ?? created.organizer?.email
182
+        return ScheduledMeeting(
183
+            id: created.id ?? UUID().uuidString,
184
+            title: (cleanedTitle?.isEmpty == false) ? cleanedTitle! : "Untitled meeting",
185
+            subtitle: subtitle,
186
+            startDate: startDate,
187
+            endDate: endDate,
188
+            meetURL: meetURL,
189
+            isAllDay: isAllDay
190
+        )
191
+    }
116 192
 }
117 193
 
118 194
 extension GoogleCalendarClientError: LocalizedError {
@@ -194,6 +270,32 @@ private struct EntryPoint: Decodable {
194 270
     let uri: String?
195 271
 }
196 272
 
273
+private struct CreateEventRequest: Encodable {
274
+    struct EventDateTime: Encodable {
275
+        let dateTime: String
276
+        let timeZone: String
277
+    }
278
+
279
+    struct ConferenceSolutionKey: Encodable {
280
+        let type: String
281
+    }
282
+
283
+    struct CreateRequest: Encodable {
284
+        let requestId: String
285
+        let conferenceSolutionKey: ConferenceSolutionKey
286
+    }
287
+
288
+    struct ConferenceData: Encodable {
289
+        let createRequest: CreateRequest
290
+    }
291
+
292
+    let summary: String
293
+    let description: String?
294
+    let start: EventDateTime
295
+    let end: EventDateTime
296
+    let conferenceData: ConferenceData
297
+}
298
+
197 299
 private extension ISO8601DateFormatter {
198 300
     static let fractional: ISO8601DateFormatter = {
199 301
         let f = ISO8601DateFormatter()

+ 835 - 1
meetings_app/ViewController.swift

@@ -353,6 +353,16 @@ final class ViewController: NSViewController {
353 353
     private weak var schedulePageCardsStack: NSStackView?
354 354
     private weak var schedulePageCardsScrollView: NSScrollView?
355 355
 
356
+    // MARK: - Calendar page (custom month UI)
357
+    private var calendarPageMonthAnchor: Date = Calendar.current.startOfDay(for: Date())
358
+    private var calendarPageSelectedDate: Date = Calendar.current.startOfDay(for: Date())
359
+    private weak var calendarPageMonthLabel: NSTextField?
360
+    private weak var calendarPageGridStack: NSStackView?
361
+    private var calendarPageGridHeightConstraint: NSLayoutConstraint?
362
+    private weak var calendarPageDaySummaryLabel: NSTextField?
363
+    private var calendarPageActionPopover: NSPopover?
364
+    private var calendarPageCreatePopover: NSPopover?
365
+
356 366
     /// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
357 367
     private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
358 368
 
@@ -1356,7 +1366,7 @@ private extension ViewController {
1356 1366
         case .photo:
1357 1367
             built = makeSchedulePageContent()
1358 1368
         case .video:
1359
-            built = makePlaceholderPage(title: "Calendar", subtitle: "View meetings by date and track your plan.")
1369
+            built = makeCalendarPageContent()
1360 1370
         case .settings:
1361 1371
             built = makePlaceholderPage(title: "Settings", subtitle: "Preferences and account options.")
1362 1372
         }
@@ -1821,6 +1831,155 @@ private extension ViewController {
1821 1831
         return panel
1822 1832
     }
1823 1833
 
1834
+    func makeCalendarPageContent() -> NSView {
1835
+        let panel = NSView()
1836
+        panel.translatesAutoresizingMaskIntoConstraints = false
1837
+        panel.userInterfaceLayoutDirection = .leftToRight
1838
+
1839
+        let contentStack = NSStackView()
1840
+        contentStack.translatesAutoresizingMaskIntoConstraints = false
1841
+        contentStack.userInterfaceLayoutDirection = .leftToRight
1842
+        contentStack.orientation = .vertical
1843
+        contentStack.spacing = 14
1844
+        contentStack.alignment = .width
1845
+        contentStack.distribution = .fill
1846
+
1847
+        let titleRow = NSStackView()
1848
+        titleRow.translatesAutoresizingMaskIntoConstraints = false
1849
+        titleRow.userInterfaceLayoutDirection = .leftToRight
1850
+        titleRow.orientation = .horizontal
1851
+        titleRow.alignment = .centerY
1852
+        titleRow.distribution = .fill
1853
+        titleRow.spacing = 12
1854
+
1855
+        let titleLabel = textLabel("Calendar", font: typography.pageTitle, color: palette.textPrimary)
1856
+        titleLabel.alignment = .left
1857
+        titleLabel.maximumNumberOfLines = 1
1858
+        titleLabel.lineBreakMode = .byTruncatingTail
1859
+        titleLabel.setContentHuggingPriority(.required, for: .horizontal)
1860
+        titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
1861
+
1862
+        let spacer = NSView()
1863
+        spacer.translatesAutoresizingMaskIntoConstraints = false
1864
+        spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
1865
+        spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
1866
+
1867
+        let prevButton = makeCalendarHeaderPillButton(title: "‹", action: #selector(calendarPrevMonthPressed(_:)))
1868
+        prevButton.widthAnchor.constraint(equalToConstant: 44).isActive = true
1869
+        let nextButton = makeCalendarHeaderPillButton(title: "›", action: #selector(calendarNextMonthPressed(_:)))
1870
+        nextButton.widthAnchor.constraint(equalToConstant: 44).isActive = true
1871
+
1872
+        let monthLabel = textLabel("", font: NSFont.systemFont(ofSize: 16, weight: .semibold), color: palette.textSecondary)
1873
+        monthLabel.alignment = .right
1874
+        monthLabel.maximumNumberOfLines = 1
1875
+        monthLabel.lineBreakMode = .byTruncatingTail
1876
+        monthLabel.setContentHuggingPriority(.required, for: .horizontal)
1877
+        monthLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
1878
+        calendarPageMonthLabel = monthLabel
1879
+
1880
+        let refreshButton = HoverButton(title: "", target: self, action: #selector(calendarRefreshPressed(_:)))
1881
+        refreshButton.translatesAutoresizingMaskIntoConstraints = false
1882
+        refreshButton.isBordered = false
1883
+        refreshButton.bezelStyle = .regularSquare
1884
+        refreshButton.wantsLayer = true
1885
+        refreshButton.layer?.cornerRadius = 15
1886
+        refreshButton.layer?.masksToBounds = true
1887
+        refreshButton.layer?.backgroundColor = palette.inputBackground.cgColor
1888
+        refreshButton.layer?.borderColor = palette.inputBorder.cgColor
1889
+        refreshButton.layer?.borderWidth = 1
1890
+        refreshButton.setButtonType(.momentaryChange)
1891
+        refreshButton.contentTintColor = palette.textSecondary
1892
+        refreshButton.image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Sync calendar")
1893
+        refreshButton.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
1894
+        refreshButton.imagePosition = .imageOnly
1895
+        refreshButton.imageScaling = .scaleProportionallyDown
1896
+        refreshButton.focusRingType = .none
1897
+        refreshButton.heightAnchor.constraint(equalToConstant: 30).isActive = true
1898
+        refreshButton.widthAnchor.constraint(equalToConstant: 30).isActive = true
1899
+
1900
+        titleRow.addArrangedSubview(titleLabel)
1901
+        titleRow.addArrangedSubview(spacer)
1902
+        titleRow.addArrangedSubview(prevButton)
1903
+        titleRow.addArrangedSubview(nextButton)
1904
+        titleRow.addArrangedSubview(monthLabel)
1905
+        titleRow.addArrangedSubview(refreshButton)
1906
+
1907
+        let weekdayRow = NSStackView()
1908
+        weekdayRow.translatesAutoresizingMaskIntoConstraints = false
1909
+        weekdayRow.userInterfaceLayoutDirection = .leftToRight
1910
+        weekdayRow.orientation = .horizontal
1911
+        weekdayRow.alignment = .centerY
1912
+        weekdayRow.distribution = .fillEqually
1913
+        weekdayRow.spacing = 10
1914
+
1915
+        for symbol in calendarWeekdaySymbolsStartingAtFirstWeekday() {
1916
+            let label = textLabel(symbol, font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: palette.textMuted)
1917
+            label.alignment = .center
1918
+            label.maximumNumberOfLines = 1
1919
+            label.lineBreakMode = .byClipping
1920
+            weekdayRow.addArrangedSubview(label)
1921
+        }
1922
+
1923
+        let gridStack = NSStackView()
1924
+        gridStack.translatesAutoresizingMaskIntoConstraints = false
1925
+        gridStack.userInterfaceLayoutDirection = .leftToRight
1926
+        gridStack.orientation = .vertical
1927
+        gridStack.alignment = .width
1928
+        gridStack.distribution = .fillEqually
1929
+        gridStack.spacing = 10
1930
+        calendarPageGridStack = gridStack
1931
+
1932
+        let gridCard = roundedContainer(cornerRadius: 14, color: palette.sectionCard)
1933
+        gridCard.translatesAutoresizingMaskIntoConstraints = false
1934
+        styleSurface(gridCard, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
1935
+        let gridHeightConstraint = gridCard.heightAnchor.constraint(equalToConstant: 320)
1936
+        gridHeightConstraint.isActive = true
1937
+        calendarPageGridHeightConstraint = gridHeightConstraint
1938
+        gridCard.addSubview(gridStack)
1939
+        NSLayoutConstraint.activate([
1940
+            gridStack.leadingAnchor.constraint(equalTo: gridCard.leadingAnchor, constant: 12),
1941
+            gridStack.trailingAnchor.constraint(equalTo: gridCard.trailingAnchor, constant: -12),
1942
+            gridStack.topAnchor.constraint(equalTo: gridCard.topAnchor, constant: 12),
1943
+            gridStack.bottomAnchor.constraint(equalTo: gridCard.bottomAnchor, constant: -12)
1944
+        ])
1945
+
1946
+        let daySummary = textLabel("", font: typography.dateHeading, color: palette.textSecondary)
1947
+        daySummary.alignment = .left
1948
+        daySummary.maximumNumberOfLines = 2
1949
+        daySummary.lineBreakMode = .byWordWrapping
1950
+        calendarPageDaySummaryLabel = daySummary
1951
+
1952
+        contentStack.addArrangedSubview(titleRow)
1953
+        contentStack.addArrangedSubview(weekdayRow)
1954
+        contentStack.addArrangedSubview(gridCard)
1955
+        contentStack.addArrangedSubview(daySummary)
1956
+
1957
+        panel.addSubview(contentStack)
1958
+        NSLayoutConstraint.activate([
1959
+            contentStack.leftAnchor.constraint(equalTo: panel.leftAnchor, constant: 28),
1960
+            contentStack.rightAnchor.constraint(equalTo: panel.rightAnchor, constant: -28),
1961
+            contentStack.topAnchor.constraint(equalTo: panel.topAnchor),
1962
+            contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -16),
1963
+            titleRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
1964
+            weekdayRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
1965
+            gridCard.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
1966
+            daySummary.widthAnchor.constraint(equalTo: contentStack.widthAnchor)
1967
+        ])
1968
+
1969
+        let calendar = Calendar.current
1970
+        calendarPageMonthAnchor = calendarStartOfMonth(for: Date())
1971
+        calendarPageSelectedDate = calendar.startOfDay(for: Date())
1972
+        calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
1973
+        renderCalendarMonthGrid()
1974
+        renderCalendarSelectedDay()
1975
+
1976
+        Task { [weak self] in
1977
+            await self?.loadSchedule()
1978
+        }
1979
+
1980
+        return panel
1981
+    }
1982
+
1824 1983
     func meetJoinSectionRow() -> NSView {
1825 1984
         let row = NSStackView()
1826 1985
         row.translatesAutoresizingMaskIntoConstraints = false
@@ -4312,6 +4471,666 @@ private extension ViewController {
4312 4471
     }
4313 4472
 }
4314 4473
 
4474
+private let calendarDayKeyFormatter: DateFormatter = {
4475
+    let f = DateFormatter()
4476
+    f.calendar = Calendar(identifier: .gregorian)
4477
+    f.locale = Locale(identifier: "en_US_POSIX")
4478
+    f.timeZone = TimeZone.current
4479
+    f.dateFormat = "yyyy-MM-dd"
4480
+    return f
4481
+}()
4482
+
4483
+// MARK: - Calendar page actions + rendering
4484
+
4485
+private extension ViewController {
4486
+    private func makeCalendarHeaderPillButton(title: String, action: Selector) -> NSButton {
4487
+        let button = makeSchedulePillButton(title: title)
4488
+        button.target = self
4489
+        button.action = action
4490
+        button.heightAnchor.constraint(equalToConstant: 30).isActive = true
4491
+        return button
4492
+    }
4493
+
4494
+    private func calendarStartOfMonth(for date: Date) -> Date {
4495
+        let calendar = Calendar.current
4496
+        let comps = calendar.dateComponents([.year, .month], from: date)
4497
+        return calendar.date(from: comps) ?? calendar.startOfDay(for: date)
4498
+    }
4499
+
4500
+    private func calendarMonthTitleText(for monthAnchor: Date) -> String {
4501
+        let f = DateFormatter()
4502
+        f.locale = Locale.current
4503
+        f.timeZone = TimeZone.current
4504
+        f.dateFormat = "MMMM yyyy"
4505
+        return f.string(from: monthAnchor)
4506
+    }
4507
+
4508
+    private func calendarWeekdaySymbolsStartingAtFirstWeekday() -> [String] {
4509
+        // Align weekday header to Calendar.current.firstWeekday
4510
+        let calendar = Calendar.current
4511
+        var symbols = DateFormatter().veryShortWeekdaySymbols ?? ["S", "M", "T", "W", "T", "F", "S"]
4512
+        // veryShortWeekdaySymbols starts with Sunday in most locales; rotate to firstWeekday.
4513
+        let first = max(1, min(7, calendar.firstWeekday)) // 1..7
4514
+        let shift = (first - 1) % 7
4515
+        if shift == 0 { return symbols }
4516
+        let head = Array(symbols[shift...])
4517
+        let tail = Array(symbols[..<shift])
4518
+        symbols = head + tail
4519
+        return symbols
4520
+    }
4521
+
4522
+    @objc func calendarPrevMonthPressed(_ sender: NSButton) {
4523
+        let calendar = Calendar.current
4524
+        calendarPageMonthAnchor = calendar.date(byAdding: .month, value: -1, to: calendarPageMonthAnchor).map(calendarStartOfMonth(for:)) ?? calendarPageMonthAnchor
4525
+        calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
4526
+        renderCalendarMonthGrid()
4527
+    }
4528
+
4529
+    @objc func calendarNextMonthPressed(_ sender: NSButton) {
4530
+        let calendar = Calendar.current
4531
+        calendarPageMonthAnchor = calendar.date(byAdding: .month, value: 1, to: calendarPageMonthAnchor).map(calendarStartOfMonth(for:)) ?? calendarPageMonthAnchor
4532
+        calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
4533
+        renderCalendarMonthGrid()
4534
+    }
4535
+
4536
+    @objc func calendarRefreshPressed(_ sender: NSButton) {
4537
+        Task { [weak self] in
4538
+            await self?.loadSchedule()
4539
+        }
4540
+    }
4541
+
4542
+    @objc func calendarDayCellPressed(_ sender: NSButton) {
4543
+        guard let raw = sender.identifier?.rawValue,
4544
+              let date = calendarDayKeyFormatter.date(from: raw) else { return }
4545
+        calendarPageSelectedDate = Calendar.current.startOfDay(for: date)
4546
+        renderCalendarMonthGrid()
4547
+        renderCalendarSelectedDay()
4548
+        if let refreshedButton = calendarButton(forDateKey: raw) {
4549
+            showCalendarDayActionPopover(relativeTo: refreshedButton)
4550
+        }
4551
+    }
4552
+
4553
+    private func showCalendarDayActionPopover(relativeTo anchor: NSView) {
4554
+        guard anchor.window != nil else { return }
4555
+        calendarPageActionPopover?.performClose(nil)
4556
+        let popover = NSPopover()
4557
+        popover.behavior = .transient
4558
+        popover.animates = true
4559
+        popover.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
4560
+        popover.contentViewController = CalendarDayActionMenuViewController(
4561
+            palette: palette,
4562
+            onSchedule: { [weak self] in
4563
+                self?.calendarPageActionPopover?.performClose(nil)
4564
+                self?.calendarPageActionPopover = nil
4565
+                self?.presentCreateMeetingPopover(relativeTo: anchor)
4566
+            }
4567
+        )
4568
+        calendarPageActionPopover = popover
4569
+        popover.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxY)
4570
+    }
4571
+
4572
+    private func presentCreateMeetingPopover(relativeTo anchor: NSView) {
4573
+        guard anchor.window != nil else { return }
4574
+        calendarPageCreatePopover?.performClose(nil)
4575
+        let popover = NSPopover()
4576
+        popover.behavior = .transient
4577
+        popover.animates = true
4578
+        popover.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
4579
+        let selectedDate = Calendar.current.startOfDay(for: calendarPageSelectedDate)
4580
+        let vc = CreateMeetingPopoverViewController(
4581
+            palette: palette,
4582
+            typography: typography,
4583
+            selectedDate: selectedDate,
4584
+            onCancel: { [weak self] in
4585
+                self?.calendarPageCreatePopover?.performClose(nil)
4586
+                self?.calendarPageCreatePopover = nil
4587
+            },
4588
+            onSave: { [weak self] draft in
4589
+                guard let self else { return }
4590
+                self.calendarPageCreatePopover?.performClose(nil)
4591
+                self.calendarPageCreatePopover = nil
4592
+                self.calendarCreateMeeting(title: draft.title, notes: draft.notes, start: draft.startDate, end: draft.endDate)
4593
+            }
4594
+        )
4595
+        popover.contentViewController = vc
4596
+        calendarPageCreatePopover = popover
4597
+        popover.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxY)
4598
+    }
4599
+
4600
+    private func calendarCreateMeeting(title: String, notes: String?, start: Date, end: Date) {
4601
+        Task { [weak self] in
4602
+            guard let self else { return }
4603
+            do {
4604
+                try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
4605
+                let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
4606
+                _ = try await self.calendarClient.createEvent(
4607
+                    accessToken: token,
4608
+                    title: title,
4609
+                    description: notes,
4610
+                    start: start,
4611
+                    end: end,
4612
+                    timeZone: .current
4613
+                )
4614
+                await self.loadSchedule()
4615
+                await MainActor.run {
4616
+                    let calendar = Calendar.current
4617
+                    self.calendarPageSelectedDate = calendar.startOfDay(for: start)
4618
+                    self.calendarPageMonthAnchor = self.calendarStartOfMonth(for: start)
4619
+                    self.calendarPageMonthLabel?.stringValue = self.calendarMonthTitleText(for: self.calendarPageMonthAnchor)
4620
+                    self.renderCalendarMonthGrid()
4621
+                    self.renderCalendarSelectedDay()
4622
+                }
4623
+            } catch {
4624
+                self.showSimpleError("Couldn’t create meeting.", error: error)
4625
+            }
4626
+        }
4627
+    }
4628
+
4629
+    private func renderCalendarMonthGrid() {
4630
+        guard let gridStack = calendarPageGridStack else { return }
4631
+        gridStack.arrangedSubviews.forEach { v in
4632
+            gridStack.removeArrangedSubview(v)
4633
+            v.removeFromSuperview()
4634
+        }
4635
+
4636
+        let calendar = Calendar.current
4637
+        let monthStart = calendarStartOfMonth(for: calendarPageMonthAnchor)
4638
+        guard let dayRange = calendar.range(of: .day, in: .month, for: monthStart),
4639
+              let monthEnd = calendar.date(byAdding: DateComponents(month: 1, day: 0), to: monthStart) else { return }
4640
+
4641
+        let firstWeekday = calendar.component(.weekday, from: monthStart) // 1..7
4642
+        let leadingEmpty = (firstWeekday - calendar.firstWeekday + 7) % 7
4643
+        let totalDays = dayRange.count
4644
+        let totalCells = leadingEmpty + totalDays
4645
+        let rowCount = Int(ceil(Double(totalCells) / 7.0))
4646
+        let rowHeight: CGFloat = 44
4647
+        let rowSpacing: CGFloat = 10
4648
+        let verticalPadding: CGFloat = 24
4649
+        calendarPageGridHeightConstraint?.constant = verticalPadding + (CGFloat(rowCount) * rowHeight) + (CGFloat(max(0, rowCount - 1)) * rowSpacing)
4650
+
4651
+        let meetingCounts = calendarMeetingCountsByDay(from: scheduleCachedMeetings, monthStart: monthStart, monthEnd: monthEnd)
4652
+
4653
+        var day = 1
4654
+        for _ in 0..<rowCount {
4655
+            let row = NSStackView()
4656
+            row.translatesAutoresizingMaskIntoConstraints = false
4657
+            row.userInterfaceLayoutDirection = .leftToRight
4658
+            row.orientation = .horizontal
4659
+            row.alignment = .top
4660
+            row.distribution = .fillEqually
4661
+            row.spacing = rowSpacing
4662
+            row.heightAnchor.constraint(equalToConstant: rowHeight).isActive = true
4663
+
4664
+            for col in 0..<7 {
4665
+                let cellIndex = (gridStack.arrangedSubviews.count * 7) + col
4666
+                if cellIndex < leadingEmpty || day > totalDays {
4667
+                    row.addArrangedSubview(calendarEmptyDayCell())
4668
+                    continue
4669
+                }
4670
+                guard let date = calendar.date(byAdding: .day, value: day - 1, to: monthStart) else {
4671
+                    row.addArrangedSubview(calendarEmptyDayCell())
4672
+                    continue
4673
+                }
4674
+                let isSelected = calendar.isDate(date, inSameDayAs: calendarPageSelectedDate)
4675
+                let key = calendarDayKeyFormatter.string(from: calendar.startOfDay(for: date))
4676
+                let count = meetingCounts[key] ?? 0
4677
+                row.addArrangedSubview(calendarDayCell(dayNumber: day, dateKey: key, meetingCount: count, isSelected: isSelected))
4678
+                day += 1
4679
+            }
4680
+            gridStack.addArrangedSubview(row)
4681
+            row.widthAnchor.constraint(equalTo: gridStack.widthAnchor).isActive = true
4682
+        }
4683
+    }
4684
+
4685
+    private func calendarButton(forDateKey key: String) -> NSButton? {
4686
+        guard let gridStack = calendarPageGridStack else { return nil }
4687
+        for rowView in gridStack.arrangedSubviews {
4688
+            guard let row = rowView as? NSStackView else { continue }
4689
+            for cell in row.arrangedSubviews {
4690
+                if let button = cell as? NSButton, button.identifier?.rawValue == key {
4691
+                    return button
4692
+                }
4693
+            }
4694
+        }
4695
+        return nil
4696
+    }
4697
+
4698
+    private func renderCalendarSelectedDay() {
4699
+        let calendar = Calendar.current
4700
+        let selectedDay = calendar.startOfDay(for: calendarPageSelectedDate)
4701
+        let nextDay = calendar.date(byAdding: .day, value: 1, to: selectedDay) ?? selectedDay.addingTimeInterval(86400)
4702
+
4703
+        let meetings = scheduleCachedMeetings
4704
+            .filter { $0.startDate >= selectedDay && $0.startDate < nextDay }
4705
+            .sorted(by: { $0.startDate < $1.startDate })
4706
+
4707
+        let f = DateFormatter()
4708
+        f.locale = Locale.current
4709
+        f.timeZone = TimeZone.current
4710
+        f.dateFormat = "EEE, d MMM"
4711
+
4712
+        if meetings.isEmpty {
4713
+            calendarPageDaySummaryLabel?.stringValue = googleOAuth.loadTokens() == nil
4714
+                ? "Connect Google to see meetings"
4715
+                : "No meetings on \(f.string(from: selectedDay))"
4716
+        } else if meetings.count == 1 {
4717
+            calendarPageDaySummaryLabel?.stringValue = "1 meeting on \(f.string(from: selectedDay))"
4718
+        } else {
4719
+            calendarPageDaySummaryLabel?.stringValue = "\(meetings.count) meetings on \(f.string(from: selectedDay))"
4720
+        }
4721
+    }
4722
+
4723
+    private func calendarMeetingCountsByDay(from meetings: [ScheduledMeeting], monthStart: Date, monthEnd: Date) -> [String: Int] {
4724
+        let calendar = Calendar.current
4725
+        var counts: [String: Int] = [:]
4726
+        for meeting in meetings {
4727
+            guard meeting.startDate >= monthStart && meeting.startDate < monthEnd else { continue }
4728
+            let key = calendarDayKeyFormatter.string(from: calendar.startOfDay(for: meeting.startDate))
4729
+            counts[key, default: 0] += 1
4730
+        }
4731
+        return counts
4732
+    }
4733
+
4734
+    private func calendarEmptyDayCell() -> NSView {
4735
+        let v = NSView()
4736
+        v.translatesAutoresizingMaskIntoConstraints = false
4737
+        v.heightAnchor.constraint(equalToConstant: 44).isActive = true
4738
+        return v
4739
+    }
4740
+
4741
+    private func calendarDayCell(dayNumber: Int, dateKey: String, meetingCount: Int, isSelected: Bool) -> NSButton {
4742
+        let button = HoverButton(title: "", target: self, action: #selector(calendarDayCellPressed(_:)))
4743
+        button.translatesAutoresizingMaskIntoConstraints = false
4744
+        button.isBordered = false
4745
+        button.bezelStyle = .regularSquare
4746
+        button.wantsLayer = true
4747
+        button.layer?.cornerRadius = 10
4748
+        button.layer?.masksToBounds = true
4749
+        button.identifier = NSUserInterfaceItemIdentifier(dateKey)
4750
+        button.heightAnchor.constraint(equalToConstant: 44).isActive = true
4751
+        button.setContentHuggingPriority(.required, for: .vertical)
4752
+        button.setContentCompressionResistancePriority(.required, for: .vertical)
4753
+        button.alignment = .left
4754
+        button.imagePosition = .noImage
4755
+
4756
+        let base = palette.inputBackground
4757
+        let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
4758
+        let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
4759
+        let selectedBackground = darkModeEnabled
4760
+            ? NSColor(calibratedRed: 30.0 / 255.0, green: 34.0 / 255.0, blue: 42.0 / 255.0, alpha: 1)
4761
+            : NSColor(calibratedRed: 255.0 / 255.0, green: 246.0 / 255.0, blue: 236.0 / 255.0, alpha: 1)
4762
+        let borderIdle = palette.inputBorder
4763
+        let borderSelected = palette.primaryBlueBorder
4764
+
4765
+        func applyAppearance(hovering: Bool) {
4766
+            button.layer?.backgroundColor = (isSelected ? selectedBackground : (hovering ? hover : base)).cgColor
4767
+            button.layer?.borderWidth = isSelected ? 1.5 : 1
4768
+            button.layer?.borderColor = (isSelected ? borderSelected : borderIdle).cgColor
4769
+        }
4770
+        applyAppearance(hovering: false)
4771
+        button.onHoverChanged = { hovering in
4772
+            applyAppearance(hovering: hovering)
4773
+        }
4774
+
4775
+        button.attributedTitle = NSAttributedString(
4776
+            string: "  \(dayNumber)",
4777
+            attributes: [
4778
+                .font: NSFont.systemFont(ofSize: 14, weight: .bold),
4779
+                .foregroundColor: palette.textPrimary
4780
+            ]
4781
+        )
4782
+
4783
+        if meetingCount > 0 {
4784
+            let dot = roundedContainer(cornerRadius: 4, color: palette.meetingBadge)
4785
+            dot.translatesAutoresizingMaskIntoConstraints = false
4786
+            dot.layer?.borderWidth = 0
4787
+            button.addSubview(dot)
4788
+            NSLayoutConstraint.activate([
4789
+                dot.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -10),
4790
+                dot.centerYAnchor.constraint(equalTo: button.centerYAnchor),
4791
+                dot.widthAnchor.constraint(equalToConstant: 8),
4792
+                dot.heightAnchor.constraint(equalToConstant: 8)
4793
+            ])
4794
+        }
4795
+
4796
+        return button
4797
+    }
4798
+}
4799
+
4800
+private final class CalendarDayActionMenuViewController: NSViewController {
4801
+    private let palette: Palette
4802
+    private let onSchedule: () -> Void
4803
+
4804
+    init(palette: Palette, onSchedule: @escaping () -> Void) {
4805
+        self.palette = palette
4806
+        self.onSchedule = onSchedule
4807
+        super.init(nibName: nil, bundle: nil)
4808
+    }
4809
+
4810
+    required init?(coder: NSCoder) { nil }
4811
+
4812
+    override func loadView() {
4813
+        let root = NSView()
4814
+        root.translatesAutoresizingMaskIntoConstraints = false
4815
+
4816
+        let stack = NSStackView()
4817
+        stack.translatesAutoresizingMaskIntoConstraints = false
4818
+        stack.orientation = .vertical
4819
+        stack.alignment = .leading
4820
+        stack.spacing = 10
4821
+
4822
+        let title = NSTextField(labelWithString: "Actions")
4823
+        title.font = NSFont.systemFont(ofSize: 12, weight: .semibold)
4824
+        title.textColor = palette.textMuted
4825
+
4826
+        let schedule = NSButton(title: "Schedule meeting", target: self, action: #selector(schedulePressed(_:)))
4827
+        schedule.bezelStyle = .rounded
4828
+        schedule.font = NSFont.systemFont(ofSize: 13, weight: .medium)
4829
+
4830
+        stack.addArrangedSubview(title)
4831
+        stack.addArrangedSubview(schedule)
4832
+        root.addSubview(stack)
4833
+
4834
+        NSLayoutConstraint.activate([
4835
+            root.widthAnchor.constraint(equalToConstant: 220),
4836
+            root.heightAnchor.constraint(greaterThanOrEqualToConstant: 86),
4837
+            stack.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 14),
4838
+            stack.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -14),
4839
+            stack.topAnchor.constraint(equalTo: root.topAnchor, constant: 12),
4840
+            stack.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -12)
4841
+        ])
4842
+
4843
+        view = root
4844
+    }
4845
+
4846
+    @objc private func schedulePressed(_ sender: NSButton) {
4847
+        onSchedule()
4848
+    }
4849
+}
4850
+
4851
+private final class CreateMeetingPopoverViewController: NSViewController {
4852
+    struct Draft {
4853
+        let title: String
4854
+        let notes: String?
4855
+        let startDate: Date
4856
+        let endDate: Date
4857
+    }
4858
+
4859
+    private let palette: Palette
4860
+    private let typography: Typography
4861
+    private let selectedDate: Date
4862
+    private let onCancel: () -> Void
4863
+    private let onSave: (Draft) -> Void
4864
+
4865
+    private var titleField: NSTextField?
4866
+    private var timePicker: NSDatePicker?
4867
+    private var durationField: NSTextField?
4868
+    private var notesView: NSTextView?
4869
+    private var errorLabel: NSTextField?
4870
+
4871
+    init(palette: Palette,
4872
+         typography: Typography,
4873
+         selectedDate: Date,
4874
+         onCancel: @escaping () -> Void,
4875
+         onSave: @escaping (Draft) -> Void) {
4876
+        self.palette = palette
4877
+        self.typography = typography
4878
+        self.selectedDate = selectedDate
4879
+        self.onCancel = onCancel
4880
+        self.onSave = onSave
4881
+        super.init(nibName: nil, bundle: nil)
4882
+    }
4883
+
4884
+    required init?(coder: NSCoder) { nil }
4885
+
4886
+    override func loadView() {
4887
+        let root = NSView()
4888
+        root.translatesAutoresizingMaskIntoConstraints = false
4889
+
4890
+        let stack = NSStackView()
4891
+        stack.translatesAutoresizingMaskIntoConstraints = false
4892
+        stack.orientation = .vertical
4893
+        stack.alignment = .width
4894
+        stack.spacing = 12
4895
+
4896
+        let header = NSTextField(labelWithString: "Schedule meeting")
4897
+        header.font = NSFont.systemFont(ofSize: 14, weight: .semibold)
4898
+        header.textColor = palette.textPrimary
4899
+
4900
+        let titleLabel = NSTextField(labelWithString: "Title")
4901
+        titleLabel.font = typography.fieldLabel
4902
+        titleLabel.textColor = palette.textSecondary
4903
+
4904
+        let titleShell = NSView()
4905
+        titleShell.translatesAutoresizingMaskIntoConstraints = false
4906
+        titleShell.wantsLayer = true
4907
+        titleShell.layer?.cornerRadius = 8
4908
+        titleShell.layer?.backgroundColor = palette.inputBackground.cgColor
4909
+        titleShell.layer?.borderColor = palette.inputBorder.cgColor
4910
+        titleShell.layer?.borderWidth = 1
4911
+        titleShell.heightAnchor.constraint(equalToConstant: 40).isActive = true
4912
+
4913
+        let titleField = NSTextField(string: "")
4914
+        titleField.translatesAutoresizingMaskIntoConstraints = false
4915
+        titleField.isBordered = false
4916
+        titleField.drawsBackground = false
4917
+        titleField.focusRingType = .none
4918
+        titleField.font = NSFont.systemFont(ofSize: 14, weight: .regular)
4919
+        titleField.textColor = palette.textPrimary
4920
+        titleField.placeholderString = "Team sync"
4921
+        titleShell.addSubview(titleField)
4922
+        NSLayoutConstraint.activate([
4923
+            titleField.leadingAnchor.constraint(equalTo: titleShell.leadingAnchor, constant: 10),
4924
+            titleField.trailingAnchor.constraint(equalTo: titleShell.trailingAnchor, constant: -10),
4925
+            titleField.centerYAnchor.constraint(equalTo: titleShell.centerYAnchor)
4926
+        ])
4927
+        self.titleField = titleField
4928
+
4929
+        let timeRow = NSStackView()
4930
+        timeRow.translatesAutoresizingMaskIntoConstraints = false
4931
+        timeRow.orientation = .horizontal
4932
+        timeRow.alignment = .centerY
4933
+        timeRow.spacing = 10
4934
+        timeRow.distribution = .fill
4935
+
4936
+        let startLabel = NSTextField(labelWithString: "Start")
4937
+        startLabel.font = typography.fieldLabel
4938
+        startLabel.textColor = palette.textSecondary
4939
+
4940
+        let pickerShell = NSView()
4941
+        pickerShell.translatesAutoresizingMaskIntoConstraints = false
4942
+        pickerShell.wantsLayer = true
4943
+        pickerShell.layer?.cornerRadius = 8
4944
+        pickerShell.layer?.backgroundColor = palette.inputBackground.cgColor
4945
+        pickerShell.layer?.borderColor = palette.inputBorder.cgColor
4946
+        pickerShell.layer?.borderWidth = 1
4947
+        pickerShell.heightAnchor.constraint(equalToConstant: 34).isActive = true
4948
+
4949
+        let timePicker = NSDatePicker()
4950
+        timePicker.translatesAutoresizingMaskIntoConstraints = false
4951
+        timePicker.isBordered = false
4952
+        timePicker.drawsBackground = false
4953
+        timePicker.focusRingType = .none
4954
+        timePicker.datePickerStyle = .textFieldAndStepper
4955
+        timePicker.datePickerElements = [.hourMinute]
4956
+        timePicker.font = typography.filterText
4957
+        timePicker.textColor = palette.textSecondary
4958
+        timePicker.dateValue = Date()
4959
+        pickerShell.addSubview(timePicker)
4960
+        NSLayoutConstraint.activate([
4961
+            timePicker.leadingAnchor.constraint(equalTo: pickerShell.leadingAnchor, constant: 8),
4962
+            timePicker.trailingAnchor.constraint(equalTo: pickerShell.trailingAnchor, constant: -8),
4963
+            timePicker.centerYAnchor.constraint(equalTo: pickerShell.centerYAnchor)
4964
+        ])
4965
+        self.timePicker = timePicker
4966
+
4967
+        let durationLabel = NSTextField(labelWithString: "Duration (min)")
4968
+        durationLabel.font = typography.fieldLabel
4969
+        durationLabel.textColor = palette.textSecondary
4970
+
4971
+        let durationShell = NSView()
4972
+        durationShell.translatesAutoresizingMaskIntoConstraints = false
4973
+        durationShell.wantsLayer = true
4974
+        durationShell.layer?.cornerRadius = 8
4975
+        durationShell.layer?.backgroundColor = palette.inputBackground.cgColor
4976
+        durationShell.layer?.borderColor = palette.inputBorder.cgColor
4977
+        durationShell.layer?.borderWidth = 1
4978
+        durationShell.heightAnchor.constraint(equalToConstant: 34).isActive = true
4979
+
4980
+        let durationField = NSTextField(string: "30")
4981
+        durationField.translatesAutoresizingMaskIntoConstraints = false
4982
+        durationField.isBordered = false
4983
+        durationField.drawsBackground = false
4984
+        durationField.focusRingType = .none
4985
+        durationField.font = typography.filterText
4986
+        durationField.textColor = palette.textSecondary
4987
+        durationField.formatter = NumberFormatter()
4988
+        durationShell.addSubview(durationField)
4989
+        NSLayoutConstraint.activate([
4990
+            durationField.leadingAnchor.constraint(equalTo: durationShell.leadingAnchor, constant: 8),
4991
+            durationField.trailingAnchor.constraint(equalTo: durationShell.trailingAnchor, constant: -8),
4992
+            durationField.centerYAnchor.constraint(equalTo: durationShell.centerYAnchor)
4993
+        ])
4994
+        self.durationField = durationField
4995
+
4996
+        let startGroup = NSStackView(views: [startLabel, pickerShell])
4997
+        startGroup.translatesAutoresizingMaskIntoConstraints = false
4998
+        startGroup.orientation = .vertical
4999
+        startGroup.alignment = .leading
5000
+        startGroup.spacing = 6
5001
+
5002
+        let durationGroup = NSStackView(views: [durationLabel, durationShell])
5003
+        durationGroup.translatesAutoresizingMaskIntoConstraints = false
5004
+        durationGroup.orientation = .vertical
5005
+        durationGroup.alignment = .leading
5006
+        durationGroup.spacing = 6
5007
+
5008
+        timeRow.addArrangedSubview(startGroup)
5009
+        timeRow.addArrangedSubview(durationGroup)
5010
+        startGroup.widthAnchor.constraint(equalTo: durationGroup.widthAnchor).isActive = true
5011
+
5012
+        let notesLabel = NSTextField(labelWithString: "Notes")
5013
+        notesLabel.font = typography.fieldLabel
5014
+        notesLabel.textColor = palette.textSecondary
5015
+
5016
+        let notesScroll = NSScrollView()
5017
+        notesScroll.translatesAutoresizingMaskIntoConstraints = false
5018
+        notesScroll.drawsBackground = true
5019
+        notesScroll.backgroundColor = palette.inputBackground
5020
+        notesScroll.hasVerticalScroller = true
5021
+        notesScroll.borderType = .noBorder
5022
+        notesScroll.wantsLayer = true
5023
+        notesScroll.layer?.cornerRadius = 8
5024
+        notesScroll.layer?.borderWidth = 1
5025
+        notesScroll.layer?.borderColor = palette.inputBorder.cgColor
5026
+        notesScroll.heightAnchor.constraint(equalToConstant: 90).isActive = true
5027
+
5028
+        let notesView = NSTextView()
5029
+        notesView.drawsBackground = false
5030
+        notesView.font = NSFont.systemFont(ofSize: 13, weight: .regular)
5031
+        notesView.textColor = palette.textPrimary
5032
+        notesView.insertionPointColor = palette.textPrimary
5033
+        notesScroll.documentView = notesView
5034
+        self.notesView = notesView
5035
+
5036
+        let error = NSTextField(labelWithString: "")
5037
+        error.translatesAutoresizingMaskIntoConstraints = false
5038
+        error.textColor = NSColor.systemRed
5039
+        error.font = NSFont.systemFont(ofSize: 12, weight: .semibold)
5040
+        error.isHidden = true
5041
+        self.errorLabel = error
5042
+
5043
+        let actions = NSStackView()
5044
+        actions.translatesAutoresizingMaskIntoConstraints = false
5045
+        actions.orientation = .horizontal
5046
+        actions.alignment = .centerY
5047
+        actions.spacing = 10
5048
+
5049
+        let cancel = NSButton(title: "Cancel", target: self, action: #selector(cancelPressed(_:)))
5050
+        cancel.bezelStyle = .rounded
5051
+
5052
+        let save = NSButton(title: "Save", target: self, action: #selector(savePressed(_:)))
5053
+        save.bezelStyle = .rounded
5054
+        save.keyEquivalent = "\r"
5055
+
5056
+        let actionsSpacer = NSView()
5057
+        actionsSpacer.translatesAutoresizingMaskIntoConstraints = false
5058
+        actionsSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
5059
+
5060
+        actions.addArrangedSubview(cancel)
5061
+        actions.addArrangedSubview(actionsSpacer)
5062
+        actions.addArrangedSubview(save)
5063
+
5064
+        stack.addArrangedSubview(header)
5065
+        stack.addArrangedSubview(titleLabel)
5066
+        stack.addArrangedSubview(titleShell)
5067
+        stack.addArrangedSubview(timeRow)
5068
+        stack.addArrangedSubview(notesLabel)
5069
+        stack.addArrangedSubview(notesScroll)
5070
+        stack.addArrangedSubview(error)
5071
+        stack.addArrangedSubview(actions)
5072
+
5073
+        root.addSubview(stack)
5074
+        NSLayoutConstraint.activate([
5075
+            root.widthAnchor.constraint(equalToConstant: 360),
5076
+            stack.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 14),
5077
+            stack.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -14),
5078
+            stack.topAnchor.constraint(equalTo: root.topAnchor, constant: 12),
5079
+            stack.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -12),
5080
+            actions.widthAnchor.constraint(equalTo: stack.widthAnchor)
5081
+        ])
5082
+
5083
+        view = root
5084
+    }
5085
+
5086
+    @objc private func cancelPressed(_ sender: NSButton) {
5087
+        onCancel()
5088
+    }
5089
+
5090
+    @objc private func savePressed(_ sender: NSButton) {
5091
+        setError(nil)
5092
+
5093
+        let title = (titleField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
5094
+        if title.isEmpty {
5095
+            setError("Please enter a title.")
5096
+            return
5097
+        }
5098
+
5099
+        let durationText = (durationField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
5100
+        let durationMinutes = Int(durationText) ?? 0
5101
+        if durationMinutes <= 0 {
5102
+            setError("Duration must be a positive number of minutes.")
5103
+            return
5104
+        }
5105
+
5106
+        let time = timePicker?.dateValue ?? Date()
5107
+        let calendar = Calendar.current
5108
+        let dateParts = calendar.dateComponents([.year, .month, .day], from: selectedDate)
5109
+        let timeParts = calendar.dateComponents([.hour, .minute], from: time)
5110
+        var merged = DateComponents()
5111
+        merged.year = dateParts.year
5112
+        merged.month = dateParts.month
5113
+        merged.day = dateParts.day
5114
+        merged.hour = timeParts.hour
5115
+        merged.minute = timeParts.minute
5116
+        guard let start = calendar.date(from: merged) else {
5117
+            setError("Invalid start time.")
5118
+            return
5119
+        }
5120
+        let end = start.addingTimeInterval(TimeInterval(durationMinutes * 60))
5121
+        let notes = notesView?.string.trimmingCharacters(in: .whitespacesAndNewlines)
5122
+        let cleanedNotes = (notes?.isEmpty == false) ? notes : nil
5123
+
5124
+        onSave(Draft(title: title, notes: cleanedNotes, startDate: start, endDate: end))
5125
+    }
5126
+
5127
+    private func setError(_ message: String?) {
5128
+        guard let errorLabel else { return }
5129
+        errorLabel.stringValue = message ?? ""
5130
+        errorLabel.isHidden = message == nil
5131
+    }
5132
+}
5133
+
4315 5134
 // MARK: - Schedule actions (OAuth entry)
4316 5135
 
4317 5136
 private extension ViewController {
@@ -4684,6 +5503,11 @@ private extension ViewController {
4684 5503
                     }
4685 5504
                     scheduleCachedMeetings = []
4686 5505
                     applySchedulePageFiltersAndRender()
5506
+                    if calendarPageGridStack != nil {
5507
+                        calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
5508
+                        renderCalendarMonthGrid()
5509
+                        renderCalendarSelectedDay()
5510
+                    }
4687 5511
                 }
4688 5512
                 return
4689 5513
             }
@@ -4702,6 +5526,11 @@ private extension ViewController {
4702 5526
                 }
4703 5527
                 scheduleCachedMeetings = meetings
4704 5528
                 applySchedulePageFiltersAndRender()
5529
+                if calendarPageGridStack != nil {
5530
+                    calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
5531
+                    renderCalendarMonthGrid()
5532
+                    renderCalendarSelectedDay()
5533
+                }
4705 5534
             }
4706 5535
         } catch {
4707 5536
             await MainActor.run {
@@ -4716,6 +5545,11 @@ private extension ViewController {
4716 5545
                 }
4717 5546
                 scheduleCachedMeetings = []
4718 5547
                 applySchedulePageFiltersAndRender()
5548
+                if calendarPageGridStack != nil {
5549
+                    calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
5550
+                    renderCalendarMonthGrid()
5551
+                    renderCalendarSelectedDay()
5552
+                }
4719 5553
                 showSimpleError("Couldn’t load schedule.", error: error)
4720 5554
             }
4721 5555
         }