Skip to content

Repository files navigation

Rail

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.

Live demo → · API health

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

Running it locally

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 dev

pnpm 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:8090

Own 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.

Seed data

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.

Scripts

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:studio

Architecture

The contract chain

Everything 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.

What the backend decides

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: 1 and unitPriceCents: 1 is 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 orderingEnabled false the API returns 409 to every new order.
  • Auto-accept changes where an order enters the lifecycleaccepted instead of pending — which changes what staff are offered next.
  • Order and lines are written in one transaction. A failed validation writes nothing.

Order status is a lifecycle, not a field

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 and history

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.

Frontend structure

  • Pages compose; they don't compute. Every screen's data and mutations live in a colocated use* hook — useOrdersScreen owns the filters, useMenuScreen owns the optimistic availability toggle, useSettingsForm owns the dirty-state save bar. The route files decide layout and nothing else.
  • One door to the network. packages/api-client/src/http-client.ts is the single Orval mutator: base URL, JSON headers, query serialisation, and error unwrapping into a typed ApiClientError that carries the server's own message and code. No screen calls fetch.
  • Domain and design meet in one file. @rail/shared knows about tones, not order statuses. @rail/api-client knows about statuses, not colours. src/lib/order-presentation.ts is 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.

Design system

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.

Why these libraries

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.

Testing

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 nothing

Domain 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 sql templates without table qualifiers, so where menu_items.category_id = menu_categories.id inside a subquery resolves the right-hand side against menu_items and silently matches nothing. It bit three separate aggregates — customer order counts, customer lifetime spend, and menu category item counts — each returning zero with a 200. All of them use LEFT JOIN + GROUP BY now.

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.


Deploying

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.

1. Database — Neon

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:seed

DATABASE_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.

2. API — Cloudflare Workers

cd services/backend
pnpm exec wrangler login
pnpm exec wrangler secret put DATABASE_URL     # paste the Neon pooled URL
pnpm exec wrangler deploy

Deploy 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.

3. Dashboard — Vercel

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.

4. Close CORS

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.app

No 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-origin

5. Nightly reset

The 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.

What this costs and where it is slow

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.

Tradeoffs and what is not here

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_id on 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/workflows reseeds the hosted demo; it does not run tests. pnpm lint && pnpm typecheck && pnpm test is 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-native wants 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/health has 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/offset and 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 zone parameter away.
  • One theme. Tokens are structured so a dark theme is a second palette object, but shipping one polished theme beat shipping two unfinished.
  • openingHours is the one hand-written type in the contract. jsonb is opaque to drizzle-zod, so its shape is declared explicitly in contracts/settings.ts — unavoidable, and scoped to a single column.

AI use

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:

  1. Orval's override.query.useQuery applies to every operation, not just GETs. The first generated client had POST /orders as a query hook — a write that fires on render.
  2. The correlated-subquery qualifier bug above, in three places.
  3. Table rows rendered as <button> while containing action <button>s.
  4. Promise.all over a single pg connection — queued silently today, throws from pg@9.

Provenance

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.

About

Restaurant operations dashboard — pnpm + Turborepo, Expo/React Native Web, Hono on Cloudflare Workers, Drizzle + Postgres, with the frontend client generated end-to-end via drizzle-zod → OpenAPI → Orval.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages