Bladeren bron

Build out the Schedule page with advanced filtering and paged card browsing.

This replaces the placeholder with a dedicated schedule experience, adds custom date-range filtering, renders cards in a 3-column scrollable layout, and keeps refresh/filter interactions aligned with the current page state.

Made-with: Cursor
huzaifahayat12 1 week geleden
bovenliggende
commit
5968d21f39
1 gewijzigde bestanden met toevoegingen van 423 en 3 verwijderingen
  1. 423 3
      meetings_app/ViewController.swift

+ 423 - 3
meetings_app/ViewController.swift

@@ -292,6 +292,14 @@ final class ViewController: NSViewController {
292 292
         case week = 2
293 293
     }
294 294
 
295
+    private enum SchedulePageFilter: Int {
296
+        case all = 0
297
+        case today = 1
298
+        case week = 2
299
+        case month = 3
300
+        case customRange = 4
301
+    }
302
+
295 303
     private var scheduleFilter: ScheduleFilter = .all
296 304
     private weak var scheduleDateHeadingLabel: NSTextField?
297 305
     private weak var scheduleCardsStack: NSStackView?
@@ -313,6 +321,25 @@ final class ViewController: NSViewController {
313 321
     private var scheduleProfileMenuAvatar: NSImage?
314 322
     private var scheduleProfileImageTask: Task<Void, Never>?
315 323
     private var googleAccountPopover: NSPopover?
324
+    private var scheduleCachedMeetings: [ScheduledMeeting] = []
325
+
326
+    private var schedulePageFilter: SchedulePageFilter = .all
327
+    private var schedulePageFromDate: Date = Calendar.current.startOfDay(for: Date())
328
+    private var schedulePageToDate: Date = Calendar.current.startOfDay(for: Date())
329
+    private var schedulePageFilteredMeetings: [ScheduledMeeting] = []
330
+    private var schedulePageVisibleCount: Int = 0
331
+    private let schedulePageBatchSize: Int = 20
332
+    private let schedulePageCardsPerRow: Int = 3
333
+    private let schedulePageCardWidth: CGFloat = 240
334
+    private let schedulePageCardSpacing: CGFloat = 12
335
+    private var schedulePageScrollObservation: NSObjectProtocol?
336
+    private weak var schedulePageDateHeadingLabel: NSTextField?
337
+    private weak var schedulePageFilterDropdown: NSPopUpButton?
338
+    private weak var schedulePageFromDatePicker: NSDatePicker?
339
+    private weak var schedulePageToDatePicker: NSDatePicker?
340
+    private weak var schedulePageRangeErrorLabel: NSTextField?
341
+    private weak var schedulePageCardsStack: NSStackView?
342
+    private weak var schedulePageCardsScrollView: NSScrollView?
316 343
 
317 344
     /// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
318 345
     private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
@@ -413,6 +440,9 @@ final class ViewController: NSViewController {
413 440
         if hasObservedAppLifecycleForUsage {
414 441
             NotificationCenter.default.removeObserver(self)
415 442
         }
443
+        if let observer = schedulePageScrollObservation {
444
+            NotificationCenter.default.removeObserver(observer)
445
+        }
416 446
         storeKitStartupTask?.cancel()
417 447
         paywallPurchaseTask?.cancel()
418 448
         launchPaywallWorkItem?.cancel()
@@ -735,6 +765,10 @@ private extension ViewController {
735 765
 
736 766
     private func reloadTheme() {
737 767
         pageCache.removeAll()
768
+        if let observer = schedulePageScrollObservation {
769
+            NotificationCenter.default.removeObserver(observer)
770
+        }
771
+        schedulePageScrollObservation = nil
738 772
         sidebarRowViews.removeAll()
739 773
         sidebarPageByView.removeAll()
740 774
         zoomJoinModeByView.removeAll()
@@ -1293,7 +1327,7 @@ private extension ViewController {
1293 1327
         case .joinMeetings:
1294 1328
             built = makeJoinMeetingsContent()
1295 1329
         case .photo:
1296
-            built = makePlaceholderPage(title: "Schedule", subtitle: "Create and manage your upcoming meetings.")
1330
+            built = makeSchedulePageContent()
1297 1331
         case .video:
1298 1332
             built = makePlaceholderPage(title: "Calendar", subtitle: "View meetings by date and track your plan.")
1299 1333
         case .settings:
@@ -1699,6 +1733,47 @@ private extension ViewController {
1699 1733
         return panel
1700 1734
     }
1701 1735
 
1736
+    func makeSchedulePageContent() -> NSView {
1737
+        let panel = NSView()
1738
+        panel.translatesAutoresizingMaskIntoConstraints = false
1739
+
1740
+        let contentStack = NSStackView()
1741
+        contentStack.translatesAutoresizingMaskIntoConstraints = false
1742
+        contentStack.orientation = .vertical
1743
+        contentStack.spacing = 12
1744
+        contentStack.alignment = .leading
1745
+
1746
+        contentStack.addArrangedSubview(schedulePageHeader())
1747
+
1748
+        let heading = textLabel(schedulePageInitialHeadingText(), font: typography.dateHeading, color: palette.textSecondary)
1749
+        schedulePageDateHeadingLabel = heading
1750
+        contentStack.addArrangedSubview(heading)
1751
+
1752
+        let rangeError = textLabel("", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: .systemRed)
1753
+        rangeError.isHidden = true
1754
+        schedulePageRangeErrorLabel = rangeError
1755
+        contentStack.addArrangedSubview(rangeError)
1756
+
1757
+        let cardsContainer = makeSchedulePageCardsContainer()
1758
+        contentStack.addArrangedSubview(cardsContainer)
1759
+
1760
+        panel.addSubview(contentStack)
1761
+
1762
+        NSLayoutConstraint.activate([
1763
+            contentStack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
1764
+            contentStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
1765
+            contentStack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 6),
1766
+            cardsContainer.leadingAnchor.constraint(equalTo: contentStack.leadingAnchor),
1767
+            cardsContainer.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -16)
1768
+        ])
1769
+
1770
+        Task { [weak self] in
1771
+            await self?.loadSchedule()
1772
+        }
1773
+
1774
+        return panel
1775
+    }
1776
+
1702 1777
     func meetJoinSectionRow() -> NSView {
1703 1778
         let row = NSStackView()
1704 1779
         row.translatesAutoresizingMaskIntoConstraints = false
@@ -2580,6 +2655,163 @@ private extension ViewController {
2580 2655
         return row
2581 2656
     }
2582 2657
 
2658
+    private func schedulePageHeader() -> NSView {
2659
+        let container = NSStackView()
2660
+        container.translatesAutoresizingMaskIntoConstraints = false
2661
+        container.orientation = .vertical
2662
+        container.spacing = 10
2663
+        container.alignment = .leading
2664
+
2665
+        let titleLabel = textLabel("Schedule", font: typography.pageTitle, color: palette.textPrimary)
2666
+        container.addArrangedSubview(titleLabel)
2667
+
2668
+        let filterRow = NSStackView()
2669
+        filterRow.translatesAutoresizingMaskIntoConstraints = false
2670
+        filterRow.orientation = .horizontal
2671
+        filterRow.alignment = .centerY
2672
+        filterRow.spacing = 10
2673
+
2674
+        let filterDropdown = makeSchedulePageFilterDropdown()
2675
+        schedulePageFilterDropdown = filterDropdown
2676
+        filterRow.addArrangedSubview(filterDropdown)
2677
+
2678
+        let fromPicker = makeScheduleDatePicker(date: schedulePageFromDate)
2679
+        schedulePageFromDatePicker = fromPicker
2680
+        filterRow.addArrangedSubview(fromPicker)
2681
+
2682
+        let toPicker = makeScheduleDatePicker(date: schedulePageToDate)
2683
+        schedulePageToDatePicker = toPicker
2684
+        filterRow.addArrangedSubview(toPicker)
2685
+
2686
+        filterRow.addArrangedSubview(makeSchedulePagePillButton(title: "Apply", action: #selector(schedulePageApplyDateRangePressed(_:))))
2687
+        filterRow.addArrangedSubview(makeSchedulePagePillButton(title: "Reset", action: #selector(schedulePageResetFiltersPressed(_:))))
2688
+        filterRow.addArrangedSubview(makeScheduleRefreshButton())
2689
+
2690
+        container.addArrangedSubview(filterRow)
2691
+        container.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
2692
+        refreshSchedulePageDateFilterUI()
2693
+        return container
2694
+    }
2695
+
2696
+    private func makeSchedulePageFilterDropdown() -> NSPopUpButton {
2697
+        let button = HoverPopUpButton(frame: .zero, pullsDown: false)
2698
+        button.translatesAutoresizingMaskIntoConstraints = false
2699
+        button.autoenablesItems = false
2700
+        button.isBordered = false
2701
+        button.bezelStyle = .regularSquare
2702
+        button.wantsLayer = true
2703
+        button.layer?.cornerRadius = 8
2704
+        button.layer?.masksToBounds = true
2705
+        button.layer?.backgroundColor = palette.inputBackground.cgColor
2706
+        button.layer?.borderColor = palette.inputBorder.cgColor
2707
+        button.layer?.borderWidth = 1
2708
+        button.font = typography.filterText
2709
+        button.contentTintColor = palette.textSecondary
2710
+        button.target = self
2711
+        button.action = #selector(schedulePageFilterDropdownChanged(_:))
2712
+        button.heightAnchor.constraint(equalToConstant: 34).isActive = true
2713
+        button.widthAnchor.constraint(equalToConstant: 190).isActive = true
2714
+
2715
+        button.removeAllItems()
2716
+        button.addItems(withTitles: ["All", "Today", "This week", "This month", "Custom range"])
2717
+        button.selectItem(at: schedulePageFilter.rawValue)
2718
+        if let menu = button.menu {
2719
+            for (index, item) in menu.items.enumerated() {
2720
+                item.tag = index
2721
+            }
2722
+        }
2723
+
2724
+        let baseColor = palette.inputBackground
2725
+        let baseBorder = palette.inputBorder
2726
+        let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
2727
+        let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
2728
+        let hoverBorder = baseBorder.blended(withFraction: 0.16, of: hoverBlend) ?? baseBorder
2729
+        button.onHoverChanged = { [weak button] hovering in
2730
+            button?.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
2731
+            button?.layer?.borderColor = (hovering ? hoverBorder : baseBorder).cgColor
2732
+        }
2733
+        button.onHoverChanged?(false)
2734
+        return button
2735
+    }
2736
+
2737
+    private func makeScheduleDatePicker(date: Date) -> NSDatePicker {
2738
+        let picker = NSDatePicker()
2739
+        picker.translatesAutoresizingMaskIntoConstraints = false
2740
+        picker.datePickerStyle = .textFieldAndStepper
2741
+        picker.datePickerElements = [.yearMonthDay]
2742
+        picker.dateValue = date
2743
+        picker.font = typography.filterText
2744
+        picker.heightAnchor.constraint(equalToConstant: 34).isActive = true
2745
+        picker.widthAnchor.constraint(equalToConstant: 156).isActive = true
2746
+        picker.target = self
2747
+        picker.action = #selector(schedulePageDatePickerChanged(_:))
2748
+        return picker
2749
+    }
2750
+
2751
+    private func makeSchedulePagePillButton(title: String, action: Selector) -> NSButton {
2752
+        let button = makeSchedulePillButton(title: title)
2753
+        button.target = self
2754
+        button.action = action
2755
+        button.widthAnchor.constraint(equalToConstant: 92).isActive = true
2756
+        return button
2757
+    }
2758
+
2759
+    private func makeSchedulePageCardsContainer() -> NSView {
2760
+        if let observer = schedulePageScrollObservation {
2761
+            NotificationCenter.default.removeObserver(observer)
2762
+        }
2763
+        schedulePageScrollObservation = nil
2764
+
2765
+        let wrapper = NSView()
2766
+        wrapper.translatesAutoresizingMaskIntoConstraints = false
2767
+
2768
+        let scroll = NSScrollView()
2769
+        scroll.translatesAutoresizingMaskIntoConstraints = false
2770
+        scroll.drawsBackground = false
2771
+        scroll.hasHorizontalScroller = false
2772
+        scroll.hasVerticalScroller = true
2773
+        scroll.autohidesScrollers = true
2774
+        scroll.borderType = .noBorder
2775
+        let viewportWidth = (schedulePageCardWidth * CGFloat(schedulePageCardsPerRow)) + (schedulePageCardSpacing * CGFloat(schedulePageCardsPerRow - 1))
2776
+        scroll.widthAnchor.constraint(equalToConstant: viewportWidth).isActive = true
2777
+        schedulePageCardsScrollView = scroll
2778
+        wrapper.addSubview(scroll)
2779
+
2780
+        let stack = NSStackView()
2781
+        stack.translatesAutoresizingMaskIntoConstraints = false
2782
+        stack.orientation = .vertical
2783
+        stack.spacing = 12
2784
+        stack.alignment = .leading
2785
+        schedulePageCardsStack = stack
2786
+        scroll.documentView = stack
2787
+
2788
+        NSLayoutConstraint.activate([
2789
+            scroll.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
2790
+            scroll.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
2791
+            scroll.topAnchor.constraint(equalTo: wrapper.topAnchor),
2792
+            scroll.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor),
2793
+            scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 420),
2794
+
2795
+            stack.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
2796
+            stack.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
2797
+            stack.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
2798
+            stack.bottomAnchor.constraint(equalTo: scroll.contentView.bottomAnchor),
2799
+            stack.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor)
2800
+        ])
2801
+
2802
+        scroll.contentView.postsBoundsChangedNotifications = true
2803
+        schedulePageScrollObservation = NotificationCenter.default.addObserver(
2804
+            forName: NSView.boundsDidChangeNotification,
2805
+            object: scroll.contentView,
2806
+            queue: .main
2807
+        ) { [weak self] _ in
2808
+            self?.schedulePageScrolled()
2809
+        }
2810
+
2811
+        renderSchedulePageCards()
2812
+        return wrapper
2813
+    }
2814
+
2583 2815
     private func scheduleTopAuthRow() -> NSView {
2584 2816
         let row = NSStackView()
2585 2817
         row.translatesAutoresizingMaskIntoConstraints = false
@@ -3960,6 +4192,10 @@ private extension ViewController {
3960 4192
         googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "Loading…"
3961 4193
     }
3962 4194
 
4195
+    private func schedulePageInitialHeadingText() -> String {
4196
+        googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "Loading schedule…"
4197
+    }
4198
+
3963 4199
     @objc func scheduleFilterDropdownChanged(_ sender: NSPopUpButton) {
3964 4200
         guard let selectedItem = sender.selectedItem,
3965 4201
               let filter = ScheduleFilter(rawValue: selectedItem.tag) else { return }
@@ -3974,6 +4210,38 @@ private extension ViewController {
3974 4210
         }
3975 4211
     }
3976 4212
 
4213
+    @objc func schedulePageFilterDropdownChanged(_ sender: NSPopUpButton) {
4214
+        guard let selectedItem = sender.selectedItem,
4215
+              let filter = SchedulePageFilter(rawValue: selectedItem.tag) else { return }
4216
+        schedulePageFilter = filter
4217
+        refreshSchedulePageDateFilterUI()
4218
+        applySchedulePageFiltersAndRender()
4219
+    }
4220
+
4221
+    @objc func schedulePageDatePickerChanged(_ sender: NSDatePicker) {
4222
+        schedulePageFromDate = schedulePageFromDatePicker?.dateValue ?? schedulePageFromDate
4223
+        schedulePageToDate = schedulePageToDatePicker?.dateValue ?? schedulePageToDate
4224
+    }
4225
+
4226
+    @objc func schedulePageApplyDateRangePressed(_ sender: NSButton) {
4227
+        schedulePageFilter = .customRange
4228
+        schedulePageFilterDropdown?.selectItem(at: SchedulePageFilter.customRange.rawValue)
4229
+        refreshSchedulePageDateFilterUI()
4230
+        applySchedulePageFiltersAndRender()
4231
+    }
4232
+
4233
+    @objc func schedulePageResetFiltersPressed(_ sender: NSButton) {
4234
+        schedulePageFilter = .all
4235
+        schedulePageFilterDropdown?.selectItem(at: SchedulePageFilter.all.rawValue)
4236
+        let today = Calendar.current.startOfDay(for: Date())
4237
+        schedulePageFromDate = today
4238
+        schedulePageToDate = today
4239
+        schedulePageFromDatePicker?.dateValue = today
4240
+        schedulePageToDatePicker?.dateValue = today
4241
+        refreshSchedulePageDateFilterUI()
4242
+        applySchedulePageFiltersAndRender()
4243
+    }
4244
+
3977 4245
     private func scheduleTimeText(for meeting: ScheduledMeeting) -> String {
3978 4246
         if meeting.isAllDay { return "All day" }
3979 4247
         let f = DateFormatter()
@@ -4073,6 +4341,148 @@ private extension ViewController {
4073 4341
         }
4074 4342
     }
4075 4343
 
4344
+    private func filteredMeetingsForSchedulePage(_ meetings: [ScheduledMeeting]) -> [ScheduledMeeting] {
4345
+        let calendar = Calendar.current
4346
+        switch schedulePageFilter {
4347
+        case .all:
4348
+            return meetings
4349
+        case .today:
4350
+            let start = calendar.startOfDay(for: Date())
4351
+            let end = calendar.date(byAdding: .day, value: 1, to: start) ?? start.addingTimeInterval(86400)
4352
+            return meetings.filter { $0.startDate >= start && $0.startDate < end }
4353
+        case .week:
4354
+            let now = Date()
4355
+            let end = calendar.date(byAdding: .day, value: 7, to: now) ?? now.addingTimeInterval(7 * 86400)
4356
+            return meetings.filter { $0.startDate >= now && $0.startDate <= end }
4357
+        case .month:
4358
+            let now = Date()
4359
+            let end = calendar.date(byAdding: .month, value: 1, to: now) ?? now.addingTimeInterval(30 * 86400)
4360
+            return meetings.filter { $0.startDate >= now && $0.startDate <= end }
4361
+        case .customRange:
4362
+            let start = calendar.startOfDay(for: schedulePageFromDate)
4363
+            let inclusiveEndDay = calendar.startOfDay(for: schedulePageToDate)
4364
+            guard let end = calendar.date(byAdding: .day, value: 1, to: inclusiveEndDay) else {
4365
+                return meetings
4366
+            }
4367
+            return meetings.filter { $0.startDate >= start && $0.startDate < end }
4368
+        }
4369
+    }
4370
+
4371
+    private func refreshSchedulePageDateFilterUI() {
4372
+        let isCustom = schedulePageFilter == .customRange
4373
+        schedulePageFromDatePicker?.isEnabled = isCustom
4374
+        schedulePageToDatePicker?.isEnabled = isCustom
4375
+        schedulePageFromDatePicker?.alphaValue = isCustom ? 1.0 : 0.65
4376
+        schedulePageToDatePicker?.alphaValue = isCustom ? 1.0 : 0.65
4377
+    }
4378
+
4379
+    private func schedulePageHasValidCustomRange() -> Bool {
4380
+        let start = Calendar.current.startOfDay(for: schedulePageFromDate)
4381
+        let end = Calendar.current.startOfDay(for: schedulePageToDate)
4382
+        return start <= end
4383
+    }
4384
+
4385
+    private func setSchedulePageRangeError(_ message: String?) {
4386
+        guard let label = schedulePageRangeErrorLabel else { return }
4387
+        label.stringValue = message ?? ""
4388
+        label.isHidden = message == nil
4389
+    }
4390
+
4391
+    private func applySchedulePageFiltersAndRender() {
4392
+        schedulePageFromDate = schedulePageFromDatePicker?.dateValue ?? schedulePageFromDate
4393
+        schedulePageToDate = schedulePageToDatePicker?.dateValue ?? schedulePageToDate
4394
+        if schedulePageFilter == .customRange && !schedulePageHasValidCustomRange() {
4395
+            setSchedulePageRangeError("Start date must be on or before end date.")
4396
+            schedulePageFilteredMeetings = []
4397
+            schedulePageVisibleCount = 0
4398
+            renderSchedulePageCards()
4399
+            schedulePageDateHeadingLabel?.stringValue = "Invalid custom date range"
4400
+            return
4401
+        }
4402
+
4403
+        setSchedulePageRangeError(nil)
4404
+        schedulePageFilteredMeetings = filteredMeetingsForSchedulePage(scheduleCachedMeetings)
4405
+        schedulePageVisibleCount = min(schedulePageBatchSize, schedulePageFilteredMeetings.count)
4406
+        renderSchedulePageCards()
4407
+        schedulePageDateHeadingLabel?.stringValue = scheduleHeadingText(for: schedulePageFilteredMeetings)
4408
+    }
4409
+
4410
+    private func appendSchedulePageBatchIfNeeded() {
4411
+        guard schedulePageVisibleCount < schedulePageFilteredMeetings.count else { return }
4412
+        let nextCount = min(schedulePageVisibleCount + schedulePageBatchSize, schedulePageFilteredMeetings.count)
4413
+        guard nextCount > schedulePageVisibleCount else { return }
4414
+        schedulePageVisibleCount = nextCount
4415
+        renderSchedulePageCards()
4416
+    }
4417
+
4418
+    private func schedulePageScrolled() {
4419
+        guard let scroll = schedulePageCardsScrollView else { return }
4420
+        let contentBounds = scroll.contentView.bounds
4421
+        let contentHeight = scroll.documentView?.bounds.height ?? 0
4422
+        guard contentHeight > 0 else { return }
4423
+        let remaining = contentHeight - (contentBounds.origin.y + contentBounds.height)
4424
+        if remaining < 180 {
4425
+            appendSchedulePageBatchIfNeeded()
4426
+        }
4427
+    }
4428
+
4429
+    private func renderSchedulePageCards() {
4430
+        guard let stack = schedulePageCardsStack else { return }
4431
+        stack.arrangedSubviews.forEach { v in
4432
+            stack.removeArrangedSubview(v)
4433
+            v.removeFromSuperview()
4434
+        }
4435
+
4436
+        let visibleMeetings = Array(schedulePageFilteredMeetings.prefix(schedulePageVisibleCount))
4437
+        if visibleMeetings.isEmpty {
4438
+            let empty = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
4439
+            empty.translatesAutoresizingMaskIntoConstraints = false
4440
+            empty.heightAnchor.constraint(equalToConstant: 120).isActive = true
4441
+            let viewportWidth = (schedulePageCardWidth * CGFloat(schedulePageCardsPerRow)) + (schedulePageCardSpacing * CGFloat(schedulePageCardsPerRow - 1))
4442
+            empty.widthAnchor.constraint(equalToConstant: viewportWidth).isActive = true
4443
+            styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
4444
+            let label = textLabel(googleOAuth.loadTokens() == nil ? "Connect to load schedule" : "No meetings for selected filters", font: typography.cardSubtitle, color: palette.textSecondary)
4445
+            label.translatesAutoresizingMaskIntoConstraints = false
4446
+            empty.addSubview(label)
4447
+            NSLayoutConstraint.activate([
4448
+                label.centerXAnchor.constraint(equalTo: empty.centerXAnchor),
4449
+                label.centerYAnchor.constraint(equalTo: empty.centerYAnchor)
4450
+            ])
4451
+            stack.addArrangedSubview(empty)
4452
+            return
4453
+        }
4454
+
4455
+        var index = 0
4456
+        while index < visibleMeetings.count {
4457
+            let row = NSStackView()
4458
+            row.translatesAutoresizingMaskIntoConstraints = false
4459
+            row.orientation = .horizontal
4460
+            row.alignment = .top
4461
+            row.spacing = schedulePageCardSpacing
4462
+            row.distribution = .fill
4463
+            let rowEnd = min(index + schedulePageCardsPerRow, visibleMeetings.count)
4464
+            for meeting in visibleMeetings[index..<rowEnd] {
4465
+                row.addArrangedSubview(scheduleCard(meeting: meeting))
4466
+            }
4467
+            if rowEnd - index < schedulePageCardsPerRow {
4468
+                for _ in 0..<(schedulePageCardsPerRow - (rowEnd - index)) {
4469
+                    let spacer = NSView()
4470
+                    spacer.translatesAutoresizingMaskIntoConstraints = false
4471
+                    spacer.widthAnchor.constraint(equalToConstant: schedulePageCardWidth).isActive = true
4472
+                    spacer.heightAnchor.constraint(equalToConstant: 150).isActive = true
4473
+                    row.addArrangedSubview(spacer)
4474
+                }
4475
+            }
4476
+            stack.addArrangedSubview(row)
4477
+            index = rowEnd
4478
+        }
4479
+
4480
+        if schedulePageVisibleCount < schedulePageFilteredMeetings.count {
4481
+            let moreLabel = textLabel("Scroll to load more (\(schedulePageVisibleCount)/\(schedulePageFilteredMeetings.count))", font: NSFont.systemFont(ofSize: 12, weight: .medium), color: palette.textMuted)
4482
+            stack.addArrangedSubview(moreLabel)
4483
+        }
4484
+    }
4485
+
4076 4486
     private func scrollScheduleCards(direction: Int) {
4077 4487
         guard let scroll = scheduleCardsScrollView else { return }
4078 4488
         let contentBounds = scroll.contentView.bounds
@@ -4091,9 +4501,12 @@ private extension ViewController {
4091 4501
                     updateGoogleAuthButtonTitle()
4092 4502
                     applyGoogleProfile(nil)
4093 4503
                     scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
4504
+                    schedulePageDateHeadingLabel?.stringValue = "Connect Google to see meetings"
4094 4505
                     if let stack = scheduleCardsStack {
4095 4506
                         renderScheduleCards(into: stack, meetings: [])
4096 4507
                     }
4508
+                    scheduleCachedMeetings = []
4509
+                    applySchedulePageFiltersAndRender()
4097 4510
                 }
4098 4511
                 return
4099 4512
             }
@@ -4110,6 +4523,8 @@ private extension ViewController {
4110 4523
                 if let stack = scheduleCardsStack {
4111 4524
                     renderScheduleCards(into: stack, meetings: filtered)
4112 4525
                 }
4526
+                scheduleCachedMeetings = meetings
4527
+                applySchedulePageFiltersAndRender()
4113 4528
             }
4114 4529
         } catch {
4115 4530
             await MainActor.run {
@@ -4118,9 +4533,12 @@ private extension ViewController {
4118 4533
                     applyGoogleProfile(nil)
4119 4534
                 }
4120 4535
                 scheduleDateHeadingLabel?.stringValue = "Couldn’t load schedule"
4536
+                schedulePageDateHeadingLabel?.stringValue = "Couldn’t load schedule"
4121 4537
                 if let stack = scheduleCardsStack {
4122 4538
                     renderScheduleCards(into: stack, meetings: [])
4123 4539
                 }
4540
+                scheduleCachedMeetings = []
4541
+                applySchedulePageFiltersAndRender()
4124 4542
                 showSimpleError("Couldn’t load schedule.", error: error)
4125 4543
             }
4126 4544
         }
@@ -4143,7 +4561,8 @@ private extension ViewController {
4143 4561
                 await MainActor.run {
4144 4562
                     self.scheduleDateHeadingLabel?.stringValue = "Refreshing…"
4145 4563
                     self.pageCache[.joinMeetings] = nil
4146
-                    self.showSidebarPage(.joinMeetings)
4564
+                    self.pageCache[.photo] = nil
4565
+                    self.showSidebarPage(self.selectedSidebarPage)
4147 4566
                 }
4148 4567
                 await self.loadSchedule()
4149 4568
             } catch {
@@ -4170,7 +4589,8 @@ private extension ViewController {
4170 4589
                     self.updateGoogleAuthButtonTitle()
4171 4590
                     self.applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
4172 4591
                     self.pageCache[.joinMeetings] = nil
4173
-                    self.showSidebarPage(.joinMeetings)
4592
+                    self.pageCache[.photo] = nil
4593
+                    self.showSidebarPage(self.selectedSidebarPage)
4174 4594
                 }
4175 4595
             } catch {
4176 4596
                 self.showSimpleError("Couldn’t connect Google account.", error: error)