Portfolio build · one domain model, three surfaces
A freelance ops desk that survives contact with real data.
Clients, tasks, timesheets and invoices behind one typed domain model — served to a Nuxt 3 app, a Nuxt 2 legacy slice and a Flutter companion. Sign in with the public demo account and change anything you like.
- Demo email
- demo@forgedesk.dev
- Password
- forge-desk-demo
- Read-only
- viewer@forgedesk.dev
Three surfaces, one contract
The interesting part of this project is not any one app. It is that types/domain.ts is the single definition of what a client, a task and an invoice are, and three very different clients have to agree with it.
Nuxt 3 app
PrimaryNuxt 3 · Nitro · Pinia · Tailwind
Server-rendered lists with URL-bound filters, a typed Nitro REST API, zod at every trust boundary, signed-cookie sessions and role-gated writes.
- SSR list pages with real pagination
- Zod-validated API, 422s carry field errors
- Owner/viewer roles enforced server-side
Nuxt 2 slice
LegacyNuxt 2 · Options API · Vuex
The same client and task screens as they existed before the migration, kept runnable so the diff between the two eras is something you can open rather than something I claim.
- asyncData and Vuex, not composables
- Same REST contract, older data flow
- MIGRATION.md walks the delta
Flutter app
CompanionFlutter · Dart models
A read-and-log mobile client hitting the same API from a second origin, which is why the Nitro routes carry CORS and the domain types are mirrored in Dart.
- Dart models mirror types/domain.ts
- Cross-origin auth against the same API
- Log time from a phone, see it on the desk
The decisions worth defending
Every one of these is a thing that bites in production, handled here rather than deferred.
Money never touches a float
Rates, line items and totals are integers in minor units. Tax is basis points. Rounding happens once, at the end.
Two storage drivers
Postgres when DATABASE_URL is set, an in-process store when it is not. /api/health names which one answered, and the app says so on screen.
Validation at the boundary
Every body and query string goes through a zod schema before it reaches storage. Failures come back as 422s with per-field messages the form renders.
Invoices from real time
Billing groups unbilled billable entries by task, stamps them with the invoice id, and refuses to bill the same minute twice.
Filters that live in the URL
Search, status, sort and page are query-string state, so a filtered list is a link you can send someone.
Accessible by default
Skip link, focus-visible rings on every control, real empty states, tables that scroll inside themselves instead of shoving the page sideways.
Deterministic seed data
A fixed PRNG seed means the demo, the tests and the screenshots all describe the same numbers.
Rate-limited auth
Fixed-window limiting on login, constant-work password checks, and a signed stateless session cookie with a 12-hour TTL.
What this actually is
Forge Desk is a portfolio build, not a product and not client work. Nobody's real business runs on it and no invoice here was ever sent to anybody.
The code is public, the demo credentials above are public on purpose, and the data is a deterministic seed you can reset from the dashboard at any time — so treat anything you type into it as visible to the next visitor.
If the deploy has no database attached it falls back to an in-process store and writes vanish on the next cold start. The app tells you that in a banner rather than letting you find out by losing work.
Go and poke at it
The dashboard reads every number off the API. The style guide is the full component kit with its prop tables, which is the fastest way to judge the front-end work.