Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
5e6f7a8
feat: add section swap page with Quest schedule import
jerryzhou196 May 21, 2026
f15c0b7
feat: improve SectionFinderPanel enrollment display and section info
jerryzhou196 May 21, 2026
e0706ff
style: center align items in DataUploadModals content wrapper
jerryzhou196 May 22, 2026
8222f68
fix: mobile-optimize welcome signup transcript and schedule steps
claude May 22, 2026
50054b3
style: merge mobile responsive styles into feature/section-swap
jerryzhou196 May 23, 2026
8796c85
chore: migrate to unified @apollo/client v3 + graphql 16
jerryzhou196 Jun 3, 2026
4493ede
refactor(calendar): extract generic Tailwind calendar, reuse on profile
jerryzhou196 Jun 3, 2026
1a995dd
Merge branch 'main' into refactor/generic-calendar
jerryzhou196 Jun 3, 2026
0fe4ba5
Merge branch 'main' into refactor/generic-calendar
jerryzhou196 Jun 6, 2026
5ebb286
Merge branch 'main' into refactor/generic-calendar
jerryzhou196 Jun 7, 2026
bb6a33a
Merge branch 'main' into refactor/generic-calendar
jerryzhou196 Jun 8, 2026
f841b03
refactor(calendar): build week-nav header into Calendar; drop dead da…
jerryzhou196 Jun 10, 2026
b61d999
refactor(calendar): update button variants and improve button styling
jerryzhou196 Jun 10, 2026
2e80909
refactor(calendar): strip no-op Tailwind classes from calendar and bu…
jerryzhou196 Jun 10, 2026
fc5b075
feat(button): show pointer cursor on hover
jerryzhou196 Jun 10, 2026
f08a898
Merge remote-tracking branch 'origin/main' into feature/section-swap
jerryzhou196 Jun 10, 2026
3ebc330
refactor(swap): address PR review feedback
jerryzhou196 Jun 10, 2026
08171a8
chore: drop unwired chunk-retry WIP files from branch
jerryzhou196 Jun 10, 2026
bca7172
chore(swap): fix all eslint warnings
jerryzhou196 Jun 10, 2026
f1f049c
Merge branch 'refactor/generic-calendar' into feature/section-swap
jerryzhou196 Jun 10, 2026
0f859f8
refactor(swap): render swap schedule with the generic Calendar component
jerryzhou196 Jun 10, 2026
1e6aef6
refactor(swap): reconcile side panel with schedule-swap-panel branch
jerryzhou196 Jun 10, 2026
6db7ebf
style(calendar): match UWFlow mockup grid and event cards
jerryzhou196 Jun 10, 2026
6117575
style(calendar): downsize event text and theme profile calendar
jerryzhou196 Jun 10, 2026
7faf300
feat(swap): mockup header and panel, client-side temporary swaps
jerryzhou196 Jun 10, 2026
93f41cf
chore: untrack WIP chunk-retry files committed by accident
jerryzhou196 Jun 10, 2026
56cb73d
style(calendar): opaque event fills, left-aligned compact text
jerryzhou196 Jun 10, 2026
7ce7ab8
style(swap): match sidebar mockup, compact section list, export button
jerryzhou196 Jun 10, 2026
b5b4bcc
fix(swap): button border reset, compact export, search normalization
jerryzhou196 Jun 10, 2026
e98590f
style(swap): icon-only export button, roomier swap pill
jerryzhou196 Jun 10, 2026
239696b
feat(swap): scope swapping to section type, add reset button
jerryzhou196 Jun 10, 2026
f6986ed
style(swap): label the reset button
jerryzhou196 Jun 10, 2026
e9889ba
Merge branch 'main' into feature/section-swap
jerryzhou196 Jun 10, 2026
f424ce3
Merge remote-tracking branch 'origin/main' into feature/section-swap
jerryzhou196 Jun 10, 2026
0815498
Merge remote-tracking branch 'origin/feature/section-swap' into featu…
jerryzhou196 Jun 10, 2026
8815df3
refactor(swap): all-Tailwind swap page, button reset via base layer
jerryzhou196 Jun 10, 2026
6f8baea
feat(swap): drop export button; reset appears only after a swap
jerryzhou196 Jun 10, 2026
bbd65a2
refactor(swap): use generated GraphQL types for swap queries
jerryzhou196 Jun 10, 2026
ad721c4
feat(swap): logged-out lock state with sample schedule (#263)
jerryzhou196 Jun 10, 2026
f94a123
style(swap): address review feedback on visual consistency
jerryzhou196 Jun 11, 2026
4e4b005
style(swap): use Preflight border-reset idiom for button base rule
jerryzhou196 Jun 13, 2026
3f33689
style(swap): address review feedback on banner, icons, fonts, code fo…
jerryzhou196 Jun 13, 2026
ed21411
style(swap): bold the Choose section button
jerryzhou196 Jun 13, 2026
9524327
fix(vercel): add SPA fallback rewrite so deep links don't 404 on refresh
jerryzhou196 Jun 13, 2026
2f5823f
feat(upload): forward failed schedule uploads to Sentry
jerryzhou196 Jun 13, 2026
37d8b32
style(swap): match page fade-in to legacy react-fade-in
jerryzhou196 Jun 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
LoadablePrivacyPage,
LoadableProfilePage,
LoadableProfPage,
LoadableSwapPage,
LoadableWelcomePage,
} from 'LoadableComponents';
import {
Expand All @@ -25,6 +26,7 @@ import {
PROF_PAGE_ROUTE,
PROFILE_PAGE_ROUTE,
SHORT_PROF_PAGE_ROUTE,
SWAP_PAGE_ROUTE,
WELCOME_PAGE_ROUTE,
} from 'Routes';

Expand Down Expand Up @@ -163,6 +165,11 @@ const App = () => {
path={WELCOME_PAGE_ROUTE}
component={() => <LoadableWelcomePage />}
/>
<SentryRoute
exact
path={SWAP_PAGE_ROUTE}
component={() => <LoadableSwapPage />}
/>
<SentryRoute path="*" component={() => <LoadableNotFoundPage />} />
</Switch>
<Footer />
Expand Down
4 changes: 4 additions & 0 deletions src/LoadableComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,7 @@ export const LoadablePrivacyPage = loadable(
export const LoadableWelcomePage = loadable(
() => import(/* webpackPrefetch: true */ './pages/welcomePage/WelcomePage'),
);

export const LoadableSwapPage = loadable(
() => import(/* webpackPrefetch: true */ './pages/swapPage/SwapPage'),
);
1 change: 1 addition & 0 deletions src/Routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { compile, pathToRegexp } from 'path-to-regexp';
/* Page Routes */
export const LANDING_PAGE_ROUTE = '/';
export const PROFILE_PAGE_ROUTE = '/profile';
export const SWAP_PAGE_ROUTE = '/swap';
export const COURSE_PAGE_ROUTE = '/course/:courseCode';
export const SHORT_PROF_PAGE_ROUTE = '/prof/:profCode';
export const PROF_PAGE_ROUTE = '/professor/:profCode';
Expand Down
51 changes: 24 additions & 27 deletions src/components/banner/AnnouncementBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import React, { useState } from 'react';
import FadeIn from 'react-fade-in';
import { DefaultTheme, useTheme } from 'styled-components';
import { ArrowRight, Repeat, X } from 'react-feather';
import { Link } from 'react-router-dom';
import { SWAP_PAGE_ROUTE } from 'Routes';

const AnnouncementBanner = () => {
const theme: DefaultTheme = useTheme();
// Bump the banner ID when announcing something new so the banner reappears
// for users who dismissed a previous announcement.
const BANNER_ID = 'class-swapper';

// Create a localStorage key based on the banner ID
const localStorageKey = `banner-dismissed`;
const AnnouncementBanner = () => {
const localStorageKey = `banner-dismissed-${BANNER_ID}`;

const [dismissed, setDismissed] = useState<boolean>(
localStorage.getItem(localStorageKey) != null,
Expand All @@ -23,32 +26,26 @@ const AnnouncementBanner = () => {

return (
<FadeIn>
<div
style={{
display: 'flex',
justifyContent: 'space-evenly',
alignItems: 'center',
padding: '15px',
backgroundColor: theme.accent,
}}
>
<div style={{ textAlign: 'center', width: '90%' }}>
<strong> UWFlow is open source! </strong> Check out the{' '}
<a href="https://github.com/UWFlow/uwflow/releases/tag/v1.0.0">
announcement here!
</a>
<div className="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 bg-accent px-6 py-3">
<Repeat aria-hidden="true" className="shrink-0 text-dark1" size={20} />
<div className="min-w-0 text-md text-dark1">
<strong>Introducing Class Swapper.</strong> No more Quest tab swapping
to figure out your ideal schedule.
</div>
<Link
className="flex shrink-0 items-center gap-2 rounded-lg bg-dark1 px-5 py-2.5 text-sm font-semibold text-white no-underline transition-colors hover:bg-primaryExtraDark hover:text-white"
to={SWAP_PAGE_ROUTE}
>
Start swapping
<ArrowRight aria-hidden="true" size={16} />
</Link>
<button
style={{
background: 'none',
fontSize: '20px',
opacity: '50%',
border: 'none',
cursor: 'pointer',
}}
aria-label="Dismiss announcement"
className="flex shrink-0 cursor-pointer items-center border-none bg-transparent p-1 text-dark1 opacity-60 transition-opacity hover:opacity-100"
onClick={handleDismiss}
type="button"
>
x
<X size={18} />
</button>
</div>
</FadeIn>
Expand Down
107 changes: 64 additions & 43 deletions src/components/calendar/Calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { cn } from 'lib/utils';
// time grid and for translating an event's start/end into a pixel offset.
export const HOUR_HEIGHT = 64;
// Height reserved at the top of each day column for its header label.
const HEADER_HEIGHT = 24;
const HEADER_HEIGHT = 32;
// Width of the left gutter that holds the hour labels.
const TIME_WIDTH = 64;

Expand All @@ -18,7 +18,7 @@ export type CalendarEventVariant = 'lecture' | 'lab' | 'tutorial' | 'other';
/**
* Visual state of an event block:
* - `default` — a normal block.
* - `selected` — emphasised with a ring in the variant colour.
* - `selected` — emphasised with a gold fill, border and left rail.
* - `dimmed` — faded, e.g. existing events while a preview is shown.
* - `preview` — a translucent, dashed, slightly blurred "ghost" laid on top;
* non-interactive so it never intercepts clicks.
Expand Down Expand Up @@ -78,29 +78,33 @@ export type CalendarProps = {
className?: string;
};

// Soft grid line, faithful to the legacy calendar. (The dotted half-hour line
// is written inline below so Tailwind's JIT scanner can see the full class.)
// Soft grid line. (The fainter half-hour line is written inline below so
// Tailwind's JIT scanner can see the full class.)
const GRID_LINE = 'border-light3';

// Per-variant accent classes. `default`/`dimmed` blocks border in the accent;
// `selected` rings in it; `preview` borders + tints in it. Backed entirely by
// existing Tailwind tokens (see tailwind.config.js) — no arbitrary hex.
// Per-variant accent classes: saturated colours for the thick left rail (the
// pastel `lecture`/`lab`/`tutorial` tokens are too washed out for it). The two
// arbitrary hexes are the legacy lab/tutorial accent colours.
const VARIANT_BORDER: Record<CalendarEventVariant, string> = {
lecture: 'border-lecture',
lab: 'border-lab',
tutorial: 'border-tutorial',
lecture: 'border-primary',
lab: 'border-[#2b8fcd]',
tutorial: 'border-[#6554c0]',
other: 'border-dark3',
};

const VARIANT_RING: Record<CalendarEventVariant, string> = {
lecture: 'ring-lecture',
lab: 'ring-lab',
tutorial: 'ring-tutorial',
other: 'ring-dark3',
// Opaque solid fill in the variant colour: each hex is the old translucent
// tint (variant colour @20%) composited on white, so gridlines no longer show
// through the blocks while the look stays the same.
const VARIANT_FILL: Record<CalendarEventVariant, string> = {
lecture: 'bg-[#f0f6ff]',
lab: 'bg-[#f0fdff]',
tutorial: 'bg-[#f2f0fc]',
other: 'bg-[#eaecef]',
};

// Translucent fill for the preview ghost, in the variant colour.
const VARIANT_PREVIEW_FILL: Record<CalendarEventVariant, string> = {
// Preview ghosts stay translucent on purpose — they're a see-through overlay
// laid on top of the real (now opaque) blocks.
const PREVIEW_FILL: Record<CalendarEventVariant, string> = {
lecture: 'bg-lecture/20',
lab: 'bg-lab/20',
tutorial: 'bg-tutorial/20',
Expand All @@ -110,7 +114,9 @@ const VARIANT_PREVIEW_FILL: Record<CalendarEventVariant, string> = {
// State -> extra block classes, layered on top of the base block + variant.
const STATE_CLASS: Record<CalendarEventState, string> = {
default: '',
selected: 'z-20 ring-2 ring-offset-1',
// Gold highlight: the fill/border swap to accent tokens happens in
// renderEvent; the ring thickens the gold border on all four sides.
selected: 'z-20 ring-1 ring-accentDark',
dimmed: 'opacity-[0.38]',
// Ghost: dashed, blurred and lifted above real events; never clickable.
preview: 'z-30 border-dashed blur-[1px] pointer-events-none',
Expand All @@ -123,11 +129,8 @@ const STATE_CLASS: Record<CalendarEventState, string> = {
const NAV_BUTTON_CLASS =
'ml-1 h-12 rounded-lg border-2 border-solid border-light3 bg-light1 font-anderson text-lg font-semibold transition-all hover:brightness-[0.85]';

const formatHour = (hour: number) => {
if (hour === 0) return '12 am';
if (hour === 12) return '12 pm';
return hour < 12 ? `${hour} am` : `${hour - 12} pm`;
};
// 24-hour gutter labels: "09:00", "10:00", ...
const formatHour = (hour: number) => `${`${hour}`.padStart(2, '0')}:00`;

// Derive left/right placement for overlapping non-preview events within each
// column. Preview ghosts are skipped so they layer cleanly on top, and any
Expand Down Expand Up @@ -165,7 +168,7 @@ const deriveTruncation = (events: CalendarEvent[]) => {
*
* The section-swap page reuses this directly:
* - enrolled classes are `default` events;
* - the class being swapped is `selected` (variant ring);
* - the class being swapped is `selected` (gold fill, border and left rail);
* - while a candidate section is previewed, the enrolled blocks it would
* replace become `dimmed`, and the candidate is a `preview` ghost
* (translucent + dashed + `blur-[1px]`, non-interactive);
Expand Down Expand Up @@ -195,6 +198,7 @@ const Calendar = ({
const variant = event.variant ?? 'lecture';
const state = event.state ?? 'default';
const isPreview = state === 'preview';
const isSelected = state === 'selected';
// Preview ghosts overlay full-width and ignore overlap truncation.
const truncate = isPreview
? undefined
Expand All @@ -215,12 +219,25 @@ const Calendar = ({
onClick={clickable ? event.onClick : undefined}
style={{ top, height }}
className={cn(
// Base block: rounded, colour-bordered with a thick left rail.
'absolute z-10 overflow-hidden rounded border border-l-4 border-solid px-1 py-0.5 text-[11px] leading-tight text-dark1',
// Preview ghosts tint in the variant colour; real blocks sit on light1.
isPreview ? VARIANT_PREVIEW_FILL[variant] : 'bg-light1',
VARIANT_BORDER[variant],
state === 'selected' && VARIANT_RING[variant],
// Base block: rounded, solid variant fill with a thick accent left
// rail; the text stack is vertically centered but left-aligned, with
// a little left padding to clear the rail, clipping rather than
// wrapping when the block is short or narrow.
'absolute z-10 flex flex-col justify-center overflow-hidden whitespace-nowrap rounded border border-l-4 border-solid pl-1.5 pr-1 leading-tight text-dark1',
// Selected blocks swap the variant fill/accent for the gold tokens
// (gold border on all four sides plus the thick gold rail). The fill
// is accent @20% composited on white, opaque like the variant fills.
isSelected
? 'border-accentDark bg-[#fff3cc]'
: [
isPreview ? PREVIEW_FILL[variant] : VARIANT_FILL[variant],
VARIANT_BORDER[variant],
],
// Outside preview ghosts, only the left rail keeps the saturated
// accent; the other sides stay transparent like the mockup.
!isSelected &&
!isPreview &&
'border-y-transparent border-r-transparent',
STATE_CLASS[state],
// Width / side when sharing a slot with an overlapping event.
truncate === 'left' && 'left-0 w-[calc(50%-4px)]',
Expand All @@ -234,17 +251,23 @@ const Calendar = ({
'transition-all hover:z-20 hover:w-[calc(100%-4px)]',
)}
>
{(event.title || event.subtitle) && (
<div className="font-semibold">
{event.title && (
<div className="w-full truncate text-xs font-semibold">
{event.title}
{event.title && event.subtitle ? (
<span className="font-normal"> - </span>
) : null}
</div>
)}
{(event.timeLabel || event.location) && (
<div className="w-full truncate text-[11px] text-dark2">
{event.timeLabel}
{event.timeLabel && event.location && ' · '}
{event.location}
</div>
)}
{event.subtitle && (
<div className="w-full truncate text-[10px] text-dark3">
{event.subtitle}
</div>
)}
{event.timeLabel && <div>{event.timeLabel}</div>}
{event.location && <div>{event.location}</div>}
</div>
);
};
Expand Down Expand Up @@ -315,13 +338,11 @@ const Calendar = ({
className={cn(
'relative border-0 border-t border-solid px-4',
GRID_LINE,
// Dotted line halfway down each hour row.
"after:absolute after:left-16 after:right-0 after:top-1/2 after:-mt-px after:border-t after:border-dotted after:border-light2 after:content-['']",
// Fainter solid line halfway down each hour row.
"after:absolute after:left-16 after:right-0 after:top-1/2 after:-mt-px after:border-t after:border-solid after:border-light2 after:content-['']",
)}
>
<div className="mt-1 text-[11px] text-dark3">
{formatHour(hour)}
</div>
<div className="mt-1 text-xs text-dark3">{formatHour(hour)}</div>
</div>
))}
</div>
Expand All @@ -344,7 +365,7 @@ const Calendar = ({
GRID_LINE,
)}
>
<div className="absolute inset-x-0 top-0 flex h-6 items-center justify-center text-[11px] font-semibold">
<div className="absolute inset-x-0 top-0 flex h-8 items-center justify-center text-xs uppercase tracking-wide text-dark3">
{label}
</div>
{events
Expand Down
4 changes: 3 additions & 1 deletion src/components/common/LastUpdatedSchedule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const classesLink = 'http://classes.uwaterloo.ca/infocour/CIR/SA/index.html';

type LastUpdatedScheduleProps = {
margin?: string;
fontSize?: string;
courseCode?: string;
term?: number;
updatedAt?: Moment;
Expand All @@ -19,6 +20,7 @@ const LastUpdatedSchedule = ({
term,
updatedAt,
margin = '8px 0 0 0',
fontSize,
}: LastUpdatedScheduleProps) => {
const buildLink = (): string => {
if (courseCode && term) {
Expand All @@ -35,7 +37,7 @@ const LastUpdatedSchedule = ({
};

return (
<LastUpdatedText margin={margin}>
<LastUpdatedText margin={margin} fontSize={fontSize}>
Last updated {updatedAt?.fromNow()} from{' '}
<LastUpdatedLink href={buildLink()} target="_blank">
classes.uwaterloo.ca
Expand Down
7 changes: 6 additions & 1 deletion src/components/common/styles/LastUpdatedSchedule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@ import breakpoint from 'styled-components-breakpoint';

import { Body, Hover, Link } from 'constants/Mixins';

export const LastUpdatedText = styled.div<{ margin: string }>`
export const LastUpdatedText = styled.div<{
margin: string;
fontSize?: string;
}>`
${Body}
color: ${({ theme }) => theme.dark3};
margin: ${({ margin }) => margin};
${({ fontSize }) => fontSize && `font-size: ${fontSize};`}

${breakpoint('zero', 'tablet')`
padding: 0 16px;
Expand All @@ -15,6 +19,7 @@ export const LastUpdatedText = styled.div<{ margin: string }>`

export const LastUpdatedLink = styled.a`
${Link}
font-size: inherit;
color: ${({ theme }) => theme.dark3};
${Hover()}
`;
12 changes: 9 additions & 3 deletions src/components/navigation/ProfileDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { useHistory, useLocation } from 'react-router-dom';
import { useQuery } from '@apollo/client';
import { GetUserQuery } from 'generated/graphql';
import { Dispatch } from 'redux';
import { isOnLandingPageRoute, PROFILE_PAGE_ROUTE } from 'Routes';
import {
isOnLandingPageRoute,
PROFILE_PAGE_ROUTE,
SWAP_PAGE_ROUTE,
} from 'Routes';
import { useTheme } from 'styled-components';

import DropdownList from 'components/input/DropdownList';
Expand Down Expand Up @@ -79,13 +83,15 @@ const ProfileDropdown = () => {
</ProfileText>
<DropdownList
selectedIndex={-1}
width={130}
width={150}
color={isLanding ? theme.white : theme.dark2}
itemColor={theme.dark1}
options={['View profile', 'Log out']}
options={['View profile', 'Section swap', 'Log out']}
onChange={(idx) => {
if (idx === 0) {
handleProfileButtonClick();
} else if (idx === 1) {
history.push(SWAP_PAGE_ROUTE);
} else {
logOut(dispatch, true);
}
Expand Down
Loading