瀏覽代碼

Add in-app Zoom meeting scheduling with timezone-aware form inputs.

This replaces web-based scheduling with a native scheduling sheet that creates meetings via Zoom API, supports 12-hour time plus timezone selection, and preserves the selected local date/time to avoid day rollover.

Made-with: Cursor
huzaifahayat12 3 月之前
父節點
當前提交
c5d044d628
共有 1 個文件被更改,包括 456 次插入11 次删除
  1. 456 11
      zoom_app/ViewController.swift

+ 456 - 11
zoom_app/ViewController.swift

@@ -190,6 +190,13 @@ class ViewController: NSViewController {
     private weak var topBarPremiumButton: NSButton?
     private var paywallWindow: NSWindow?
     private var joinMeetingWindow: NSWindow?
+    private var scheduleMeetingWindow: NSWindow?
+    private weak var scheduleTopicField: NSTextField?
+    private weak var scheduleDateField: NSTextField?
+    private weak var scheduleTimeField: NSTextField?
+    private weak var scheduleTimeZoneCombo: NSComboBox?
+    private weak var scheduleDurationField: NSTextField?
+    private weak var scheduleSubmitButton: NSButton?
     private weak var joinURLField: NSTextField?
     private weak var joinMeetingIDField: NSTextField?
     private weak var joinPasscodeField: NSTextField?
@@ -550,12 +557,392 @@ class ViewController: NSViewController {
         }
     }
 
