All work

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
Overview with weekly hours by client, utilization, and unbilled value (demo data)

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

Weekly timesheet grouped by day (demo data)To-do list with per-task timers (demo data)

Status

Running on a dev deployment from the homelab k3s cluster. Screenshots use a demo workspace. Private repository.

Evidence