This guide covers frontend development patterns and conventions for the Guava frontend.
frontend/
├── app/
│ ├── routes/ # Route components (file-based routing)
│ │ ├── home.tsx # / (index route)
│ │ ├── auth.login.tsx # /auth/login
│ │ ├── residents.tsx # /residents
│ │ ├── rotations.tsx # /rotations
│ │ ├── rotations.new.tsx # /rotations/new
│ │ ├── rotations.$id.tsx # /rotations/:id
│ │ ├── call-schedules.tsx # /call-schedules
│ │ ├── call-schedules.$id.tsx # /call-schedules/:id
│ │ ├── settings.program.tsx # /settings/program
│ │ └── p.$token.tsx # /p/:token (public preference entry)
│ ├── routes.ts # Route configuration (CRITICAL!)
│ ├── gen/ # Generated protobuf types (from backend)
│ ├── components/ # UI components
│ │ └── ui/ # shadcn/ui components (owned by you)
│ ├── lib/ # Shared utilities
│ │ ├── connect-client.ts # Connect RPC client setup
│ │ ├── auth-context.tsx # Auth state management
│ │ ├── query-provider.tsx # React Query setup
│ │ └── utils.ts # Utility functions (cn helper)
│ ├── root.tsx # Root layout component
│ └── app.css # Global styles (Tailwind + shadcn theme)
CRITICAL: React Router v7 uses explicit
route configuration in frontend/app/routes.ts.
File names like auth.login.tsx do NOT automatically
create routes. You MUST manually register all routes.
When adding a new route: 1. Create the route file
in frontend/app/routes/ (e.g.,
auth.login.tsx) 2. IMMEDIATELY add it to
frontend/app/routes.ts:
typescript export default [ index("routes/home.tsx"), // / (root) route("auth/login", "routes/auth.login.tsx"), // /auth/login // ... other routes ] satisfies RouteConfig;
3. Run make dev-up to rebuild with the new route
Common mistake: Creating a route file but
forgetting to register it in routes.ts results in 404
errors.
cd frontend
pnpm install # Install dependencies
pnpm run dev # Start dev server (usually on :5173 or :5174)
pnpm run build # Build for production
pnpm run typecheck # Run TypeScript type checking
pnpm run lint # Run Biome linting
pnpm run lint:fix # Auto-fix linting issues
pnpm run format # Format code with Biome
pnpm run format:check # Check code formattingThe frontend uses shadcn/ui - a collection of reusable components built on Radix UI.
Adding New Components:
cd frontend
pnpm dlx shadcn@latest add [component-name] # e.g., button, input, dialog, tabsKey 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
Biome handles linting and formatting (replaces ESLint + Prettier).
Code Style: - Double quotes, semicolons required - 2-space indentation, 100 char line width
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 type
const VALID_THEMES = ["dark", "light", "system"] as const;
type Theme = (typeof VALID_THEMES)[number];
function isValidTheme(value: string): value is Theme {
return VALID_THEMES.some((t) => t === value);
}
function toTheme(value: string, fallback: Theme = "system"): Theme {
return VALID_THEMES.find((t) => t === value) ?? fallback;
}The frontend uses HTTP-only cookie authentication:
/auth/login → backend sets
session_token HTTP-only cookiecredentials: "include"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";
function MyComponent() {
const client = createClient(UserService, transport);
// Cookies automatically included
}Protecting Routes:
import { ProtectedRoute } from "~/lib/protected-route";
export default function MyPage() {
return (
<ProtectedRoute>
<MyPageContent />
</ProtectedRoute>
);
}Use direct useQuery with a null guard. No loaders, no
loading spinners.
import { useQuery } from "@connectrpc/connect-query";
import { listSites } from "~/gen/site/v1/site-SiteService_connectquery";
function SitesContent() {
const { data } = useQuery(listSites);
if (!data) return null;
// data is guaranteed to exist after this point
const sites = data.sites;
return <SitesList sites={sites} />;
}function ScheduleContent({ id }: { id: string }) {
const { data: scheduleData } = useQuery(getSchedule, { id });
const { data: blocksData } = useQuery(listBlocks, { scheduleId: id });
if (!scheduleData || !blocksData) return null;
// Both guaranteed to exist
}clientLoader exports - Loaders
add complexity without benefitisPending / isLoading
checks - Backend is fast, spinners feel slowerif (!data) return null -
Shows nothing until data arrivesdata is guaranteed
- No optional chaining neededMany 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
Usage:
import { useProgramResidents } from "~/lib/program-queries";
function ResidentsContent() {
const { data } = useProgramResidents();
if (!data) return null;
return <ResidentsList residents={data.residents} />;
}CRITICAL: Do NOT call program-scoped endpoints directly:
// DON'T DO THIS - risks empty programId
const { data } = useQuery(listResidents, { programId: someProgramId ?? "" });
// DO THIS - uses the safe wrapper hook
const { data } = useProgramResidents();For other program-scoped endpoints, follow the
pattern in program-queries.ts:
export function useProgramNewFeature() {
const { currentProgramId, isLoading } = useProgram();
const enabled = !isLoading && !!currentProgramId;
return useConnectQuery(listNewFeature, { programId: currentProgramId ?? "" }, { enabled });
}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 data
function SchedulePage() {
const { data: sitesData } = useProgramSites();
const sites = sitesData?.sites ?? [];
return (
<CreateShiftDialog sites={sites} callScheduleId={id} />
);
}
// Dialog receives data as props
interface CreateShiftDialogProps {
sites: Site[];
callScheduleId: string;
}
function CreateShiftDialog({ sites, callScheduleId }: CreateShiftDialogProps) {
// Use sites directly — no fetching here
}Incorrect:
// Dialog fetches its own reference data
function CreateShiftDialog({ callScheduleId }: { callScheduleId: string }) {
const { data: sitesData } = useProgramSites(); // DON'T — hoist to parent
const 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
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 invalidation
const createMutation = useMutation(createFoo);
// onSuccess is only for UI state changes
const createMutation = useMutation(createFoo, {
onSuccess: () => {
setIsDialogOpen(false);
form.reset();
},
});NEVER manually invalidate in components - the central schema handles it.
frontend/app/routes.ts - Route configuration
(MUST be updated when adding routes)frontend/app/root.tsx - Root layout with
providersfrontend/app/lib/connect-client.ts - RPC client
configurationfrontend/app/lib/auth-context.tsx - Authentication
state managementfrontend/app/lib/query-provider.tsx - TanStack Query
with MutationCache auto-invalidationfrontend/app/lib/query-relationships.ts - Central
mutation → query schemafrontend/app/lib/form-utils.ts - Type-safe form data
extractionfrontend/app/components/ui/ - shadcn/ui
componentsfrontend/biome.json - Biome linter and formatter
configurationfrontend/tsconfig.json - TypeScript
configurationfrontend/app/gen/ - Generated TypeScript types from
protobuf