Frontend Development Guide

This guide covers frontend development patterns and conventions for the Guava frontend.

Structure

frontend/
├── app/
│   ├── routes/              # Route components (file-based routing)
│   │   ├── home.tsx         # / (index route)
│   │   ├── auth.login.tsx   # /auth/login
│   │   ├── p.$token.tsx     # /p/:token (public preference entry)
│   │   ├── rotation-schedules.new.tsx # /rotation-schedules/new
│   │   └── rotation-schedules.$id.setup.tsx # /rotation-schedules/:id/setup
│   ├── 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)

React Router v7 Configuration

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. The dev server will hot-reload with the new route

Common mistake: Creating a route file but forgetting to register it in routes.ts results in 404 errors.

Development Commands

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 formatting

UI Components (shadcn/ui)

The 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, tabs

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

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;
}

Authentication

The frontend uses HTTP-only cookie authentication:

  1. User logs in via /auth/login → backend sets session_token HTTP-only cookie
  2. Frontend automatically includes cookies via credentials: "include"
  3. 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";

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>
  );
}

Data Loading Pattern

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";

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} />;
}

Multiple Queries

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
}

Rules

  1. No clientLoader exports - Loaders add complexity without benefit
  2. No isPending / isLoading checks - Backend is fast, spinners feel slower
  3. Guard with if (!data) return null - Shows nothing until data arrives
  4. After the guard, data is guaranteed - No optional chaining needed

What Happens at Runtime

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

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 });
}

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

Important Files