Tempo
Time tracking, approvals, and invoicing for small studios, in one workspace.
- Year
- 2026
- Role
- Design and build
- Type
- Product · API and web app
- Stack
- .NET 10 · PostgreSQL · Next.js 15 · React 19 · Tailwind v4 · Kubernetes

Problem
Freelancers and agencies track hours in one tool, approve them in another, and invoice from a third. Tempo joins the loop: projects, tasks, timesheets, approvals, invoices, and a client portal in one place.
Constraints
- The architecture split had to preserve every route, status code, and JSON shape.
- Migration history stays linear; no rewrites of shipped migrations.
- Multi-workspace permissions with server-side checks on every endpoint.
- One deploy target: the homelab k3s cluster, served through a single ingress.
System
- API
- .NET 10 minimal API split into Domain, Shared, Application, Infrastructure, and Api. Business rules are static classes with unit tests; handlers use EF Core directly, with no repository layer.
- Data and jobs
- PostgreSQL with EF Core migrations applied at startup behind an advisory lock. An outbox dispatcher handles email; an overdue sweep marks invoices. Timesheet approval is a state machine, not a status field.
- Client
- Next.js 15 App Router with React 19, Tailwind v4, and Radix primitives. The client portal and internal workspace share components but not permissions.
Decisions
1.
Rules in static classes, not services
Invoice totals, time rounding, and the task state machine are pure functions. Integration tests cover the HTTP contract; unit tests cover the arithmetic and transitions.
Tradeoff Less indirection, but handlers must stay thin or the rules leak into them.
2.
Outbox instead of direct email
Writes commit a message in the same transaction as the business change. A dispatcher retries outside the request.
Tradeoff One more table and a background worker to operate.
3.
One workspace per request
CurrentWorkspace is resolved once and required by every endpoint. There is no ambient tenant in the database connection.
Tradeoff Every query must carry the workspace predicate; tests enforce it.
4.
Migrations on startup
A Postgres advisory lock serializes migrations across replicas. Deploys stay one step.
Tradeoff A bad migration takes the API down with it; the lock only prevents races.
Screens


Status
Running on a dev deployment from the homelab k3s cluster. Screenshots use a demo workspace. Private repository.
Evidence
- Dev deployment: dev-tempo.byros.dev (sign-in required)
- Private repository
- GitHub profile