Skip to content

Fix Cloudflare deploy: pin Node 22 + revert Zack's partial revert#170

Merged
rchalamala merged 2 commits into
mainfrom
devin/1782097178-fix-cf-deploy
Jun 22, 2026
Merged

Fix Cloudflare deploy: pin Node 22 + revert Zack's partial revert#170
rchalamala merged 2 commits into
mainfrom
devin/1782097178-fix-cf-deploy

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

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:

  1. Pin Node >=20.19 — adds .node-version file (22) + "engines": { "node": ">=20.19" } in package.json
  2. Revert Zack's partial revertgit revert 7a7ebf1 restores 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)

$ node -v
v18.20.8
$ npm run build

> tsc && vite build

You are using Node.js 18.20.8. Vite requires Node.js version 20.19+ or 22.12+.
Please upgrade your Node.js version.

ReferenceError: CustomEvent is not defined
    at CAC.parse (node_modules/vite/dist/node/cli.js:533:28)

Note

NODE_VERSION=22 may also need to be set in the Cloudflare Workers Builds dashboard if it doesn't pick up the .node-version file.

Link to Devin session: https://app.devin.ai/sessions/2a1e057e9b584456bf6c5009f6573070
Requested by: @rchalamala


Open in Devin Review

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>
@rchalamala rchalamala self-assigned this Jun 22, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 22, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 5 potential issues.

Open in Devin Review

Comment thread src/Workspace.tsx
Comment on lines +35 to 46
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);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/Planner.tsx
Comment on lines +138 to 161
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} />;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/Workspace.tsx
Comment on lines 429 to 430
selector: (item) => `${item.number} ${item.name}`,
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 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)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/Workspace.tsx
Comment on lines +153 to 171
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
});
}
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/Workspace.tsx
Comment on lines +190 to +193
return value
.replace(/^BEGIN:VEVENT/m, `${VTIMEZONE}\r\nBEGIN:VEVENT`)
.replace(/^DTSTART:/gm, `DTSTART;TZID=${CAMPUS_TZID}:`)
.replace(/^DTEND:/gm, `DTEND;TZID=${CAMPUS_TZID}:`);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Test Results — E2E Testing on Local Dev Server (Node 22)

Ran npm run dev locally and tested the restored modern stack end-to-end in Chrome.

All 5 tests passed
Test Result
App renders full layout with modern stack (Schedule-X calendar, workspace tabs, search, controls) Passed
Course search & add via MUI Autocomplete + Fzf fuzzy search Passed
Default schedule loads 4 courses (Ma 1a, Ph 1a, Ch 1a, CS 1) with calendar blocks Passed
More Info modal opens with course details (description, prerequisites, TQFR rating) Passed
Workspace tab switching preserves state Passed
Screenshots

App initial load — Calendar (Mon-Fri), workspace tabs 1-5, search input, controls all render correctly. Console clean (no errors).
App initial load

Course search — Typing "CS 1" fuzzy-filters the catalog; selecting "CS 1" adds card + calendar blocks on MWF at 2 PM.
Course search and add

Default schedule — 4 Fall courses loaded, 33 units (14-4-15), calendar filled with colored blocks.
Default schedule

More Info modal — Course description, prerequisites, TQFR rating (4.31 +/- 0.74), close button works.
More Info modal

Workspace tab switching — Tab 2 empty → Tab 1 restored with all courses.
Workspace restored

CI & Notes
  • All 5 CI checks passed (verify, CodeQL, Analyze x2, Workers Builds)
  • Devin Review flagged 5 informational notes (no bugs/regressions)
  • NODE_VERSION=22 may also need to be set in the Cloudflare Workers Builds dashboard for production deploys

Devin session

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>
@devin-ai-integration devin-ai-integration Bot force-pushed the devin/1782097178-fix-cf-deploy branch from 0827062 to 7026407 Compare June 22, 2026 03:59

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

Open in Devin Review

Comment thread src/Workspace.tsx
Comment on lines 108 to +193
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}:`);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@rchalamala rchalamala merged commit 4802d88 into main Jun 22, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant