Skip to content

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

Primary

Nuxt 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

Legacy

Nuxt 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

Companion

Flutter · 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.

Forge Desk — Nuxt 3 SSR + Nitro API. Portfolio demo, not a real billing system.

Style guidelocal