Procházet zdrojové kódy

Replace schedule meetings with Google Classroom to-do.

Switch the schedule sections to load and render Classroom assignments/quizzes for the signed-in user, add required Classroom OAuth scopes, and handle re-consent when legacy tokens are missing new scopes.

Made-with: Cursor
huzaifahayat12 před 1 týdnem
rodič
revize
86c3ea175d

+ 4 - 1
classroom_app/Auth/GoogleOAuthService.swift

@@ -43,7 +43,10 @@ final class GoogleOAuthService: NSObject {
43 43
         "openid",
44 44
         "email",
45 45
         "profile",
46
-        "https://www.googleapis.com/auth/calendar.events"
46
+        "https://www.googleapis.com/auth/calendar.events",
47
+        // Classroom To-do (assignments/quizzes)
48
+        "https://www.googleapis.com/auth/classroom.courses.readonly",
49
+        "https://www.googleapis.com/auth/classroom.coursework.me.readonly"
47 50
     ]
48 51
 
49 52
     private let tokenStore = KeychainTokenStore()

+ 210 - 0
classroom_app/Google/GoogleClassroomClient.swift

@@ -0,0 +1,210 @@
1
+import Foundation
2
+
3
+enum GoogleClassroomClientError: Error {
4
+    case invalidResponse
5
+    case httpStatus(Int, String)
6
+    case decodeFailed(String)
7
+}
8
+
9
+/// Minimal Google Classroom REST wrapper for a student's to-do list.
10
+final class GoogleClassroomClient {
11
+    struct Options: Sendable {
12
+        var maxCourses: Int
13
+        var maxCourseWorkPerCourse: Int
14
+
15
+        init(maxCourses: Int = 20, maxCourseWorkPerCourse: Int = 50) {
16
+            self.maxCourses = maxCourses
17
+            self.maxCourseWorkPerCourse = maxCourseWorkPerCourse
18
+        }
19
+    }
20
+
21
+    private let session: URLSession
22
+
23
+    init(session: URLSession = .shared) {
24
+        self.session = session
25
+    }
26
+
27
+    func fetchTodo(accessToken: String, options: Options? = nil) async throws -> [ClassroomTodoItem] {
28
+        let resolvedOptions = options ?? Options()
29
+        let courses = try await listActiveCourses(accessToken: accessToken, pageSize: resolvedOptions.maxCourses)
30
+        if courses.isEmpty { return [] }
31
+
32
+        var todos: [ClassroomTodoItem] = []
33
+        todos.reserveCapacity(courses.count * 8)
34
+
35
+        for course in courses.prefix(max(1, resolvedOptions.maxCourses)) {
36
+            let courseWork = try await listPublishedCourseWork(
37
+                accessToken: accessToken,
38
+                courseId: course.id,
39
+                pageSize: resolvedOptions.maxCourseWorkPerCourse
40
+            )
41
+            for work in courseWork {
42
+                let due = work.resolvedDueDate
43
+                let link = work.alternateLink.flatMap(URL.init(string:))
44
+                let workType = ClassroomTodoWorkType(rawValue: work.workType ?? "") ?? .unspecified
45
+                todos.append(
46
+                    ClassroomTodoItem(
47
+                        id: "\(course.id)::\(work.id ?? UUID().uuidString)",
48
+                        courseId: course.id,
49
+                        courseName: course.name ?? "Course",
50
+                        title: (work.title?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? (work.title ?? "Untitled") : "Untitled",
51
+                        dueDate: due,
52
+                        alternateLink: link,
53
+                        workType: workType
54
+                    )
55
+                )
56
+            }
57
+        }
58
+
59
+        // Keep only items with a due date near-term first; undated items at end.
60
+        todos.sort {
61
+            switch ($0.dueDate, $1.dueDate) {
62
+            case let (a?, b?):
63
+                if a != b { return a < b }
64
+                return $0.courseName < $1.courseName
65
+            case (_?, nil):
66
+                return true
67
+            case (nil, _?):
68
+                return false
69
+            case (nil, nil):
70
+                if $0.courseName != $1.courseName { return $0.courseName < $1.courseName }
71
+                return $0.title < $1.title
72
+            }
73
+        }
74
+        return todos
75
+    }
76
+
77
+    // MARK: - Courses
78
+
79
+    private func listActiveCourses(accessToken: String, pageSize: Int) async throws -> [Course] {
80
+        var components = URLComponents(string: "https://classroom.googleapis.com/v1/courses")!
81
+        components.queryItems = [
82
+            URLQueryItem(name: "studentId", value: "me"),
83
+            URLQueryItem(name: "courseStates", value: "ACTIVE"),
84
+            URLQueryItem(name: "pageSize", value: String(max(1, min(100, pageSize))))
85
+        ]
86
+
87
+        var request = URLRequest(url: components.url!)
88
+        request.httpMethod = "GET"
89
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
90
+
91
+        let (data, response) = try await session.data(for: request)
92
+        guard let http = response as? HTTPURLResponse else { throw GoogleClassroomClientError.invalidResponse }
93
+        guard (200..<300).contains(http.statusCode) else {
94
+            let body = String(data: data, encoding: .utf8) ?? "<no body>"
95
+            throw GoogleClassroomClientError.httpStatus(http.statusCode, body)
96
+        }
97
+
98
+        let decoded: CoursesList
99
+        do {
100
+            decoded = try JSONDecoder().decode(CoursesList.self, from: data)
101
+        } catch {
102
+            let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
103
+            throw GoogleClassroomClientError.decodeFailed(raw)
104
+        }
105
+        return decoded.courses ?? []
106
+    }
107
+
108
+    // MARK: - CourseWork
109
+
110
+    private func listPublishedCourseWork(accessToken: String, courseId: String, pageSize: Int) async throws -> [CourseWork] {
111
+        var components = URLComponents(string: "https://classroom.googleapis.com/v1/courses/\(courseId)/courseWork")!
112
+        components.queryItems = [
113
+            URLQueryItem(name: "courseWorkStates", value: "PUBLISHED"),
114
+            URLQueryItem(name: "orderBy", value: "dueDate desc"),
115
+            URLQueryItem(name: "pageSize", value: String(max(1, min(100, pageSize))))
116
+        ]
117
+
118
+        var request = URLRequest(url: components.url!)
119
+        request.httpMethod = "GET"
120
+        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
121
+
122
+        let (data, response) = try await session.data(for: request)
123
+        guard let http = response as? HTTPURLResponse else { throw GoogleClassroomClientError.invalidResponse }
124
+        guard (200..<300).contains(http.statusCode) else {
125
+            let body = String(data: data, encoding: .utf8) ?? "<no body>"
126
+            throw GoogleClassroomClientError.httpStatus(http.statusCode, body)
127
+        }
128
+
129
+        let decoded: CourseWorkList
130
+        do {
131
+            decoded = try JSONDecoder().decode(CourseWorkList.self, from: data)
132
+        } catch {
133
+            let raw = String(data: data, encoding: .utf8) ?? "<unreadable body>"
134
+            throw GoogleClassroomClientError.decodeFailed(raw)
135
+        }
136
+        return decoded.courseWork ?? []
137
+    }
138
+}
139
+
140
+extension GoogleClassroomClientError: LocalizedError {
141
+    var errorDescription: String? {
142
+        switch self {
143
+        case .invalidResponse:
144
+            return "Google Classroom returned an invalid response."
145
+        case let .httpStatus(status, body):
146
+            return "Google Classroom API error (\(status)): \(body)"
147
+        case let .decodeFailed(raw):
148
+            return "Failed to parse Google Classroom response: \(raw)"
149
+        }
150
+    }
151
+}
152
+
153
+// MARK: - REST models (minimal)
154
+
155
+private struct CoursesList: Decodable {
156
+    let courses: [Course]?
157
+    let nextPageToken: String?
158
+}
159
+
160
+private struct Course: Decodable {
161
+    let id: String
162
+    let name: String?
163
+}
164
+
165
+private struct CourseWorkList: Decodable {
166
+    let courseWork: [CourseWork]?
167
+    let nextPageToken: String?
168
+}
169
+
170
+private struct CourseWork: Decodable {
171
+    let id: String?
172
+    let title: String?
173
+    let alternateLink: String?
174
+    let workType: String?
175
+    let dueDate: DateParts?
176
+    let dueTime: TimeOfDay?
177
+
178
+    var resolvedDueDate: Date? {
179
+        guard let dueDate else { return nil }
180
+        var comps = DateComponents()
181
+        comps.calendar = Calendar.current
182
+        comps.timeZone = TimeZone.current
183
+        comps.year = dueDate.year
184
+        comps.month = dueDate.month
185
+        comps.day = dueDate.day
186
+        if let dueTime {
187
+            comps.hour = dueTime.hours
188
+            comps.minute = dueTime.minutes
189
+            comps.second = dueTime.seconds
190
+        } else {
191
+            comps.hour = 23
192
+            comps.minute = 59
193
+            comps.second = 0
194
+        }
195
+        return comps.date
196
+    }
197
+}
198
+
199
+private struct DateParts: Decodable {
200
+    let year: Int?
201
+    let month: Int?
202
+    let day: Int?
203
+}
204
+
205
+private struct TimeOfDay: Decodable {
206
+    let hours: Int?
207
+    let minutes: Int?
208
+    let seconds: Int?
209
+    let nanos: Int?
210
+}

+ 30 - 0
classroom_app/Models/ClassroomTodoItem.swift

@@ -0,0 +1,30 @@
1
+import Foundation
2
+
3
+enum ClassroomTodoWorkType: String, Codable, Equatable {
4
+    case assignment = "ASSIGNMENT"
5
+    case shortAnswerQuestion = "SHORT_ANSWER_QUESTION"
6
+    case multipleChoiceQuestion = "MULTIPLE_CHOICE_QUESTION"
7
+    case unspecified = "COURSE_WORK_TYPE_UNSPECIFIED"
8
+
9
+    var displayName: String {
10
+        switch self {
11
+        case .assignment:
12
+            return "Assignment"
13
+        case .shortAnswerQuestion, .multipleChoiceQuestion:
14
+            return "Quiz"
15
+        case .unspecified:
16
+            return "Coursework"
17
+        }
18
+    }
19
+}
20
+
21
+struct ClassroomTodoItem: Identifiable, Equatable {
22
+    let id: String
23
+    let courseId: String
24
+    let courseName: String
25
+    let title: String
26
+    let dueDate: Date?
27
+    let alternateLink: URL?
28
+    let workType: ClassroomTodoWorkType
29
+}
30
+

+ 140 - 89
classroom_app/ViewController.swift

@@ -272,6 +272,7 @@ final class ViewController: NSViewController {
272 272
     private var inAppBrowserWindowController: InAppBrowserWindowController?
273 273
     private let googleOAuth = GoogleOAuthService.shared
274 274
     private let calendarClient = GoogleCalendarClient()
275
+    private let classroomClient = GoogleClassroomClient()
275 276
     private let storeKitCoordinator = StoreKitCoordinator()
276 277
     private var storeKitStartupTask: Task<Void, Never>?
277 278
     private var paywallPurchaseTask: Task<Void, Never>?
@@ -285,7 +286,7 @@ final class ViewController: NSViewController {
285 286
     private var launchPaywallWorkItem: DispatchWorkItem?
286 287
     private var hasViewAppearedOnce = false
287 288
     private var lastKnownPremiumAccess = false
288
-    private var displayedScheduleMeetings: [ScheduledMeeting] = []
289
+    private var displayedScheduleTodos: [ClassroomTodoItem] = []
289 290
     private var appUsageSessionStartDate: Date?
290 291
     private var hasObservedAppLifecycleForUsage = false
291 292
     private var premiumUpgradeRatingPromptWorkItem: DispatchWorkItem?
@@ -326,11 +327,12 @@ final class ViewController: NSViewController {
326 327
     private var scheduleProfileImageTask: Task<Void, Never>?
327 328
     private var googleAccountPopover: NSPopover?
328 329
     private var scheduleCachedMeetings: [ScheduledMeeting] = []
330
+    private var scheduleCachedTodos: [ClassroomTodoItem] = []
329 331
 
330 332
     private var schedulePageFilter: SchedulePageFilter = .all
331 333
     private var schedulePageFromDate: Date = Calendar.current.startOfDay(for: Date())
332 334
     private var schedulePageToDate: Date = Calendar.current.startOfDay(for: Date())
333
-    private var schedulePageFilteredMeetings: [ScheduledMeeting] = []
335
+    private var schedulePageFilteredTodos: [ClassroomTodoItem] = []
334 336
     private var schedulePageVisibleCount: Int = 0
335 337
     private let schedulePageBatchSize: Int = 6
336 338
     private let schedulePageCardsPerRow: Int = 3
@@ -1371,7 +1373,7 @@ private extension ViewController {
1371 1373
 
1372 1374
     private func refreshScheduleCardsForPremiumStateChange() {
1373 1375
         if let stack = scheduleCardsStack {
1374
-            renderScheduleCards(into: stack, meetings: displayedScheduleMeetings)
1376
+            renderScheduleCards(into: stack, todos: displayedScheduleTodos)
1375 1377
         }
1376 1378
         applySchedulePageFiltersAndRender()
1377 1379
     }
@@ -2123,7 +2125,7 @@ private extension ViewController {
2123 2125
         contentStack.addArrangedSubview(dateHeading)
2124 2126
         contentStack.setCustomSpacing(joinPageDateToMeetingCardsSpacing, after: dateHeading)
2125 2127
 
2126
-        let cardsRow = scheduleCardsRow(meetings: [])
2128
+        let cardsRow = scheduleCardsRow(todos: [])
2127 2129
         contentStack.addArrangedSubview(cardsRow)
2128 2130
 
2129 2131
         panel.addSubview(contentStack)
@@ -3265,7 +3267,7 @@ private extension ViewController {
3265 3267
         row.distribution = .fill
3266 3268
         row.spacing = 12
3267 3269
 
3268
-        row.addArrangedSubview(textLabel("Schedule", font: typography.sectionTitleBold, color: palette.textPrimary))
3270
+        row.addArrangedSubview(textLabel("To-do", font: typography.sectionTitleBold, color: palette.textPrimary))
3269 3271
 
3270 3272
         let spacer = NSView()
3271 3273
         spacer.translatesAutoresizingMaskIntoConstraints = false
@@ -3295,7 +3297,7 @@ private extension ViewController {
3295 3297
         titleRow.distribution = .fill
3296 3298
         titleRow.spacing = 0
3297 3299
 
3298
-        let titleLabel = textLabel("Schedule", font: typography.pageTitle, color: palette.textPrimary)
3300
+        let titleLabel = textLabel("To-do", font: typography.pageTitle, color: palette.textPrimary)
3299 3301
         titleLabel.alignment = .left
3300 3302
         titleLabel.userInterfaceLayoutDirection = .leftToRight
3301 3303
         titleLabel.maximumNumberOfLines = 1
@@ -3309,10 +3311,7 @@ private extension ViewController {
3309 3311
         titleRowSpacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
3310 3312
 
3311 3313
         titleRow.addArrangedSubview(titleLabel)
3312
-        if googleOAuth.loadTokens() != nil && storeKitCoordinator.hasPremiumAccess {
3313
-            titleRow.addArrangedSubview(makeSchedulePageAddButton())
3314
-            titleRow.setCustomSpacing(12, after: titleLabel)
3315
-        }
3314
+        // To-do items are read-only from Classroom.
3316 3315
         titleRow.addArrangedSubview(titleRowSpacer)
3317 3316
         container.addArrangedSubview(titleRow)
3318 3317
 
@@ -3725,7 +3724,7 @@ private extension ViewController {
3725 3724
         return button
3726 3725
     }
3727 3726
 
3728
-    func scheduleCardsRow(meetings: [ScheduledMeeting]) -> NSView {
3727
+    func scheduleCardsRow(todos: [ClassroomTodoItem]) -> NSView {
3729 3728
         let cardWidth: CGFloat = 240
3730 3729
         let cardsPerViewport: CGFloat = 3
3731 3730
         let viewportWidth = (cardWidth * cardsPerViewport) + (12 * (cardsPerViewport - 1))
@@ -3774,7 +3773,7 @@ private extension ViewController {
3774 3773
             row.heightAnchor.constraint(equalToConstant: 150)
3775 3774
         ])
3776 3775
 
3777
-        renderScheduleCards(into: row, meetings: meetings)
3776
+        renderScheduleCards(into: row, todos: todos)
3778 3777
         wrapper.addArrangedSubview(scroll)
3779 3778
         let rightButton = makeScheduleScrollButton(systemSymbol: "chevron.right", action: #selector(scheduleScrollRightPressed(_:)))
3780 3779
         scheduleScrollRightButton = rightButton
@@ -3785,7 +3784,7 @@ private extension ViewController {
3785 3784
         return wrapper
3786 3785
     }
3787 3786
 
3788
-    func scheduleCard(meeting: ScheduledMeeting, useFlexibleWidth: Bool = false, contentHeight: CGFloat = 150) -> NSView {
3787
+    func scheduleCard(todo: ClassroomTodoItem, useFlexibleWidth: Bool = false, contentHeight: CGFloat = 150) -> NSView {
3789 3788
         let cardWidth: CGFloat = 240
3790 3789
 
3791 3790
         let card = roundedContainer(cornerRadius: 12, color: palette.sectionCard)
@@ -3807,7 +3806,17 @@ private extension ViewController {
3807 3806
         icon.heightAnchor.constraint(equalToConstant: 28).isActive = true
3808 3807
         let iconView = NSImageView()
3809 3808
         iconView.translatesAutoresizingMaskIntoConstraints = false
3810
-        iconView.image = NSImage(systemSymbolName: "book.closed.fill", accessibilityDescription: "Class event")
3809
+        let iconSymbolName: String = {
3810
+            switch todo.workType {
3811
+            case .assignment:
3812
+                return "doc.text.fill"
3813
+            case .shortAnswerQuestion, .multipleChoiceQuestion:
3814
+                return "checkmark.seal.fill"
3815
+            case .unspecified:
3816
+                return "book.closed.fill"
3817
+            }
3818
+        }()
3819
+        iconView.image = NSImage(systemSymbolName: iconSymbolName, accessibilityDescription: "Classroom to-do")
3811 3820
         iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
3812 3821
         iconView.contentTintColor = .white
3813 3822
         icon.addSubview(iconView)
@@ -3816,18 +3825,18 @@ private extension ViewController {
3816 3825
             iconView.centerYAnchor.constraint(equalTo: icon.centerYAnchor)
3817 3826
         ])
3818 3827
 
3819
-        let title = textLabel(meeting.title, font: typography.cardTitle, color: palette.textPrimary)
3828
+        let title = textLabel(todo.title, font: typography.cardTitle, color: palette.textPrimary)
3820 3829
         title.lineBreakMode = .byTruncatingTail
3821 3830
         title.maximumNumberOfLines = 1
3822 3831
         title.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
3823
-        let subtitle = textLabel(meeting.subtitle ?? "Google Calendar", font: typography.cardSubtitle, color: palette.textPrimary)
3824
-        let time = textLabel(scheduleTimeText(for: meeting), font: typography.cardTime, color: palette.textSecondary)
3825
-        let duration = textLabel(scheduleDurationText(for: meeting), font: NSFont.systemFont(ofSize: 11, weight: .medium), color: palette.textMuted)
3832
+        let subtitle = textLabel(todo.courseName, font: typography.cardSubtitle, color: palette.textPrimary)
3833
+        let time = textLabel(todoDueText(for: todo), font: typography.cardTime, color: palette.textSecondary)
3834
+        let duration = textLabel(todo.workType.displayName, font: NSFont.systemFont(ofSize: 11, weight: .medium), color: palette.textMuted)
3826 3835
         let dayChip = roundedContainer(cornerRadius: 7, color: palette.inputBackground)
3827 3836
         dayChip.translatesAutoresizingMaskIntoConstraints = false
3828 3837
         dayChip.layer?.borderWidth = 1
3829 3838
         dayChip.layer?.borderColor = palette.inputBorder.withAlphaComponent(0.8).cgColor
3830
-        let dayText = textLabel(scheduleDayText(for: meeting), font: NSFont.systemFont(ofSize: 11, weight: .semibold), color: palette.textSecondary)
3839
+        let dayText = textLabel(todoDayText(for: todo), font: NSFont.systemFont(ofSize: 11, weight: .semibold), color: palette.textSecondary)
3831 3840
         dayText.translatesAutoresizingMaskIntoConstraints = false
3832 3841
         dayChip.addSubview(dayText)
3833 3842
         NSLayoutConstraint.activate([
@@ -3876,7 +3885,9 @@ private extension ViewController {
3876 3885
         hit.translatesAutoresizingMaskIntoConstraints = false
3877 3886
         hit.isBordered = false
3878 3887
         hit.bezelStyle = .regularSquare
3879
-        hit.identifier = NSUserInterfaceItemIdentifier(meeting.meetURL.absoluteString)
3888
+        if let url = todo.alternateLink {
3889
+            hit.identifier = NSUserInterfaceItemIdentifier(url.absoluteString)
3890
+        }
3880 3891
         hit.heightAnchor.constraint(equalToConstant: contentHeight).isActive = true
3881 3892
         if useFlexibleWidth {
3882 3893
             hit.setContentHuggingPriority(.defaultLow, for: .horizontal)
@@ -5815,7 +5826,7 @@ private extension ViewController {
5815 5826
         }
5816 5827
         guard let raw = sender.identifier?.rawValue,
5817 5828
               let url = URL(string: raw) else { return }
5818
-        openMeetingURL(url)
5829
+        openURL(url)
5819 5830
     }
5820 5831
 
5821 5832
     @objc func scheduleConnectButtonPressed(_ sender: NSButton) {
@@ -5827,11 +5838,11 @@ private extension ViewController {
5827 5838
     }
5828 5839
 
5829 5840
     private func scheduleInitialHeadingText() -> String {
5830
-        googleOAuth.loadTokens() == nil ? "Connect Google to see your calendar" : "Loading…"
5841
+        googleOAuth.loadTokens() == nil ? "Connect Google to see your to-do" : "Loading…"
5831 5842
     }
5832 5843
 
5833 5844
     private func schedulePageInitialHeadingText() -> String {
5834
-        googleOAuth.loadTokens() == nil ? "Connect Google to see your calendar" : "Loading schedule…"
5845
+        googleOAuth.loadTokens() == nil ? "Connect Google to see your to-do" : "Loading to-do…"
5835 5846
     }
5836 5847
 
5837 5848
     @objc func scheduleFilterDropdownChanged(_ sender: NSPopUpButton) {
@@ -5907,41 +5918,31 @@ private extension ViewController {
5907 5918
         presentCreateMeetingPopover(relativeTo: sender)
5908 5919
     }
5909 5920
 
5910
-    private func scheduleTimeText(for meeting: ScheduledMeeting) -> String {
5911
-        if meeting.isAllDay { return "All day" }
5921
+    private func todoDueText(for todo: ClassroomTodoItem) -> String {
5922
+        guard let due = todo.dueDate else { return "No due date" }
5912 5923
         let f = DateFormatter()
5913 5924
         f.locale = Locale.current
5914 5925
         f.timeZone = TimeZone.current
5915 5926
         f.dateStyle = .none
5916 5927
         f.timeStyle = .short
5917
-        return "\(f.string(from: meeting.startDate)) - \(f.string(from: meeting.endDate))"
5928
+        return "Due \(f.string(from: due))"
5918 5929
     }
5919 5930
 
5920
-    private func scheduleDayText(for meeting: ScheduledMeeting) -> String {
5931
+    private func todoDayText(for todo: ClassroomTodoItem) -> String {
5932
+        guard let due = todo.dueDate else { return "Anytime" }
5921 5933
         let f = DateFormatter()
5922 5934
         f.locale = Locale.current
5923 5935
         f.timeZone = TimeZone.current
5924 5936
         f.dateFormat = "EEE, d MMM"
5925
-        return f.string(from: meeting.startDate)
5926
-    }
5927
-
5928
-    private func scheduleDurationText(for meeting: ScheduledMeeting) -> String {
5929
-        if meeting.isAllDay { return "Duration: all day" }
5930
-        let duration = max(0, meeting.endDate.timeIntervalSince(meeting.startDate))
5931
-        let totalMinutes = Int(duration / 60)
5932
-        let hours = totalMinutes / 60
5933
-        let minutes = totalMinutes % 60
5934
-        if hours > 0, minutes > 0 { return "Duration: \(hours)h \(minutes)m" }
5935
-        if hours > 0 { return "Duration: \(hours)h" }
5936
-        return "Duration: \(minutes)m"
5937
+        return f.string(from: due)
5937 5938
     }
5938 5939
 
5939
-    private func scheduleHeadingText(for meetings: [ScheduledMeeting]) -> String {
5940
-        guard let first = meetings.first else {
5941
-            return googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "No upcoming meetings"
5940
+    private func scheduleHeadingText(for todos: [ClassroomTodoItem]) -> String {
5941
+        guard let first = todos.first else {
5942
+            return googleOAuth.loadTokens() == nil ? "Connect Google to see to-do" : "No upcoming work"
5942 5943
         }
5943
-
5944
-        let day = Calendar.current.startOfDay(for: first.startDate)
5944
+        guard let due = first.dueDate else { return "No due dates" }
5945
+        let day = Calendar.current.startOfDay(for: due)
5945 5946
         let f = DateFormatter()
5946 5947
         f.locale = Locale.current
5947 5948
         f.timeZone = TimeZone.current
@@ -5949,13 +5950,13 @@ private extension ViewController {
5949 5950
         return f.string(from: day)
5950 5951
     }
5951 5952
 
5952
-    private func openMeetingURL(_ url: URL) {
5953
+    private func openURL(_ url: URL) {
5953 5954
         NSWorkspace.shared.open(url)
5954 5955
     }
5955 5956
 
5956
-    private func renderScheduleCards(into stack: NSStackView, meetings: [ScheduledMeeting]) {
5957
-        displayedScheduleMeetings = meetings
5958
-        let shouldShowScrollControls = meetings.count > 3
5957
+    private func renderScheduleCards(into stack: NSStackView, todos: [ClassroomTodoItem]) {
5958
+        displayedScheduleTodos = todos
5959
+        let shouldShowScrollControls = todos.count > 3
5959 5960
         scheduleScrollLeftButton?.isHidden = !shouldShowScrollControls
5960 5961
         scheduleScrollRightButton?.isHidden = !shouldShowScrollControls
5961 5962
         scheduleCardsScrollView?.contentView.setBoundsOrigin(.zero)
@@ -5968,14 +5969,14 @@ private extension ViewController {
5968 5969
             v.removeFromSuperview()
5969 5970
         }
5970 5971
 
5971
-        if meetings.isEmpty {
5972
+        if todos.isEmpty {
5972 5973
             let empty = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
5973 5974
             empty.translatesAutoresizingMaskIntoConstraints = false
5974 5975
             empty.widthAnchor.constraint(equalToConstant: 240).isActive = true
5975 5976
             empty.heightAnchor.constraint(equalToConstant: 150).isActive = true
5976 5977
             styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
5977 5978
 
5978
-            let label = textLabel(googleOAuth.loadTokens() == nil ? "Connect to load schedule" : "No meetings", font: typography.cardSubtitle, color: palette.textSecondary)
5979
+            let label = textLabel(googleOAuth.loadTokens() == nil ? "Connect to load to-do" : "No work due", font: typography.cardSubtitle, color: palette.textSecondary)
5979 5980
             label.translatesAutoresizingMaskIntoConstraints = false
5980 5981
             empty.addSubview(label)
5981 5982
             NSLayoutConstraint.activate([
@@ -5986,50 +5987,65 @@ private extension ViewController {
5986 5987
             return
5987 5988
         }
5988 5989
 
5989
-        for meeting in meetings {
5990
-            stack.addArrangedSubview(scheduleCard(meeting: meeting))
5990
+        for todo in todos {
5991
+            stack.addArrangedSubview(scheduleCard(todo: todo))
5991 5992
         }
5992 5993
     }
5993 5994
 
5994
-    private func filteredMeetings(_ meetings: [ScheduledMeeting]) -> [ScheduledMeeting] {
5995
+    private func filteredTodos(_ todos: [ClassroomTodoItem]) -> [ClassroomTodoItem] {
5995 5996
         switch scheduleFilter {
5996 5997
         case .all:
5997
-            return meetings
5998
+            return todos
5998 5999
         case .today:
5999 6000
             let start = Calendar.current.startOfDay(for: Date())
6000 6001
             let end = Calendar.current.date(byAdding: .day, value: 1, to: start) ?? start.addingTimeInterval(86400)
6001
-            return meetings.filter { $0.startDate >= start && $0.startDate < end }
6002
+            return todos.filter { ($0.dueDate ?? .distantPast) >= start && ($0.dueDate ?? .distantPast) < end }
6002 6003
         case .week:
6003 6004
             let now = Date()
6004 6005
             let end = Calendar.current.date(byAdding: .day, value: 7, to: now) ?? now.addingTimeInterval(7 * 86400)
6005
-            return meetings.filter { $0.startDate >= now && $0.startDate <= end }
6006
+            return todos.filter {
6007
+                guard let due = $0.dueDate else { return false }
6008
+                return due >= now && due <= end
6009
+            }
6006 6010
         }
6007 6011
     }
6008 6012
 
6009
-    private func filteredMeetingsForSchedulePage(_ meetings: [ScheduledMeeting]) -> [ScheduledMeeting] {
6013
+    private func filteredTodosForSchedulePage(_ todos: [ClassroomTodoItem]) -> [ClassroomTodoItem] {
6010 6014
         let calendar = Calendar.current
6011 6015
         switch schedulePageFilter {
6012 6016
         case .all:
6013
-            return meetings
6017
+            return todos
6014 6018
         case .today:
6015 6019
             let start = calendar.startOfDay(for: Date())
6016 6020
             let end = calendar.date(byAdding: .day, value: 1, to: start) ?? start.addingTimeInterval(86400)
6017
-            return meetings.filter { $0.startDate >= start && $0.startDate < end }
6021
+            return todos.filter {
6022
+                guard let due = $0.dueDate else { return false }
6023
+                return due >= start && due < end
6024
+            }
6018 6025
         case .week:
6019 6026
             let now = Date()
6020 6027
             let end = calendar.date(byAdding: .day, value: 7, to: now) ?? now.addingTimeInterval(7 * 86400)
6021
-            return meetings.filter { $0.startDate >= now && $0.startDate <= end }
6028
+            return todos.filter {
6029
+                guard let due = $0.dueDate else { return false }
6030
+                return due >= now && due <= end
6031
+            }
6022 6032
         case .month:
6023 6033
             let now = Date()
6024 6034
             let end = calendar.date(byAdding: .month, value: 1, to: now) ?? now.addingTimeInterval(30 * 86400)
6025
-            return meetings.filter { $0.startDate >= now && $0.startDate <= end }
6035
+            return todos.filter {
6036
+                guard let due = $0.dueDate else { return false }
6037
+                return due >= now && due <= end
6038
+            }
6026 6039
         case .customRange:
6027 6040
             let start = calendar.startOfDay(for: schedulePageFromDate)
6028 6041
             let inclusiveEndDay = calendar.startOfDay(for: schedulePageToDate)
6029 6042
             guard let end = calendar.date(byAdding: .day, value: 1, to: inclusiveEndDay) else {
6030
-                return meetings
6043
+                return todos
6044
+            }
6045
+            return todos.filter {
6046
+                guard let due = $0.dueDate else { return false }
6047
+                return due >= start && due < end
6031 6048
             }
6032
-            return meetings.filter { $0.startDate >= start && $0.startDate < end }
6033 6049
         }
6034 6050
     }
6035 6051
 
@@ -6061,7 +6077,7 @@ private extension ViewController {
6061 6077
         schedulePageToDate = schedulePageToDatePicker?.dateValue ?? schedulePageToDate
6062 6078
         if schedulePageFilter == .customRange && !schedulePageHasValidCustomRange() {
6063 6079
             setSchedulePageRangeError("Start date must be on or before end date.")
6064
-            schedulePageFilteredMeetings = []
6080
+            schedulePageFilteredTodos = []
6065 6081
             schedulePageVisibleCount = 0
6066 6082
             renderSchedulePageCards()
6067 6083
             schedulePageDateHeadingLabel?.stringValue = "Invalid custom date range"
@@ -6069,15 +6085,15 @@ private extension ViewController {
6069 6085
         }
6070 6086
 
6071 6087
         setSchedulePageRangeError(nil)
6072
-        schedulePageFilteredMeetings = filteredMeetingsForSchedulePage(scheduleCachedMeetings)
6073
-        schedulePageVisibleCount = min(schedulePageBatchSize, schedulePageFilteredMeetings.count)
6088
+        schedulePageFilteredTodos = filteredTodosForSchedulePage(scheduleCachedTodos)
6089
+        schedulePageVisibleCount = min(schedulePageBatchSize, schedulePageFilteredTodos.count)
6074 6090
         renderSchedulePageCards()
6075
-        schedulePageDateHeadingLabel?.stringValue = scheduleHeadingText(for: schedulePageFilteredMeetings)
6091
+        schedulePageDateHeadingLabel?.stringValue = scheduleHeadingText(for: schedulePageFilteredTodos)
6076 6092
     }
6077 6093
 
6078 6094
     private func appendSchedulePageBatchIfNeeded() {
6079
-        guard schedulePageVisibleCount < schedulePageFilteredMeetings.count else { return }
6080
-        let nextCount = min(schedulePageVisibleCount + schedulePageBatchSize, schedulePageFilteredMeetings.count)
6095
+        guard schedulePageVisibleCount < schedulePageFilteredTodos.count else { return }
6096
+        let nextCount = min(schedulePageVisibleCount + schedulePageBatchSize, schedulePageFilteredTodos.count)
6081 6097
         guard nextCount > schedulePageVisibleCount else { return }
6082 6098
         schedulePageVisibleCount = nextCount
6083 6099
         renderSchedulePageCards()
@@ -6106,13 +6122,13 @@ private extension ViewController {
6106 6122
             v.removeFromSuperview()
6107 6123
         }
6108 6124
 
6109
-        let visibleMeetings = Array(schedulePageFilteredMeetings.prefix(schedulePageVisibleCount))
6110
-        if visibleMeetings.isEmpty {
6125
+        let visibleTodos = Array(schedulePageFilteredTodos.prefix(schedulePageVisibleCount))
6126
+        if visibleTodos.isEmpty {
6111 6127
             let empty = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
6112 6128
             empty.translatesAutoresizingMaskIntoConstraints = false
6113 6129
             empty.heightAnchor.constraint(equalToConstant: 140).isActive = true
6114 6130
             styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
6115
-            let label = textLabel(googleOAuth.loadTokens() == nil ? "Connect to load schedule" : "No meetings for selected filters", font: typography.cardSubtitle, color: palette.textSecondary)
6131
+            let label = textLabel(googleOAuth.loadTokens() == nil ? "Connect to load to-do" : "No work for selected filters", font: typography.cardSubtitle, color: palette.textSecondary)
6116 6132
             label.translatesAutoresizingMaskIntoConstraints = false
6117 6133
             empty.addSubview(label)
6118 6134
             NSLayoutConstraint.activate([
@@ -6125,7 +6141,7 @@ private extension ViewController {
6125 6141
         }
6126 6142
 
6127 6143
         var index = 0
6128
-        while index < visibleMeetings.count {
6144
+        while index < visibleTodos.count {
6129 6145
             let row = NSStackView()
6130 6146
             row.translatesAutoresizingMaskIntoConstraints = false
6131 6147
             row.userInterfaceLayoutDirection = .leftToRight
@@ -6133,16 +6149,16 @@ private extension ViewController {
6133 6149
             row.alignment = .top
6134 6150
             row.spacing = schedulePageCardSpacing
6135 6151
             row.distribution = .fillEqually
6136
-            let rowEnd = min(index + schedulePageCardsPerRow, visibleMeetings.count)
6137
-            for meeting in visibleMeetings[index..<rowEnd] {
6138
-                row.addArrangedSubview(scheduleCard(meeting: meeting, useFlexibleWidth: true, contentHeight: schedulePageCardHeight))
6152
+            let rowEnd = min(index + schedulePageCardsPerRow, visibleTodos.count)
6153
+            for todo in visibleTodos[index..<rowEnd] {
6154
+                row.addArrangedSubview(scheduleCard(todo: todo, useFlexibleWidth: true, contentHeight: schedulePageCardHeight))
6139 6155
             }
6140 6156
             stack.addArrangedSubview(row)
6141 6157
             row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
6142 6158
             index = rowEnd
6143 6159
         }
6144 6160
 
6145
-        if schedulePageVisibleCount < schedulePageFilteredMeetings.count {
6161
+        if schedulePageVisibleCount < schedulePageFilteredTodos.count {
6146 6162
             let pagination = NSStackView()
6147 6163
             pagination.translatesAutoresizingMaskIntoConstraints = false
6148 6164
             pagination.orientation = .horizontal
@@ -6150,7 +6166,7 @@ private extension ViewController {
6150 6166
             pagination.spacing = 10
6151 6167
 
6152 6168
             let moreLabel = textLabel(
6153
-                "Showing \(schedulePageVisibleCount) of \(schedulePageFilteredMeetings.count)",
6169
+                "Showing \(schedulePageVisibleCount) of \(schedulePageFilteredTodos.count)",
6154 6170
                 font: NSFont.systemFont(ofSize: 12, weight: .medium),
6155 6171
                 color: palette.textMuted
6156 6172
             )
@@ -6177,18 +6193,27 @@ private extension ViewController {
6177 6193
         scroll.reflectScrolledClipView(scroll.contentView)
6178 6194
     }
6179 6195
 
6196
+    private func errorRequiresReconsentForClassroomScopes(_ error: Error) -> Bool {
6197
+        if case let GoogleClassroomClientError.httpStatus(status, body) = error {
6198
+            guard status == 403 else { return false }
6199
+            return body.contains("ACCESS_TOKEN_SCOPE_INSUFFICIENT") || body.contains("insufficient authentication scopes")
6200
+        }
6201
+        return false
6202
+    }
6203
+
6180 6204
     private func loadSchedule() async {
6181 6205
         do {
6182 6206
             if googleOAuth.loadTokens() == nil {
6183 6207
                 await MainActor.run {
6184 6208
                     updateGoogleAuthButtonTitle()
6185 6209
                     applyGoogleProfile(nil)
6186
-                    scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
6187
-                    schedulePageDateHeadingLabel?.stringValue = "Connect Google to see meetings"
6210
+                    scheduleDateHeadingLabel?.stringValue = "Connect Google to see your to-do"
6211
+                    schedulePageDateHeadingLabel?.stringValue = "Connect Google to see your to-do"
6188 6212
                     if let stack = scheduleCardsStack {
6189
-                        renderScheduleCards(into: stack, meetings: [])
6213
+                        renderScheduleCards(into: stack, todos: [])
6190 6214
                     }
6191 6215
                     scheduleCachedMeetings = []
6216
+                    scheduleCachedTodos = []
6192 6217
                     applySchedulePageFiltersAndRender()
6193 6218
                     if calendarPageGridStack != nil {
6194 6219
                         calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
@@ -6201,17 +6226,19 @@ private extension ViewController {
6201 6226
 
6202 6227
             let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
6203 6228
             let profile = try? await googleOAuth.fetchUserProfile(accessToken: token)
6229
+            let todos = try await classroomClient.fetchTodo(accessToken: token)
6230
+            let filtered = filteredTodos(todos)
6204 6231
             let meetings = try await calendarClient.fetchUpcomingMeetings(accessToken: token)
6205
-            let filtered = filteredMeetings(meetings)
6206 6232
 
6207 6233
             await MainActor.run {
6208 6234
                 updateGoogleAuthButtonTitle()
6209 6235
                 applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
6210 6236
                 scheduleDateHeadingLabel?.stringValue = scheduleHeadingText(for: filtered)
6211 6237
                 if let stack = scheduleCardsStack {
6212
-                    renderScheduleCards(into: stack, meetings: filtered)
6238
+                    renderScheduleCards(into: stack, todos: filtered)
6213 6239
                 }
6214 6240
                 scheduleCachedMeetings = meetings
6241
+                scheduleCachedTodos = todos
6215 6242
                 applySchedulePageFiltersAndRender()
6216 6243
                 if calendarPageGridStack != nil {
6217 6244
                     calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
@@ -6221,31 +6248,55 @@ private extension ViewController {
6221 6248
             }
6222 6249
         } catch {
6223 6250
             await MainActor.run {
6251
+                if errorRequiresReconsentForClassroomScopes(error) {
6252
+                    _ = try? googleOAuth.signOut()
6253
+                    applyGoogleProfile(nil)
6254
+                    updateGoogleAuthButtonTitle()
6255
+                    scheduleDateHeadingLabel?.stringValue = "Reconnect Google to enable Classroom permissions"
6256
+                    schedulePageDateHeadingLabel?.stringValue = "Reconnect Google to enable Classroom permissions"
6257
+                    if let stack = scheduleCardsStack {
6258
+                        renderScheduleCards(into: stack, todos: [])
6259
+                    }
6260
+                    scheduleCachedMeetings = []
6261
+                    scheduleCachedTodos = []
6262
+                    applySchedulePageFiltersAndRender()
6263
+                    if calendarPageGridStack != nil {
6264
+                        calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
6265
+                        renderCalendarMonthGrid()
6266
+                        renderCalendarSelectedDay()
6267
+                    }
6268
+                    showSimpleAlert(
6269
+                        title: "Reconnect Google",
6270
+                        message: "We added Google Classroom permissions. Please connect your Google account again so Google can grant access to assignments and quizzes."
6271
+                    )
6272
+                    return
6273
+                }
6224 6274
                 updateGoogleAuthButtonTitle()
6225 6275
                 if googleOAuth.loadTokens() == nil {
6226 6276
                     applyGoogleProfile(nil)
6227 6277
                 }
6228
-                scheduleDateHeadingLabel?.stringValue = "Couldn’t load schedule"
6229
-                schedulePageDateHeadingLabel?.stringValue = "Couldn’t load schedule"
6278
+                scheduleDateHeadingLabel?.stringValue = "Couldn’t load to-do"
6279
+                schedulePageDateHeadingLabel?.stringValue = "Couldn’t load to-do"
6230 6280
                 if let stack = scheduleCardsStack {
6231
-                    renderScheduleCards(into: stack, meetings: [])
6281
+                    renderScheduleCards(into: stack, todos: [])
6232 6282
                 }
6233 6283
                 scheduleCachedMeetings = []
6284
+                scheduleCachedTodos = []
6234 6285
                 applySchedulePageFiltersAndRender()
6235 6286
                 if calendarPageGridStack != nil {
6236 6287
                     calendarPageMonthLabel?.stringValue = calendarMonthTitleText(for: calendarPageMonthAnchor)
6237 6288
                     renderCalendarMonthGrid()
6238 6289
                     renderCalendarSelectedDay()
6239 6290
                 }
6240
-                showSimpleError("Couldn’t load schedule.", error: error)
6291
+                showSimpleError("Couldn’t load to-do.", error: error)
6241 6292
             }
6242 6293
         }
6243 6294
     }
6244 6295
 
6245 6296
     func showScheduleHelp() {
6246 6297
         let alert = NSAlert()
6247
-        alert.messageText = "Google schedule"
6248
-        alert.informativeText = "To show scheduled meetings, connect your Google account. You’ll need a Google OAuth client ID (Desktop) and a redirect URI matching the app’s callback scheme."
6298
+        alert.messageText = "Google Classroom to-do"
6299
+        alert.informativeText = "To show assignments and quizzes, connect your Google account. You’ll need a Google OAuth client ID (Desktop) and a redirect URI matching the app’s callback scheme."
6249 6300
         alert.addButton(withTitle: "OK")
6250 6301
         alert.runModal()
6251 6302
     }