A restaurant operations dashboard — ordering, menu, CRM and settings for a single venue.
The rail is the bar above a kitchen pass where order chits hang for the line to work through. That is what the Orders screen is.
The demo has no auth: anyone can place orders, move them through the lifecycle and edit the menu. A scheduled job reseeds it nightly, so it is always the same venue by morning.
The point is not that it works. It is how it is put together: one source of truth for every persisted shape, a contract that is generated rather than duplicated, a backend with real opinions about what it will accept, and a design system that a second engineer could build a sixth page from without inventing anything.
apps/dashboard Expo + React Native Web, expo-router (runs on web, native-ready)
services/backend Hono + @hono/zod-openapi on Cloudflare Workers
packages/types The order lifecycle. Plain tuples, no dependencies.
packages/shared Design tokens, UI primitives, formatting utilities
packages/api-client Orval-generated types and React Query hooks
Prerequisites: Node 22.13+ (the pinned pnpm requires it), pnpm 11 (the repo pins pnpm@11.22.0 via packageManager), and Docker — or a PostgreSQL 16 you can reach.
pnpm install && pnpm setup
pnpm devpnpm setup writes services/backend/.dev.vars, starts the Postgres in
docker-compose.yml, waits for it, then migrates and seeds. It is safe to
re-run. pnpm dev starts the backend on :8787 and the dashboard on
:8090 together, via turbo.
The same thing, one step at a time
docker compose up -d
cp services/backend/.dev.vars.example services/backend/.dev.vars
pnpm db:migrate
pnpm db:seed
pnpm dev:backend # Hono on http://localhost:8787
pnpm dev:dashboard # Expo web on http://localhost:8090Own Postgres instead of the compose file? Put its connection string in
services/backend/.dev.vars and run SKIP_DOCKER=1 pnpm setup.
Open http://localhost:8090. There is a UI Library link at the bottom of the sidebar — that route is the design system, rendered.
Using your own Postgres instead of the compose file? Create the role and databases the default connection string expects:
CREATE ROLE rail WITH LOGIN PASSWORD 'rail' CREATEDB;
CREATE DATABASE rail_dev OWNER rail;
CREATE DATABASE rail_test OWNER rail;DATABASE_URL is an ordinary Postgres connection string, so a Neon URL works in
production with no code change.
pnpm db:seed builds a plausible venue: 6 menu categories, 29 items (2 of them
switched off, so the "unavailable" rules have something to bite on), 20
customers, and ~80 orders spread across 30 days.
It is deterministic — a seeded PRNG, not Math.random() — so two runs produce
the same numbers and a screenshot still matches the app tomorrow. Statuses and
channels for recent orders are dealt round-robin rather than sampled, because a
random draw reliably leaves two or three status filters empty and anyone
clicking "Preparing" would find nothing there.
pnpm db:reset drops everything and rebuilds from scratch.
pnpm setup # .dev.vars + Postgres + migrate + seed. Re-runnable.
pnpm dev # backend + dashboard together, via turbo
pnpm dev:dashboard
pnpm dev:backend
pnpm gen:contract # Drizzle -> OpenAPI -> Orval. Run after touching a table or route.
pnpm build # static web bundle -> apps/dashboard/dist
pnpm lint
pnpm typecheck
pnpm test # everything, including integration tests (needs Postgres)
pnpm test:unit # the database-free subset
pnpm db:migrate | db:seed | db:reset | db:generate | db:studioEverything hangs off this. A shape is written down once and everything downstream is derived from it:
packages/types/src/order-status.ts ORDER_STATUSES tuple + transition map
↓ imported by
services/backend/src/db/schema.ts pgEnum('order_status', ORDER_STATUSES)
↓ drizzle-zod
services/backend/src/contracts/*.ts createSelectSchema → .openapi() components
↓ pnpm gen:contract
services/backend/openapi.json emitted from the live route definitions
↓ orval
packages/api-client/src/generated/ TypeScript models + React Query hooks
↓
apps/dashboard/** imports only from @rail/api-client
The order status union is declared exactly once, in packages/types, as a plain
const tuple. That tuple is what pgEnum is built from, which is what
drizzle-zod reads, which is what lands in the OpenAPI document as an enum, which
is what Orval turns into a TypeScript union. The frontend never imports the
union directly — it gets every API shape from generated code.
packages/types also holds the two pure functions both ends genuinely share: the
transition map (the server enforces it, and it is what availableActions is
computed from) and calculateOrderTotals (the server prices with it; the order
form previews with it). Neither is an API shape, so neither belongs in the
generated client — and duplicating them is how a quoted total ends up
disagreeing with a receipt.
Why a tuple in packages/types rather than the Drizzle schema? A pgEnum cannot
be imported by the frontend, and writing the union out a second time is exactly
the duplication this whole chain exists to prevent. The tuple is the one that can
reach both ends.
To prove the chain is live rather than a stale artifact: pnpm gen:contract
regenerates openapi.json and the whole client, and produces an empty git diff. Change a column type and it will not.
POST /api/orders takes item ids and quantities. It does not take prices,
totals, or a status — those are not the client's business:
- Totals are calculated server-side from live menu prices and the tax rate in
force, then written to the order. A payload carrying
totalCents: 1andunitPriceCents: 1is priced at the real menu price. - Unavailable and unknown items are rejected with a 422 that names them, so the dashboard can say "Steel-Cut Oats is not available right now" instead of "request failed".
- Ordering can be switched off. Settings is not decoration: with
orderingEnabledfalse the API returns 409 to every new order. - Auto-accept changes where an order enters the lifecycle —
acceptedinstead ofpending— which changes what staff are offered next. - Order and lines are written in one transaction. A failed validation writes nothing.
Staff act through named actions (accept, start_prep, mark_ready, …), never
by writing a status. POST /api/orders/{id}/transitions checks the action
against the transition map and returns 409 if it is illegal — with the
current status and the list of actions that would be legal:
{
"error": {
"code": "invalid_transition",
"message": "Cannot 'complete' an order that is pending.",
"details": {
"currentStatus": "pending",
"availableActions": ["accept", "reject", "cancel"]
}
}
}Every order the API returns carries a server-computed availableActions
array, and the dashboard renders its status buttons straight from it. Completing
an order makes the buttons disappear because the server stopped offering any —
not because a component knows that completed is terminal. The client has no
copy of the lifecycle to drift out of sync.
Money is integer cents everywhere. numeric round-trips through JSON as a
string and double precision loses pennies; integers do neither.
Order lines snapshot the item's name and unit price, and orders snapshot the tax rate. A restaurant edits its menu constantly, and last week's receipts must not silently re-price when tonight's prices change.
- Pages compose; they don't compute. Every screen's data and mutations live
in a colocated
use*hook —useOrdersScreenowns the filters,useMenuScreenowns the optimistic availability toggle,useSettingsFormowns the dirty-state save bar. The route files decide layout and nothing else. - One door to the network.
packages/api-client/src/http-client.tsis the single Orval mutator: base URL, JSON headers, query serialisation, and error unwrapping into a typedApiClientErrorthat carries the server's own message and code. No screen callsfetch. - Domain and design meet in one file.
@rail/sharedknows about tones, not order statuses.@rail/api-clientknows about statuses, not colours.src/lib/order-presentation.tsis the only place the two meet, so a status never picks up a colour inline in a screen. - Filters go to the API, not to a fetched array. A venue with 40,000 tickets should not be shipping all of them to the browser to filter three of them.
Tokens live in packages/shared/src/theme — colour, typography, spacing, radius,
borders, elevation, layout. No hex value or magic number appears outside that
directory. The visual direction is a light operations console: near-white
surfaces, one accent, colour reserved for order status so the things still
needing a human are the things that stand out.
Components are built on React Native primitives (View/Text/Pressable), so
the native-readiness bonus comes for free. Hover, focus, active and disabled are
resolved centrally in ui/interaction.ts — every Pressable gets all four
rather than each screen remembering to add them.
The /ui route was built before any product screen, deliberately. Every
primitive is proven there in all of its states first, so the pages compose from
components already known to work instead of growing one-off styles.
| Decision | Reason |
|---|---|
pg (node-postgres) over the Neon serverless driver |
Cloudflare's documented Workers driver, and it speaks to any Postgres. It runs against localhost in development; production points at Neon or Hyperdrive with no code change. |
| A connection per request | Workers have no process to pool in. Production would put Hyperdrive in front, which needs no change here. |
@hono/zod-openapi v1 + Zod 4 |
The only combination where drizzle-zod's output feeds OpenAPI generation directly. Zod 4 is required by @hono/zod-openapi v1 and supported by drizzle-zod. |
| Orval's custom-client mode | The fetch httpClient wraps every response in a {data, status, headers} union, so each call site would narrow before reading a field. The custom client returns the model and throws on error — ordinary React Query. |
| Internal packages export TypeScript source | No build step between packages. Metro compiles them for the dashboard, esbuild for the Worker. |
81 tests. Not exhaustive — targeted at the things that would actually break.
pnpm test # all 81 (the backend's 45 need Postgres)
pnpm test:unit # the 36 that need nothingDomain units (packages/types) — the transition map across every
status × action pair, and the pricing calculator including the rounding rule
(tax rounded once on the subtotal, never per line). Both live here because both
are used by the server and the dashboard.
Backend integration (services/backend/test/**) — the real Hono app against
a real Postgres, using the same createApp() the Worker exports. Every test in
that package is an integration test, which is why it has no test:unit script:
the pure logic it used to hold now lives in packages/types, where both ends
share it. Mocking Drizzle
would have hidden exactly the class of bug these exist to catch, and did: the
aggregates suite caught a correlated subquery that reported every menu category
as holding zero items — while still returning 200. Nothing about that was
visible from the types.
Drizzle renders column references inside
sqltemplates without table qualifiers, sowhere menu_items.category_id = menu_categories.idinside a subquery resolves the right-hand side againstmenu_itemsand silently matches nothing. It bit three separate aggregates — customer order counts, customer lifetime spend, and menu category item counts — each returning zero with a200. All of them useLEFT JOIN+GROUP BYnow.
Frontend — the presentation maps, iterated over the generated unions, so adding a status to the Drizzle enum fails a test rather than shipping a blank badge; and the order form's pricing preview, pinned to the same cases as the backend's pricing tests so the number an operator reads out matches the receipt.
List states — loading / empty / error precedence is extracted out of
DataTable into a pure resolveListState and tested exhaustively. Every list in
the product goes through it, so those cases are the empty and error behaviour of
Orders, Menu and CRM at once. The rule that matters: error beats empty, because
telling someone their order list is empty when the API is unreachable is a lie
that costs them a phone call.
This is deployed and running:
| Dashboard | https://rail-rosy.vercel.app (Vercel) |
| API | https://rail-api.rail-apikanjustinworkersdev.workers.dev (Cloudflare Workers) |
| Database | Neon Postgres, us-west-2 |
Three free tiers, no card required: Neon for Postgres, Cloudflare Workers for the API, Vercel for the dashboard. Roughly twenty minutes end to end if you are reproducing it.
The order below is deliberate — each step produces the URL the next one needs, so nothing has to be guessed and then corrected.
Create a project at console.neon.tech and copy the
pooled connection string (the one whose host contains -pooler). Then, from
this repo, point the migrate and seed scripts at it:
export DATABASE_URL="postgresql://...-pooler...neon.tech/neondb?sslmode=require"
pnpm db:migrate
pnpm db:seedDATABASE_URL is an ordinary connection string and the scripts prefer the
environment variable over .dev.vars, so this needs no code change and does not
disturb your local database.
cd services/backend
pnpm exec wrangler login
pnpm exec wrangler secret put DATABASE_URL # paste the Neon pooled URL
pnpm exec wrangler deployDeploy prints the URL: https://rail-api.<your-subdomain>.workers.dev. Check it:
curl https://rail-api.<your-subdomain>.workers.dev/api/health
# {"status":"ok","database":"connected"}If database comes back error, the secret is wrong or missing ?sslmode=require.
vercel.json at the repo root already sets the install command, the build
command, the output directory and the SPA rewrite, so the only manual part is two
environment variables. Import the repo at
vercel.com/new, leave the root directory as the repo
root, and set:
| Variable | Value |
|---|---|
EXPO_PUBLIC_API_URL |
https://rail-api.<your-subdomain>.workers.dev |
EXPO_PUBLIC_DEMO_MODE |
1 |
Both are read at build time, not runtime — Metro inlines EXPO_PUBLIC_* into
the bundle. Changing either one needs a redeploy, not just a restart.
apps/dashboard/.env sets the same variable locally, but it is gitignored and
never reaches Vercel; where both exist the real environment variable wins. The
export also runs with --clear, because Metro's transform cache keys on file
contents and not on environment variables — without it, a changed
EXPO_PUBLIC_API_URL can be silently ignored and the deployed bundle keeps
calling localhost.
Until this step the Worker reflects whatever origin asks. Pin it to the Vercel domain and redeploy the Worker — seconds, and it does not rebuild the frontend:
cd services/backend
pnpm exec wrangler deploy --var ALLOWED_ORIGINS:https://<your-app>.vercel.appNo trailing slash: the match is exact. To keep Vercel preview deployments working, pass a comma-separated list.
Verify the allowlist is actually doing something:
# the real origin is allowed
curl -sD- -o/dev/null -H "Origin: https://<your-app>.vercel.app" \
"https://rail-api.<sub>.workers.dev/api/health" | grep -i access-control-allow-origin
# anything else gets no header at all, and the browser blocks it
curl -sD- -o/dev/null -H "Origin: https://evil.example.com" \
"https://rail-api.<sub>.workers.dev/api/health" | grep -i access-control-allow-originThe demo has no auth, so anyone with the link can edit its data. .github/workflows/reseed-demo.yml
re-runs the deterministic seed every night at 09:00 UTC, which makes vandalism
self-healing and keeps screenshots matching. It needs one repository secret:
Settings → Secrets and variables → Actions → New repository secret, named
DATABASE_URL, set to the Neon pooled URL.
Run it once by hand from the Actions tab (Run workflow) to confirm it works rather than finding out at 2am. Note that GitHub disables scheduled workflows on public repos after 60 days without commits.
Everything above sits inside free tiers: Neon 0.5 GB, Workers 100k requests/day, Vercel Hobby, Actions unlimited on public repos.
The one thing you will feel is the first request after a quiet period —
roughly one to two seconds. Neon autosuspends after about five minutes idle, and
the Worker opens a connection per request rather than pooling, so a cold click
pays for both. Every subsequent click is fast. The fix, if it ever matters, is
Cloudflare Hyperdrive in front of Neon: it pools on Cloudflare's side and needs
no code change, which is what the comment in src/db/client.ts already assumes.
Held deliberately to one venue and one operator. These are absent on purpose, not forgotten:
- No auth or multi-tenancy. Settings is a single row pinned by id. Real
multi-venue support means a
venue_idon every table and a tenant in request context — a different exercise. - No reservations, loyalty, payments or realtime. All belong in a real restaurant platform; none are in scope here. Order status updates on refetch and on mutation, not over a socket.
- No auth on the deployment. The hosted demo is genuinely open: every mutation the local app allows, a stranger can perform. That is deliberate — a read-only demo would hide the order lifecycle, which is the most interesting thing here — and it is why the nightly reseed exists. Real multi-user operation is the same exercise as multi-tenancy above.
- No CI workflow. The single file in
.github/workflowsreseeds the hosted demo; it does not run tests.pnpm lint && pnpm typecheck && pnpm testis still the whole pipeline, and wiring that to Actions is ten lines and no new information. - Component rendering is not asserted in the test suite. Vitest, React 19 and
react-native-web could not be made to share a single React instance under
pnpm's layout —
@testing-library/react-nativewants Jest, which the rest of the repo does not use. Rather than leave a broken harness in the tree, the logic behind each UI state is tested as a pure function and the rendering was verified by driving the real app. With more time this becomes a Jest project scoped to the dashboard. GET /api/healthhas no screen. It is an operations probe, not a feature — it reports whether the Worker can reach Postgres so a deploy can be gated on it. It is the one generated hook the dashboard does not call, deliberately.- Pagination is API-side but the UI does not page. Endpoints take
limit/offsetand the screens request 100. A real venue needs the pager wired up; the backend is already ready for it. - Daily metrics are bucketed in UTC. A venue's "today" is really its own
local day, which needs a timezone on the business settings. Both sides of the
aggregation use UTC consistently, which is correct and predictable; making it
venue-local is a settings field and a
at time zoneparameter away. - One theme. Tokens are structured so a dark theme is a second palette object, but shipping one polished theme beat shipping two unfinished.
openingHoursis the one hand-written type in the contract.jsonbis opaque to drizzle-zod, so its shape is declared explicitly incontracts/settings.ts— unavoidable, and scoped to a single column.
Written with heavy AI assistance. What mattered was the guardrails: every schema derived rather than retyped, the generated client committed but never hand-edited, and — most usefully — running the thing instead of trusting it. Four real defects came out of that, none of which type-checking or reading would have found:
- Orval's
override.query.useQueryapplies to every operation, not just GETs. The first generated client hadPOST /ordersas a query hook — a write that fires on render. - The correlated-subquery qualifier bug above, in three places.
- Table rows rendered as
<button>while containing action<button>s. Promise.allover a single pg connection — queued silently today, throws from pg@9.
Built in August 2026 as a take-home exercise for a full-stack role, then renamed and kept as a portfolio project. That is where the scope comes from: one venue, one operator, no auth, no payments. The constraints in Tradeoffs above are the constraints the exercise set, not shortcuts taken under one.