How a screen gets its numbers
Fetch layer → frozen wire contracts → view models → components, with no shortcuts.
Every number on every screen travels one pipeline, and each stage has exactly one job:
1. Fetch layer
src/lib/api/client.ts (postflop) and src/lib/api/preflop-client.ts
(preflop) are the only modules that talk to the network. Both read
OLLIN_STUDY_API_URL (trailing slash stripped) and share one apiGet
pattern: return the parsed body, or null on any failure — env unset,
non-2xx, or a network throw. Every fetcher then decides between live data and
the mock fallback, and every result is tagged
{ data, source: "live" | "mock" } so pages can raise the demo ribbon.
Solve data is immutable per dataset stamp, so caching is generous
(next: { revalidate } per request):
| Request | Revalidate |
|---|---|
GET /v1/families (catalog) | 300s |
GET /v1/families/{fam} (detail) | 300s |
GET /v1/families/{fam}/boards/{board} (node document) | 3600s |
GET /v1/preflop (catalog) | 300s |
GET /v1/preflop/{depth} (depth pack) | 3600s, plus an in-process 1h memo |
The aggregate flop report has no endpoint yet: fetchFamilyAggregate fans out
over the per-board endpoint with bounded concurrency (limit 12) and builds the
report client-side from each document's server-sent grid169. Failed boards
become mix: null — rendered as gaps, never fabricated.
2. The contract mirror
The API's wire contracts are frozen v1 documents, defined once in the API repo and mirrored file-for-file client-side:
| API schema (source of truth) | App mirror |
|---|---|
ollin-study-api/schema/node-document.v1.ts | src/lib/api/contract.ts |
ollin-study-api/schema/preflop.v1.ts | src/lib/api/preflop-contract.ts |
Breaking changes require a v2 document and a new schema file — v1 is never mutated in place. The two contracts are documented in full on the next pages: NodeDocument v1 and Preflop Pack v1.
3. View models
src/lib/api/view-model.ts and preflop-view-model.ts derive everything
display-side from wire documents: action labels and colors, display ordering,
169-class grouping, lineage, pot geometry, URL state. Components never touch
wire shapes — a component that needs a new number gets it from a view model,
which derives it from the contract. Details:
View models.
4. Components
Server components (src/app/(product)/study/…) call the fetch layer, build
view models, and pass plain view objects into client components
(src/components/study/*, src/components/preflop/*). Client-side state is
interaction only (hover, pin, filters, search); data state lives in the URL
(?depth=, ?line=) and the server render.
Why this shape
- One derivation per number. The 13×13 grid is aggregated by the server
(
grid169); the client renders it and never recomputes it from per-combo data when a live document is present. - Honest fallback. Every page knows whether it rendered live or mock data and shows the ribbon accordingly — there is no path where fabricated numbers render unlabeled.
- Contract-first evolution. The tree endpoint already exists (as a typed 501), the UI already renders its slots (locked). When coverage arrives, nothing above the fetch layer changes.