Live demo: https://sapa-dashboard.vercel.app
A real-time analytics dashboard for SAPA, a local FastAPI spaced-repetition tracker. Built as a 0→1 TypeScript + React learning project against a real backend I already owned.
The Vercel deployment runs in demo mode with a baked snapshot of real SAPA analytics and a real Claude-generated insights response — the frontend code path is identical, just short-circuited before the network call so there's no need to expose a local backend. Clone the repo and run it against your own SAPA instance (see below) to see it hit the live API.
The point was to build a production-quality TS + React frontend in a few focused sessions, hitting every major pattern Ashby-class and PostHog-class codebases care about, while shipping something I actually use.
- Pulls a rich analytics payload from SAPA's
/api/analyticsendpoint and renders it as six dashboard widgets: streak stats, confidence distribution, due-reviews list, weekly activity chart (hand-rolled SVG), sortable topics explorer, and an AI insights card - Validates every network response at runtime with Zod so shape drift surfaces as a readable error immediately rather than crashing three renders later
- Surfaces a custom AI Insights widget that POSTs the current learning state to
/api/ai/insightson the backend, which in turn calls Claude Sonnet 4.5 with a coaching system prompt and returns 2–3 personalized, actionable suggestions - Handles loading, error, and retry states cleanly across every async surface, powered by a single generic
useAsyncData<T>hook and a discriminated-unionAsyncState<T>type - Covered by 21 passing tests: 7 Zod schema assertions on typed-as-
unknownfixtures, and 14 React Testing Library component tests using accessible role-based queries
| Layer | Choice | Why |
|---|---|---|
| Bundler | Vite 8 | Fast, ESM-native, Vitest reuses the same transform |
| Runtime | React 19 | Latest stable, auto JSX transform, automatic batching |
| Language | TypeScript in strict + noUncheckedIndexedAccess + exactOptionalPropertyTypes + verbatimModuleSyntax |
Senior-grade strictness from day one, not bolted on later |
| Runtime validation | Zod v4 with z.infer for type derivation |
Single source of truth: schemas drive types |
| Data fetching | Hand-rolled generic apiGet<T> / apiPost<T> with schema-constrained generics |
No React Query until the use case justifies it (YAGNI) |
| Styling | CSS Modules + CSS custom properties | Scoped, type-friendly, zero runtime cost |
| Charts | Hand-rolled SVG (no Recharts, no D3) | The whole chart is ~100 lines; a charting library would cost ~90KB for one visualization |
| Tests | Vitest + jsdom + React Testing Library + user-event | Schema tests + behavior-based component tests in one toolchain |
| Deployment | Vercel (free tier) with VITE_DEMO_MODE=true |
Hosted demo without exposing the local backend |
| Backend | SAPA — FastAPI, SQLite, watchdog, Anthropic Python SDK | Already built; this project talks to it |
┌────────────────────────────┐ ┌──────────────────────────┐
│ sapa-dashboard (this) │ │ sapa-public (FastAPI) │
│ │ │ │
│ App.tsx │ │ /api/analytics │
│ └─ useAsyncData<T> │◀──────▶ │ /api/topics │
│ └─ apiGet<T> │ HTTP │ /api/ai/insights ──────┼──▶ Claude
│ └─ zod parse │ │ │
│ │ │ SQLite + watchdog │
│ ┌──────────────────────┐ │ │ spaced-repetition core │
│ │ StreakCard │ │ │ │
│ │ ConfidenceBreakdown │ │ └──────────────────────────┘
│ │ DueReviewsList │ │
│ │ WeeklyActivityChart │ │
│ │ TopicsExplorer │ │
│ │ InsightsCard ────────┼─┘
│ └──────────────────────┘
└────────────────────────────┘
If you're evaluating this code, these are the bits I'd point at first:
- Discriminated union for async state (
src/types.ts+src/useAsyncData.ts) —AsyncState<T>is a four-variant union with astatusdiscriminant. Every switch inApp.tsxandInsightsCard.tsxuses exhaustiveness checking viaconst _exhaustive: never = statein the default branch. Adding a fifth variant would cause a compile error at every incomplete switch. - Schema-first types (
src/types.ts) —z.infer<typeof AnalyticsSchema>derives the TS type from the zod schema. There is no way to drift the type and the validator. Rename a field, everything updates. - Generic typed API client (
src/api.ts) —apiGet<T>(path, schema: z.ZodType<T>)usesz.ZodType<T>as the constraint so a call site likeapiGet('/api/analytics', AnalyticsSchema)infersPromise<Analytics>with zero explicit type arguments. Same forapiPost<T>. Adding a new endpoint is one line — seefetchTopics(). - Generic custom hook (
src/useAsyncData.ts) —useAsyncData<T>(fetcher: () => Promise<T>)returns{ state: AsyncState<T>, refetch }.refetchis memoized withuseCallback([]), uses aversioncounter and a cancellation flag to prevent stale state updates on unmount or race. Sets loading state in the event handler rather than inside the effect to avoid thereact-hooks/set-state-in-effectsmell. - Zod at the boundary, cast-free (
src/api.ts) — every network response goes throughschema.safeParse()withz.prettifyError()error messages. Noas Tcasts. Runtime-invalid responses surface as readable errors in the UI's error state. - Typed column config for sortable tables (
src/components/TopicsExplorer.tsx) —SortKeyis a discriminated string-literal union,Columnis a readonly record with per-columnformat(topic)functions, andcompareTopics()dispatches on runtime value type (numeric subtraction for numbers,localeComparefor strings — which happens to be the right sort for ISO 8601 date strings too). Adding a column is one record; typos are caught at compile time. - Hand-rolled typed SVG chart (
src/components/WeeklyActivityChart.tsx) — pure TS + SVG with aviewBoxfor responsive scaling,<title>elements per bar for free hover tooltips and screen-reader access, and module-level constants for layout math. No charting library dependency. Record<keyof T, V>label maps (src/components/ConfidenceBreakdown.tsx) — links UI labels to schema keys at compile time. Add a new confidence bucket and TS forces you to add its label.readonly T[]prop contracts (src/components/DueReviewsList.tsx,TopicsExplorer.tsx,WeeklyActivityChart.tsx) — widgets can't mutate their inputs. TS stripspush/sort/etc. at the type level.- Module-scope
Intl.DateTimeFormat(src/components/DueReviewsList.tsx,TopicsExplorer.tsx) — expensive locale-aware formatters created once, not on every render. - Behavior-based component tests (
src/components/*.test.tsx) — every RTL test queries by accessible role (getByRole('button', { name: /generate/i })) rather than bydata-testidor CSS class, so tests constrain behavior, not structure. Refactors that don't change user-visible behavior leave them passing. - Import-after-mock for ESM-safe mocking (
src/components/InsightsCard.test.tsx) — usesvi.mock('../api', …)followed byconst { InsightsCard } = await import('./InsightsCard')so the component binds to the mock, not the real module. Subtle but important: a normal top-levelimportwould resolve before the mock is registered.
You'll need SAPA running on port 8002 (see sapa-public — CORS is pre-configured for localhost:5173).
# Start SAPA backend in one terminal
cd ~/dev/sapa-public
PYTHONPATH=. .venv/bin/python -m sapa.app --port 8002
# Start the dashboard in another
cd ~/dev/sapa-dashboard
npm install
npm run devOpen http://localhost:5173.
For the AI Insights widget, SAPA needs a valid ANTHROPIC_API_KEY in its env (either export or ~/dev/sapa-public/.env — python-dotenv is installed).
Set VITE_DEMO_MODE=true to short-circuit every fetch to baked real-data fixtures (snapshots of real SAPA responses and a real Claude-generated insights response). The frontend code path is identical — only the network call is bypassed. Used by the hosted Vercel deployment so the live URL always works without exposing a local backend.
VITE_DEMO_MODE=true npm run devYou can also override the API base URL with VITE_API_BASE if SAPA is running somewhere other than http://localhost:8002.
Set VITE_POSTHOG_KEY to enable event tracking, autocapture, session replay, and feature flags. The deployed demo uses this to dogfood PostHog on a dashboard built against a PostHog-stack-alike backend. Without the env var set, trackEvent() calls silently no-op on the PostHog side and continue to populate the in-app Dashboard Analytics widget as before.
VITE_POSTHOG_KEY=phc_xxx VITE_DEMO_MODE=true npm run devSee .env.example for all supported variables.
| Command | What it does |
|---|---|
npm run dev |
Dev server with HMR |
npm run build |
Production build (typecheck + bundle) |
npm run typecheck |
tsc --noEmit — types only, no bundle |
npm run lint |
ESLint — TS-aware, react-hooks rules |
npm test |
Vitest in watch mode |
npm test -- --run |
Vitest one-shot (for CI) |
src/
├── api.ts # apiGet/apiPost + fetchAnalytics/fetchTopics/fetchInsights
├── types.ts # zod schemas + z.infer types + AsyncState<T>
├── demo.ts # baked real-data fixtures for VITE_DEMO_MODE
├── useAsyncData.ts # generic fetch hook with refetch + cancellation
├── App.tsx # top-level composition + discriminated-union switch
├── App.module.css # app shell, header, grid, loading states
├── index.css # design tokens + global resets
├── main.tsx # React 19 createRoot entry
├── test-setup.ts # RTL cleanup + jest-dom matchers
├── components/
│ ├── StreakCard.tsx # 2x2 stat grid over Overview
│ ├── ConfidenceBreakdown.tsx # color-coded progress bars with % of total
│ ├── DueReviewsList.tsx # typed readonly list with empty state
│ ├── WeeklyActivityChart.tsx # hand-rolled SVG bar chart
│ ├── TopicsExplorer.tsx # self-fetching sortable table, all 89 topics
│ ├── InsightsCard.tsx # AI Insights widget (self-managed async state)
│ ├── Card.module.css # shared card chrome
│ ├── WeeklyActivityChart.module.css # chart-specific styles
│ ├── TopicsExplorer.module.css # table + scroll styles
│ ├── InsightsCard.module.css # accent-tinted widget styles
│ ├── StreakCard.test.tsx # 3 RTL tests
│ ├── ConfidenceBreakdown.test.tsx # 3 RTL tests
│ ├── DueReviewsList.test.tsx # 4 RTL tests
│ └── InsightsCard.test.tsx # 4 RTL tests with vi.mock
├── types.test.ts # 7 zod schema assertions
└── __fixtures__/
└── analytics.ts # typed-as-unknown fixture variants
I kept the scope tight. Things I considered and declined until there's a concrete need:
- No React Query / SWR — one generic hook covers every call site so far
- No state management library — every widget either takes narrow props or manages its own async state
- No react-router — single view; routing gets added the moment I add a second page
- No component snapshot tests — behavior-based RTL tests instead, never snapshots
- No charting library — the one chart is pure SVG, ~100 lines, ~2KB gzipped
- No barrel
index.tsfiles — they complicate tree-shaking and don't help readability at this scale - No CSS-in-JS runtime — plain CSS Modules are cheaper, faster, and the TS story is fine
- No client-side routing layer for the demo —
VITE_DEMO_MODEflips at build time, not at request time
When VITE_POSTHOG_KEY is set, this dashboard sends autocaptured interaction events and session replays to PostHog. No SAPA content (topic text, session notes, AI insight responses) is sent — only dashboard-usage telemetry (clicks, profile switches, timings).
MIT, same as SAPA.
