Переглянути джерело

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 3 місяців тому
батько
коміт
1167c17384

+ 1 - 1
meetings_app/Auth/GoogleOAuthService.swift

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

+ 102 - 0
meetings_app/Google/GoogleCalendarClient.swift

@@ -113,6 +113,82 @@ final class GoogleCalendarClient {
         }
         return meetings
     }
+
+    func createEvent(accessToken: String,
+                     title: String,
+                     description: String?,
+                     start: Date,
+                     end: Date,
+                     timeZone: TimeZone = .current) async throws -> ScheduledMeeting {
+        var components = URLComponents(string: "https://www.googleapis.com/calendar/v3/calendars/primary/events")!
+        components.queryItems = [
+            URLQueryItem(name: "conferenceDataVersion", value: "1")
+        ]
+
+        let requestID = UUID().uuidString
+        let body = CreateEventRequest(
+            summary: title,
+            description: description,
+            start: CreateEventRequest.EventDateTime(dateTime: ISO8601DateFormatter.fractional.string(from: start), timeZone: timeZone.identifier),
+            end: CreateEventRequest.EventDateTime(dateTime: ISO8601DateFormatter.fractional.string(from: end), timeZone: timeZone.identifier),
+            conferenceData: CreateEventRequest.ConferenceData(
+                createRequest: CreateEventRequest.CreateRequest(
+                    requestId: requestID,
+                    conferenceSolutionKey: CreateEventRequest.ConferenceSolutionKey(type: "hangoutsMeet")
+                )
+            )
+        )
+
+        var request = URLRequest(url: components.url!)
+        request.httpMethod = "POST"
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+        request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
+        request.httpBody = try JSONEncoder().encode(body)
+
+        let (data, response) = try await session.data(for: request)
+        guard let http = response as? HTTPURLResponse else { throw GoogleCalendarClientError.invalidResponse }
+        guard (200..<300).contains(http.statusCode) else {
+            let raw = String(data: data, encoding: .utf8) ?? "<no body>"
+            throw GoogleCalendarClientError.httpStatus(http.statusCode, raw)
+        }
+
+        let created: EventItem
+        do {
+            created = try JSONDecoder().decode(EventItem.self, from: data)
+        } catch {
+            let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
+            throw GoogleCalendarClientError.decodeFailed(raw)
+        }
+
+        guard let startDate = created.start?.resolvedDate,
+              let endDate = created.end?.resolvedDate else {
+            throw GoogleCalendarClientError.decodeFailed("Created event missing start/end.")
+        }
+
+        let isAllDay = created.start?.date != nil
+        let meetURL: URL? = {
+            if let hangout = created.hangoutLink, let u = URL(string: hangout) { return u }
+            let entry = created.conferenceData?.entryPoints?.first(where: { $0.entryPointType == "video" && ($0.uri?.contains("meet.google.com") ?? false) })
+            if let uri = entry?.uri, let u = URL(string: uri) { return u }
+            if let htmlLink = created.htmlLink, let u = URL(string: htmlLink) { return u }
+            return nil
+        }()
+        guard let meetURL else {
+            throw GoogleCalendarClientError.decodeFailed("Created event missing Meet/HTML link.")
+        }
+
+        let cleanedTitle = created.summary?.trimmingCharacters(in: .whitespacesAndNewlines)
+        let subtitle = created.organizer?.displayName ?? created.organizer?.email
+        return ScheduledMeeting(
+            id: created.id ?? UUID().uuidString,
+            title: (cleanedTitle?.isEmpty == false) ? cleanedTitle! : "Untitled meeting",
+            subtitle: subtitle,
+            startDate: startDate,
+            endDate: endDate,
+            meetURL: meetURL,
+            isAllDay: isAllDay
+        )
+    }
 }
 
 extension GoogleCalendarClientError: LocalizedError {
@@ -194,6 +270,32 @@ private struct EntryPoint: Decodable {
     let uri: String?
 }
 
+private struct CreateEventRequest: Encodable {
+    struct EventDateTime: Encodable {
+        let dateTime: String
+        let timeZone: String
+    }
+
+    struct ConferenceSolutionKey: Encodable {
+        let type: String
+    }
+
+    struct CreateRequest: Encodable {
+        let requestId: String
+        let conferenceSolutionKey: ConferenceSolutionKey
+    }
+
+    struct ConferenceData: Encodable {
+        let createRequest: CreateRequest
+    }
+
+    let summary: String
+    let description: String?
+    let start: EventDateTime
+    let end: EventDateTime
+    let conferenceData: ConferenceData
+}
+
 private extension ISO8601DateFormatter {
     static let fractional: ISO8601DateFormatter = {
         let f = ISO8601DateFormatter()

+ 835 - 1
meetings_app/ViewController.swift

@@ -353,6 +353,16 @@ final class ViewController: NSViewController {
     private weak var schedulePageCardsStack: NSStackView?
     private weak var schedulePageCardsScrollView: NSScrollView?
 
+    // MARK: - Calendar page (custom month UI)
+    private var calendarPageMonthAnchor: Date = Calendar.current.startOfDay(for: Date())
+    private var calendarPageSelectedDate: Date = Calendar.current.startOfDay(for: Date())
+    private weak var calendarPageMonthLabel: NSTextField?
+    private weak var calendarPageGridStack: NSStackView?
+    private var calendarPageGridHeightConstraint: NSLayoutConstraint?
+    private weak var calendarPageDaySummaryLabel: NSTextField?
+    private var calendarPageActionPopover: NSPopover?
+    private var calendarPageCreatePopover: NSPopover?
+
     /// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
     private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
 
@@ -1356,7 +1366,7 @@ private extension ViewController {
         case .photo:
             built = makeSchedulePageContent()
         case .video:
-            built = makePlaceholderPage(title: "Calendar", subtitle: "View meetings by date and track your plan.")
+            built = makeCalendarPageContent()
         case .settings:
             built = makePlaceholderPage(title: "Settings", subtitle: "Preferences and account options.")
         }
@@ -1821,6 +1831,155 @@ private extension ViewController {
         return panel
     }
 
+    func makeCalendarPageContent() -> NSView {
+        let panel = NSView()
+        panel.translatesAutoresizingMaskIntoConstraints = false
+        panel.userInterfaceLayoutDirection = .leftToRight
+
+        let contentStack = NSStackView()
+        contentStack.translatesAutoresizingMaskIntoConstraints = false
+        contentStack.userInterfaceLayoutDirection = .leftToRight
+        contentStack.orientation = .vertical
+        contentStack.spacing = 14
+        contentStack.alignment = .width
+        contentStack.distribution = .fill
+
+        let titleRow = NSStackView()
+        titleRow.translatesAutoresizingMaskIntoConstraints = false
+        titleRow.userInterfaceLayoutDirection = .leftToRight
+        titleRow.orientation = .horizontal
+        titleRow.alignment = .centerY
+        titleRow.distribution = .fill
+        titleRow.spacing = 12
+
+        let titleLabel = textLabel("Calendar", font: typography.pageTitle, color: palette.textPrimary)
+        titleLabel.alignment = .left
+        titleLabel.maximumNumberOfLines = 1
+        titleLabel.lineBreakMode = .byTruncatingTail
+        titleLabel.setContentHuggingPriority(.required, for: .horizontal)
+        titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
+
+        let spacer = NSView()
+        spacer.translatesAutoresizingMaskIntoConstraints = false
+        spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
+        spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+
+        let prevButton = makeCalendarHeaderPillButton(title: "‹", action: #selector(calendarPrevMonthPressed(_:)))
+        prevButton.widthAnchor.constraint(equalToConstant: 44).isActive = true
+        let nextButton = makeCalendarHeaderPillButton(title: "›", action: #selector(calendarNextMonthPressed(_:)))
+        nextButton.widthAnchor.constraint(equalToConstant: 44).isActive = true
+
+        let monthLabel = textLabel("", font: NSFont.systemFont(ofSize: 16, weight: .semibold), color: palette.textSecondary)
+        monthLabel.alignment = .right
+        monthLabel.maximumNumberOfLines = 1
+        monthLabel.lineBreakMode = .byTruncatingTail
+        monthLabel.setContentHuggingPriority(.required, for: .horizontal)
+        monthLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
+        calendarPageMonthLabel = monthLabel
+
+        let refreshButton = HoverButton(title: "", target: self, action: #selector(calendarRefreshPressed(_:)))
+        refreshButton.translatesAutoresizingMaskIntoConstraints = false
+        refreshButton.isBordered = false
+        refreshButton.bezelStyle = .regularSquare
+        refreshButton.wantsLayer = true
+        refreshButton.layer?.cornerRadius = 15
+        refreshButton.layer?.masksToBounds = true
+        refreshButton.layer?.backgroundColor = palette.inputBackground.cgColor
+        refreshButton.layer?.borderColor = palette.inputBorder.cgColor
+        refreshButton.layer?.borderWidth = 1
+        refreshButton.setButtonType(.momentaryChange)
+        refreshButton.contentTintColor = palette.textSecondary
+        refreshButton.image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Sync calendar")
+        refreshButton.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
+        refreshButton.imagePosition = .imageOnly
+        refreshButton.imageScaling = .scaleProportionallyDown
+        refreshButton.focusRingType = .none
+        refreshButton.heightAnchor.constraint(equalToConstant: 30).isActive = true
+        refreshButton.widthAnchor.constraint(equalToConstant: 30).isActive = true
+
+        titleRow.addArrangedSubview(titleLabel)
+        titleRow.addArrangedSubview(spacer)
+        titleRow.addArrangedSubview(prevButton)
+        titleRow.addArrangedSubview(nextButton)
+        titleRow.addArrangedSubview(monthLabel)
+        titleRow.addArrangedSubview(refreshButton)
+
+        let weekdayRow = NSStackView()
+        weekdayRow.translatesAutoresizingMaskIntoConstraints = false
+        weekdayRow.userInterfaceLayoutDirection = .leftToRight
+        weekdayRow.orientation = .horizontal
+        weekdayRow.alignment = .centerY
+        weekdayRow.distribution = .fillEqually
+        weekdayRow.spacing = 10
+
+        for symbol in calendarWeekdaySymbolsStartingAtFirstWeekday() {
+            let label = textLabel(symbol, font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: palette.textMuted)
+            label.alignment = .center
+            label.maximumNumberOfLines = 1
+            label.lineBreakMode = .byClipping
+            weekdayRow.addArrangedSubview(label)
+        }
+
+        let gridStack = NSStackView()
+        gridStack.translatesAutoresizingMaskIntoConstraints = false
+        gridStack.userInterfaceLayoutDirection = .leftToRight
+        gridStack.orientation = .vertical
+        gridStack.alignment = .width
+        gridStack.distribution = .fillEqually
+        gridStack.spacing = 10
+        calendarPageGridStack = gridStack
+
+        let gridCard = roundedContainer(cornerRadius: 14, color: palette.sectionCard)
+        gridCard.translatesAutoresizingMaskIntoConstraints = false
+        styleSurface(gridCard, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
+        let gridHeightConstraint = gridCard.heightAnchor.constraint(equalToConstant: 320)
+        gridHeightConstraint.isActive = true
+        calendarPageGridHeightConstraint = gridHeightConstraint
+        gridCard.addSubview(gridStack)
+        NSLayoutConstraint.activate([
+            gridStack.leadingAnchor.constraint(equalTo: gridCard.leadingAnchor, constant: 12),
+            gridStack.trailingAnchor.constraint(equalTo: gridCard.trailingAnchor, constant: -12),
+            gridStack.topAnchor.constraint(equalTo: gridCard.topAnchor, constant: 12),
+            gridStack.bottomAnchor.constraint(equalTo: gridCard.bottomAnchor, constant: -12)
+        ])
+
+        let daySummary = textLabel("", font: typography.dateHeading, color: palette.textSecondary)
+        daySummary.alignment = .left
+        daySummary.maximumNumberOfLines = 2
+        daySummary.lineBreakMode = .byWordWrapping
+        calendarPageDaySummaryLabel = daySummary
+
+        contentStack.addArrangedSubview(titleRow)
+        contentStack.addArrangedSubview(weekdayRow)
+        contentStack.addArrangedSubview(gridCard)
+        contentStack.addArrangedSubview(daySummary)
+
+        panel.addSubview(contentStack)
+        NSLayoutConstraint.activate([
+            contentStack.leftAnchor.constraint(equalTo: panel.leftAnchor, constant: 28),
+            contentStack.rightAnchor.constraint(equalTo: panel.rightAnchor, constant: -28),
+            contentStack.topAnchor.constraint(equalTo: panel.topAnchor),
+            contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -16),
+            titleRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
+            weekdayRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
+            gridCard.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
+            daySummary.widthAnchor.constraint(equalTo: contentStack.widthAnchor)
+        ])
+
+        let calendar = Calendar.current
+        calendarPageMonthAnchor = calendarStartOfMonth(for: Date())
+        calendarPageSelectedDate = calendar.startOfDay(for: Date())
+        calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
+        renderCalendarMonthGrid()
+        renderCalendarSelectedDay()
+
+        Task { [weak self] in
+            await self?.loadSchedule()
+        }
+
+        return panel
+    }
+
     func meetJoinSectionRow() -> NSView {
         let row = NSStackView()
         row.translatesAutoresizingMaskIntoConstraints = false
@@ -4312,6 +4471,666 @@ private extension ViewController {
     }
 }
 
+private let calendarDayKeyFormatter: DateFormatter = {
+    let f = DateFormatter()
+    f.calendar = Calendar(identifier: .gregorian)
+    f.locale = Locale(identifier: "en_US_POSIX")
+    f.timeZone = TimeZone.current
+    f.dateFormat = "yyyy-MM-dd"
+    return f
+}()
+
+// MARK: - Calendar page actions + rendering
+
+private extension ViewController {
+    private func makeCalendarHeaderPillButton(title: String, action: Selector) -> NSButton {
+        let button = makeSchedulePillButton(title: title)
+        button.target = self
+        button.action = action
+        button.heightAnchor.constraint(equalToConstant: 30).isActive = true
+        return button
+    }
+
+    private func calendarStartOfMonth(for date: Date) -> Date {
+        let calendar = Calendar.current
+        let comps = calendar.dateComponents([.year, .month], from: date)
+        return calendar.date(from: comps) ?? calendar.startOfDay(for: date)
+    }
+
+    private func calendarMonthTitleText(for monthAnchor: Date) -> String {
+        let f = DateFormatter()
+        f.locale = Locale.current
+        f.timeZone = TimeZone.current
+        f.dateFormat = "MMMM yyyy"
+        return f.string(from: monthAnchor)
+    }
+
+    private func calendarWeekdaySymbolsStartingAtFirstWeekday() -> [String] {
+        // Align weekday header to Calendar.current.firstWeekday
+        let calendar = Calendar.current
+        var symbols = DateFormatter().veryShortWeekdaySymbols ?? ["S", "M", "T", "W", "T", "F", "S"]
+        // veryShortWeekdaySymbols starts with Sunday in most locales; rotate to firstWeekday.
+        let first = max(1, min(7, calendar.firstWeekday)) // 1..7
+        let shift = (first - 1) % 7
+        if shift == 0 { return symbols }
+        let head = Array(symbols[shift...])
+        let tail = Array(symbols[..<shift])
+        symbols = head + tail
+        return symbols
+    }
+
+    @objc func calendarPrevMonthPressed(_ sender: NSButton) {
+        let calendar = Calendar.current
+        calendarPageMonthAnchor = calendar.date(byAdding: .month, value: -1, to: calendarPageMonthAnchor).map(calendarStartOfMonth(for:)) ?? calendarPageMonthAnchor
+        calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
+        renderCalendarMonthGrid()
+    }
+
+    @objc func calendarNextMonthPressed(_ sender: NSButton) {
+        let calendar = Calendar.current
+        calendarPageMonthAnchor = calendar.date(byAdding: .month, value: 1, to: calendarPageMonthAnchor).map(calendarStartOfMonth(for:)) ?? calendarPageMonthAnchor
+        calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
+        renderCalendarMonthGrid()
+    }
+
+    @objc func calendarRefreshPressed(_ sender: NSButton) {
+        Task { [weak self] in
+            await self?.loadSchedule()
+        }
+    }
+
+    @objc func calendarDayCellPressed(_ sender: NSButton) {
+        guard let raw = sender.identifier?.rawValue,
+              let date = calendarDayKeyFormatter.date(from: raw) else { return }
+        calendarPageSelectedDate = Calendar.current.startOfDay(for: date)
+        renderCalendarMonthGrid()
+        renderCalendarSelectedDay()
+        if let refreshedButton = calendarButton(forDateKey: raw) {
+            showCalendarDayActionPopover(relativeTo: refreshedButton)
+        }
+    }
+
+    private func showCalendarDayActionPopover(relativeTo anchor: NSView) {
+        guard anchor.window != nil else { return }
+        calendarPageActionPopover?.performClose(nil)
+        let popover = NSPopover()
+        popover.behavior = .transient
+        popover.animates = true
+        popover.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
+        popover.contentViewController = CalendarDayActionMenuViewController(
+            palette: palette,
+            onSchedule: { [weak self] in
+                self?.calendarPageActionPopover?.performClose(nil)
+                self?.calendarPageActionPopover = nil
+                self?.presentCreateMeetingPopover(relativeTo: anchor)
+            }
+        )
+        calendarPageActionPopover = popover
+        popover.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxY)
+    }
+
+    private func presentCreateMeetingPopover(relativeTo anchor: NSView) {
+        guard anchor.window != nil else { return }
+        calendarPageCreatePopover?.performClose(nil)
+        let popover = NSPopover()
+        popover.behavior = .transient
+        popover.animates = true
+        popover.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
+        let selectedDate = Calendar.current.startOfDay(for: calendarPageSelectedDate)
+        let vc = CreateMeetingPopoverViewController(
+            palette: palette,
+            typography: typography,
+            selectedDate: selectedDate,
+            onCancel: { [weak self] in
+                self?.calendarPageCreatePopover?.performClose(nil)
+                self?.calendarPageCreatePopover = nil
+            },
+            onSave: { [weak self] draft in
+                guard let self else { return }
+                self.calendarPageCreatePopover?.performClose(nil)
+                self.calendarPageCreatePopover = nil
+                self.calendarCreateMeeting(title: draft.title, notes: draft.notes, start: draft.startDate, end: draft.endDate)
+            }
+        )
+        popover.contentViewController = vc
+        calendarPageCreatePopover = popover
+        popover.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxY)
+    }
+
+    private func calendarCreateMeeting(title: String, notes: String?, start: Date, end: Date) {
+        Task { [weak self] in
+            guard let self else { return }
+            do {
+                try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
+                let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
+                _ = try await self.calendarClient.createEvent(
+                    accessToken: token,
+                    title: title,
+                    description: notes,
+                    start: start,
+                    end: end,
+                    timeZone: .current
+                )
+                await self.loadSchedule()
+                await MainActor.run {
+                    let calendar = Calendar.current
+                    self.calendarPageSelectedDate = calendar.startOfDay(for: start)
+                    self.calendarPageMonthAnchor = self.calendarStartOfMonth(for: start)
+                    self.calendarPageMonthLabel?.stringValue = self.calendarMonthTitleText(for: self.calendarPageMonthAnchor)
+                    self.renderCalendarMonthGrid()
+                    self.renderCalendarSelectedDay()
+                }
+            } catch {
+                self.showSimpleError("Couldn’t create meeting.", error: error)
+            }
+        }
+    }
+
+    private func renderCalendarMonthGrid() {
+        guard let gridStack = calendarPageGridStack else { return }
+        gridStack.arrangedSubviews.forEach { v in
+            gridStack.removeArrangedSubview(v)
+            v.removeFromSuperview()
+        }
+
+        let calendar = Calendar.current
+        let monthStart = calendarStartOfMonth(for: calendarPageMonthAnchor)
+        guard let dayRange = calendar.range(of: .day, in: .month, for: monthStart),
+              let monthEnd = calendar.date(byAdding: DateComponents(month: 1, day: 0), to: monthStart) else { return }
+
+        let firstWeekday = calendar.component(.weekday, from: monthStart) // 1..7
+        let leadingEmpty = (firstWeekday - calendar.firstWeekday + 7) % 7
+        let totalDays = dayRange.count
+        let totalCells = leadingEmpty + totalDays
+        let rowCount = Int(ceil(Double(totalCells) / 7.0))
+        let rowHeight: CGFloat = 44
+        let rowSpacing: CGFloat = 10
+        let verticalPadding: CGFloat = 24
+        calendarPageGridHeightConstraint?.constant = verticalPadding + (CGFloat(rowCount) * rowHeight) + (CGFloat(max(0, rowCount - 1)) * rowSpacing)
+
+        let meetingCounts = calendarMeetingCountsByDay(from: scheduleCachedMeetings, monthStart: monthStart, monthEnd: monthEnd)
+
+        var day = 1
+        for _ in 0..<rowCount {
+            let row = NSStackView()
+            row.translatesAutoresizingMaskIntoConstraints = false
+            row.userInterfaceLayoutDirection = .leftToRight
+            row.orientation = .horizontal
+            row.alignment = .top
+            row.distribution = .fillEqually
+            row.spacing = rowSpacing
+            row.heightAnchor.constraint(equalToConstant: rowHeight).isActive = true
+
+            for col in 0..<7 {
+                let cellIndex = (gridStack.arrangedSubviews.count * 7) + col
+                if cellIndex < leadingEmpty || day > totalDays {
+                    row.addArrangedSubview(calendarEmptyDayCell())
+                    continue
+                }
+                guard let date = calendar.date(byAdding: .day, value: day - 1, to: monthStart) else {
+                    row.addArrangedSubview(calendarEmptyDayCell())
+                    continue
+                }
+                let isSelected = calendar.isDate(date, inSameDayAs: calendarPageSelectedDate)
+                let key = calendarDayKeyFormatter.string(from: calendar.startOfDay(for: date))
+                let count = meetingCounts[key] ?? 0
+                row.addArrangedSubview(calendarDayCell(dayNumber: day, dateKey: key, meetingCount: count, isSelected: isSelected))
+                day += 1
+            }
+            gridStack.addArrangedSubview(row)
+            row.widthAnchor.constraint(equalTo: gridStack.widthAnchor).isActive = true
+        }
+    }
+
+    private func calendarButton(forDateKey key: String) -> NSButton? {
+        guard let gridStack = calendarPageGridStack else { return nil }
+        for rowView in gridStack.arrangedSubviews {
+            guard let row = rowView as? NSStackView else { continue }
+            for cell in row.arrangedSubviews {
+                if let button = cell as? NSButton, button.identifier?.rawValue == key {
+                    return button
+                }
+            }
+        }
+        return nil
+    }
+
+    private func renderCalendarSelectedDay() {
+        let calendar = Calendar.current
+        let selectedDay = calendar.startOfDay(for: calendarPageSelectedDate)
+        let nextDay = calendar.date(byAdding: .day, value: 1, to: selectedDay) ?? selectedDay.addingTimeInterval(86400)
+
+        let meetings = scheduleCachedMeetings
+            .filter { $0.startDate >= selectedDay && $0.startDate < nextDay }
+            .sorted(by: { $0.startDate < $1.startDate })
+
+        let f = DateFormatter()
+        f.locale = Locale.current
+        f.timeZone = TimeZone.current
+        f.dateFormat = "EEE, d MMM"
+
+        if meetings.isEmpty {
+            calendarPageDaySummaryLabel?.stringValue = googleOAuth.loadTokens() == nil
+                ? "Connect Google to see meetings"
+                : "No meetings on \(f.string(from: selectedDay))"
+        } else if meetings.count == 1 {
+            calendarPageDaySummaryLabel?.stringValue = "1 meeting on \(f.string(from: selectedDay))"
+        } else {
+            calendarPageDaySummaryLabel?.stringValue = "\(meetings.count) meetings on \(f.string(from: selectedDay))"
+        }
+    }
+
+    private func calendarMeetingCountsByDay(from meetings: [ScheduledMeeting], monthStart: Date, monthEnd: Date) -> [String: Int] {
+        let calendar = Calendar.current
+        var counts: [String: Int] = [:]
+        for meeting in meetings {
+            guard meeting.startDate >= monthStart && meeting.startDate < monthEnd else { continue }
+            let key = calendarDayKeyFormatter.string(from: calendar.startOfDay(for: meeting.startDate))
+            counts[key, default: 0] += 1
+        }
+        return counts
+    }
+
+    private func calendarEmptyDayCell() -> NSView {
+        let v = NSView()
+        v.translatesAutoresizingMaskIntoConstraints = false
+        v.heightAnchor.constraint(equalToConstant: 44).isActive = true
+        return v
+    }
+
+    private func calendarDayCell(dayNumber: Int, dateKey: String, meetingCount: Int, isSelected: Bool) -> NSButton {
+        let button = HoverButton(title: "", target: self, action: #selector(calendarDayCellPressed(_:)))
+        button.translatesAutoresizingMaskIntoConstraints = false
+        button.isBordered = false
+        button.bezelStyle = .regularSquare
+        button.wantsLayer = true
+        button.layer?.cornerRadius = 10
+        button.layer?.masksToBounds = true
+        button.identifier = NSUserInterfaceItemIdentifier(dateKey)
+        button.heightAnchor.constraint(equalToConstant: 44).isActive = true
+        button.setContentHuggingPriority(.required, for: .vertical)
+        button.setContentCompressionResistancePriority(.required, for: .vertical)
+        button.alignment = .left
+        button.imagePosition = .noImage
+
+        let base = palette.inputBackground
+        let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
+        let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
+        let selectedBackground = darkModeEnabled
+            ? NSColor(calibratedRed: 30.0 / 255.0, green: 34.0 / 255.0, blue: 42.0 / 255.0, alpha: 1)
+            : NSColor(calibratedRed: 255.0 / 255.0, green: 246.0 / 255.0, blue: 236.0 / 255.0, alpha: 1)
+        let borderIdle = palette.inputBorder
+        let borderSelected = palette.primaryBlueBorder
+
+        func applyAppearance(hovering: Bool) {
+            button.layer?.backgroundColor = (isSelected ? selectedBackground : (hovering ? hover : base)).cgColor
+            button.layer?.borderWidth = isSelected ? 1.5 : 1
+            button.layer?.borderColor = (isSelected ? borderSelected : borderIdle).cgColor
+        }
+        applyAppearance(hovering: false)
+        button.onHoverChanged = { hovering in
+            applyAppearance(hovering: hovering)
+        }
+
+        button.attributedTitle = NSAttributedString(
+            string: "  \(dayNumber)",
+            attributes: [
+                .font: NSFont.systemFont(ofSize: 14, weight: .bold),
+                .foregroundColor: palette.textPrimary
+            ]
+        )
+
+        if meetingCount > 0 {
+            let dot = roundedContainer(cornerRadius: 4, color: palette.meetingBadge)
+            dot.translatesAutoresizingMaskIntoConstraints = false
+            dot.layer?.borderWidth = 0
+            button.addSubview(dot)
+            NSLayoutConstraint.activate([
+                dot.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -10),
+                dot.centerYAnchor.constraint(equalTo: button.centerYAnchor),
+                dot.widthAnchor.constraint(equalToConstant: 8),
+                dot.heightAnchor.constraint(equalToConstant: 8)
+            ])
+        }
+
+        return button
+    }
+}
+
+private final class CalendarDayActionMenuViewController: NSViewController {
+    private let palette: Palette
+    private let onSchedule: () -> Void
+
+    init(palette: Palette, onSchedule: @escaping () -> Void) {
+        self.palette = palette
+        self.onSchedule = onSchedule
+        super.init(nibName: nil, bundle: nil)
+    }
+
+    required init?(coder: NSCoder) { nil }
+
+    override func loadView() {
+        let root = NSView()
+        root.translatesAutoresizingMaskIntoConstraints = false
+
+        let stack = NSStackView()
+        stack.translatesAutoresizingMaskIntoConstraints = false
+        stack.orientation = .vertical
+        stack.alignment = .leading
+        stack.spacing = 10
+
+        let title = NSTextField(labelWithString: "Actions")
+        title.font = NSFont.systemFont(ofSize: 12, weight: .semibold)
+        title.textColor = palette.textMuted
+
+        let schedule = NSButton(title: "Schedule meeting", target: self, action: #selector(schedulePressed(_:)))
+        schedule.bezelStyle = .rounded
+        schedule.font = NSFont.systemFont(ofSize: 13, weight: .medium)
+
+        stack.addArrangedSubview(title)
+        stack.addArrangedSubview(schedule)
+        root.addSubview(stack)
+
+        NSLayoutConstraint.activate([
+            root.widthAnchor.constraint(equalToConstant: 220),
+            root.heightAnchor.constraint(greaterThanOrEqualToConstant: 86),
+            stack.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 14),
+            stack.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -14),
+            stack.topAnchor.constraint(equalTo: root.topAnchor, constant: 12),
+            stack.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -12)
+        ])
+
+        view = root
+    }
+
+    @objc private func schedulePressed(_ sender: NSButton) {
+        onSchedule()
+    }
+}
+
+private final class CreateMeetingPopoverViewController: NSViewController {
+    struct Draft {
+        let title: String
+        let notes: String?
+        let startDate: Date
+        let endDate: Date
+    }
+
+    private let palette: Palette
+    private let typography: Typography
+    private let selectedDate: Date
+    private let onCancel: () -> Void
+    private let onSave: (Draft) -> Void
+
+    private var titleField: NSTextField?
+    private var timePicker: NSDatePicker?
+    private var durationField: NSTextField?
+    private var notesView: NSTextView?
+    private var errorLabel: NSTextField?
+
+    init(palette: Palette,
+         typography: Typography,
+         selectedDate: Date,
+         onCancel: @escaping () -> Void,
+         onSave: @escaping (Draft) -> Void) {
+        self.palette = palette
+        self.typography = typography
+        self.selectedDate = selectedDate
+        self.onCancel = onCancel
+        self.onSave = onSave
+        super.init(nibName: nil, bundle: nil)
+    }
+
+    required init?(coder: NSCoder) { nil }
+
+    override func loadView() {
+        let root = NSView()
+        root.translatesAutoresizingMaskIntoConstraints = false
+
+        let stack = NSStackView()
+        stack.translatesAutoresizingMaskIntoConstraints = false
+        stack.orientation = .vertical
+        stack.alignment = .width
+        stack.spacing = 12
+
+        let header = NSTextField(labelWithString: "Schedule meeting")
+        header.font = NSFont.systemFont(ofSize: 14, weight: .semibold)
+        header.textColor = palette.textPrimary
+
+        let titleLabel = NSTextField(labelWithString: "Title")
+        titleLabel.font = typography.fieldLabel
+        titleLabel.textColor = palette.textSecondary
+
+        let titleShell = NSView()
+        titleShell.translatesAutoresizingMaskIntoConstraints = false
+        titleShell.wantsLayer = true
+        titleShell.layer?.cornerRadius = 8
+        titleShell.layer?.backgroundColor = palette.inputBackground.cgColor
+        titleShell.layer?.borderColor = palette.inputBorder.cgColor
+        titleShell.layer?.borderWidth = 1
+        titleShell.heightAnchor.constraint(equalToConstant: 40).isActive = true
+
+        let titleField = NSTextField(string: "")
+        titleField.translatesAutoresizingMaskIntoConstraints = false
+        titleField.isBordered = false
+        titleField.drawsBackground = false
+        titleField.focusRingType = .none
+        titleField.font = NSFont.systemFont(ofSize: 14, weight: .regular)
+        titleField.textColor = palette.textPrimary
+        titleField.placeholderString = "Team sync"
+        titleShell.addSubview(titleField)
+        NSLayoutConstraint.activate([
+            titleField.leadingAnchor.constraint(equalTo: titleShell.leadingAnchor, constant: 10),
+            titleField.trailingAnchor.constraint(equalTo: titleShell.trailingAnchor, constant: -10),
+            titleField.centerYAnchor.constraint(equalTo: titleShell.centerYAnchor)
+        ])
+        self.titleField = titleField
+
+        let timeRow = NSStackView()
+        timeRow.translatesAutoresizingMaskIntoConstraints = false
+        timeRow.orientation = .horizontal
+        timeRow.alignment = .centerY
+        timeRow.spacing = 10
+        timeRow.distribution = .fill
+
+        let startLabel = NSTextField(labelWithString: "Start")
+        startLabel.font = typography.fieldLabel
+        startLabel.textColor = palette.textSecondary
+
+        let pickerShell = NSView()
+        pickerShell.translatesAutoresizingMaskIntoConstraints = false
+        pickerShell.wantsLayer = true
+        pickerShell.layer?.cornerRadius = 8
+        pickerShell.layer?.backgroundColor = palette.inputBackground.cgColor
+        pickerShell.layer?.borderColor = palette.inputBorder.cgColor
+        pickerShell.layer?.borderWidth = 1
+        pickerShell.heightAnchor.constraint(equalToConstant: 34).isActive = true
+
+        let timePicker = NSDatePicker()
+        timePicker.translatesAutoresizingMaskIntoConstraints = false
+        timePicker.isBordered = false
+        timePicker.drawsBackground = false
+        timePicker.focusRingType = .none
+        timePicker.datePickerStyle = .textFieldAndStepper
+        timePicker.datePickerElements = [.hourMinute]
+        timePicker.font = typography.filterText
+        timePicker.textColor = palette.textSecondary
+        timePicker.dateValue = Date()
+        pickerShell.addSubview(timePicker)
+        NSLayoutConstraint.activate([
+            timePicker.leadingAnchor.constraint(equalTo: pickerShell.leadingAnchor, constant: 8),
+            timePicker.trailingAnchor.constraint(equalTo: pickerShell.trailingAnchor, constant: -8),
+            timePicker.centerYAnchor.constraint(equalTo: pickerShell.centerYAnchor)
+        ])
+        self.timePicker = timePicker
+
+        let durationLabel = NSTextField(labelWithString: "Duration (min)")
+        durationLabel.font = typography.fieldLabel
+        durationLabel.textColor = palette.textSecondary
+
+        let durationShell = NSView()
+        durationShell.translatesAutoresizingMaskIntoConstraints = false
+        durationShell.wantsLayer = true
+        durationShell.layer?.cornerRadius = 8
+        durationShell.layer?.backgroundColor = palette.inputBackground.cgColor
+        durationShell.layer?.borderColor = palette.inputBorder.cgColor
+        durationShell.layer?.borderWidth = 1
+        durationShell.heightAnchor.constraint(equalToConstant: 34).isActive = true
+
+        let durationField = NSTextField(string: "30")
+        durationField.translatesAutoresizingMaskIntoConstraints = false
+        durationField.isBordered = false
+        durationField.drawsBackground = false
+        durationField.focusRingType = .none
+        durationField.font = typography.filterText
+        durationField.textColor = palette.textSecondary
+        durationField.formatter = NumberFormatter()
+        durationShell.addSubview(durationField)
+        NSLayoutConstraint.activate([
+            durationField.leadingAnchor.constraint(equalTo: durationShell.leadingAnchor, constant: 8),
+            durationField.trailingAnchor.constraint(equalTo: durationShell.trailingAnchor, constant: -8),
+            durationField.centerYAnchor.constraint(equalTo: durationShell.centerYAnchor)
+        ])
+        self.durationField = durationField
+
+        let startGroup = NSStackView(views: [startLabel, pickerShell])
+        startGroup.translatesAutoresizingMaskIntoConstraints = false
+        startGroup.orientation = .vertical
+        startGroup.alignment = .leading
+        startGroup.spacing = 6
+
+        let durationGroup = NSStackView(views: [durationLabel, durationShell])
+        durationGroup.translatesAutoresizingMaskIntoConstraints = false
+        durationGroup.orientation = .vertical
+        durationGroup.alignment = .leading
+        durationGroup.spacing = 6
+
+        timeRow.addArrangedSubview(startGroup)
+        timeRow.addArrangedSubview(durationGroup)
+        startGroup.widthAnchor.constraint(equalTo: durationGroup.widthAnchor).isActive = true
+
+        let notesLabel = NSTextField(labelWithString: "Notes")
+        notesLabel.font = typography.fieldLabel
+        notesLabel.textColor = palette.textSecondary
+
+        let notesScroll = NSScrollView()
+        notesScroll.translatesAutoresizingMaskIntoConstraints = false
+        notesScroll.drawsBackground = true
+        notesScroll.backgroundColor = palette.inputBackground
+        notesScroll.hasVerticalScroller = true
+        notesScroll.borderType = .noBorder
+        notesScroll.wantsLayer = true
+        notesScroll.layer?.cornerRadius = 8
+        notesScroll.layer?.borderWidth = 1
+        notesScroll.layer?.borderColor = palette.inputBorder.cgColor
+        notesScroll.heightAnchor.constraint(equalToConstant: 90).isActive = true
+
+        let notesView = NSTextView()
+        notesView.drawsBackground = false
+        notesView.font = NSFont.systemFont(ofSize: 13, weight: .regular)
+        notesView.textColor = palette.textPrimary
+        notesView.insertionPointColor = palette.textPrimary
+        notesScroll.documentView = notesView
+        self.notesView = notesView
+
+        let error = NSTextField(labelWithString: "")
+        error.translatesAutoresizingMaskIntoConstraints = false
+        error.textColor = NSColor.systemRed
+        error.font = NSFont.systemFont(ofSize: 12, weight: .semibold)
+        error.isHidden = true
+        self.errorLabel = error
+
+        let actions = NSStackView()
+        actions.translatesAutoresizingMaskIntoConstraints = false
+        actions.orientation = .horizontal
+        actions.alignment = .centerY
+        actions.spacing = 10
+
+        let cancel = NSButton(title: "Cancel", target: self, action: #selector(cancelPressed(_:)))
+        cancel.bezelStyle = .rounded
+
+        let save = NSButton(title: "Save", target: self, action: #selector(savePressed(_:)))
+        save.bezelStyle = .rounded
+        save.keyEquivalent = "\r"
+
+        let actionsSpacer = NSView()
+        actionsSpacer.translatesAutoresizingMaskIntoConstraints = false
+        actionsSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
+
+        actions.addArrangedSubview(cancel)
+        actions.addArrangedSubview(actionsSpacer)
+        actions.addArrangedSubview(save)
+
+        stack.addArrangedSubview(header)
+        stack.addArrangedSubview(titleLabel)
+        stack.addArrangedSubview(titleShell)
+        stack.addArrangedSubview(timeRow)
+        stack.addArrangedSubview(notesLabel)
+        stack.addArrangedSubview(notesScroll)
+        stack.addArrangedSubview(error)
+        stack.addArrangedSubview(actions)
+
+        root.addSubview(stack)
+        NSLayoutConstraint.activate([
+            root.widthAnchor.constraint(equalToConstant: 360),
+            stack.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 14),
+            stack.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -14),
+            stack.topAnchor.constraint(equalTo: root.topAnchor, constant: 12),
+            stack.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -12),
+            actions.widthAnchor.constraint(equalTo: stack.widthAnchor)
+        ])
+
+        view = root
+    }
+
+    @objc private func cancelPressed(_ sender: NSButton) {
+        onCancel()
+    }
+
+    @objc private func savePressed(_ sender: NSButton) {
+        setError(nil)
+
+        let title = (titleField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+        if title.isEmpty {
+            setError("Please enter a title.")
+            return
+        }
+
+        let durationText = (durationField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+        let durationMinutes = Int(durationText) ?? 0
+        if durationMinutes <= 0 {
+            setError("Duration must be a positive number of minutes.")
+            return
+        }
+
+        let time = timePicker?.dateValue ?? Date()
+        let calendar = Calendar.current
+        let dateParts = calendar.dateComponents([.year, .month, .day], from: selectedDate)
+        let timeParts = calendar.dateComponents([.hour, .minute], from: time)
+        var merged = DateComponents()
+        merged.year = dateParts.year
+        merged.month = dateParts.month
+        merged.day = dateParts.day
+        merged.hour = timeParts.hour
+        merged.minute = timeParts.minute
+        guard let start = calendar.date(from: merged) else {
+            setError("Invalid start time.")
+            return
+        }
+        let end = start.addingTimeInterval(TimeInterval(durationMinutes * 60))
+        let notes = notesView?.string.trimmingCharacters(in: .whitespacesAndNewlines)
+        let cleanedNotes = (notes?.isEmpty == false) ? notes : nil
+
+        onSave(Draft(title: title, notes: cleanedNotes, startDate: start, endDate: end))
+    }
+
+    private func setError(_ message: String?) {
+        guard let errorLabel else { return }
+        errorLabel.stringValue = message ?? ""
+        errorLabel.isHidden = message == nil
+    }
+}
+
 // MARK: - Schedule actions (OAuth entry)
 
 private extension ViewController {
@@ -4684,6 +5503,11 @@ private extension ViewController {
                     }
                     scheduleCachedMeetings = []
                     applySchedulePageFiltersAndRender()
+                    if calendarPageGridStack != nil {
+                        calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
+                        renderCalendarMonthGrid()
+                        renderCalendarSelectedDay()
+                    }
                 }
                 return
             }
@@ -4702,6 +5526,11 @@ private extension ViewController {
                 }
                 scheduleCachedMeetings = meetings
                 applySchedulePageFiltersAndRender()
+                if calendarPageGridStack != nil {
+                    calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
+                    renderCalendarMonthGrid()
+                    renderCalendarSelectedDay()
+                }
             }
         } catch {
             await MainActor.run {
@@ -4716,6 +5545,11 @@ private extension ViewController {
                 }
                 scheduleCachedMeetings = []
                 applySchedulePageFiltersAndRender()
+                if calendarPageGridStack != nil {
+                    calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
+                    renderCalendarMonthGrid()
+                    renderCalendarSelectedDay()
+                }
                 showSimpleError("Couldn’t load schedule.", error: error)
             }
         }