Fix Cloudflare deploy: pin Node 22 + revert Zack's partial revert#170
Conversation
Add .node-version (22) so Cloudflare Workers Builds picks up a compatible Node runtime, and declare engines.node >= 20.19 in package.json (Vite 8 minimum). Co-Authored-By: Rahul Chalamala <22563365+rchalamala@users.noreply.github.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
caltech-dev | 7026407 | Commit Preview URL Branch Preview URL |
Jun 22 2026, 04:00 AM |
| function decodeShareCode(code: string): string { | ||
| const decompressed = decompressFromEncodedURIComponent(code); | ||
| if (decompressed) { | ||
| try { | ||
| JSON.parse(decompressed); | ||
| return decompressed; | ||
| } catch { | ||
| // fall through to legacy base64 | ||
| } | ||
| } | ||
| return window.atob(code); | ||
| } |
There was a problem hiding this comment.
📝 Info: Share-code encoding change requires attention for cross-version sharing
Export now uses compressToEncodedURIComponent (src/Workspace.tsx:555) while import uses decodeShareCode (src/Workspace.tsx:35-46) which tries lz-string decompression first, then falls back to legacy window.atob. This preserves backward compatibility for importing old codes. However, codes generated by this new version cannot be imported by older deployed versions (which only support base64). If users share codes between an old deployment and a new one, imports will fail with "Error importing workspace." This is likely acceptable for a one-time migration, but worth noting.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const calendar = useCalendarApp({ | ||
| views: [createViewWeek()], | ||
| selectedDate: Temporal.PlainDate.from("2018-01-01"), | ||
| firstDayOfWeek: 1, | ||
| dayBoundaries: { | ||
| start: `${String(minHour).padStart(2, "0")}:00`, | ||
| end: `${String(maxHour).padStart(2, "0")}:00`, | ||
| }, | ||
| weekOptions: { | ||
| nDays: hasWeekendCourse ? 7 : 5, | ||
| gridHeight: 36 * (maxHour - minHour), | ||
| eventOverlap: false, | ||
| }, | ||
| calendars, | ||
| events: toExternalEvents(calEvents), | ||
| plugins: [eventsService], | ||
| }); | ||
|
|
||
| useEffect(() => { | ||
| eventsService.set(toExternalEvents(calEvents)); | ||
| }, [calEvents, eventsService]); | ||
|
|
||
| return <ScheduleXCalendar calendarApp={calendar} />; | ||
| } |
There was a problem hiding this comment.
📝 Info: Schedule-X useCalendarApp config is only read on mount — keyed remount pattern is correct but subtle
The ScheduleCalendar component (src/Planner.tsx:116-161) calls useCalendarApp with config values (dayBoundaries, calendars) derived from calEvents. Since useCalendarApp only uses its config on the first call (mount), subsequent prop changes within the same mount don't update these structural settings. This is intentionally handled via the key={calendarKey} prop at src/Planner.tsx:213, which forces a full remount when hour bounds or course IDs change. Event data updates within the same mount are handled reactively via eventsService.set() in the useEffect at line 156-158. This is a correct but non-obvious pattern — future maintainers should understand that removing or changing the calendarKey logic would break calendar config synchronization.
Was this helpful? React with 👍 or 👎 to provide feedback.
| selector: (item) => `${item.number} ${item.name}`, | ||
| }); |
There was a problem hiding this comment.
📝 Info: Fzf index is rebuilt on every render of WorkspaceSearch
At src/Workspace.tsx:428-430, new Fzf(courses, ...) is called in the component body, rebuilding the fuzzy-search index on every render. For a catalog of hundreds of courses this is likely fast enough, but wrapping it in useMemo keyed on courses would avoid redundant work during re-renders triggered by unrelated state changes (e.g. toggling a course, navigating arrangements).
(Refers to lines 428-430)
Was this helpful? React with 👍 or 👎 to provide feedback.
| const times = section.times.split("\n"); // Split multiple times on newline | ||
| const locations = section.locations.split("\n"); // Split multiple locations on newline | ||
|
|
||
| // Zip times and locations together | ||
| times.forEach((time, index) => { | ||
| const location = locations[index] || "Unknown"; // Match time with corresponding location | ||
| const [days, startTime, , endTime] = time.split(" "); // Separate days and time range | ||
| if (days === "A") return; // skip to-be-announced times | ||
| if (!startTime || !endTime) return; // skip malformed time entries | ||
|
|
||
| for (const day of days) { | ||
| parsedEvents.push({ | ||
| name: course.courseData.number, // Use course number for the title | ||
| location, // Set the matched location for this time | ||
| startTime: getFirstOccurrence(day, startTime), | ||
| endTime: getFirstOccurrence(day, endTime), // Parse the end time | ||
| }); | ||
| } | ||
| }); |
There was a problem hiding this comment.
📝 Info: ICS export parses times independently from parseTimes.ts — some edge-case courses may be handled differently
The exportICS function (src/Workspace.tsx:108-193) parses section times by splitting on \n and then spaces, while the calendar display uses parseTimes from src/parseTimes.ts which splits on [,\n] and applies a \nA cleanup hack. For unusual catalog entries like "OM,M 15:00 -\nA 15:55", parseTimes correctly extracts "M 15:00-15:55" after cleanup, but the ICS export skips the entry entirely (the first line lacks a valid end time, the second starts with "A"). This means some courses visible on the calendar won't appear in the exported .ics file. This is a pre-existing discrepancy that wasn't introduced by this PR, but worth noting since the ICS export was otherwise substantially improved.
Was this helpful? React with 👍 or 👎 to provide feedback.
| return value | ||
| .replace(/^BEGIN:VEVENT/m, `${VTIMEZONE}\r\nBEGIN:VEVENT`) | ||
| .replace(/^DTSTART:/gm, `DTSTART;TZID=${CAMPUS_TZID}:`) | ||
| .replace(/^DTEND:/gm, `DTEND;TZID=${CAMPUS_TZID}:`); |
There was a problem hiding this comment.
📝 Info: ICS VTIMEZONE injection uses first-match-only regex — correct for single timezone but fragile
At src/Workspace.tsx:191, .replace(/^BEGIN:VEVENT/m, ...) (without the g flag) intentionally replaces only the first BEGIN:VEVENT to insert the VTIMEZONE block once. The subsequent DTSTART/DTEND replacements use /gm to hit all events. This is correct but relies on the ics library always outputting BEGIN:VEVENT at the start of a line and always using bare DTSTART: / DTEND: (no extra parameters). If the ics library changes its output format in a future version, these regexes could silently stop matching, producing ICS files without timezone info.
Was this helpful? React with 👍 or 👎 to provide feedback.
This reverts commit 7a7ebf1, restoring the full modernized dependency stack (React 19, MUI 9, motion 12, Tailwind 4, Schedule-X, Vite 8, TypeScript 6). wrangler.jsonc is intentionally left unchanged. Co-Authored-By: Rahul Chalamala <22563365+rchalamala@users.noreply.github.com>
0827062 to
7026407
Compare
| function exportICS(term: string, courses: CourseStorage[]): string { | ||
| const termStartDate = new Date(( TERM_START_DATES as {[key: string] : string} )[term]); | ||
| const termStartDate = (TERM_START_DATES as { [key: string]: string })[term]; | ||
| if (!termStartDate) { | ||
| throw new Error(`No term start date is available for ${term}`); | ||
| } | ||
| const [startYear, startMonth, startDay] = termStartDate | ||
| .split("-") | ||
| .map(Number); | ||
|
|
||
| // Map weekdays to indices for easy comparison | ||
| const dayMap = "MTWRFSU"; | ||
|
|
||
| // Helper function to get the first occurrence of a day after the term start date | ||
| function getFirstOccurrence(startDate: Date, dayOfWeek: string, timeString: string): Date { | ||
| const date = new Date(startDate); // Copy the term start date | ||
| // First occurrence of a weekday on/after the term start date, as Pacific | ||
| // wall-clock components [year, month, day, hour, minute] | ||
| function getFirstOccurrence( | ||
| dayOfWeek: string, | ||
| timeString: string, | ||
| ): [number, number, number, number, number] { | ||
| const date = new Date(Date.UTC(startYear, startMonth - 1, startDay)); | ||
| const targetDay = dayMap.indexOf(dayOfWeek) + 1; // Get index for the weekday | ||
| const currentDay = date.getDay(); | ||
|
|
||
| // Move the date to the first occurrence of the target weekday | ||
| const dayOffset = (targetDay - currentDay + 7) % 7; | ||
| date.setDate(date.getDate() + dayOffset); // Ensure we don't return the start date if it's the same day | ||
|
|
||
| // Parse time (e.g., "09:00") and set the time explicitly using local time | ||
| const [hours, minutes] = timeString.split(':').map(Number); | ||
| date.setHours(hours, minutes, 0, 0); // Set hours and minutes in the local timezone | ||
|
|
||
| return date; | ||
| const dayOffset = (targetDay - date.getUTCDay() + 7) % 7; | ||
| date.setUTCDate(date.getUTCDate() + dayOffset); | ||
|
|
||
| const [hours, minutes] = timeString.split(":").map(Number); | ||
| return [ | ||
| date.getUTCFullYear(), | ||
| date.getUTCMonth() + 1, | ||
| date.getUTCDate(), | ||
| hours, | ||
| minutes, | ||
| ]; | ||
| } | ||
|
|
||
| // Flatten the courses and parse times with start and end times, matching locations | ||
| const parsedEvents = courses | ||
| .filter(course => course.enabled) | ||
| .flatMap(course => { | ||
| return course.courseData.sections | ||
| .filter(section => section.number - 1 === course.sectionId) // Filter by selected section | ||
| .flatMap(section => { | ||
| const times = section.times.split('\n'); // Split multiple times on newline | ||
| const locations = section.locations.split('\n'); // Split multiple locations on newline | ||
|
|
||
| // Zip times and locations together | ||
| return times.flatMap((time, index) => { | ||
| const location = locations[index] || 'Unknown'; // Match time with corresponding location | ||
| const [days, startTime, , endTime] = time.split(' '); // Separate days and time range | ||
| if (days === 'A') return []; // skip to-be-announced times | ||
|
|
||
| return days.split('').map(day => ({ | ||
| name: course.courseData.number, // Use course number for the title | ||
| location, // Set the matched location for this time | ||
| startTime: getFirstOccurrence(termStartDate, day, startTime), | ||
| endTime: getFirstOccurrence(termStartDate, day, endTime) // Parse the end time | ||
| })); | ||
| }); | ||
| const parsedEvents: { | ||
| name: string; | ||
| location: string; | ||
| startTime: [number, number, number, number, number]; | ||
| endTime: [number, number, number, number, number]; | ||
| }[] = []; | ||
| for (const course of courses) { | ||
| if (!course.enabled) continue; | ||
| if (course.sectionId === null) continue; | ||
| const section = course.courseData.sections[course.sectionId]; // sectionId is an array index | ||
| if (!section) continue; | ||
| const times = section.times.split("\n"); // Split multiple times on newline | ||
| const locations = section.locations.split("\n"); // Split multiple locations on newline | ||
|
|
||
| // Zip times and locations together | ||
| times.forEach((time, index) => { | ||
| const location = locations[index] || "Unknown"; // Match time with corresponding location | ||
| const [days, startTime, , endTime] = time.split(" "); // Separate days and time range | ||
| if (days === "A") return; // skip to-be-announced times | ||
| if (!startTime || !endTime) return; // skip malformed time entries | ||
|
|
||
| for (const day of days) { | ||
| parsedEvents.push({ | ||
| name: course.courseData.number, // Use course number for the title | ||
| location, // Set the matched location for this time | ||
| startTime: getFirstOccurrence(day, startTime), | ||
| endTime: getFirstOccurrence(day, endTime), // Parse the end time | ||
| }); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| // Create a basic ICS header (no timezone needed as we rely on UTC conversion) | ||
| let icsContent = `BEGIN:VCALENDAR | ||
| VERSION:2.0 | ||
| PRODID:-//YourApp//Course Planner//EN | ||
| CALSCALE:GREGORIAN | ||
| METHOD:PUBLISH | ||
| `; | ||
|
|
||
| // Generate the events in ICS format | ||
| parsedEvents.forEach(event => { | ||
| const dtStart = event.startTime.toISOString().replace(/-|:|\.\d+/g, ""); // Convert to UTC in .ics format | ||
| const dtEnd = event.endTime.toISOString().replace(/-|:|\.\d+/g, ""); // Convert to UTC in .ics format | ||
|
|
||
| // Add each event to the ICS content | ||
| icsContent += `BEGIN:VEVENT | ||
| SUMMARY:${event.name} | ||
| LOCATION:${event.location} | ||
| DTSTART:${dtStart} | ||
| DTEND:${dtEnd} | ||
| RRULE:FREQ=WEEKLY;COUNT=10 | ||
| UID:${Date.now() + Math.random()}@caltech.dev | ||
| END:VEVENT | ||
| `; | ||
| }); | ||
|
|
||
| // Close the calendar | ||
| icsContent += `END:VCALENDAR`; | ||
| const { error, value } = createEvents( | ||
| parsedEvents.map((event) => ({ | ||
| title: event.name, | ||
| location: event.location, | ||
| start: event.startTime, | ||
| end: event.endTime, | ||
| startInputType: "local", | ||
| endInputType: "local", | ||
| startOutputType: "local", | ||
| endOutputType: "local", | ||
| recurrenceRule: "FREQ=WEEKLY;COUNT=10", | ||
| })), | ||
| ); | ||
| if (error || value == null) throw error ?? new Error("ICS export failed"); | ||
|
|
||
| return icsContent; | ||
| // Anchor the floating local times to the campus timezone | ||
| return value | ||
| .replace(/^BEGIN:VEVENT/m, `${VTIMEZONE}\r\nBEGIN:VEVENT`) | ||
| .replace(/^DTSTART:/gm, `DTSTART;TZID=${CAMPUS_TZID}:`) | ||
| .replace(/^DTEND:/gm, `DTEND;TZID=${CAMPUS_TZID}:`); |
There was a problem hiding this comment.
📝 Info: ICS export timezone fix is a meaningful behavioral change
The old exportICS in src/Workspace.tsx used new Date(termStartDate) (parsed as UTC midnight) then called .getDay() (local timezone) and .setHours(hours, minutes) (local timezone), followed by .toISOString() (UTC output). This meant the exported ICS times were incorrect for users outside the Pacific timezone—a class at 2:00 PM PST would appear at the wrong time. The new code constructs dates with Date.UTC() and uses getUTCDay()/setUTCDate()/getUTCFullYear() consistently, then anchors times to America/Los_Angeles via a VTIMEZONE block and DTSTART;TZID= properties. This is a correct and important fix, but it changes the ICS output format significantly. Users who previously exported ICS files and re-import them won't be affected (old files keep their format), but the new files will behave differently in calendar clients.
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary
Fixes the Cloudflare Workers Builds deploy failure caused by Vite 8 requiring Node >=20.19, while CF defaults to Node 18.
Exactly 2 commits:
.node-versionfile (22) +"engines": { "node": ">=20.19" }inpackage.jsongit revert 7a7ebf1restores the full modern stack (React 19, MUI 9, motion 12, Tailwind 4, Schedule-X, Vite 8, TS 6, wrangler 4.98.0)Reproduced failure (Node 18, without the pin)
Note
NODE_VERSION=22may also need to be set in the Cloudflare Workers Builds dashboard if it doesn't pick up the.node-versionfile.Link to Devin session: https://app.devin.ai/sessions/2a1e057e9b584456bf6c5009f6573070
Requested by: @rchalamala