Conversation
- Delete public/404.html (spa-github-pages 404 redirect hack) - Remove inline query-string redirect script from index.html - Cloudflare Workers handles SPA routing natively via wrangler.jsonc not_found_handling: single-page-application - Add package-lock.json to .gitignore (repo uses bun.lock) Co-authored-by: Rahul Chalamala <rchalamala@users.noreply.github.com>
- Add .github/workflows/deploy.yml for CD on push to main - Update SKILL.md: bun references, correct codebase constants, add CI/CD and validation sections - Update README.md: add Deployment section with secrets docs, manual deploy, and production preview instructions - Update AGENTS.md: correct package manager, lint/test/CI info Co-authored-by: Rahul Chalamala <rchalamala@users.noreply.github.com>
- Move exportICS() from Workspace.tsx to src/lib/ics.ts - Add 7 unit tests covering: VCALENDAR structure, single/multi-day events, disabled courses, TBA times, multiline times, section selection - Remove unused TERM_START_DATES import from Workspace.tsx Co-authored-by: Rahul Chalamala <rchalamala@users.noreply.github.com>
- Workspace tests (13): empty state, course rendering, unit totals, delete/toggle/lock interactions, tab switching, arrangements nav, control buttons - App routing tests (6): default route, valid term, invalid term, error state details, uppercase normalization - Planner tests (6): calendar rendering, time pickers, course events, disabled courses, section-less courses, multiple courses Total: 44 tests across 8 files (up from 12/4) Co-authored-by: Rahul Chalamala <rchalamala@users.noreply.github.com>
- Modal: reduce padding on mobile (p-6 vs p-16), add max-height with overflow scroll, use min() for min-width to avoid overflow - Planner: tighten time-control margins and font sizes on mobile - Workspace: stack entry content vertically on mobile, enlarge touch targets for workspace tabs and scheduler buttons, adjust control density - Global: enable body margin:0, prevent horizontal scroll via overflow-x:hidden, add mobile footer padding, allow planner column to scroll horizontally on very narrow viewports - Fix type error in Workspace.test.tsx (Element → HTMLElement) Co-authored-by: Rahul Chalamala <rchalamala@users.noreply.github.com>
|
Cursor Agent can help with this pull request. Just |
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
caltech-dev | fd1ec4b | Commit Preview URL Branch Preview URL |
Mar 11 2026, 06:17 AM |
- Create src/hooks/useWorkspaceState.ts: encapsulates all workspace state management (5 workspaces, course mutations, arrangement navigation, time filtering, localStorage persistence) - Create src/lib/persistence.ts: isolates localStorage read/write - Move clearUnlockedSections to src/lib/scheduling.ts - Core dedup: reconcileWorkspace() replaces 8 duplicated generate-arrange-update patterns in App.tsx - App.tsx: 600 → 265 lines (all inline state + mutations removed) - All 44 existing tests pass unchanged Co-authored-by: Rahul Chalamala <rchalamala@users.noreply.github.com>
- Install react-router v7 for proper SPA routing - Define routes: / → redirect to default term, /:term → TermPage, * → NotFoundPage - Extract TermPage component (data loading + workspace + calendar) - Extract ErrorPage component (reused for data errors and 404) - Convert in-app term link to <Link> for client-side navigation - Wrap app in <BrowserRouter> in index.tsx - Update App.test.tsx to use <MemoryRouter> instead of pushState - Remove custom useReactPath hook (replaced by react-router) - All 44 tests pass Co-authored-by: Rahul Chalamala <rchalamala@users.noreply.github.com>
Modal: - Add role=dialog, aria-modal=true - Focus trap: Tab cycles within modal, focus moves to dialog on open, returns to trigger on close - aria-label on close and backdrop buttons Workspace: - Tabs: role=tablist/tab/tabpanel, aria-selected, arrow key navigation - Scheduler: aria-live=polite on arrangement counter, aria-labels on prev/next buttons - Course entries: aria-label on every switch, lock, delete, expand button; aria-expanded on collapse toggle Global: - Skip-to-content link (sr-only, visible on focus) - id=main-content on <main> - aria-live=polite on loading state - aria-label=Help on help button Tests: 45 passing (new modal accessibility test) Co-authored-by: Rahul Chalamala <rchalamala@users.noreply.github.com>
- Workspace tabs: move focus to the newly selected tab on arrow key navigation so keyboard users can continue navigating - Skip-to-content link: use CSS-based sr-only pattern instead of Tailwind utility classes (more reliable with Tailwind v4) Co-authored-by: Rahul Chalamala <rchalamala@users.noreply.github.com>
There was a problem hiding this comment.
🚩 package.json scripts still use npm run internally despite Bun-first policy
The check and build scripts in package.json:34-35 still use npm run for sub-script invocation ("check": "npm run lint && npm run test:run && npm run build"). When invoked via bun run check, this spawns npm as a subprocess. While functionally correct (npm can run scripts without installing), it's inconsistent with the Bun-first policy documented in AGENTS.md and README.md. These lines are unchanged in this PR (pre-existing), but worth noting since this PR establishes the Bun-first tooling more firmly.
(Refers to lines 34-35)
Was this helpful? React with 👍 or 👎 to provide feedback.
| const [workspaces, setWorkspaces] = useState<Workspace[]>(() => | ||
| loadWorkspaces(termPath), | ||
| ); | ||
| const [workspaceIdx, setWorkspaceIdxRaw] = useState<number>(() => | ||
| loadWorkspaceIdx(termPath), | ||
| ); |
There was a problem hiding this comment.
🔴 useWorkspaceState does not reinitialize when termPath changes via client-side navigation
The useState initializers at src/hooks/useWorkspaceState.ts:93-98 only run on first mount. When the user navigates between terms via React Router client-side navigation (e.g., clicking the <Link to={DEFAULT_TERM_PATH}> on the error page at src/App.tsx:240-245), the TermPage component re-renders rather than remounts because the same <Route path="/:term"> matches. This means loadWorkspaces(termPath) and loadWorkspaceIdx(termPath) are never called for the new term.
Worse, the persistence useEffect at line 101-104 fires immediately with the new termPath but the old workspaces value, overwriting the new term's localStorage data with the previous term's workspace state. This corrupts the user's saved workspaces for the destination term.
Reproduction path
- Visit
/badterm— TermPage mounts, workspace state initialized for/badterm(empty defaults) - Error page appears with a Link to
/sp2026 - Click the link — React Router client-side nav, TermPage re-renders with
term="sp2026" useWorkspaceState("/sp2026", ...)keeps the old empty defaults in memorysaveWorkspaces("/sp2026", oldEmptyDefaults)overwrites any previously saved sp2026 data- User sees empty workspaces instead of their saved ones
Prompt for agents
In src/hooks/useWorkspaceState.ts, add a useEffect that watches termPath and reinitializes the workspace state when it changes. Something like:
useEffect(() => {
setWorkspaces(loadWorkspaces(termPath));
setWorkspaceIdxRaw(loadWorkspaceIdx(termPath));
}, [termPath]);
Place this after the useState declarations (around line 98) and before the persistence useEffect. Make sure the persistence useEffect doesn't fire synchronously with stale data during the term change — you may need to gate the save behind a check that the state has been initialized for the current term, or use a ref to track whether a term switch is in progress.
Alternatively, add a key prop to the TermPage element in the route definition in src/App.tsx to force remounting on term change. This is simpler but slightly less efficient. In App() around line 282, change:
<Route path="/:term" element={<TermPage />} />
to something that forces a remount on term change. One approach is to create a wrapper component that reads useParams and passes key={term} to TermPage. But the useEffect approach in the hook is more targeted.
Was this helpful? React with 👍 or 👎 to provide feedback.
| export function exportICS(term: string, courses: CourseStorage[]): string { | ||
| const [termStartYear, termStartMonth, termStartDay] = ( | ||
| TERM_START_DATES as { [key: string]: string } | ||
| )[term] | ||
| .split("-") | ||
| .map(Number); |
There was a problem hiding this comment.
🔴 exportICS crashes with TypeError for terms missing from term_start_dates.json
exportICS at src/lib/ics.ts:53-57 accesses TERM_START_DATES[term] without checking if the key exists, then immediately calls .split("-") on the result. The term_start_dates.json file only contains entries for fa2025 through sp2026, but the app supports terms back to FA2022. If a user on an older term route (e.g., /fa2023) clicks "Export .ics" in src/Workspace.tsx:617, this throws TypeError: Cannot read properties of undefined (reading 'split'), crashing the UI with no error handling.
| export function exportICS(term: string, courses: CourseStorage[]): string { | |
| const [termStartYear, termStartMonth, termStartDay] = ( | |
| TERM_START_DATES as { [key: string]: string } | |
| )[term] | |
| .split("-") | |
| .map(Number); | |
| export function exportICS(term: string, courses: CourseStorage[]): string { | |
| const termStartStr = (TERM_START_DATES as { [key: string]: string })[term]; | |
| if (!termStartStr) { | |
| throw new Error(`No start date configured for term: ${term}`); | |
| } | |
| const [termStartYear, termStartMonth, termStartDay] = termStartStr | |
| .split("-") | |
| .map(Number); |
Was this helpful? React with 👍 or 👎 to provide feedback.
| jobs: | ||
| deploy: | ||
| name: Build and deploy | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 15 | ||
|
|
||
| steps: | ||
| - name: Check out repository | ||
| uses: actions/checkout@v4 | ||
|
|
||
| - name: Set up Bun | ||
| uses: oven-sh/setup-bun@v2 | ||
| with: | ||
| bun-version-file: ".bun-version" | ||
|
|
||
| - name: Restore Bun cache | ||
| uses: actions/cache@v4 | ||
| with: | ||
| path: ~/.bun/install/cache | ||
| key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} | ||
| restore-keys: | | ||
| ${{ runner.os }}-bun- | ||
|
|
||
| - name: Install dependencies | ||
| run: bun install --frozen-lockfile | ||
|
|
||
| - name: Build app | ||
| run: bun run build | ||
|
|
||
| - name: Deploy to Cloudflare | ||
| uses: cloudflare/wrangler-action@v3 | ||
| with: | ||
| apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} | ||
| accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | ||
| command: deploy |
There was a problem hiding this comment.
🚩 Deploy workflow lacks build verification before deploy
The deploy workflow at .github/workflows/deploy.yml:50-58 runs bun run build then immediately deploys via wrangler deploy. Unlike the CI workflow, there's no lint or test step before deployment. While CI runs on all pushes (including to main), the deploy workflow uses concurrency: cancel-in-progress: true which could theoretically race with CI — a deploy could start and complete before CI finishes, deploying code that hasn't passed checks. In practice, both workflows start simultaneously from the same push event, so the deploy usually finishes first.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Pull request overview
This PR modernizes the app’s deployment + runtime structure by moving to Cloudflare-based SPA routing, extracting/persisting workspace state into dedicated modules, and improving UI accessibility/mobile responsiveness.
Changes:
- Introduces React Router-based routing with term-specific pages and new error/not-found pages.
- Refactors workspace localStorage persistence and state reconciliation into
src/lib/persistence.tsandsrc/hooks/useWorkspaceState.ts. - Extracts
.icsexport intosrc/lib/ics.tswith accompanying Vitest coverage; adds multiple UI accessibility and mobile CSS improvements.
Reviewed changes
Copilot reviewed 24 out of 25 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| src/lib/scheduling.ts | Adds helper to clear unlocked section selections. |
| src/lib/persistence.ts | New localStorage persistence helpers for workspaces and active index. |
| src/lib/ics.ts | New .ics exporter used by Workspace UI. |
| src/lib/ics.test.ts | Adds unit tests for .ics export behaviors. |
| src/index.tsx | Wraps app in a router. |
| src/hooks/useWorkspaceState.ts | New centralized workspace state hook + persistence. |
| src/Workspace.tsx | Uses extracted .ics export and adds accessibility/tab semantics improvements. |
| src/Workspace.test.tsx | Adds component tests for Workspace UI interactions. |
| src/Planner.test.tsx | Adds component tests for Planner rendering behavior. |
| src/Modal.tsx / src/Modal.test.tsx | Improves modal focus/keyboard accessibility and updates tests. |
| src/App.tsx / src/App.test.tsx | Replaces custom routing with React Router and adds routing/error-state tests. |
| src/css/*.css | Mobile responsiveness tweaks + skip-link styling + modal width improvements. |
| public/404.html / index.html | Removes GitHub Pages SPA redirect hack. |
| package.json | Adds React Router dependency. |
| README.md / AGENTS.md / .cursor/... | Updates docs to reflect Bun/CI/CD/Cloudflare deployment. |
| .github/workflows/deploy.yml | Adds Cloudflare deploy workflow. |
| .gitignore | Ignores package-lock.json and normalizes Icon entry. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const { term } = useParams<{ term: string }>(); | ||
| const termPath = `/${(term ?? "").toLowerCase()}`; | ||
|
|
There was a problem hiding this comment.
termPath is normalized to lowercase for data loading/storage, but the UI continues to use the raw term param (e.g., WorkspacePanel term={term ?? ""} and footer/loader display). For routes like /SP2026, this passes an uppercase term into Workspace, which then does DEFAULT_COURSES[term.substring(0, 2)] and will throw because the keys are lowercase. Use a single normalized term string (lowercased, without the leading /) consistently for display and for the WorkspacePanel prop.
| @@ -1,5 +1,6 @@ | |||
| import { StrictMode } from "react"; | |||
| import ReactDOM from "react-dom/client"; | |||
| import { BrowserRouter } from "react-router"; | |||
There was a problem hiding this comment.
BrowserRouter is imported from react-router, but in React Router’s DOM bindings it is typically provided by react-router-dom. With only react-router in package.json, this import is likely to fail at build/runtime. Consider adding react-router-dom and importing BrowserRouter from there (and similarly update other DOM components like Link/MemoryRouter if needed).
| import { BrowserRouter } from "react-router"; | |
| import { BrowserRouter } from "react-router-dom"; |
| @@ -0,0 +1,90 @@ | |||
| import { render, screen, waitFor } from "@testing-library/react"; | |||
| import { describe, expect, it, beforeEach } from "vitest"; | |||
| import { MemoryRouter } from "react-router"; | |||
There was a problem hiding this comment.
MemoryRouter is imported from react-router. If the app switches to the DOM package (needed for BrowserRouter/Link), the test should import MemoryRouter from react-router-dom as well to match the runtime router implementation and avoid version/export mismatches.
| import { MemoryRouter } from "react-router"; | |
| import { MemoryRouter } from "react-router-dom"; |
| icsContent += `BEGIN:VEVENT | ||
| SUMMARY:${event.name} | ||
| LOCATION:${event.location} | ||
| DTSTART:${dtStart} | ||
| DTEND:${dtEnd} | ||
| RRULE:FREQ=WEEKLY;COUNT=10 |
There was a problem hiding this comment.
ICS text fields should escape special characters per RFC 5545 (notably backslashes, commas, semicolons, and newlines). SUMMARY:${event.name} and LOCATION:${event.location} are currently injected raw, so course numbers/locations containing commas or newlines can produce an invalid .ics file in some calendar clients. Add an escaping helper and apply it to text properties (at least SUMMARY/LOCATION, and any future DESCRIPTION fields).
| .filter(course => course.enabled) | ||
| .flatMap(course => { | ||
| return course.courseData.sections | ||
| .filter(section => section.number - 1 === course.sectionId) // Filter by selected section |
There was a problem hiding this comment.
exportICS selects the active section via section.number - 1 === course.sectionId, but throughout the app sectionId is treated as the index into courseData.sections (e.g., SectionDropdown stores findIndex(...), and getSelectedSection indexes by sectionId). This will export the wrong section whenever section numbers aren’t a simple 1..N mapping or sections are reordered. Filter by array index (or reuse the same “selected section” lookup logic) instead of section.number - 1.
| .filter(section => section.number - 1 === course.sectionId) // Filter by selected section | |
| .filter((section, index) => index === course.sectionId) // Filter by selected section using index |
|
|
||
| // Persist to localStorage on every change | ||
| useEffect(() => { | ||
| saveWorkspaces(termPath, workspaces); | ||
| saveWorkspaceIdx(termPath, workspaceIdx); | ||
| }, [workspaces, workspaceIdx, termPath]); |
There was a problem hiding this comment.
useWorkspaceState initializes workspaces/workspaceIdx from termPath only once. If the user navigates between terms in-app (e.g., /sp2026 -> /fa2026), the hook will keep the previous term’s state but start saving it under the new termPath key due to the persistence effect, corrupting localStorage. Add an effect that resets state when termPath changes (and avoid persisting until after re-hydration), or otherwise force a remount keyed by termPath.
| // Persist to localStorage on every change | |
| useEffect(() => { | |
| saveWorkspaces(termPath, workspaces); | |
| saveWorkspaceIdx(termPath, workspaceIdx); | |
| }, [workspaces, workspaceIdx, termPath]); | |
| // Track which term the in-memory state has been hydrated for | |
| const [hydratedTermPath, setHydratedTermPath] = useState(termPath); | |
| // Re-hydrate state from persistence whenever the term changes | |
| useEffect(() => { | |
| const nextWorkspaces = loadWorkspaces(termPath); | |
| const nextWorkspaceIdx = loadWorkspaceIdx(termPath); | |
| setWorkspaces(nextWorkspaces); | |
| setWorkspaceIdxRaw(nextWorkspaceIdx); | |
| setHydratedTermPath(termPath); | |
| }, [termPath]); | |
| // Persist to localStorage on every change, but only after re-hydration | |
| useEffect(() => { | |
| if (hydratedTermPath !== termPath) return; | |
| saveWorkspaces(termPath, workspaces); | |
| saveWorkspaceIdx(termPath, workspaceIdx); | |
| }, [workspaces, workspaceIdx, termPath, hydratedTermPath]); |
| // Derived state for the active workspace | ||
| const activeWorkspace = workspaces[workspaceIdx]; | ||
| const courses = activeWorkspace.courses; |
There was a problem hiding this comment.
activeWorkspace is read as workspaces[workspaceIdx] without validating that workspaceIdx is within bounds. Since loadWorkspaceIdx() accepts any parsed number (including corrupted/tampered values), this can become undefined and crash on activeWorkspace.courses. Consider clamping workspaceIdx to [0, workspaces.length - 1] (or falling back to 0 when invalid) before indexing.
| export function loadWorkspaces(termPath: string): Workspace[] { | ||
| const raw = localStorage.getItem("workspaces" + termPath); | ||
| if (raw) { | ||
| try { | ||
| return JSON.parse(raw) as Workspace[]; | ||
| } catch { | ||
| // corrupted — fall through to defaults | ||
| } | ||
| } | ||
| return Array.from({ length: NUM_WORKSPACES }, () => createEmptyWorkspace()); | ||
| } |
There was a problem hiding this comment.
loadWorkspaces() returns whatever JSON is stored (any length/shape) and loadWorkspaceIdx() returns any parsed number. If localStorage is corrupted, you can end up with fewer than 5 workspaces or an out-of-range index and the UI will crash. Consider validating the parsed values (ensure array length is NUM_WORKSPACES, pad/truncate as needed, and clamp workspaceIdx to a valid integer).
Co-authored-by: Rahul Chalamala <rchalamala@users.noreply.github.com>
| export function exportICS(term: string, courses: CourseStorage[]): string { | ||
| const [termStartYear, termStartMonth, termStartDay] = ( | ||
| TERM_START_DATES as { [key: string]: string } | ||
| )[term] | ||
| .split("-") | ||
| .map(Number); |
There was a problem hiding this comment.
🚩 exportICS will crash for terms without start date entries
In src/lib/ics.ts:52-57, exportICS accesses TERM_START_DATES[term] and calls .split("-") on it. The term_start_dates.json only contains entries for fa2025 through sp2026, but the app supports terms back to FA2022. If a user viewing an older term clicks "Export .ics", this will throw a TypeError (cannot read .split of undefined). This is a pre-existing issue (carried over unchanged from the old Workspace.tsx implementation), not introduced by this PR, but worth noting since the code was moved to a new module.
Was this helpful? React with 👍 or 👎 to provide feedback.
Implements all Tier 1 priorities by cleaning up deployment, adding comprehensive tests, and improving mobile responsiveness.