浏览代码

Improve schedule cards layout and scrolling behavior.

Upgrade card visuals with richer metadata, load more calendar events with pagination, and keep a fixed three-card viewport with working left/right controls for horizontal browsing.

Made-with: Cursor
huzaifahayat12 1 周之前
父节点
当前提交
cb234b99df
共有 2 个文件被更改,包括 197 次插入58 次删除
  1. 53 35
      meetings_app/Google/GoogleCalendarClient.swift
  2. 144 23
      meetings_app/ViewController.swift

+ 53 - 35
meetings_app/Google/GoogleCalendarClient.swift

@@ -12,7 +12,7 @@ final class GoogleCalendarClient {
12 12
         var maxResults: Int
13 13
         var includeNonMeetEvents: Bool
14 14
 
15
-        init(daysAhead: Int = 7, maxResults: Int = 25, includeNonMeetEvents: Bool = false) {
15
+        init(daysAhead: Int = 180, maxResults: Int = 200, includeNonMeetEvents: Bool = true) {
16 16
             self.daysAhead = daysAhead
17 17
             self.maxResults = maxResults
18 18
             self.includeNonMeetEvents = includeNonMeetEvents
@@ -31,41 +31,49 @@ final class GoogleCalendarClient {
31 31
 
32 32
     func fetchUpcomingMeetings(accessToken: String, options: Options) async throws -> [ScheduledMeeting] {
33 33
         let now = Date()
34
-        let end = Calendar.current.date(byAdding: .day, value: max(1, options.daysAhead), to: now) ?? now.addingTimeInterval(7 * 24 * 60 * 60)
35
-
36
-        var components = URLComponents(string: "https://www.googleapis.com/calendar/v3/calendars/primary/events")!
34
+        let end = Calendar.current.date(byAdding: .day, value: max(1, options.daysAhead), to: now) ?? now.addingTimeInterval(180 * 24 * 60 * 60)
37 35
         let formatter = ISO8601DateFormatter()
38 36
         formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
39
-
40
-        components.queryItems = [
41
-            URLQueryItem(name: "timeMin", value: formatter.string(from: now)),
42
-            URLQueryItem(name: "timeMax", value: formatter.string(from: end)),
43
-            URLQueryItem(name: "singleEvents", value: "true"),
44
-            URLQueryItem(name: "orderBy", value: "startTime"),
45
-            URLQueryItem(name: "maxResults", value: String(options.maxResults)),
46
-            URLQueryItem(name: "conferenceDataVersion", value: "1")
47
-        ]
48
-
49
-        var request = URLRequest(url: components.url!)
50
-        request.httpMethod = "GET"
51
-        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
52
-
53
-        let (data, response) = try await session.data(for: request)
54
-        guard let http = response as? HTTPURLResponse else { throw GoogleCalendarClientError.invalidResponse }
55
-        guard (200..<300).contains(http.statusCode) else {
56
-            let body = String(data: data, encoding: .utf8) ?? "<no body>"
57
-            throw GoogleCalendarClientError.httpStatus(http.statusCode, body)
58
-        }
59
-
60
-        let decoded: EventsList
61
-        do {
62
-            decoded = try JSONDecoder().decode(EventsList.self, from: data)
63
-        } catch {
64
-            let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
65
-            throw GoogleCalendarClientError.decodeFailed(raw)
66
-        }
67
-
68
-        let meetings: [ScheduledMeeting] = decoded.items.compactMap { item in
37
+        let totalLimit = max(1, options.maxResults)
38
+        let pageSize = min(250, totalLimit)
39
+        var nextPageToken: String?
40
+        var meetings: [ScheduledMeeting] = []
41
+
42
+        repeat {
43
+            var components = URLComponents(string: "https://www.googleapis.com/calendar/v3/calendars/primary/events")!
44
+            var queryItems = [
45
+                URLQueryItem(name: "timeMin", value: formatter.string(from: now)),
46
+                URLQueryItem(name: "timeMax", value: formatter.string(from: end)),
47
+                URLQueryItem(name: "singleEvents", value: "true"),
48
+                URLQueryItem(name: "orderBy", value: "startTime"),
49
+                URLQueryItem(name: "maxResults", value: String(pageSize)),
50
+                URLQueryItem(name: "conferenceDataVersion", value: "1")
51
+            ]
52
+            if let nextPageToken {
53
+                queryItems.append(URLQueryItem(name: "pageToken", value: nextPageToken))
54
+            }
55
+            components.queryItems = queryItems
56
+
57
+            var request = URLRequest(url: components.url!)
58
+            request.httpMethod = "GET"
59
+            request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
60
+
61
+            let (data, response) = try await session.data(for: request)
62
+            guard let http = response as? HTTPURLResponse else { throw GoogleCalendarClientError.invalidResponse }
63
+            guard (200..<300).contains(http.statusCode) else {
64
+                let body = String(data: data, encoding: .utf8) ?? "<no body>"
65
+                throw GoogleCalendarClientError.httpStatus(http.statusCode, body)
66
+            }
67
+
68
+            let decoded: EventsList
69
+            do {
70
+                decoded = try JSONDecoder().decode(EventsList.self, from: data)
71
+            } catch {
72
+                let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
73
+                throw GoogleCalendarClientError.decodeFailed(raw)
74
+            }
75
+
76
+            let pageMeetings: [ScheduledMeeting] = decoded.items.compactMap { item in
69 77
             let title = item.summary?.trimmingCharacters(in: .whitespacesAndNewlines)
70 78
             let subtitle = item.organizer?.displayName ?? item.organizer?.email
71 79
 
@@ -78,6 +86,7 @@ final class GoogleCalendarClient {
78 86
                 if let hangout = item.hangoutLink, let u = URL(string: hangout) { return u }
79 87
                 let entry = item.conferenceData?.entryPoints?.first(where: { $0.entryPointType == "video" && ($0.uri?.contains("meet.google.com") ?? false) })
80 88
                 if let uri = entry?.uri, let u = URL(string: uri) { return u }
89
+                if options.includeNonMeetEvents, let htmlLink = item.htmlLink, let u = URL(string: htmlLink) { return u }
81 90
                 return nil
82 91
             }()
83 92
 
@@ -93,8 +102,15 @@ final class GoogleCalendarClient {
93 102
                 meetURL: meetURL,
94 103
                 isAllDay: isAllDay
95 104
             )
96
-        }
105
+            }
97 106
 
107
+            meetings.append(contentsOf: pageMeetings)
108
+            nextPageToken = decoded.nextPageToken
109
+        } while nextPageToken != nil && meetings.count < totalLimit
110
+
111
+        if meetings.count > totalLimit {
112
+            meetings = Array(meetings.prefix(totalLimit))
113
+        }
98 114
         return meetings
99 115
     }
100 116
 }
@@ -116,12 +132,14 @@ extension GoogleCalendarClientError: LocalizedError {
116 132
 
117 133
 private struct EventsList: Decodable {
118 134
     let items: [EventItem]
135
+    let nextPageToken: String?
119 136
 }
120 137
 
121 138
 private struct EventItem: Decodable {
122 139
     let id: String?
123 140
     let summary: String?
124 141
     let hangoutLink: String?
142
+    let htmlLink: String?
125 143
     let organizer: Organizer?
126 144
     let start: EventDateTime?
127 145
     let end: EventDateTime?

+ 144 - 23
meetings_app/ViewController.swift

@@ -74,6 +74,9 @@ final class ViewController: NSViewController {
74 74
     private var scheduleFilter: ScheduleFilter = .all
75 75
     private weak var scheduleDateHeadingLabel: NSTextField?
76 76
     private weak var scheduleCardsStack: NSStackView?
77
+    private weak var scheduleCardsScrollView: NSScrollView?
78
+    private weak var scheduleScrollLeftButton: NSView?
79
+    private weak var scheduleScrollRightButton: NSView?
77 80
     private weak var scheduleFilterDropdown: NSPopUpButton?
78 81
 
79 82
     /// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
@@ -1883,24 +1886,40 @@ private extension ViewController {
1883 1886
     }
1884 1887
 
1885 1888
     func scheduleCardsRow(meetings: [ScheduledMeeting]) -> NSView {
1889
+        let cardWidth: CGFloat = 240
1890
+        let cardsPerViewport: CGFloat = 3
1891
+        let viewportWidth = (cardWidth * cardsPerViewport) + (12 * (cardsPerViewport - 1))
1892
+
1893
+        let wrapper = NSStackView()
1894
+        wrapper.translatesAutoresizingMaskIntoConstraints = false
1895
+        wrapper.orientation = .horizontal
1896
+        wrapper.alignment = .centerY
1897
+        wrapper.spacing = 10
1898
+        let leftButton = makeScheduleScrollButton(systemSymbol: "chevron.left", action: #selector(scheduleScrollLeftPressed(_:)))
1899
+        scheduleScrollLeftButton = leftButton
1900
+        wrapper.addArrangedSubview(leftButton)
1901
+
1886 1902
         let scroll = NSScrollView()
1903
+        scheduleCardsScrollView = scroll
1887 1904
         scroll.translatesAutoresizingMaskIntoConstraints = false
1888 1905
         scroll.drawsBackground = false
1889
-        scroll.hasHorizontalScroller = true
1906
+        scroll.hasHorizontalScroller = false
1890 1907
         scroll.hasVerticalScroller = false
1891 1908
         scroll.horizontalScrollElasticity = .allowed
1892 1909
         scroll.verticalScrollElasticity = .none
1893
-        scroll.autohidesScrollers = true
1910
+        scroll.autohidesScrollers = false
1894 1911
         scroll.borderType = .noBorder
1912
+        scroll.heightAnchor.constraint(equalToConstant: 150).isActive = true
1913
+        scroll.widthAnchor.constraint(equalToConstant: viewportWidth).isActive = true
1895 1914
 
1896 1915
         let row = NSStackView()
1897 1916
         row.translatesAutoresizingMaskIntoConstraints = false
1898 1917
         row.orientation = .horizontal
1899
-        row.spacing = 10
1918
+        row.spacing = 12
1900 1919
         row.alignment = .top
1901 1920
         row.distribution = .fill
1902 1921
         row.setContentHuggingPriority(.defaultHigh, for: .horizontal)
1903
-        row.heightAnchor.constraint(equalToConstant: 136).isActive = true
1922
+        row.heightAnchor.constraint(equalToConstant: 150).isActive = true
1904 1923
         scheduleCardsStack = row
1905 1924
 
1906 1925
         scroll.documentView = row
@@ -1909,59 +1928,94 @@ private extension ViewController {
1909 1928
         // Ensure the stack view determines content size for horizontal scrolling.
1910 1929
         NSLayoutConstraint.activate([
1911 1930
             row.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
1912
-            row.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
1931
+            row.trailingAnchor.constraint(greaterThanOrEqualTo: scroll.contentView.trailingAnchor),
1913 1932
             row.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
1914 1933
             row.bottomAnchor.constraint(equalTo: scroll.contentView.bottomAnchor),
1915
-            row.heightAnchor.constraint(equalToConstant: 136)
1934
+            row.heightAnchor.constraint(equalToConstant: 150)
1916 1935
         ])
1917 1936
 
1918 1937
         renderScheduleCards(into: row, meetings: meetings)
1919
-        return scroll
1938
+        wrapper.addArrangedSubview(scroll)
1939
+        let rightButton = makeScheduleScrollButton(systemSymbol: "chevron.right", action: #selector(scheduleScrollRightPressed(_:)))
1940
+        scheduleScrollRightButton = rightButton
1941
+        wrapper.addArrangedSubview(rightButton)
1942
+        scroll.setContentHuggingPriority(.defaultLow, for: .horizontal)
1943
+        scroll.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
1944
+        wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
1945
+        return wrapper
1920 1946
     }
1921 1947
 
1922 1948
     func scheduleCard(meeting: ScheduledMeeting) -> NSView {
1923
-        let cardWidth: CGFloat = 264
1949
+        let cardWidth: CGFloat = 240
1924 1950
 
1925
-        let card = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
1951
+        let card = roundedContainer(cornerRadius: 12, color: palette.sectionCard)
1926 1952
         styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: true)
1927 1953
         card.translatesAutoresizingMaskIntoConstraints = false
1928 1954
         card.widthAnchor.constraint(equalToConstant: cardWidth).isActive = true
1929
-        card.heightAnchor.constraint(equalToConstant: 136).isActive = true
1955
+        card.heightAnchor.constraint(equalToConstant: 150).isActive = true
1930 1956
 
1931
-        let icon = roundedContainer(cornerRadius: 5, color: palette.meetingBadge)
1957
+        let icon = roundedContainer(cornerRadius: 8, color: palette.meetingBadge)
1932 1958
         icon.translatesAutoresizingMaskIntoConstraints = false
1933
-        icon.widthAnchor.constraint(equalToConstant: 22).isActive = true
1934
-        icon.heightAnchor.constraint(equalToConstant: 22).isActive = true
1935
-        let iconText = textLabel("••", font: typography.cardIcon, color: .white)
1936
-        iconText.translatesAutoresizingMaskIntoConstraints = false
1937
-        icon.addSubview(iconText)
1959
+        icon.widthAnchor.constraint(equalToConstant: 28).isActive = true
1960
+        icon.heightAnchor.constraint(equalToConstant: 28).isActive = true
1961
+        let iconView = NSImageView()
1962
+        iconView.translatesAutoresizingMaskIntoConstraints = false
1963
+        iconView.image = NSImage(systemSymbolName: "video.circle.fill", accessibilityDescription: "Meeting")
1964
+        iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
1965
+        iconView.contentTintColor = .white
1966
+        icon.addSubview(iconView)
1938 1967
         NSLayoutConstraint.activate([
1939
-            iconText.centerXAnchor.constraint(equalTo: icon.centerXAnchor),
1940
-            iconText.centerYAnchor.constraint(equalTo: icon.centerYAnchor)
1968
+            iconView.centerXAnchor.constraint(equalTo: icon.centerXAnchor),
1969
+            iconView.centerYAnchor.constraint(equalTo: icon.centerYAnchor)
1941 1970
         ])
1942 1971
 
1943 1972
         let title = textLabel(meeting.title, font: typography.cardTitle, color: palette.textPrimary)
1944 1973
         let subtitle = textLabel(meeting.subtitle ?? "Google Calendar", font: typography.cardSubtitle, color: palette.textPrimary)
1945 1974
         let time = textLabel(scheduleTimeText(for: meeting), font: typography.cardTime, color: palette.textSecondary)
1975
+        let duration = textLabel(scheduleDurationText(for: meeting), font: NSFont.systemFont(ofSize: 11, weight: .medium), color: palette.textMuted)
1976
+        let dayChip = roundedContainer(cornerRadius: 7, color: palette.inputBackground)
1977
+        dayChip.translatesAutoresizingMaskIntoConstraints = false
1978
+        dayChip.layer?.borderWidth = 1
1979
+        dayChip.layer?.borderColor = palette.inputBorder.withAlphaComponent(0.8).cgColor
1980
+        let dayText = textLabel(scheduleDayText(for: meeting), font: NSFont.systemFont(ofSize: 11, weight: .semibold), color: palette.textSecondary)
1981
+        dayText.translatesAutoresizingMaskIntoConstraints = false
1982
+        dayChip.addSubview(dayText)
1983
+        NSLayoutConstraint.activate([
1984
+            dayText.leadingAnchor.constraint(equalTo: dayChip.leadingAnchor, constant: 8),
1985
+            dayText.trailingAnchor.constraint(equalTo: dayChip.trailingAnchor, constant: -8),
1986
+            dayText.topAnchor.constraint(equalTo: dayChip.topAnchor, constant: 4),
1987
+            dayText.bottomAnchor.constraint(equalTo: dayChip.bottomAnchor, constant: -4)
1988
+        ])
1946 1989
 
1947 1990
         card.addSubview(icon)
1991
+        card.addSubview(dayChip)
1948 1992
         card.addSubview(title)
1949 1993
         card.addSubview(subtitle)
1950 1994
         card.addSubview(time)
1995
+        card.addSubview(duration)
1951 1996
 
1952 1997
         NSLayoutConstraint.activate([
1953 1998
             icon.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
1954 1999
             icon.topAnchor.constraint(equalTo: card.topAnchor, constant: 10),
1955 2000
 
2001
+            dayChip.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -10),
2002
+            dayChip.centerYAnchor.constraint(equalTo: icon.centerYAnchor),
2003
+
1956 2004
             title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 6),
1957 2005
             title.centerYAnchor.constraint(equalTo: icon.centerYAnchor),
1958
-            title.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
2006
+            title.trailingAnchor.constraint(lessThanOrEqualTo: dayChip.leadingAnchor, constant: -8),
1959 2007
 
1960 2008
             subtitle.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
1961
-            subtitle.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 7),
2009
+            subtitle.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 10),
2010
+            subtitle.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
1962 2011
 
1963 2012
             time.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
1964
-            time.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 4)
2013
+            time.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 5),
2014
+            time.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
2015
+
2016
+            duration.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
2017
+            duration.topAnchor.constraint(equalTo: time.bottomAnchor, constant: 4),
2018
+            duration.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10)
1965 2019
         ])
1966 2020
 
1967 2021
         let hit = HoverTrackingView()
@@ -1987,6 +2041,27 @@ private extension ViewController {
1987 2041
 
1988 2042
         return hit
1989 2043
     }
2044
+
2045
+    private func makeScheduleScrollButton(systemSymbol: String, action: Selector) -> NSButton {
2046
+        let button = NSButton(title: "", target: self, action: action)
2047
+        button.translatesAutoresizingMaskIntoConstraints = false
2048
+        button.isBordered = false
2049
+        button.bezelStyle = .regularSquare
2050
+        button.wantsLayer = true
2051
+        button.layer?.cornerRadius = 16
2052
+        button.layer?.backgroundColor = palette.inputBackground.cgColor
2053
+        button.layer?.borderColor = palette.inputBorder.cgColor
2054
+        button.layer?.borderWidth = 1
2055
+        button.image = NSImage(systemSymbolName: systemSymbol, accessibilityDescription: "Scroll meetings")
2056
+        button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
2057
+        button.imagePosition = .imageOnly
2058
+        button.imageScaling = .scaleProportionallyDown
2059
+        button.contentTintColor = palette.textSecondary
2060
+        button.focusRingType = .none
2061
+        button.heightAnchor.constraint(equalToConstant: 32).isActive = true
2062
+        button.widthAnchor.constraint(equalToConstant: 32).isActive = true
2063
+        return button
2064
+    }
1990 2065
 }
1991 2066
 
1992 2067
 extension ViewController: NSTextFieldDelegate {
@@ -2538,6 +2613,14 @@ private extension ViewController {
2538 2613
         scheduleReloadClicked()
2539 2614
     }
2540 2615
 
2616
+    @objc func scheduleScrollLeftPressed(_ sender: NSButton) {
2617
+        scrollScheduleCards(direction: -1)
2618
+    }
2619
+
2620
+    @objc func scheduleScrollRightPressed(_ sender: NSButton) {
2621
+        scrollScheduleCards(direction: 1)
2622
+    }
2623
+
2541 2624
     @objc func scheduleConnectButtonPressed(_ sender: NSButton) {
2542 2625
         scheduleConnectClicked()
2543 2626
     }
@@ -2570,6 +2653,25 @@ private extension ViewController {
2570 2653
         return "\(f.string(from: meeting.startDate)) - \(f.string(from: meeting.endDate))"
2571 2654
     }
2572 2655
 
2656
+    private func scheduleDayText(for meeting: ScheduledMeeting) -> String {
2657
+        let f = DateFormatter()
2658
+        f.locale = Locale.current
2659
+        f.timeZone = TimeZone.current
2660
+        f.dateFormat = "EEE, d MMM"
2661
+        return f.string(from: meeting.startDate)
2662
+    }
2663
+
2664
+    private func scheduleDurationText(for meeting: ScheduledMeeting) -> String {
2665
+        if meeting.isAllDay { return "Duration: all day" }
2666
+        let duration = max(0, meeting.endDate.timeIntervalSince(meeting.startDate))
2667
+        let totalMinutes = Int(duration / 60)
2668
+        let hours = totalMinutes / 60
2669
+        let minutes = totalMinutes % 60
2670
+        if hours > 0, minutes > 0 { return "Duration: \(hours)h \(minutes)m" }
2671
+        if hours > 0 { return "Duration: \(hours)h" }
2672
+        return "Duration: \(minutes)m"
2673
+    }
2674
+
2573 2675
     private func scheduleHeadingText(for meetings: [ScheduledMeeting]) -> String {
2574 2676
         guard let first = meetings.first else {
2575 2677
             return googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "No upcoming meetings"
@@ -2588,6 +2690,14 @@ private extension ViewController {
2588 2690
     }
2589 2691
 
2590 2692
     private func renderScheduleCards(into stack: NSStackView, meetings: [ScheduledMeeting]) {
2693
+        let shouldShowScrollControls = meetings.count > 3
2694
+        scheduleScrollLeftButton?.isHidden = !shouldShowScrollControls
2695
+        scheduleScrollRightButton?.isHidden = !shouldShowScrollControls
2696
+        scheduleCardsScrollView?.contentView.setBoundsOrigin(.zero)
2697
+        if let scroll = scheduleCardsScrollView {
2698
+            scroll.reflectScrolledClipView(scroll.contentView)
2699
+        }
2700
+
2591 2701
         stack.arrangedSubviews.forEach { v in
2592 2702
             stack.removeArrangedSubview(v)
2593 2703
             v.removeFromSuperview()
@@ -2596,8 +2706,8 @@ private extension ViewController {
2596 2706
         if meetings.isEmpty {
2597 2707
             let empty = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
2598 2708
             empty.translatesAutoresizingMaskIntoConstraints = false
2599
-            empty.widthAnchor.constraint(equalToConstant: 264).isActive = true
2600
-            empty.heightAnchor.constraint(equalToConstant: 136).isActive = true
2709
+            empty.widthAnchor.constraint(equalToConstant: 240).isActive = true
2710
+            empty.heightAnchor.constraint(equalToConstant: 150).isActive = true
2601 2711
             styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
2602 2712
 
2603 2713
             let label = textLabel(googleOAuth.loadTokens() == nil ? "Connect to load schedule" : "No meetings", font: typography.cardSubtitle, color: palette.textSecondary)
@@ -2631,6 +2741,17 @@ private extension ViewController {
2631 2741
         }
2632 2742
     }
2633 2743
 
2744
+    private func scrollScheduleCards(direction: Int) {
2745
+        guard let scroll = scheduleCardsScrollView else { return }
2746
+        let contentBounds = scroll.contentView.bounds
2747
+        let step = max(220, contentBounds.width * 0.7)
2748
+        let proposedX = contentBounds.origin.x + (CGFloat(direction) * step)
2749
+        let maxX = max(0, scroll.documentView?.bounds.width ?? 0 - contentBounds.width)
2750
+        let nextX = min(max(0, proposedX), maxX)
2751
+        scroll.contentView.animator().setBoundsOrigin(NSPoint(x: nextX, y: 0))
2752
+        scroll.reflectScrolledClipView(scroll.contentView)
2753
+    }
2754
+
2634 2755
     private func loadSchedule() async {
2635 2756
         do {
2636 2757
             if googleOAuth.loadTokens() == nil {