live
./docs / frontend

Frontend

The apps/web/ app is Astro in SSR mode (output: 'server') on the Cloudflare adapter, deployed to Cloudflare Pages. React is available as an island renderer (@astrojs/react) but is used sparingly — most of the UI is .astro.

Stack

ConcernChoice
FrameworkAstro output: 'server', @astrojs/cloudflare adapter
IslandsReact 18 via @astrojs/react — only where interactivity is required
StylingTailwind, dark theme fixed on <html class="dark">
Iconslucide-react
FontsSora + JetBrains Mono (Google Fonts, preconnected in BaseLayout)
Path alias@/* → src/* (Vite + tsconfig)
DataPUBLIC_API_URL → the Hono API; no other backends

Entry points: astro.config.mjs (adapter, sitemap, Vite aliases), src/layouts/BaseLayout.astro (document shell, <head>, Navbar/Footer, view transitions), src/layouts/LandingLayout.astro (bare shell for /).

Data fetching is server-side only

All data fetching happens in Astro frontmatter (--- blocks), never in a client:* island. Pages call the typed client in src/lib/api.ts and pass plain data down as props. A page fans its calls out with Promise.allSettled and degrades per-slice on failure:

---
import { api } from '@/lib/api';

const year = Number(Astro.url.searchParams.get('year') ?? new Date().getFullYear());

let history: PredictionHistoryItem[] = [];
const [historyResult] = await Promise.allSettled([api.getPredictionHistory(year)]);
if (historyResult.status === 'fulfilled') history = historyResult.value;
---

The three sanctioned client-fetch exceptions

Client-side fetching is allowed only for UI whose data depends on arbitrary user input at view time, so it can’t be pre-rendered into frontmatter:

ComponentDirectiveWhy it fetches client-side
features/search/components/GlobalSearch.tsxclient:idlelive search query
features/compare/components/DriverCompareTool.tsxclient:loadarbitrary driver pair × year, picked on demand
features/compare/components/TeamCompareTool.tsxclient:loadarbitrary team pair × year, picked on demand

These are the only client-side fetch calls in the app, and the only reason the e2e fixture server sends CORS headers. Any new client fetch must fit the same shape — data genuinely unknowable until the user interacts. If it’s knowable at request time (a circuit’s coordinates, a fixed list), it belongs in frontmatter. CircuitsGrid.tsx is client:load but only for filtering/sorting a list already passed in as a prop — it does not fetch.

The API client

src/lib/api.ts is the single place any fetch to the API happens. Every page imports api and calls a named method — never a raw fetch. get<T>() unwraps the { data, error } envelope and throws on error or a non-2xx status, so callers get T directly. Response types come from src/types/index.ts.

Directory layout

src/
├── pages/              file-based routes — one .astro per URL, frontmatter fetches + composes
├── layouts/            BaseLayout (app chrome) + LandingLayout (bare)
├── components/         generic, cross-feature only
│   ├── layout/         Navbar, MobileNav, Footer, nav model
│   ├── shared/         YearSelect, ConfidenceBadge, …
│   ├── docs/           OnThisPage TOC
│   └── ui/             Shadcn primitives (badge, button, card, table, search-select)
├── features/           domain UI, grouped by area
│   └── <area>/
│       ├── components/ the area's .astro / .tsx pieces
│       └── *.ts        pure logic + hooks (compareStats, useCompareController, raceTabs, …)
├── lib/                truly cross-feature helpers (api, teamColors, teamLogos, predictionMath, utils)
├── types/index.ts      all TypeScript types in one file
├── content/config.ts   Astro content collection for docs/ (globs ../../docs, minus adr/)
└── styles/globals.css  Tailwind base + CSS custom properties

Rule of thumb: domain UI lives in features/<area>/; components/ is only for pieces used across unrelated areas. A helper used by one feature stays in that feature; promote to lib/ only when a second unrelated feature needs it.

Routes

RouteData source
/static (no API)
/predictionpredictions/upcoming + sprint/upcoming + predictions/accuracy (+ history, standings)
/prediction/[id]predictions/race/:id
/prediction/sprint/[id]sprint/race/:id
/prediction/recap · /prediction/recap/[year]predictions/accuracy · predictions/history?year=
/races · /races/[id] · /races/[id]/sprintraces?year= · races/:id · sprint/race/:id/detail
/circuits · /circuits/[key]races/circuits · races/circuit/:key
/drivers · /drivers/[id] · /drivers/comparedrivers/standings?year= · drivers/:id?year= · drivers?year=
/teams · /teams/[id] · /teams/compareteams/standings?year= · teams/:id?year= · teams?year=
/docs · /docs/[slug]Astro content collection (prerender = true)
/health-qualityquality?year= — dev-only, 404-guarded outside import.meta.env.DEV

Year-scoped pages read ?year= from Astro.url.searchParams, defaulting to the current year; YearSelect / RaceYearSelect navigate by changing that param.

Component conventions

  • .astro for display, .tsx for interactive islands. Prefer .astro.
  • A client:* directive is added only when interactivity is strictly required. Current islands: GlobalSearch (client:idle), DriverCompareTool / TeamCompareTool / CircuitsGrid (client:load). Everything else is server-rendered.
  • React hooks correctness is linted (react-hooks/rules-of-hooks error, exhaustive-deps warn).
  • No chart libraries — charts are hand-rolled SVG (LapChart.astro, CalibrationChart.astro, DriverRadarCompare.astro). No heavy animation libraries.
  • Small co-located <script> blocks in .astro files are fine for view-local behaviour (tab toggles, countdowns — see features/races/raceTabs.ts).

Styling

Tailwind for everything; the dark theme is fixed (<html class="dark">), colour tokens are CSS custom properties in styles/globals.css. lib/teamColors.ts and lib/teamLogos.ts map a team_key to an official hex colour / static logo path under public/teams/.

File-size budget

eslint.config.js enforces a tiered max-lines (error, blank lines and comments excluded), run via bun run lint and in CI:

GlobBudget
src/pages/**, src/layouts/** (.astro)300
src/**/components/** (.astro, .tsx)250
default src/** (.ts, .tsx, .astro)200
src/types/**, tests/**, *.config.*off

When a page or component grows past its budget, extract sections into features/<area>/components/ rather than raising the limit. (apps/api has its own parallel budget — see the Testing doc and apps/api/eslint.config.js.)

Types

src/types/index.ts holds all frontend types in one file. It is a hand-maintained mirror of the API’s response shapes (apps/api/src/common/types.ts) — the two are separate files and can drift, so when an endpoint’s response changes, update both. Same applies to lib/predictionMath.ts, whose GP_WEIGHTS / SPRINT_WEIGHTS are hand-copied from the Python model.

Local development

cd apps/web
bun install
bun run dev          # astro dev on :4321
bun run build        # production build (also the CI gate)
bun run typecheck    # astro check
bun run lint         # eslint (max-lines)

bun run dev from the repo root runs apps/web and apps/api together. The web app expects PUBLIC_API_URL pointing at a running API (http://localhost:8787 by default).