Quellcode durchsuchen

Add a dedicated Meetings page with range-based filters and load-more pagination.

This introduces a full-width professional Meetings view, fixes meetings card rendering/crash issues, and wires refresh/filter interactions so users can browse scheduled Zoom meetings reliably.

Made-with: Cursor
huzaifahayat12 vor 3 Monaten
Ursprung
Commit
de3a862384
1 geänderte Dateien mit 421 neuen und 8 gelöschten Zeilen
  1. 421 8
      zoom_app/ViewController.swift

+ 421 - 8
zoom_app/ViewController.swift

@@ -218,6 +218,16 @@ class ViewController: NSViewController {
     private weak var schedulerMeetingsListStack: NSStackView?
     private weak var schedulerMeetingsEmptyLabel: NSTextField?
     private weak var schedulerTodayButton: NSButton?
+    private weak var meetingsPageRootView: NSView?
+    private weak var meetingsPageCategoryFilter: NSPopUpButton?
+    private weak var meetingsPageFromDatePicker: NSDatePicker?
+    private weak var meetingsPageToDatePicker: NSDatePicker?
+    private weak var meetingsPageRefreshButton: NSButton?
+    private weak var meetingsPageCountLabel: NSTextField?
+    private weak var meetingsPageStatusLabel: NSTextField?
+    private weak var meetingsPageListStack: NSStackView?
+    private weak var meetingsPageEmptyLabel: NSTextField?
+    private weak var meetingsPageLoadMoreButton: NSButton?
     private var schedulerDayButtons: [Date: NSButton] = [:]
     private var schedulerDateByButtonTag: [Int: Date] = [:]
     private var nextSchedulerButtonTag: Int = 1
@@ -279,6 +289,12 @@ class ViewController: NSViewController {
     private let meetingsRefreshInterval: TimeInterval = 60
     private let scrollRefreshCooldown: TimeInterval = 3
     private var meetingsRateLimitedUntil: Date?
+    private var meetingsPageFilteredMeetings: [ScheduledMeeting] = []
+    private var meetingsPageSelectedRangePreset: String = "All"
+    private var meetingsPageFromDateFilter: Date?
+    private var meetingsPageToDateFilter: Date?
+    private var meetingsPageVisibleCount: Int = 0
+    private let meetingsPageSize: Int = 8
     
     private enum SidebarStyle {
         case login
@@ -485,6 +501,21 @@ class ViewController: NSViewController {
         removeSearchShortcutMonitor()
         homeSearchField = nil
         homeSearchPill = nil
+        meetingsPageRootView = nil
+        meetingsPageCategoryFilter = nil
+        meetingsPageFromDatePicker = nil
+        meetingsPageToDatePicker = nil
+        meetingsPageRefreshButton = nil
+        meetingsPageCountLabel = nil
+        meetingsPageStatusLabel = nil
+        meetingsPageListStack = nil
+        meetingsPageEmptyLabel = nil
+        meetingsPageLoadMoreButton = nil
+        meetingsPageFilteredMeetings = []
+        meetingsPageSelectedRangePreset = "All"
+        meetingsPageFromDateFilter = nil
+        meetingsPageToDateFilter = nil
+        meetingsPageVisibleCount = 0
         allScheduledMeetings = []
         homeView?.removeFromSuperview()
         homeView = nil
@@ -507,6 +538,7 @@ class ViewController: NSViewController {
         removeSearchShortcutMonitor()
         homeSearchField = nil
         homeSearchPill = nil
+        meetingsPageRootView = nil
         homeView?.removeFromSuperview()
         selectedMeetingsDayStart = Calendar.current.startOfDay(for: Date())
         homeView = makeHomeView(profile: profile)
@@ -1360,6 +1392,10 @@ class ViewController: NSViewController {
     @MainActor
     private func setMeetingsLoadingUI(_ loading: Bool) {
         refreshMeetingsButton?.isEnabled = loading == false
+        meetingsPageRefreshButton?.isEnabled = loading == false
+        if loading {
+            meetingsPageStatusLabel?.stringValue = "Refreshing meetings..."
+        }
     }
 
     @MainActor
@@ -1557,6 +1593,9 @@ class ViewController: NSViewController {
         let end: Date?
         let host: String
         let source: String
+        let meetingID: String?
+        let typeLabel: String
+        let statusLabel: String
         let webURL: URL?
     }
 
@@ -1564,6 +1603,7 @@ class ViewController: NSViewController {
     private func applyMeetings(_ meetings: [ScheduledMeeting]) {
         allScheduledMeetings = meetings
         applyFilteredMeetings()
+        applyMeetingsPageFilters(resetVisible: true)
     }
 
     @MainActor
@@ -1621,6 +1661,137 @@ class ViewController: NSViewController {
         renderSchedulerCalendarGrid()
     }
 
+    @MainActor
+    private func applyMeetingsPageFilters(resetVisible: Bool) {
+        let calendar = Calendar.current
+        var filtered = allScheduledMeetings.filter { meeting in
+            switch meetingsPageSelectedRangePreset {
+            case "Today":
+                return calendar.isDate(meeting.start, inSameDayAs: Date())
+            case "This week":
+                return calendar.isDate(meeting.start, equalTo: Date(), toGranularity: .weekOfYear)
+            case "This month":
+                return calendar.isDate(meeting.start, equalTo: Date(), toGranularity: .month)
+            case "Custom range":
+                if let fromDate = meetingsPageFromDateFilter {
+                    let startOfFrom = calendar.startOfDay(for: fromDate)
+                    if meeting.start < startOfFrom { return false }
+                }
+                if let toDate = meetingsPageToDateFilter {
+                    let startOfTo = calendar.startOfDay(for: toDate)
+                    let endOfTo = calendar.date(byAdding: .day, value: 1, to: startOfTo) ?? toDate
+                    if meeting.start >= endOfTo { return false }
+                }
+                return true
+            default:
+                return true
+            }
+        }
+
+        filtered.sort { $0.start < $1.start }
+
+        meetingsPageFilteredMeetings = filtered
+        if resetVisible {
+            meetingsPageVisibleCount = min(meetingsPageSize, filtered.count)
+        } else if filtered.count < meetingsPageVisibleCount {
+            meetingsPageVisibleCount = filtered.count
+        }
+        renderMeetingsPageList()
+    }
+
+    @MainActor
+    private func renderMeetingsPageList() {
+        guard let stack = meetingsPageListStack else { return }
+        stack.arrangedSubviews.forEach { view in
+            stack.removeArrangedSubview(view)
+            view.removeFromSuperview()
+        }
+
+        let totalCount = meetingsPageFilteredMeetings.count
+        let visibleCount = min(meetingsPageVisibleCount, totalCount)
+        meetingsPageCountLabel?.stringValue = "\(totalCount) scheduled"
+
+        if totalCount == 0 {
+            meetingsPageStatusLabel?.stringValue = "Showing 0 meetings"
+            meetingsPageEmptyLabel?.isHidden = false
+            meetingsPageLoadMoreButton?.isHidden = true
+            return
+        }
+
+        meetingsPageEmptyLabel?.isHidden = true
+        meetingsPageStatusLabel?.stringValue = "Showing \(visibleCount) of \(totalCount) meetings"
+        for meeting in meetingsPageFilteredMeetings.prefix(visibleCount) {
+            let card = makeMeetingRowCard(meeting)
+            stack.addArrangedSubview(card)
+            if card.constraints.contains(where: { $0.firstAttribute == .width }) == false {
+                card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
+            }
+        }
+        let hasMore = visibleCount < totalCount
+        meetingsPageLoadMoreButton?.isHidden = hasMore == false
+        meetingsPageLoadMoreButton?.title = hasMore ? "Load more" : "All meetings loaded"
+    }
+
+    @objc private func meetingsPageApplyFiltersTapped() {
+        let selectedPreset = meetingsPageCategoryFilter?.titleOfSelectedItem ?? "All"
+        meetingsPageSelectedRangePreset = selectedPreset
+        updateMeetingsDatePickersEnabledState()
+
+        if selectedPreset == "Custom range",
+           let fromPicker = meetingsPageFromDatePicker, let toPicker = meetingsPageToDatePicker {
+            let fromDay = Calendar.current.startOfDay(for: fromPicker.dateValue)
+            let toDay = Calendar.current.startOfDay(for: toPicker.dateValue)
+            if fromDay <= toDay {
+                meetingsPageFromDateFilter = fromDay
+                meetingsPageToDateFilter = toDay
+            } else {
+                meetingsPageFromDateFilter = toDay
+                meetingsPageToDateFilter = fromDay
+            }
+        } else {
+            meetingsPageFromDateFilter = nil
+            meetingsPageToDateFilter = nil
+        }
+
+        Task { @MainActor in
+            self.applyMeetingsPageFilters(resetVisible: true)
+        }
+    }
+
+    @objc private func meetingsPageResetFiltersTapped() {
+        meetingsPageSelectedRangePreset = "All"
+        meetingsPageFromDateFilter = nil
+        meetingsPageToDateFilter = nil
+        meetingsPageCategoryFilter?.selectItem(withTitle: "All")
+        meetingsPageFromDatePicker?.dateValue = Date()
+        meetingsPageToDatePicker?.dateValue = Date()
+        updateMeetingsDatePickersEnabledState()
+        Task { @MainActor in
+            self.applyMeetingsPageFilters(resetVisible: true)
+        }
+    }
+
+    @objc private func meetingsPageRangePresetChanged(_ sender: NSPopUpButton) {
+        meetingsPageSelectedRangePreset = sender.titleOfSelectedItem ?? "All"
+        updateMeetingsDatePickersEnabledState()
+    }
+
+    private func updateMeetingsDatePickersEnabledState() {
+        let isCustomRange = meetingsPageSelectedRangePreset == "Custom range"
+        meetingsPageFromDatePicker?.isEnabled = isCustomRange
+        meetingsPageToDatePicker?.isEnabled = isCustomRange
+        meetingsPageFromDatePicker?.alphaValue = isCustomRange ? 1.0 : 0.55
+        meetingsPageToDatePicker?.alphaValue = isCustomRange ? 1.0 : 0.55
+    }
+
+    @objc private func meetingsPageLoadMoreTapped() {
+        Task { @MainActor in
+            let nextCount = meetingsPageVisibleCount + meetingsPageSize
+            meetingsPageVisibleCount = min(nextCount, meetingsPageFilteredMeetings.count)
+            self.renderMeetingsPageList()
+        }
+    }
+
     private func meetingsForDay(_ dayStart: Date) -> [ScheduledMeeting] {
         let calendar = Calendar.current
         let safeDayStart = calendar.startOfDay(for: dayStart)
@@ -1789,7 +1960,7 @@ class ViewController: NSViewController {
         isToday: Bool,
         hasMeetings: Bool
     ) -> NSButton {
-        let button = NSButton(title: "\(day)", target: self, action: #selector(schedulerDayTapped(_:)))
+        let button = HoverButton(title: "\(day)", target: self, action: #selector(schedulerDayTapped(_:)))
         button.isBordered = false
         button.bezelStyle = .shadowlessSquare
         button.focusRingType = .none
@@ -1798,9 +1969,33 @@ class ViewController: NSViewController {
         button.wantsLayer = true
         button.layer?.cornerRadius = 16
         button.layer?.borderWidth = selected ? 1.5 : (hasMeetings ? 1 : 0)
-        button.layer?.backgroundColor = selected
-            ? accentBlue.withAlphaComponent(palette.isDarkMode ? 0.92 : 0.16).cgColor
-            : NSColor.clear.cgColor
+        let normalBackground = selected
+            ? accentBlue.withAlphaComponent(palette.isDarkMode ? 0.92 : 0.16)
+            : NSColor.clear
+        let hoverBackground = selected
+            ? accentBlue.withAlphaComponent(palette.isDarkMode ? 1.0 : 0.24)
+            : (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.08) : NSColor.black.withAlphaComponent(0.06))
+        button.normalColor = normalBackground
+        button.hoverColor = hoverBackground
+        button.onHoverChanged = { [weak button] hovering in
+            guard let button, selected == false else { return }
+            if hovering {
+                button.layer?.borderWidth = max(button.layer?.borderWidth ?? 0, 1)
+                button.layer?.borderColor = (hasMeetings
+                    ? self.accentBlue.withAlphaComponent(0.65)
+                    : self.accentBlue.withAlphaComponent(0.45)
+                ).cgColor
+            } else if isToday {
+                button.layer?.borderColor = self.accentOrange.withAlphaComponent(0.8).cgColor
+                button.layer?.borderWidth = 1
+            } else if hasMeetings {
+                button.layer?.borderColor = self.accentBlue.withAlphaComponent(0.45).cgColor
+                button.layer?.borderWidth = 1
+            } else {
+                button.layer?.borderColor = NSColor.clear.cgColor
+                button.layer?.borderWidth = 0
+            }
+        }
         if selected {
             button.layer?.borderColor = accentBlue.cgColor
         } else if isToday {
@@ -1878,20 +2073,25 @@ class ViewController: NSViewController {
                 self.applyMeetings([])
                 if case ZoomOAuthError.missingClientId = error {
                     self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
+                    self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
                     self.promptForZoomOAuthCredentialsIfNeeded()
                 } else if case ZoomOAuthError.missingClientSecret = error {
                     self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
+                    self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
                     self.promptForZoomOAuthCredentialsIfNeeded()
                 } else if case ZoomOAuthError.missingRequiredScope = error {
                     self.zoomOAuth.clearSavedTokens()
                     self.meetingsStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
+                    self.meetingsPageStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
                 } else if case ZoomOAuthError.rateLimited(let retryAfterSeconds) = error {
                     let seconds = max(retryAfterSeconds ?? 300, 30)
                     self.meetingsRateLimitedUntil = Date().addingTimeInterval(TimeInterval(seconds))
                     let minutes = Int(ceil(Double(seconds) / 60.0))
                     self.meetingsStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
+                    self.meetingsPageStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
                 } else {
                     self.meetingsStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
+                    self.meetingsPageStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
                 }
             }
         }
@@ -2039,6 +2239,8 @@ class ViewController: NSViewController {
             let start_time: String?
             let duration: Int?
             let host_id: String?
+            let type: Int?
+            let status: String?
             let join_url: String?
         }
 
@@ -2096,6 +2298,9 @@ class ViewController: NSViewController {
                     end: end,
                     host: meeting.host_id ?? "Zoom Host",
                     source: "Zoom",
+                    meetingID: meeting.id.map(String.init),
+                    typeLabel: meeting.type == 8 ? "Recurring" : "Scheduled",
+                    statusLabel: (meeting.status?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? meeting.status! : "scheduled").capitalized,
                     webURL: webURL
                 )
             }
@@ -3362,6 +3567,8 @@ class ViewController: NSViewController {
 
         let settingsView = makeSettingsView()
         settingsView.isHidden = selectedHomeSidebarItem != "Settings"
+        let meetingsPageView = makeMeetingsPageView()
+        meetingsPageView.isHidden = selectedHomeSidebarItem != "Meetings"
 
         let schedulerRoot = NSView()
         schedulerRoot.isHidden = selectedHomeSidebarItem != "Scheduler"
@@ -3485,7 +3692,7 @@ class ViewController: NSViewController {
         [searchIcon, searchField, searchHintLabel].forEach {
             searchPill.addSubview($0)
         }
-        [welcome, timeTitle, dateTitle, actions, panel, panelHeader, meetingsStatus, meetingsDayNav, noMeeting, meetingsScrollView, refreshMeetingsButton, placeholder, settingsView, schedulerRoot].forEach {
+        [welcome, timeTitle, dateTitle, actions, panel, panelHeader, meetingsStatus, meetingsDayNav, noMeeting, meetingsScrollView, refreshMeetingsButton, placeholder, settingsView, meetingsPageView, schedulerRoot].forEach {
             $0.translatesAutoresizingMaskIntoConstraints = false
             contentColumn.addSubview($0)
         }
@@ -3608,6 +3815,11 @@ class ViewController: NSViewController {
             settingsView.trailingAnchor.constraint(equalTo: contentColumn.trailingAnchor),
             settingsView.bottomAnchor.constraint(equalTo: contentColumn.bottomAnchor),
 
+            meetingsPageView.topAnchor.constraint(equalTo: contentColumn.topAnchor),
+            meetingsPageView.leadingAnchor.constraint(equalTo: contentColumn.leadingAnchor),
+            meetingsPageView.trailingAnchor.constraint(equalTo: contentColumn.trailingAnchor),
+            meetingsPageView.bottomAnchor.constraint(equalTo: contentColumn.bottomAnchor),
+
             schedulerRoot.topAnchor.constraint(equalTo: contentColumn.topAnchor, constant: 8),
             schedulerRoot.leadingAnchor.constraint(equalTo: contentColumn.leadingAnchor, constant: 8),
             schedulerRoot.trailingAnchor.constraint(equalTo: contentColumn.trailingAnchor, constant: -8),
@@ -3671,6 +3883,7 @@ class ViewController: NSViewController {
         homeMeetingsPanel = panel
         homePlaceholderLabel = placeholder
         homeSettingsView = settingsView
+        meetingsPageRootView = meetingsPageView
         schedulerRootView = schedulerRoot
         self.schedulerMonthLabel = schedulerMonthLabel
         schedulerCalendarGridStack = schedulerCalendarGrid
@@ -3689,6 +3902,7 @@ class ViewController: NSViewController {
         updateClock()
         updateMeetingsDayUI()
         applyFilteredMeetings()
+        applyMeetingsPageFilters(resetVisible: true)
         renderSchedulerCalendarGrid()
         updateSchedulerDateHeader()
         applySchedulerMeetingsForSelectedDate()
@@ -3709,6 +3923,195 @@ class ViewController: NSViewController {
         return root
     }
 
+    private func makeMeetingsPageView() -> NSView {
+        let root = NSView()
+        root.wantsLayer = true
+        root.layer?.backgroundColor = NSColor.clear.cgColor
+
+        let container = NSView()
+        container.translatesAutoresizingMaskIntoConstraints = false
+        container.wantsLayer = true
+        container.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
+        container.layer?.cornerRadius = 16
+        container.layer?.borderWidth = 1
+        container.layer?.borderColor = palette.inputBorder.cgColor
+        root.addSubview(container)
+
+        let title = makeLabel("Meetings", size: 28, color: primaryText, weight: .bold, centered: false)
+        let subtitle = makeLabel("All scheduled Zoom meetings", size: 13, color: secondaryText, weight: .regular, centered: false)
+        let count = makeLabel("0 scheduled", size: 12, color: secondaryText, weight: .medium, centered: false)
+        let titleStack = NSStackView(views: [title, subtitle, count])
+        titleStack.orientation = .vertical
+        titleStack.spacing = 4
+        titleStack.alignment = .leading
+        titleStack.translatesAutoresizingMaskIntoConstraints = false
+
+        let categoryFilter = NSPopUpButton()
+        categoryFilter.translatesAutoresizingMaskIntoConstraints = false
+        categoryFilter.addItems(withTitles: ["All", "Today", "This week", "This month", "Custom range"])
+        categoryFilter.selectItem(withTitle: "All")
+        categoryFilter.target = self
+        categoryFilter.action = #selector(meetingsPageRangePresetChanged(_:))
+
+        let fromDatePicker = NSDatePicker()
+        fromDatePicker.translatesAutoresizingMaskIntoConstraints = false
+        fromDatePicker.datePickerStyle = .textFieldAndStepper
+        fromDatePicker.datePickerMode = .single
+        fromDatePicker.datePickerElements = [.yearMonthDay]
+        fromDatePicker.dateValue = Date()
+        fromDatePicker.font = .systemFont(ofSize: 13, weight: .regular)
+
+        let toDatePicker = NSDatePicker()
+        toDatePicker.translatesAutoresizingMaskIntoConstraints = false
+        toDatePicker.datePickerStyle = .textFieldAndStepper
+        toDatePicker.datePickerMode = .single
+        toDatePicker.datePickerElements = [.yearMonthDay]
+        toDatePicker.dateValue = Date()
+        toDatePicker.font = .systemFont(ofSize: 13, weight: .regular)
+
+        let applyFiltersButton = NSButton(title: "Apply", target: self, action: #selector(meetingsPageApplyFiltersTapped))
+        applyFiltersButton.translatesAutoresizingMaskIntoConstraints = false
+        applyFiltersButton.isBordered = false
+        applyFiltersButton.wantsLayer = true
+        applyFiltersButton.layer?.backgroundColor = accentBlue.cgColor
+        applyFiltersButton.layer?.cornerRadius = 10
+        applyFiltersButton.contentTintColor = .white
+        applyFiltersButton.font = .systemFont(ofSize: 13, weight: .semibold)
+
+        let resetFiltersButton = NSButton(title: "Reset", target: self, action: #selector(meetingsPageResetFiltersTapped))
+        resetFiltersButton.translatesAutoresizingMaskIntoConstraints = false
+        resetFiltersButton.isBordered = false
+        resetFiltersButton.wantsLayer = true
+        resetFiltersButton.layer?.backgroundColor = palette.inputBackground.cgColor
+        resetFiltersButton.layer?.cornerRadius = 10
+        resetFiltersButton.layer?.borderWidth = 1
+        resetFiltersButton.layer?.borderColor = palette.inputBorder.cgColor
+        resetFiltersButton.contentTintColor = primaryText
+        resetFiltersButton.font = .systemFont(ofSize: 13, weight: .semibold)
+
+        let refreshButton = NSButton(title: "", target: self, action: #selector(refreshMeetingsTapped))
+        refreshButton.translatesAutoresizingMaskIntoConstraints = false
+        refreshButton.isBordered = false
+        refreshButton.wantsLayer = true
+        refreshButton.layer?.backgroundColor = palette.inputBackground.cgColor
+        refreshButton.layer?.cornerRadius = 10
+        refreshButton.layer?.borderWidth = 1
+        refreshButton.layer?.borderColor = palette.inputBorder.cgColor
+        refreshButton.contentTintColor = primaryText
+        refreshButton.font = .systemFont(ofSize: 13, weight: .semibold)
+        if let image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh") {
+            refreshButton.image = image
+            refreshButton.imagePosition = .imageOnly
+        }
+
+        let filterRow = NSStackView(views: [categoryFilter, fromDatePicker, toDatePicker, applyFiltersButton, resetFiltersButton, refreshButton])
+        filterRow.orientation = .horizontal
+        filterRow.spacing = 10
+        filterRow.alignment = .centerY
+        filterRow.translatesAutoresizingMaskIntoConstraints = false
+
+        let statusLabel = makeLabel("Showing 0 meetings", size: 12, color: secondaryText, weight: .medium, centered: false)
+        statusLabel.translatesAutoresizingMaskIntoConstraints = false
+
+        let listScroll = NSScrollView()
+        listScroll.translatesAutoresizingMaskIntoConstraints = false
+        listScroll.drawsBackground = false
+        listScroll.hasVerticalScroller = true
+        listScroll.hasHorizontalScroller = false
+        listScroll.autohidesScrollers = true
+
+        let listDocument = FlippedView()
+        listDocument.translatesAutoresizingMaskIntoConstraints = false
+        let listStack = NSStackView()
+        listStack.translatesAutoresizingMaskIntoConstraints = false
+        listStack.orientation = .vertical
+        listStack.spacing = 12
+        listStack.alignment = .leading
+        listStack.setContentHuggingPriority(.required, for: .vertical)
+        listStack.setContentCompressionResistancePriority(.required, for: .vertical)
+        listScroll.documentView = listDocument
+        listDocument.addSubview(listStack)
+
+        let empty = makeLabel("No meetings match the current filters.", size: 14, color: secondaryText, weight: .regular, centered: true)
+        empty.translatesAutoresizingMaskIntoConstraints = false
+        empty.isHidden = true
+
+        let loadMoreButton = NSButton(title: "Load more", target: self, action: #selector(meetingsPageLoadMoreTapped))
+        loadMoreButton.translatesAutoresizingMaskIntoConstraints = false
+        loadMoreButton.isBordered = false
+        loadMoreButton.wantsLayer = true
+        loadMoreButton.layer?.backgroundColor = palette.inputBackground.cgColor
+        loadMoreButton.layer?.cornerRadius = 10
+        loadMoreButton.layer?.borderWidth = 1
+        loadMoreButton.layer?.borderColor = palette.inputBorder.cgColor
+        loadMoreButton.contentTintColor = primaryText
+        loadMoreButton.font = .systemFont(ofSize: 13, weight: .semibold)
+        loadMoreButton.isHidden = true
+
+        [titleStack, filterRow, statusLabel, listScroll, empty, loadMoreButton].forEach { container.addSubview($0) }
+
+        NSLayoutConstraint.activate([
+            container.topAnchor.constraint(equalTo: root.topAnchor, constant: 8),
+            container.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 8),
+            container.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -8),
+            container.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -8),
+
+            titleStack.topAnchor.constraint(equalTo: container.topAnchor, constant: 20),
+            titleStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18),
+            titleStack.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -18),
+
+            filterRow.topAnchor.constraint(equalTo: titleStack.bottomAnchor, constant: 14),
+            filterRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18),
+            filterRow.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -18),
+
+            categoryFilter.widthAnchor.constraint(equalToConstant: 170),
+            fromDatePicker.widthAnchor.constraint(equalToConstant: 170),
+            toDatePicker.widthAnchor.constraint(equalToConstant: 170),
+            applyFiltersButton.widthAnchor.constraint(equalToConstant: 96),
+            resetFiltersButton.widthAnchor.constraint(equalToConstant: 96),
+            refreshButton.widthAnchor.constraint(equalToConstant: 44),
+            applyFiltersButton.heightAnchor.constraint(equalToConstant: 34),
+            resetFiltersButton.heightAnchor.constraint(equalToConstant: 34),
+            refreshButton.heightAnchor.constraint(equalToConstant: 34),
+
+            statusLabel.topAnchor.constraint(equalTo: filterRow.bottomAnchor, constant: 12),
+            statusLabel.leadingAnchor.constraint(equalTo: filterRow.leadingAnchor),
+            statusLabel.trailingAnchor.constraint(equalTo: filterRow.trailingAnchor),
+
+            listScroll.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 10),
+            listScroll.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
+            listScroll.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
+            listScroll.bottomAnchor.constraint(equalTo: loadMoreButton.topAnchor, constant: -12),
+
+            listDocument.widthAnchor.constraint(equalTo: listScroll.contentView.widthAnchor),
+            listStack.topAnchor.constraint(equalTo: listDocument.topAnchor),
+            listStack.leadingAnchor.constraint(equalTo: listDocument.leadingAnchor),
+            listStack.trailingAnchor.constraint(equalTo: listDocument.trailingAnchor),
+            // Pin bottom edge so document height grows with arranged subviews.
+            listStack.bottomAnchor.constraint(equalTo: listDocument.bottomAnchor),
+
+            empty.centerXAnchor.constraint(equalTo: listScroll.centerXAnchor),
+            empty.centerYAnchor.constraint(equalTo: listScroll.centerYAnchor),
+
+            loadMoreButton.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
+            loadMoreButton.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
+            loadMoreButton.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -14),
+            loadMoreButton.heightAnchor.constraint(equalToConstant: 40)
+        ])
+
+        meetingsPageCategoryFilter = categoryFilter
+        meetingsPageFromDatePicker = fromDatePicker
+        meetingsPageToDatePicker = toDatePicker
+        meetingsPageRefreshButton = refreshButton
+        meetingsPageCountLabel = count
+        meetingsPageStatusLabel = statusLabel
+        meetingsPageListStack = listStack
+        meetingsPageEmptyLabel = empty
+        meetingsPageLoadMoreButton = loadMoreButton
+        updateMeetingsDatePickersEnabledState()
+        return root
+    }
+
     deinit {
         removeSearchFieldObserver()
         removeSearchShortcutMonitor()
@@ -3969,10 +4372,11 @@ class ViewController: NSViewController {
     @MainActor
     private func updateSelectedHomeSectionUI() {
         let isHome = selectedHomeSidebarItem == "Home"
+        let isMeetings = selectedHomeSidebarItem == "Meetings"
         let isSettings = selectedHomeSidebarItem == "Settings"
         let isScheduler = selectedHomeSidebarItem == "Scheduler"
         homeWelcomeLabel?.stringValue = "Home"
-        homeWelcomeLabel?.isHidden = (isHome == false) || isSettings
+        homeWelcomeLabel?.isHidden = (isHome == false) || isSettings || isMeetings
 
         let dashboardViews: [NSView?] = [
             homeTimeLabelView,
@@ -3987,7 +4391,7 @@ class ViewController: NSViewController {
             meetingsScrollView,
             refreshMeetingsButton
         ]
-        let hideDashboard = isHome == false || isSettings
+        let hideDashboard = isHome == false || isSettings || isMeetings || isScheduler
         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.
@@ -3998,6 +4402,7 @@ class ViewController: NSViewController {
             emptyMeetingLabel?.isHidden = hasMeetingCards
         }
         homeSettingsView?.isHidden = isSettings == false
+        meetingsPageRootView?.isHidden = isMeetings == false
         schedulerRootView?.isHidden = isScheduler == false
 
         if isHome {
@@ -4012,6 +4417,9 @@ class ViewController: NSViewController {
             applySchedulerMeetingsForSelectedDate()
             renderSchedulerCalendarGrid()
         }
+        if isMeetings {
+            applyMeetingsPageFilters(resetVisible: false)
+        }
     }
 
     private func sidebarSymbolName(for item: String, filled: Bool = false) -> String {
@@ -5020,7 +5428,12 @@ private final class OAuthLoopbackServer {
     /// Fixed port so Zoom/Google OAuth redirect URLs can be registered exactly (Zoom allow list does not support wildcards for ports).
     private static let loopbackOAuthPort: UInt16 = 8742
 
-    private let queue = DispatchQueue(label: "google.oauth.loopback.server")
+    // Use a high-enough QoS to avoid Thread Performance Checker QoS inversion
+    // warnings when auth is triggered from user-interactive UI actions.
+    private let queue = DispatchQueue(
+        label: "google.oauth.loopback.server",
+        qos: .userInitiated
+    )
     private let listener: NWListener
     private var readyContinuation: CheckedContinuation<Void, Error>?
     private var callbackContinuation: CheckedContinuation<URL, Error>?