-    @objc private func scheduleMeetingWebTapped() {
-        guard let url = URL(string: "https://zoom.us/meeting/schedule") else { return }
-        let opened = NSWorkspace.shared.open(url)
-        if opened == false {
-            meetingsStatusLabel?.stringValue = "Unable to open Zoom schedule page."
+    @objc private func scheduleMeetingTapped() {
+        if let existing = scheduleMeetingWindow {
+            existing.makeKeyAndOrderFront(nil)
+            NSApp.activate(ignoringOtherApps: true)
+            DispatchQueue.main.async { [weak self] in
+                _ = self?.scheduleTopicField?.becomeFirstResponder()
+            }
+            return
+        }
+
+        let content = makeScheduleMeetingPanelContent()
+        let controller = NSViewController()
+        controller.view = content
+
+        let panel = NSPanel(
+            contentRect: NSRect(x: 0, y: 0, width: 520, height: 580),
+            styleMask: [.titled, .closable, .fullSizeContentView],
+            backing: .buffered,
+            defer: false
+        )
+        panel.title = "Schedule a meeting"
+        panel.titleVisibility = .hidden
+        panel.titlebarAppearsTransparent = true
+        panel.hidesOnDeactivate = true
+        panel.isReleasedWhenClosed = false
+        panel.standardWindowButton(.closeButton)?.isHidden = true
+        panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
+        panel.standardWindowButton(.zoomButton)?.isHidden = true
+        panel.isMovableByWindowBackground = true
+        panel.becomesKeyOnlyIfNeeded = false
+        panel.appearance = NSAppearance(named: palette.isDarkMode ? .darkAqua : .aqua)
+        panel.center()
+        panel.contentViewController = controller
+        panel.delegate = self
+        applyWindowBackgroundForCurrentTheme(panel)
+        panel.makeKeyAndOrderFront(nil)
+        NSApp.activate(ignoringOtherApps: true)
+        scheduleMeetingWindow = panel
+        DispatchQueue.main.async { [weak self] in
+            _ = self?.scheduleTopicField?.becomeFirstResponder()
+        }
+    }
+
+    private func makeScheduleMeetingPanelContent() -> NSView {
+        let root = NSView()
+        root.translatesAutoresizingMaskIntoConstraints = false
+        root.wantsLayer = true
+        root.layer?.backgroundColor = appBackground.cgColor
+
+        let titleLabel = makeLabel("Schedule a meeting", size: 18, color: primaryText, weight: .semibold, centered: false)
+        let subtitleLabel = makeLabel("Creates a Zoom meeting on your account", size: 12, color: mutedText, weight: .regular, centered: false)
+
+        let closeButton = HoverButton(title: "✕", target: self, action: #selector(scheduleMeetingCancelTapped))
+        closeButton.translatesAutoresizingMaskIntoConstraints = false
+        closeButton.isBordered = false
+        closeButton.bezelStyle = .regularSquare
+        closeButton.wantsLayer = true
+        closeButton.layer?.cornerRadius = 14
+        closeButton.normalColor = palette.inputBackground
+        closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
+        closeButton.layer?.borderColor = palette.inputBorder.cgColor
+        closeButton.layer?.borderWidth = 1
+        closeButton.font = .systemFont(ofSize: 13, weight: .bold)
+        closeButton.contentTintColor = secondaryText
+        closeButton.toolTip = "Close"
+        closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
+        closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
+
+        let titleRow = NSStackView()
+        titleRow.orientation = .horizontal
+        titleRow.alignment = .centerY
+        titleRow.spacing = 10
+        titleRow.translatesAutoresizingMaskIntoConstraints = false
+        let titleStack = NSStackView(views: [titleLabel, subtitleLabel])
+        titleStack.orientation = .vertical
+        titleStack.spacing = 2
+        titleStack.alignment = .leading
+        let titleSpacer = NSView()
+        titleSpacer.translatesAutoresizingMaskIntoConstraints = false
+        titleRow.addArrangedSubview(titleStack)
+        titleRow.addArrangedSubview(titleSpacer)
+        titleRow.addArrangedSubview(closeButton)
+
+        let headerDivider = NSView()
+        headerDivider.wantsLayer = true
+        headerDivider.layer?.backgroundColor = palette.inputBorder.cgColor
+        headerDivider.translatesAutoresizingMaskIntoConstraints = false
+
+        let topicBox = makeJoinFormField(placeholder: "Meeting topic")
+        scheduleTopicField = topicBox.textField
+        topicBox.textField.stringValue = "Zoom meeting"
+
+        let defaultStart = Date().addingTimeInterval(3600)
+        let dateOnlyFormatter = DateFormatter()
+        dateOnlyFormatter.locale = Locale(identifier: "en_US_POSIX")
+        dateOnlyFormatter.timeZone = TimeZone.current
+        dateOnlyFormatter.dateFormat = "yyyy-MM-dd"
+        let time12Formatter = DateFormatter()
+        time12Formatter.locale = Locale(identifier: "en_US_POSIX")
+        time12Formatter.timeZone = TimeZone.current
+        time12Formatter.dateFormat = "h:mm a"
+
+        let dateBox = makeJoinFormField(placeholder: "YYYY-MM-DD")
+        scheduleDateField = dateBox.textField
+        dateBox.textField.stringValue = dateOnlyFormatter.string(from: defaultStart)
+
+        let timeBox = makeJoinFormField(placeholder: "2:30 PM")
+        scheduleTimeField = timeBox.textField
+        timeBox.textField.stringValue = time12Formatter.string(from: defaultStart)
+
+        let timeHint = makeLabel("12-hour time in the timezone below · example 2:30 PM", size: 11, color: mutedText, weight: .regular, centered: false)
+
+        let tzLabel = makeLabel("Timezone", size: 12, color: secondaryText, weight: .medium, centered: false)
+        let tzCombo = NSComboBox()
+        tzCombo.translatesAutoresizingMaskIntoConstraints = false
+        tzCombo.font = .systemFont(ofSize: 14, weight: .regular)
+        tzCombo.textColor = primaryText
+        tzCombo.backgroundColor = palette.inputBackground
+        tzCombo.isSelectable = true
+        tzCombo.completes = true
+        tzCombo.numberOfVisibleItems = 10
+        tzCombo.addItems(withObjectValues: TimeZone.knownTimeZoneIdentifiers.sorted())
+        tzCombo.stringValue = TimeZone.current.identifier
+        tzCombo.toolTip = "IANA timezone (type to filter). Meeting start is interpreted in this zone."
+        scheduleTimeZoneCombo = tzCombo
+        let tzHint = makeLabel("Type to search, e.g. America/New_York or Europe/London", size: 11, color: mutedText, weight: .regular, centered: false)
+
+        let tzStack = NSStackView(views: [tzLabel, tzCombo, tzHint])
+        tzStack.orientation = .vertical
+        tzStack.spacing = 8
+        tzStack.alignment = .leading
+        tzStack.translatesAutoresizingMaskIntoConstraints = false
+
+        let durationBox = makeJoinFormField(placeholder: "Duration in minutes")
+        durationBox.textField.stringValue = "60"
+        scheduleDurationField = durationBox.textField
+
+        let topicLabel = makeLabel("Topic", size: 12, color: secondaryText, weight: .medium, centered: false)
+        let startLabel = makeLabel("Start date & time", size: 12, color: secondaryText, weight: .medium, centered: false)
+        let durationLabel = makeLabel("Duration", size: 12, color: secondaryText, weight: .medium, centered: false)
+
+        let topicStack = NSStackView(views: [topicLabel, topicBox])
+        topicStack.orientation = .vertical
+        topicStack.spacing = 8
+        topicStack.alignment = .leading
+        topicStack.translatesAutoresizingMaskIntoConstraints = false
+
+        let dateTimeRow = NSStackView(views: [dateBox, timeBox])
+        dateTimeRow.orientation = .horizontal
+        dateTimeRow.spacing = 10
+        dateTimeRow.alignment = .top
+        dateTimeRow.distribution = .fillEqually
+        dateTimeRow.translatesAutoresizingMaskIntoConstraints = false
+
+        let startStack = NSStackView(views: [startLabel, dateTimeRow, timeHint])
+        startStack.orientation = .vertical
+        startStack.spacing = 8
+        startStack.alignment = .leading
+        startStack.translatesAutoresizingMaskIntoConstraints = false
+
+        let durationStack = NSStackView(views: [durationLabel, durationBox])
+        durationStack.orientation = .vertical
+        durationStack.spacing = 8
+        durationStack.alignment = .leading
+        durationStack.translatesAutoresizingMaskIntoConstraints = false
+
+        let formCard = NSView()
+        formCard.translatesAutoresizingMaskIntoConstraints = false
+        formCard.wantsLayer = true
+        formCard.layer?.backgroundColor = secondaryCardBackground.cgColor
+        formCard.layer?.cornerRadius = 14
+        formCard.layer?.borderWidth = 1
+        formCard.layer?.borderColor = palette.inputBorder.cgColor
+
+        let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(scheduleMeetingCancelTapped))
+        cancelButton.isBordered = false
+        cancelButton.wantsLayer = true
+        cancelButton.layer?.cornerRadius = 10
+        cancelButton.layer?.backgroundColor = palette.inputBackground.cgColor
+        cancelButton.layer?.borderWidth = 1
+        cancelButton.layer?.borderColor = palette.inputBorder.cgColor
+        cancelButton.contentTintColor = primaryText
+        cancelButton.font = .systemFont(ofSize: 13, weight: .semibold)
+
+        let submitButton = HoverButton(title: "Schedule", target: self, action: #selector(scheduleMeetingSubmitTapped))
+        submitButton.isBordered = false
+        submitButton.wantsLayer = true
+        submitButton.layer?.cornerRadius = 10
+        submitButton.normalColor = accentBlue
+        submitButton.hoverColor = accentBlue.blended(withFraction: 0.12, of: .white) ?? accentBlue
+        submitButton.contentTintColor = .white
+        submitButton.font = .systemFont(ofSize: 13, weight: .bold)
+        submitButton.keyEquivalent = "\r"
+        scheduleSubmitButton = submitButton
+
+        let buttons = NSStackView(views: [cancelButton, submitButton])
+        buttons.orientation = .horizontal
+        buttons.spacing = 12
+        buttons.alignment = .centerY
+        buttons.distribution = .fillEqually
+
+        let innerStack = NSStackView(views: [topicStack, tzStack, startStack, durationStack, buttons])
+        innerStack.orientation = .vertical
+        innerStack.spacing = 18
+        innerStack.alignment = .leading
+        innerStack.translatesAutoresizingMaskIntoConstraints = false
+        formCard.addSubview(innerStack)
+
+        [titleRow, headerDivider, formCard].forEach { root.addSubview($0) }
+
+        NSLayoutConstraint.activate([
+            titleRow.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
+            titleRow.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
+            titleRow.topAnchor.constraint(equalTo: root.topAnchor, constant: 18),
+
+            headerDivider.topAnchor.constraint(equalTo: titleRow.bottomAnchor, constant: 14),
+            headerDivider.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
+            headerDivider.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
+            headerDivider.heightAnchor.constraint(equalToConstant: 1),
+
+            formCard.topAnchor.constraint(equalTo: headerDivider.bottomAnchor, constant: 14),
+            formCard.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
+            formCard.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
+            formCard.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -20),
+
+            innerStack.leadingAnchor.constraint(equalTo: formCard.leadingAnchor, constant: 16),
+            innerStack.trailingAnchor.constraint(equalTo: formCard.trailingAnchor, constant: -16),
+            innerStack.topAnchor.constraint(equalTo: formCard.topAnchor, constant: 16),
+            innerStack.bottomAnchor.constraint(equalTo: formCard.bottomAnchor, constant: -16),
+
+            topicBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
+            tzCombo.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
+            tzCombo.heightAnchor.constraint(equalToConstant: 46),
+            dateTimeRow.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
+            durationBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
+            buttons.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
+            submitButton.heightAnchor.constraint(equalToConstant: 40),
+            cancelButton.heightAnchor.constraint(equalToConstant: 40)
+        ])
+
+        return root
+    }
+
+    @objc private func scheduleMeetingCancelTapped() {
+        scheduleMeetingWindow?.performClose(nil)
+    }
+
+    @objc private func scheduleMeetingSubmitTapped() {
+        let topicRaw = scheduleTopicField?.stringValue ?? ""
+        let topic = topicRaw.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard topic.isEmpty == false else {
+            showSimpleAlert(title: "Topic required", message: "Enter a name for your meeting.")
+            return
         }
+        let tzId = scheduleTimeZoneCombo?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        if tzId.isEmpty == false, TimeZone(identifier: tzId) == nil {
+            showSimpleAlert(
+                title: "Timezone",
+                message: "Enter a valid IANA timezone (pick from the list or type to search), for example America/New_York."
+            )
+            return
+        }
+        guard let draft = scheduleMeetingDraftFromFormFields() else {
+            showSimpleAlert(
+                title: "Date & time",
+                message: "Enter the date as YYYY-MM-DD and the time in 12-hour form with AM or PM (in the selected timezone), for example 2026-04-17 and 2:30 PM."
+            )
+            return
+        }
+        if draft.startDate < Date().addingTimeInterval(-30) {
+            showSimpleAlert(title: "Start time", message: "Choose a start time in the future.")
+            return
+        }
+        let durationRaw = scheduleDurationField?.stringValue ?? ""
+        let digits = durationRaw.filter(\.isNumber)
+        guard let duration = Int(digits), duration >= 1, duration <= 24 * 60 else {
+            showSimpleAlert(title: "Duration", message: "Enter duration in minutes (1–1440).")
+            return
+        }
+
+        scheduleSubmitButton?.isEnabled = false
+        let tz = draft.timeZone
+        Task {
+            do {
+                let configured = await MainActor.run { self.ensureZoomOAuthClientConfigured() }
+                guard configured else {
+                    await MainActor.run { self.scheduleSubmitButton?.isEnabled = true }
+                    return
+                }
+                let token = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
+                let result = try await createZoomMeeting(
+                    accessToken: token,
+                    topic: topic,
+                    startTimeWithOffset: draft.startTimeWithOffset,
+                    durationMinutes: duration,
+                    timeZone: tz
+                )
+                await MainActor.run {
+                    self.scheduleSubmitButton?.isEnabled = true
+                    self.scheduleMeetingWindow?.performClose(nil)
+                    self.meetingsStatusLabel?.stringValue = "Meeting scheduled."
+                    self.triggerMeetingsRefresh(force: true)
+                    if let join = result.join_url, join.isEmpty == false {
+                        NSPasteboard.general.clearContents()
+                        NSPasteboard.general.setString(join, forType: .string)
+                        self.showSimpleAlert(
+                            title: "Meeting scheduled",
+                            message: "The join link was copied to your clipboard. It will also appear in your upcoming meetings list below."
+                        )
+                    } else {
+                        self.showSimpleAlert(title: "Meeting scheduled", message: "The meeting was added to your list.")
+                    }
+                }
+            } catch {
+                await MainActor.run {
+                    self.scheduleSubmitButton?.isEnabled = true
+                    if case ZoomOAuthError.missingRequiredScope(_) = error {
+                        self.zoomOAuth.clearSavedTokens()
+                        self.showSimpleAlert(
+                            title: "Zoom permissions",
+                            message: "Your Zoom app needs the meeting:write scope to schedule meetings. Add it in the Zoom Marketplace app settings, then sign in again."
+                        )
+                    } else if case ZoomOAuthError.rateLimited(let retryAfter) = error {
+                        let seconds = max(retryAfter ?? 300, 30)
+                        let minutes = Int(ceil(Double(seconds) / 60.0))
+                        self.showSimpleAlert(title: "Rate limited", message: "Zoom asked to wait before scheduling again. Try in about \(minutes) min.")
+                    } else {
+                        self.showSimpleError("Could not schedule", error: error)
+                    }
+                }
+            }
+        }
+    }
+
+    private func resetScheduleMeetingPanelReferences() {
+        scheduleMeetingWindow = nil
+        scheduleTopicField = nil
+        scheduleDateField = nil
+        scheduleTimeField = nil
+        scheduleTimeZoneCombo = nil
+        scheduleDurationField = nil
+        scheduleSubmitButton = nil
+    }
+
+    /// Resolves the schedule panel timezone (IANA id from combo, or system default).
+    private func resolvedScheduleTimeZoneForMeeting() -> TimeZone {
+        let raw = scheduleTimeZoneCombo?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        guard raw.isEmpty == false, let tz = TimeZone(identifier: raw) else { return TimeZone.current }
+        return tz
+    }
+
+    private struct ScheduleMeetingDraft {
+        let startDate: Date
+        let startTimeWithOffset: String
+        let timeZone: TimeZone
+    }
+
+    /// Keeps entered local date/time exact for Zoom (`start_time`) while also producing `Date` for validation.
+    private func scheduleMeetingDraftFromFormFields() -> ScheduleMeetingDraft? {
+        let datePart = scheduleDateField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        let timePart = scheduleTimeField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        guard datePart.isEmpty == false, timePart.isEmpty == false else { return nil }
+        let tz = resolvedScheduleTimeZoneForMeeting()
+        let normalizedTime = timePart.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
+        let combined = "\(datePart) \(normalizedTime)"
+        let df = DateFormatter()
+        df.locale = Locale(identifier: "en_US_POSIX")
+        df.timeZone = tz
+        df.isLenient = true
+        let formats = ["yyyy-MM-dd h:mm a", "yyyy-MM-dd hh:mm a", "yyyy-MM-dd h:mm:ss a"]
+        for format in formats {
+            df.dateFormat = format
+            if let d = df.date(from: combined) {
+                let localFormatter = DateFormatter()
+                localFormatter.locale = Locale(identifier: "en_US_POSIX")
+                localFormatter.timeZone = tz
+                localFormatter.calendar = Calendar(identifier: .gregorian)
+                localFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssXXXXX"
+                return ScheduleMeetingDraft(
+                    startDate: d,
+                    startTimeWithOffset: localFormatter.string(from: d),
+                    timeZone: tz
+                )
+            }
+        }
+        return nil
     }
 
     @objc private func joinMeetingTapped() {
@@ -1250,7 +1637,7 @@ class ViewController: NSViewController {
         let alert = NSAlert()
         alert.alertStyle = .informational
         alert.messageText = "Configure Zoom OAuth"
-        alert.informativeText = "Enter your Zoom Marketplace OAuth app Client ID and Client Secret once (or set ZoomOAuthClientId in Info.plist and ZOOM_OAUTH_CLIENT_SECRET in the run environment). After this, sign-in and token refresh run automatically."
+        alert.informativeText = "Enter your Zoom Marketplace OAuth app Client ID and Client Secret once (or set ZoomOAuthClientId in Info.plist and ZOOM_OAUTH_CLIENT_SECRET in the run environment). After this, sign-in and token refresh run automatically. Enable meeting:read and meeting:write scopes on the app so listing and scheduling work."
 
         let wrapper = NSStackView()
         wrapper.orientation = .vertical
@@ -1332,6 +1719,54 @@ class ViewController: NSViewController {
         return try JSONDecoder().decode(ZoomUserMeResponse.self, from: data)
     }
 
+    private struct ZoomCreateMeetingBody: Encodable {
+        let topic: String
+        let type: Int
+        let start_time: String
+        let duration: Int
+        let timezone: String
+    }
+
+    private struct ZoomCreateMeetingAPIResult: Decodable {
+        let join_url: String?
+    }
+
+    /// Creates a scheduled meeting via [Zoom Create meeting](https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#operation/meetingCreate).
+    private func createZoomMeeting(accessToken: String, topic: String, startTimeWithOffset: String, durationMinutes: Int, timeZone: TimeZone) async throws -> ZoomCreateMeetingAPIResult {
+        let url = URL(string: "https://api.zoom.us/v2/users/me/meetings")!
+        var request = URLRequest(url: url)
+        request.httpMethod = "POST"
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+        let body = ZoomCreateMeetingBody(
+            topic: topic,
+            type: 2,
+            start_time: startTimeWithOffset,
+            duration: durationMinutes,
+            timezone: timeZone.identifier
+        )
+        request.httpBody = try JSONEncoder().encode(body)
+
+        let (data, response) = try await URLSession.shared.data(for: request)
+        guard let http = response as? HTTPURLResponse else {
+            throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom")
+        }
+        if http.statusCode == 429 {
+            let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
+            let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
+            throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
+        }
+        guard (200..<300).contains(http.statusCode) else {
+            let raw = String(data: data, encoding: .utf8) ?? "Failed to create meeting"
+            if raw.localizedCaseInsensitiveContains("does not contain scopes") {
+                throw ZoomOAuthError.missingRequiredScope(raw)
+            }
+            throw GoogleOAuthError.tokenExchangeFailed(raw)
+        }
+        return try JSONDecoder().decode(ZoomCreateMeetingAPIResult.self, from: data)
+    }
+
     private func fetchZoomScheduledMeetings(accessToken: String) async throws -> [ScheduledMeeting] {
         struct ZoomMeeting: Decodable {
             let id: Int?
@@ -2591,7 +3026,7 @@ class ViewController: NSViewController {
         let actions = NSStackView(views: [
             makeActionTile(title: "New meeting", symbol: "video.fill", color: accentOrange),
             makeActionTile(title: "Join", symbol: "plus", color: accentBlue, action: #selector(joinMeetingTapped)),
-            makeActionTile(title: "Schedule", symbol: "calendar", color: accentBlue, action: #selector(scheduleMeetingWebTapped))
+            makeActionTile(title: "Schedule", symbol: "calendar", color: accentBlue, action: #selector(scheduleMeetingTapped))
         ])
         actions.orientation = .horizontal
         actions.spacing = 12
@@ -3090,6 +3525,7 @@ class ViewController: NSViewController {
     @MainActor
     private func updateSelectedHomeSectionUI() {
         let isHome = selectedHomeSidebarItem == "Home"
+        let isScheduler = selectedHomeSidebarItem == "Scheduler"
         let isSettings = selectedHomeSidebarItem == "Settings"
         let title = selectedHomeSidebarItem
 
@@ -3109,7 +3545,8 @@ class ViewController: NSViewController {
             meetingsScrollView,
             refreshMeetingsButton
         ]
-        let hideDashboard = isHome == false || isSettings
+        // Keep the main dashboard (including Schedule) visible on Home and Scheduler; other sidebar items are placeholders.
+        let hideDashboard = (isHome == false && isScheduler == false) || isSettings
         dashboardViews.forEach { $0?.isHidden = hideDashboard }
         // Do not toggle emptyMeetingLabel with other dashboard views — that overrode applyFilteredMeetings()
         // and showed "No meetings…" on top of meeting cards when returning to Home.
@@ -3121,7 +3558,7 @@ class ViewController: NSViewController {
         }
         homeSettingsView?.isHidden = isSettings == false
 
-        if isHome {
+        if isHome || isScheduler {
             homePlaceholderLabel?.isHidden = true
         } else {
             // Keep non-Home pages empty for now.
@@ -3409,8 +3846,13 @@ class ViewController: NSViewController {
 
 extension ViewController: NSWindowDelegate {
     func windowWillClose(_ notification: Notification) {
-        guard let window = notification.object as? NSWindow, window === joinMeetingWindow else { return }
-        resetJoinMeetingPanelReferences()
+        guard let window = notification.object as? NSWindow else { return }
+        if window === joinMeetingWindow {
+            resetJoinMeetingPanelReferences()
+        }
+        if window === scheduleMeetingWindow {
+            resetScheduleMeetingPanelReferences()
+        }
     }
 }
 
@@ -3911,7 +4353,10 @@ final class ZoomOAuthService: NSObject {
         return parts.contains { part in
             part == "meeting:read"
                 || part == "meeting:read:admin"
+                || part == "meeting:write"
+                || part == "meeting:write:admin"
                 || part.contains("meeting:read")
+                || part.contains("meeting:write")
                 || part.contains("list_meetings")
                 || part.contains("list_user_meetings")
         }