League tables, spray geometry, and Statcast-backed panels—built for a dark dugout, not a bright dashboard template.
Tip
Try it live: caught-looking.com/standings · API reference: docs.caught-looking.com
Run it locally: make install then make dev — API on :8080, site on :5173, browser calls /api through the Vite proxy.
- Start here
- Overview
- What you can explore
- Architecture
- Design tokens
- Tech stack
- CI
- Automation
- Project layout
- Prerequisites
- Editor setup
- Run locally
- Configuration
- Deployment
- Cursor
- License
| I want to… | Go here |
|---|---|
| Explore the live app | caught-looking.com/standings — no install |
| Run it on my machine | Prerequisites → Run locally |
| Understand the product | Overview → What you can explore |
| See what CI does | CI → Automation |
| Tune env vars or ship to the cloud | Configuration · Deployment |
| Read the “why” docs | docs/ — ADRs, SLOs, threat model, glossary |
New to terms like TTL, QPS, or singleflight? Plain-language definitions: glossary.
Caught Looking is a web app for exploring MLB statistics with charts and comparisons. Browse standings, season leaders, team and player views, and game-day boards — including Statcast spray and pitch location when the data is there. Under the hood, a Go API proxies and caches the public MLB Stats API and, for some game views, Baseball Savant.
A natural path through the app: Standings → Leaders → Teams → Players → Games (slate, then a single game’s timeline, box score, and Statcast).
| Area | What it’s for | Shareable URL |
|---|---|---|
| Standings | League tables for the configured season — where every club sits | — |
| Leaders | Season hitting and pitching leaders, with a top-10 bar above the table | Filters in the URL |
| Teams | Season stats and record timelines for a club | Team, season, panel tab |
| Players | Side-by-side compare (radar, trends, game log); hitting or pitching | ids, season, scope, group |
| Games | Day slate plus per-game timeline, box score, and Statcast (spray / pitch dots or zone density); live games refresh while you’re watching | Slate date / optional team |
| API docs | Human-readable OpenAPI (Redoc) from the same contract the app uses | docs.caught-looking.com |
Routes: /standings (default), /leaders, /teams, /players, /games, /games/:gamePk — see App.tsx.
On a game detail page, the box score and inning timeline refresh while the game is still unsettled (about every 45 seconds, matching the live cache TTL). Statcast loads once. Polling pauses when the browser tab is hidden, refreshes immediately when you come back, and stops when the game is final, postponed, or cancelled — so the board stays fresh without hammering league APIs when nobody is watching.
Try it: open a live (or unsettled) game → watch Network for boxscore/timeline on a ~45s cadence → switch tabs (polls stop) → return (one immediate refresh). Hook: useAsyncResource poll + pauseWhenHidden. See ADR 0001.
| Aspect | Theme |
|---|---|
| Surfaces | Near-black fields (#070b10, #0a1018) with cool gray body type |
| Accent | Teal --accent (#00f5c4) for links, focus, and primary chart chrome |
| Type | DM Sans for prose; Space Mono for numerals and axes |
| Charts | Team ink via registry + brand palette; neonChartPalette.ts is a non-team series helper (not extra html CSS variables) |
| Aspect | What stands out (for builders) |
|---|---|
| Contract | Handlers and the SPA share one OpenAPI spec → generated TypeScript types + Redoc |
| Color | Shell tokens in SCSS; team ink from registry + MLB brand picks, contrast-adjusted per chart surface |
| Ops | Per-IP limits, outbound QPS caps, and TTL caches so public upstreams stay friendly at scale |
In plain English: the browser only ever talks to our own Go API, never directly to MLB or Savant. The API remembers recent answers for a while (caching) and paces how often it asks the league for fresh ones — that's the whole story behind most of the jargon below. Unfamiliar term? Check the glossary.
| Layer | Role |
|---|---|
| Browser | React SPA → same-origin /api (Vite proxy in dev; VITE_API_BASE in prod) |
| Go API | Cache TTLs, per-IP limits, outbound QPS caps |
| Upstream | MLB Stats API (JSON) for most routes; Savant (CSV) for Statcast game views only |
Hosted as Cloudflare Pages (site) + Cloud Run (API). See Deployment.
Rationale for cache TTLs, outbound QPS, and the OpenAPI contract: docs/adr/ (ADRs 0001–0003). Skills under .claude/skills/ are the how-to; ADRs record the tradeoffs. Start with the friendly docs home.
In plain English: every choice below trades a little bit of "not infinitely scalable" for keeping the cloud bill near $0. That's a deliberate tradeoff for a hobby-scale public app, not an oversight.
Built for a low/zero incremental cloud bill (Cloud Run scale-to-zero + Cloudflare Pages) without treating public MLB/Savant as free bandwidth. Conscious limits — not missing infrastructure:
| Choice | Why it stays this way | Revisit when |
|---|---|---|
| In-process TTL cache + singleflight | No Redis/Memorystore cost; concurrent misses coalesce per process; hit/miss/coalesce + latency histograms show up on GET /metrics (SLOs). Prove locally with make load-smoke. |
Warm instance count or cold-start stampede forces a shared L2 |
| Per-process MLB/Savant QPS caps | Predictable outbound load without a global rate coordinator | Aggregate max-instances × QPS risks upstream 429s |
CLOUDRUN_MAX_INSTANCES default 2 |
Caps spend and the outbound budget multiplier | Traffic needs more capacity and a shared cache/limiter story |
CLOUDRUN_MIN_INSTANCES default 0 |
Idle ≈ $0; cold starts refill the in-memory cache | Latency SLOs require a warm process (docs/slo.md) |
Residual risks (per-process QPS × instances, in-memory-only stampede) are spelled out in docs/threat-model.md. Informal latency/hit-ratio targets: docs/slo.md. Deploy knobs: Deployment.
The same picture as the table above, drawn out: the browser only ever calls our API, and only the API talks to MLB or Savant.
%%{init: {'theme':'dark'}}%%
flowchart LR
subgraph ui["Browser"]
SPA["React SPA<br/>Recharts · neon chrome"]
end
subgraph api["Go API · chi"]
SRV["Handlers + cache<br/>rate limit · QPS caps"]
end
subgraph up["Upstream"]
MLB["MLB Stats API<br/>JSON"]
SAV["Baseball Savant<br/>CSV / Statcast"]
end
SPA -->|"GET /api/*"| SRV
SRV -->|"most routes"| MLB
SRV -->|"Statcast only"| SAV
In plain English: cache hits answer in single-digit milliseconds; concurrent cache misses share one upstream call instead of each triggering its own, so latency stays flat as concurrent traffic grows rather than degrading.
Warm cache-hit p95: 4.8ms (target < 100ms) · cold-miss latency stays flat as concurrency scales to 500 clients, proving singleflight coalescing rather than serialized upstream calls — measured 2026-10-12. Cold numbers reflect a fixture-only injected test delay, not real-world timing — see the full sweep and methodology.
| SPA area | Primary upstream | Notes |
|---|---|---|
| Standings, Leaders, Teams, Players, Games slate / boxscore / timeline | MLB Stats API (JSON) | Typical path below |
| Game Statcast (spray / launch metrics) | Savant CSV | Optional parallel MLB /schedule for venue only — MLB failure does not fail the request |
| Game Statcast pitches (location / type) | Savant CSV only | Shared Savant download cache with batted-ball Statcast (savant-csv:{gamePk}) |
Most of the app: JSON in, JSON out. Go adds cache keys, rate limits, and upstream timeouts.
%%{init: {'theme':'dark'}}%%
sequenceDiagram
autonumber
participant SPA as React SPA
participant API as Go API chi
participant MLB as MLB Stats API
SPA->>+API: GET /api/…
API->>MLB: proxied GET (TTL cache)
MLB-->>API: JSON
API-->>-SPA: 200 + JSON
Game Statcast panels do not follow the MLB-primary path. Savant CSV is the source of truth; both Statcast endpoints share one cached CSV fetch per gamePk.
%%{init: {'theme':'dark'}}%%
sequenceDiagram
autonumber
participant SPA as React SPA
participant API as Go API chi
participant SV as Savant CSV
participant MLB as MLB Stats API
SPA->>+API: GET /api/games/{gamePk}/statcast…
par Required
API->>SV: Statcast Search CSV (TTL cache)
SV-->>API: CSV rows
and Optional (batted-ball endpoint only)
API->>MLB: GET /schedule?gamePks=… (venue)
MLB-->>API: JSON or skip on error
end
API-->>-SPA: 200 + JSON (parsed Statcast)
Note over API,MLB: Pitches endpoint skips MLB entirely
Global shell only (bottom → top). This stack does not include chart series colors.
Arrows = stacking narrative for the shell, not layout or data flow.
%%{init: {'theme':'dark'}}%%
flowchart BT
void["#070b10<br/>void"] --> surface["#0a1018<br/>surface"] --> teal["#00f5c4<br/>--accent"]
style void fill:#070b10,stroke:#0f1e2d,color:#8b9cad
style surface fill:#0a1018,stroke:#0f1e2d,color:#c8d8e8
style teal fill:#0a1018,stroke:#00f5c4,color:#00f5c4
Team-branded series are keyed by team id from the API — not the three-node shell stack above.
| Step | Module | Role |
|---|---|---|
| Obsidian pairs | mlbTeamObsidianRegistry.ts |
Ink / label pairs tuned for dark charts |
| Brand resolve | mlbTeamColors.ts |
MLB primaries/secondaries, comparison fallbacks, distinctness |
| Contrast | chartColorContrast.ts |
Nudge colors vs the plot surface |
| Non-team palette | neonChartPalette.ts |
Teal / violet / pink-tinted series helper (unit-tested; app charts today use the team path above) |
%%{init: {'theme':'dark'}}%%
flowchart LR
ID["Team id(s)<br/>from API"] --> pick["Registry + / or<br/>brand palette"]
pick --> adj["Contrast vs<br/>chart surface"]
adj --> R["Recharts<br/>fills · strokes"]
Defined on html in frontend/src/styles/_base.scss. Update these tables when values change.
Typography — font stacks on html (full fallbacks in the SCSS file):
| Variable | Face | Use |
|---|---|---|
--sans / --heading |
DM Sans | UI and headings |
--mono |
Space Mono | Numerals and axis ticks |
| CSS variable | Hex / value | Role |
|---|---|---|
--bg |
#070b10 |
Deepest background |
--surface |
#0a1018 |
Panels and cards |
--text |
#8b9cad |
Body (AA vs --bg) |
--text-h |
#c8d8e8 |
Headings, code foreground |
--muted |
#5a6b7c |
Secondary labels |
--border |
#0f1e2d |
Shell edges, dividers |
--code-bg |
#0d121a |
Inline code background |
| CSS variable | Hex / value | Role |
|---|---|---|
--chart-tick |
#4a5f72 |
Default axis tick color |
--chart-grid-faint |
#0d1a26 |
Faint grid lines |
--chart-y-mid |
#0f2030 |
Reference band (e.g. 50% win line) |
--chart-y-mid-tick |
#1a3a30 |
Tick on that reference |
| CSS variable | Hex / value | Role |
|---|---|---|
--accent |
#00f5c4 |
Teal neon — links, active chrome, primary chart emphasis |
--accent-bg |
rgba(0,245,196,0.08) |
Soft teal wash on surfaces |
--accent-border |
rgba(0,245,196,0.45) |
Focus / selected borders |
--shadow |
layered rgba blacks |
Panel depth (see file) |
| Layer | Technology |
|---|---|
| Frontend | React 19, TypeScript 6.0, Vite, React Router, Recharts, ESLint, Prettier |
| Backend | Go 1.26, chi, TTL cache, per-IP rate limit, inbound body size cap, token-bucket QPS caps (MLB + Savant) |
| Data | MLB Stats API v1 (JSON); Baseball Savant (CSV) for Statcast game data |
In plain English: every push to main and every non-draft pull request runs required
frontend, backend, and lint-backend checks; draft PRs skip CI until marked ready for review.
Local parity: make ci-local.
Full CI reference — required gates, optional jobs, triggers, Dependabot (expand)
| Job | Checks |
|---|---|
| Frontend | make check-stack-docs, OpenAPI lint (api:validate), type drift (api:types:check), ESLint, Prettier (format:check), TypeScript, npm audit (high+), Vitest + V8 coverage, production build |
| Backend | go vet, govulncheck, go test -race, go test + coverage (Cobertura via gocover-cobertura), go build |
| lint-backend | golangci-lint (errcheck, staticcheck, govet, and more) on backend/** — see make lint-backend |
| Job | What it does |
|---|---|
| e2e | Playwright: stub smoke + deep-link/density matrix (vite preview + stubbed /api), contract path (real Go API + fixture MLB/Savant), and chaos path (fixture PUT /_chaos → 429/5xx/slow) |
| sbom | Syft SPDX SBOM of the repo — uploaded as a workflow artifact |
| Trigger | Behavior |
|---|---|
| Draft PR | CI skipped until Ready for review |
| PR (ready) | Path filters skip heavy frontend/backend/lint-backend steps when that side’s paths are unchanged (jobs still report green for required checks — this is why lint-backend keeps its own in-job filter rather than being skipped entirely like the optional jobs below). Optional e2e / sbom are skipped entirely when their paths are unchanged (via Detect optional CI paths; not a green no-op) |
Push to main |
Required frontend/backend/lint-backend gates always run fully. Optional e2e / sbom skip entirely when paths are unchanged. Deploy may also run when vars/secrets are set — see Deployment |
| Cloudflare PR preview | Workflow starts only when frontend/**, .nvmrc, or the preview workflow change; job still skips drafts, Dependabot, and forks |
| Workflow / job | Role |
|---|---|
| PR guide | On open / reopen / ready-for-review: scaffolds empty/default PR description (verify commands, Touches), sticky checklist comment, area:* labels from changed paths (does not re-run on every push). Authors/agents should still write a why-first Summary. |
| Coverage comments | Frontend and Backend each post their own Cobertura coverage comment directly after their own tests run (same-repo PRs only; does not run on main) — no separate combining job or artifact hand-off |
| Pages preview | Builds SPA with VITE_API_BASE → Cloudflare branch preview; does not run at all for non-SPA path PRs |
| Lighthouse (job in pages preview) | After the preview deploys, audits that live URL with Lighthouse CI and comments the report link; warn-level thresholds in .lighthouserc.json, non-blocking |
| Accessibility (job in pages preview) | After the preview deploys, scans a handful of routes on that live URL with axe-core via Playwright and comments a violation summary; informational only for now, does not block merge |
| Preview cleanup | Deletes preview deployments when the PR is closed or merged |
Fork PRs may skip guide, coverage comments, or previews (GITHUB_TOKEN / secrets limits); those steps are non-blocking or skipped.
.github/dependabot.yml opens daily version PRs for:
- Go modules (
backend/— minor/patch grouped, plus a grouped security-update bucket) - npm (
frontend/— minor/patch grouped, plus a grouped security-update bucket) - GitHub Actions (ungrouped)
The grouped npm/gomod minor/patch PRs, and grouped security-update PRs at patch/minor level, auto-merge on their own once required CI passes (dependabot-auto-merge.yml). Everything else — GitHub Actions bumps, any ungrouped npm/gomod major bump, and major-level security updates — still needs a human merge; a weekly scheduled Claude Code routine reads each one's changelog and required CI to classify risk and post a report, but only merges PRs a person names explicitly (see dependabot-triage for the logic it follows, and AI-assisted development & automation for the full picture of what runs unattended here vs. on request).
It does not update README badges or Prerequisites. When a bump changes React / Vite / TypeScript / Go / CI Node majors (or TypeScript major.minor), update those docs in the same PR — CI’s make check-stack-docs catches drift. Review Dependabot PRs like any other change (govulncheck and npm audit still gate merges).
Also enable Dependabot alerts and Dependabot security updates under GitHub Settings → Code security for advisory fix PRs outside the daily cadence.
In plain English: the grouped npm/gomod minor/patch and grouped patch/minor security-update Dependabot PRs merge themselves once CI is green (see CI & quality gates → Dependabot above); everything riskier — GitHub Actions bumps, npm/gomod majors — still needs a person. The other piece runs outside this repo entirely and never writes to it: a scheduled Claude Code routine, defined in danibsheehan/portfolio-automation's weekly-project-update skill, reads this repo once a week alongside three others and — only when there's something people-relevant to report — opens a PR against danibsheehan.github.io updating this project's page.
See AI-assisted development & automation for the full table of what runs unattended here vs. on request, and portfolio-automation's README for the cross-repo autonomy boundary.
| Concern | Path |
|---|---|
| Shell / routes | frontend/src/App.tsx, _shell.scss |
| Global theme | _base.scss; feature SCSS under frontend/src/styles/features/ |
| Pages (routes) | frontend/src/pages/ |
| Pages (Cloudflare) | frontend/public/_redirects (SPA fallback), frontend/public/_headers (CSP / nosniff / frame / referrer) |
| SEO | frontend/public/robots.txt, frontend/public/sitemap.xml (static top-level routes only); description / Open Graph / Twitter card meta in frontend/index.html |
| API client | frontend/src/api/client.ts — VITE_API_BASE or /api in dev |
| Types | frontend/src/types/api.generated.ts, frontend/src/types/api.compat.ts |
| Backend | backend/ — chi, MLB + Savant clients, backend/apidocs/openapi.yaml |
| Docs home | docs/ — map, glossary, ADRs, SLOs, threat model, configuration, deploy |
| README art | docs/readme-banner.svg, docs/badge-live.svg |
| Load / coalesce proof + latency sweep | scripts/load-smoke.sh — make load-smoke (fixture upstream, no live MLB) |
backend/ # Go HTTP API, MLB + Savant clients, handlers, models
frontend/ # React SPA (src/, Vite, Vitest, Playwright e2e/)
docs/ # ADRs, SLOs, threat model, configuration, deploy
.vscode/ # Editor: format on save (Prettier), recommended extensions
Makefile # install, dev, backend, frontend, ci-local, test-*, cover-*
- Go 1.26+ — builds the API
- Node.js 22+ and npm — builds the site (CI uses Node 22; pin with
.nvmrc/nvm use)
You do not need cloud accounts to explore locally.
VS Code / Cursor: format-on-save for frontend/ via Prettier. Install the recommended Prettier extension when prompted; accept workspace settings if asked.
| What | Where |
|---|---|
| Format on save | .vscode/settings.json — Prettier for TS/TSX/JS/JSON/SCSS/HTML under frontend/ |
| Prettier config | frontend/prettier.config.js |
| EditorConfig | .editorconfig — indent, charset, newlines (Go 4-space; Makefile tabs) |
| Node pin | .nvmrc — Actions and local nvm use |
| Agent conventions | AGENTS.md's Prettier formatting subsection — npx prettier --write on changed files |
CI still runs npm run format:check.
From the repository root:
make install # once: Go modules + frontend npm dependencies
make dev # API on :8080 + Vite dev server (with /api proxy)make install also sets up a Husky pre-commit hook (.husky/pre-commit) that runs
lint-staged, formatting staged frontend/ files with Prettier before each commit — the
same check npm run format:check enforces in CI.
- API: http://127.0.0.1:8080 —
GET /health(liveness →ok),GET /ready(process invariants →ready, never probes MLB); responses includeX-Request-ID(SPA surfaces it on API errors);GET /metricsexposes Prometheus text (see docs/slo.md);GET /docs/GET /openapi.yamlserve the contract. Ops paths are documented in OpenAPI (tag Ops). Prove singleflight under concurrency withmake load-smoke. - Frontend: Vite (typically http://localhost:5173) proxies
/apito the backend so the browser uses same-origin/apiin development.
Run services separately if you prefer:
make backend # Go API only
make frontend # Vite only (expects API on 127.0.0.1:8080 for `/api`)| Command | Purpose |
|---|---|
npm run dev |
Dev server |
npm run build |
Production bundle |
npm run preview |
Preview production build |
npm run lint |
ESLint |
npm audit --audit-level=high |
Fail on high/critical advisories (CI) |
npm run format |
Prettier — write (src/types/api.generated.ts is ignored; run api:types separately) |
npm run format:check |
Prettier — check only (CI) |
npm run typecheck |
TypeScript --noEmit |
npm run api:validate |
Lint OpenAPI (backend/apidocs/openapi.yaml) |
npm run api:types |
Generate src/types/api.generated.ts from OpenAPI |
npm run api:types:check |
Regenerate + fail if api.generated.ts is stale |
npm run test |
Vitest (watch mode) |
npm run test:run |
Vitest once (matches CI) |
npm run test:coverage |
Vitest once with V8 coverage (frontend/coverage/, open index.html) |
npm run test:e2e |
Playwright Chromium stub smoke + matrix (vite build + preview; stubbed /api) |
npm run test:e2e:contract |
Playwright contract path (Go API + e2e-upstream fixtures; no live MLB) |
npm run test:e2e:chaos |
Playwright degradation path (fixture upstream PUT /_chaos → 429/5xx/slow) |
| Concern | Detail |
|---|---|
| Unit tests | Vitest (jsdom) + Testing Library + @testing-library/jest-dom (frontend/src/test/setup.ts) |
| Mocking | Prefer mocking frontend/src/api/client over the real API |
| Browser smoke | frontend/e2e/ (Playwright) — stub smoke + deep-link/density matrix: npm run test:e2e / make test-e2e; contract: npm run test:e2e:contract / make test-e2e-contract; chaos (fixture 429/5xx/slow): npm run test:e2e:chaos / make test-e2e-chaos; install once: npx playwright install chromium |
| Step | Command / path |
|---|---|
| Spec source | backend/apidocs/openapi.yaml |
| Live Redoc | docs.caught-looking.com |
| Validate | cd frontend && npm run api:validate |
| Generate types | cd frontend && npm run api:types |
| App-facing types | frontend/src/types/api.compat.ts (from api.generated.ts) |
| Docs deploy | .github/workflows/openapi-pages.yml — Redoc from main when the OpenAPI file changes |
| Target | Purpose |
|---|---|
make ci-local |
Full local parity with GitHub Actions CI (stack docs + frontend + backend jobs) |
make ci-local-frontend |
Frontend CI job only (audit, OpenAPI, lint, format, typecheck, coverage gate (see docs/coverage.md), build) |
make ci-local-backend |
Backend CI job only (vet, govulncheck, race, coverage gate (see docs/coverage.md), build) |
make check-stack-docs |
README / AGENTS.md versions match package.json, go.mod, .nvmrc, and the Dockerfile Go tag |
make check-openapi |
Lint OpenAPI + verify generated frontend API types are current |
make test-backend |
Faster backend subset: go vet, govulncheck, tests, build |
make test-backend-race |
Backend tests with the race detector |
make test-frontend |
npm run test:run in frontend/ |
make test-e2e |
Playwright Chromium stub smoke (frontend/e2e/; stubbed /api) |
make test-e2e-contract |
Playwright against Go API + fixture upstream (cmd/e2e-upstream) |
make test-e2e-chaos |
Playwright upstream chaos (429/5xx/slow via PUT /_chaos) |
make load-smoke |
Concurrent /standings burst across a 10/40/100/500 sweep; asserts coalesce + warm hits and reports cold/warm p50/p95 latency via /metrics |
make cover-backend |
Go coverage summary (backend/coverage.out) |
make cover-backend-html |
Same + backend/coverage.html |
make cover-frontend |
Vitest coverage report under frontend/coverage/ |
| Where tests live | Convention |
|---|---|
| Backend | *_test.go next to packages under backend/ — .claude/skills/backend-go-tests/SKILL.md |
| Frontend | *.test.ts / *.test.tsx next to sources — .claude/skills/frontend-vitest-tests/SKILL.md |
Env vars for the API and SPA (listen address, cache TTLs, rate limits, VITE_API_BASE, and more) live in docs/configuration.md. Defaults work for local make dev; change them when deploying or practicing limits. Cache/QPS rationale stays in the ADRs.
Production ship path (Cloud Run + Cloudflare Pages), GitHub variables/secrets, one-time cloud setup, and rollback notes: docs/deploy.md.
This project is developed with Claude Code. Conventions (backend Go, React, OpenAPI contract,
BEM naming, and more) live directly in AGENTS.md / CLAUDE.md —
there are no separate .cursor/rules/*.mdc files. .cursor/skills is kept only as a symlink to
the canonical .claude/skills/ directory, for compatibility if this repo is
opened in Cursor.
| Path | Purpose |
|---|---|
.claude/skills/*/ |
Repo-local canonical skills — add-api-endpoint, adr-doc-sync-check, backend-go-tests, backend-http-security, caching-and-upstream-perf, doc-sync-patch, frontend-vitest-tests, local-ci-parity, openapi-maintain, venue-data-sync — .cursor/skills symlinks here. Skills namespaced foundations:* come from the installed foundations plugin instead of living here — see AGENTS.md. |
| Editor / formatting config | See Editor setup above for Prettier / EditorConfig details. |
MIT — use it, fork it, learn from it.