cd frontendpnpm install # Install dependenciespnpm run dev # Start dev server (usually on :5173 or :5174)pnpm run build # Build for productionpnpm run typecheck # Run TypeScript type checkingpnpm run lint # Run Biome lintingpnpm run lint:fix # Auto-fix linting issuespnpm run format # Format code with Biomepnpm run format:check # Check code formatting
UI Components (shadcn/ui)
The frontend uses shadcn/ui - a collection of reusable components built on Radix UI.
Key Features: - Components live in app/components/ui/, you own the code - Built on Radix UI with accessibility support - Tailwind CSS for styling - Documentation: https://ui.shadcn.com/docs
Code Quality & Type Safety
Biome handles linting and formatting (replaces ESLint + Prettier).
Type Safety Requirements: - NO type casting/assertions - Type assertions (as Type) are prohibited except: - as const for literal type narrowing - Context default values in React (e.g., {} as ContextType) - Use type guards and validation - Validate data at runtime instead of asserting - Form data extraction - Use getRequiredFormField() or getOptionalFormField() from ~/lib/form-utils - Strict TypeScript - noUncheckedIndexedAccess and all strict flags enabled - No explicit any - Biome enforces noExplicitAny: "error" - No non-null assertions - Biome enforces noNonNullAssertion: "error"
Type Guard Pattern:
// Single source of truth - array defines the typeconst VALID_THEMES = ["dark","light","system"] asconst;type Theme = (typeof VALID_THEMES)[number];functionisValidTheme(value:string): value is Theme {return VALID_THEMES.some((t) => t === value);}functiontoTheme(value:string, fallback: Theme ="system"): Theme {return VALID_THEMES.find((t) => t === value) ?? fallback;}
Authentication
The frontend uses HTTP-only cookie authentication:
User logs in via /auth/login → backend sets session_token HTTP-only cookie
Frontend automatically includes cookies via credentials: "include"
Backend validates session from cookie on each request
Key Files: - frontend/app/lib/auth-context.tsx - Manages auth state via getCurrentUser() RPC - frontend/app/lib/connect-client.ts - Provides transport with automatic cookie handling - frontend/app/lib/protected-route.tsx - Wrapper component to protect routes
Making Authenticated API Calls:
import { createClient } from"@connectrpc/connect";import { UserService } from"~/gen/user/v1/user_pb";import { transport } from"~/lib/connect-client";functionMyComponent() {const client =createClient(UserService, transport);// Cookies automatically included}
Use direct useQuery with a null guard. No loaders, no loading spinners.
Standard Pattern
import { useQuery } from"@connectrpc/connect-query";import { listSites } from"~/gen/site/v1/site-SiteService_connectquery";functionSitesContent() {const { data } =useQuery(listSites);if (!data) returnnull;// data is guaranteed to exist after this pointconst sites = data.sites;return<SitesList sites={sites} />;}
Multiple Queries
functionScheduleContent({ id }: { id:string }) {const { data: scheduleData } =useQuery(getSchedule, { id });const { data: blocksData } =useQuery(listBlocks, { scheduleId: id });if (!scheduleData ||!blocksData) returnnull;// Both guaranteed to exist}
Rules
No clientLoader exports - Loaders add complexity without benefit
No isPending / isLoading checks - Backend is fast, spinners feel slower
Guard with if (!data) return null - Shows nothing until data arrives
After the guard, data is guaranteed - No optional chaining needed
What Happens at Runtime
AppLayout (sidebar, header) renders immediately
Content area is empty for 100-200ms while data fetches
Data arrives, content appears
On revisit: cached data shows instantly (stale-while-revalidate)
Program-Scoped Queries
Many endpoints require a programId parameter. To prevent requests from firing with empty programId before the context is initialized, use the program-scoped query hooks from ~/lib/program-queries.ts.
Available hooks: - useProgramResidents() - Fetches residents in the current program - useProgramRotationSchedules() - Fetches rotation schedules in the current program - useProgramAcademicYears() - Fetches academic years in the current program - useProgramCallSchedules() - Fetches call schedules in the current program
How they work: 1. Each hook checks both !isLoading and !!currentProgramId before enabling the query 2. Queries NEVER fire with an empty programId 3. Components render immediately (no blocking spinner) while queries wait for programId
CRITICAL: Do NOT call program-scoped endpoints directly:
// DON'T DO THIS - risks empty programIdconst { data } =useQuery(listResidents, { programId: someProgramId ??"" });// DO THIS - uses the safe wrapper hookconst { data } =useProgramResidents();
For other program-scoped endpoints, follow the pattern in program-queries.ts:
Convention: Dialogs receive reference data as props from their parent. They do not fetch it themselves.
Dialog components (modals, drawers, etc.) should not call useQuery or useProgramX() hooks to fetch reference data like rotations, sites, tags, or residents. Instead, the parent component that renders the dialog should fetch shared data and pass it down as props.
Why: - Makes data dependencies explicit — you can see from the outside what a dialog needs - Eliminates duplicate fetching logic when sibling dialogs share the same data - Makes components easier to test in isolation - TanStack Query deduplicates at the network level, but the code-level duplication still obscures intent
Correct:
// Parent fetches and passes datafunctionSchedulePage() {const { data: sitesData } =useProgramSites();const sites = sitesData?.sites?? [];return (<CreateShiftDialog sites={sites} callScheduleId={id} /> );}// Dialog receives data as propsinterface CreateShiftDialogProps { sites: Site[]; callScheduleId:string;}functionCreateShiftDialog({ sites, callScheduleId }: CreateShiftDialogProps) {// Use sites directly — no fetching here}
Incorrect:
// Dialog fetches its own reference datafunctionCreateShiftDialog({ callScheduleId }: { callScheduleId:string }) {const { data: sitesData } =useProgramSites();// DON'T — hoist to parentconst sites = sitesData?.sites?? [];}
Exceptions — dialogs may fetch data internally when: - The query is specific to that dialog and no sibling shares it (e.g., listResidentTagCombinations in a single dialog) - The query depends on dialog-internal state (e.g., a search query typed by the user) - Mutations — dialogs always own their own useConnectMutation calls
Mutation Patterns & Cache Invalidation
All mutation → query invalidation relationships are defined in frontend/app/lib/query-relationships.ts: - Single source of truth for which queries need invalidation after each mutation - Automatic invalidation via MutationCache.onSuccess in query-provider.tsx
Adding a new mutation: 1. Import the mutation from the connect-query generated file 2. Import any query schemas it affects 3. Add an entry to mutationEffects:
[createFoo.name]: [listFoos, getFoo],
Component mutations:
// DO THIS: Simple useMutation with no onSuccess invalidationconst createMutation =useMutation(createFoo);// onSuccess is only for UI state changesconst createMutation =useMutation(createFoo, { onSuccess: () => {setIsDialogOpen(false); form.reset(); },});
NEVER manually invalidate in components - the central schema handles it.
Important Files
frontend/app/routes.ts - Route configuration (MUST be updated when adding routes)
frontend/app/root.tsx - Root layout with providers