Timer state machines, weekly timesheets, billable hours reporting, and the invoicing handoff that turns hours into revenue.
Research-based methodology. This guide draws on Toggl, Harvest, and Clockify public docs and feature comparisons, our research into freelancer-survey data on time tracker preferences, and our own builds with Claude. Where we cite specific behavior we link the source. How we research.
Time tracking is one of the most crowded SaaS verticals on the planet. Toggl Track, Harvest, Clockify (free, ad-supported), Hubstaff, Time Doctor, Everhour, ClickUp, and TickTick all do this competently. The category has been “solved” for a decade if you accept the generic version. Building “another time tracker” with no specific positioning is a way to spend three months on a project that nobody discovers.
You can still win, but only with a specific bet. Read the next section before writing a line of code.
Three viable wedges in 2026:
The general SaaS scaffolding underneath this is in our how to build a SaaS with Claude guide; this one focuses on the time-tracking-specific pieces.
The model is small but the constraints matter. The hot path is “list this user’s entries for the current week” (rendered every time someone opens the app). The cold path is reporting (“show me billable hours by project for Q1”). Both have to be fast.
I'm building a time tracker SaaS for [freelance lawyers / dev consultants / design agencies]. Postgres + Supabase. Tables: - workspaces (id, name, plan, default_billable_rate, billing_currency) - users (id, workspace_id, email, name, role [admin, member]) - clients (id, workspace_id, name, hourly_rate (override), archived) - projects (id, workspace_id, client_id, name, color, hourly_rate (override), billable boolean (default true), archived) - tags (id, workspace_id, name, color) - time_entries (id, user_id, workspace_id (denormalized for fast filter), project_id, description, started_at, ended_at nullable, duration_seconds (computed when ended_at is set), billable boolean, hourly_rate_snapshot numeric, tag_ids uuid[], created_at, updated_at) - entry_locks (workspace_id, week_start_date, locked_by_user_id, locked_at) -- after a week is approved/invoiced, no edits Required: - Exactly ONE running timer per user at a time. A "running" timer is a time_entries row where ended_at IS NULL. Enforce with a UNIQUE partial index on user_id WHERE ended_at IS NULL. - A check constraint that ended_at > started_at when ended_at is set - duration_seconds maintained by a BEFORE INSERT/UPDATE trigger as EXTRACT(EPOCH FROM ended_at - started_at)::int when ended_at is set - hourly_rate_snapshot captured at entry-create from the most specific rate (project > client > workspace default), so changing rates later doesn't retroactively rebill old work - A locked entry (its week is in entry_locks) cannot be UPDATEd or DELETEd — enforce with a trigger Indexes: - (user_id, started_at DESC) for the user's recent entries (homepage) - (workspace_id, started_at) BRIN for big reporting queries - (project_id, started_at) for project rollups - partial index on (user_id) WHERE ended_at IS NULL for "find my running timer" (very fast) RLS: workspace members see only their workspace. Admins see all entries in workspace; members only their own. Output as one runnable SQL file. Comment why hourly_rate_snapshot is captured at entry time (legal/audit reason: the bill the client sees must match the rate that was active when the work was done).
The unique partial index on the running timer is the key piece. Without it, a flaky network can leave a user with two running timers, both ticking, and the bug is invisible until they invoice the client.
The timer is the most-used feature in the entire product. It also has more edge cases than any other UI you’ll build: tab closed mid-entry, computer slept, network dropped, edited retroactively, “I forgot to start” gap-fills. A clean state machine prevents most of the bugs.
Design a finite state machine for the timer in a time tracker SaaS,
implemented as a React custom hook + Postgres functions.
States:
- idle: no running entry
- running: an entry has started_at set, ended_at IS NULL
- editing_running: user is editing project/description/tags of a
running entry (changes save optimistically; entry stays running)
- syncing: a network operation is in flight (visual indicator only)
Transitions and the SQL/RPC behind each:
1. start({ project_id, description }) from idle → running
- RPC start_timer(user_id, project_id, description, started_at):
- Atomic check: any open entry for this user? If yes, raise
'concurrent_timer' (the unique partial index would also
reject, this is just for a clean error)
- INSERT a time_entries row with ended_at=NULL,
hourly_rate_snapshot from project/client/workspace cascade
- Return the new entry
2. stop() from running → idle
- RPC stop_timer(user_id, ended_at):
- UPDATE the open entry SET ended_at=$1
- Trigger fills duration_seconds
- Reject if no open entry exists
3. edit_running({ project_id?, description?, tag_ids? }) from running
→ running (just an update; ended_at stays NULL)
- If project_id changes, snapshot the new project's rate too
(audit-friendly: store the change in entry_history)
4. discard() from running → idle
- DELETE the open entry (only allowed within 60 seconds of start;
after that, force user to stop + edit instead)
5. retroactive_create({ project_id, started_at, ended_at,
description }) from idle → idle
- INSERT a complete entry. Validate:
- ended_at > started_at
- Does not overlap with any existing entry for this user
(raise 'overlap' error pointing to the conflict so the UI
can offer "merge" or "shift")
6. gap_fill_suggestion(user_id, day): a query that returns gaps in
the user's day >15 min where they have no entry but their
working hours suggest they were working. The UI shows these as
ghost suggestions the user can accept.
Output: the React hook (useTimer) with the state, the Postgres
functions, and the React Query mutation wiring so optimistic
updates work cleanly.
One detail Claude won’t give you unprompted: persist the running timer’s start time in localStorage as well as Postgres, so a refresh during a flaky network shows the correct elapsed time instead of jumping back to zero. Reconcile on next successful fetch.
This is the screen power users live in. The pattern that wins for agencies and consultancies is a 7-column grid with rows per project, cells showing hours per day, totals on the right, and inline editing. Toggl and Harvest both do this; do it well or your tool feels like a toy.
Build a weekly timesheet view at /timesheet/[week-start-iso]. Layout: - Top: a week-picker pill row (prev / current week label / next), total billable hours + total non-billable hours for the week - Body: a grid with one row per project the user logged time on this week, columns: Project | Mon | Tue | Wed | Thu | Fri | Sat | Sun | Total - Each cell shows the sum of hours that day on that project (e.g., "3.25h"). Clicking a cell expands an inline editor showing the individual time_entries on that day in that project, each with start time, duration, description, billable toggle — all inline-editable. - A "+ row" button at the bottom adds a new project to the week (lets you pre-fill a row for a project before logging entries to it) - A "Submit week" button at the top right that calls a "lock_week" RPC (inserts an entry_locks row, makes all entries for that week immutable). Re-clicking opens an "Unlock" confirmation only for admins. Requirements: - Server-rendered with the user's entries for the week prefetched via React Query - All edits are optimistic (immediate UI update, then server confirm, rollback + toast on error) - Keyboard navigation: arrow keys move between cells, Enter expands, Esc collapses, Tab cycles within an expanded cell's fields - Locked week shows a padlock icon and disables all edits - Empty state if no entries: a "Get started" hint pointing to the global timer button Output the page.tsx, the cell components, the React Query keys, and the lock_week RPC.
The report screen is where the value of the tool shows up at month-end: who billed how many hours on what client, what’s recoverable, what was non-billable internal time. The data is already there in time_entries — the work is shaping it into the queries each report needs without 30-second pageloads.
I need a /reports page with three pre-built reports plus a custom
report builder.
Pre-built reports:
1. "Hours by client this week" — a bar chart per client, with
billable + non-billable stacked, plus a table beneath showing
client, hours billable, hours non-billable, total revenue
(sum of duration_seconds * hourly_rate_snapshot / 3600 where
billable=true).
2. "Hours by project, this month" — same shape, projects on
the y-axis. Allow grouping by client → project (drill-down).
3. "User hours, this week" (admin only) — for each member,
their billable hours, non-billable hours, total revenue, and a
"utilization rate" (billable / capacity, where capacity is
workspace.weekly_hours_capacity, default 40).
Each report shares filters: date range, client(s), project(s),
billable flag, tag(s). Filters are URL params (so reports are
shareable / bookmarkable).
For each report, give me the Postgres query (parameterized, no
string interpolation) and the indexes that make it fast on a
workspace with 500k time_entries rows. Use materialized views ONLY
if the data is provably stale-tolerant (workspace_daily_summary
refreshed every 15 min is acceptable for the user-hours report).
Custom report builder:
- A small JSON DSL: { date_range, group_by [client, project, user,
tag, day, week, month], filter [...], metric [hours, revenue,
count] }
- Compiles to one SQL query
- Saved reports: insert a saved_reports row per workspace; show
them in a sidebar
Output the queries, the indexes, the materialized view DDL (if used),
and the DSL compiler.
Time tracking that doesn’t turn into invoices is half a product. You have two paths:
Build a "Generate invoice" flow that converts a set of unbilled
time entries into either an internal invoice (if I'm bundling
invoicing) or a payload for a third-party invoicing API
(QuickBooks, FreshBooks).
Inputs to the flow:
- workspace_id, client_id
- A date range or a specific list of time_entry_ids
- Optional: invoice_template_id (line item grouping rule)
Steps:
1. Validate: every selected entry has billable=true, has
ended_at IS NOT NULL, has hourly_rate_snapshot > 0, and is not
already on an existing invoice (link table: invoice_lines pointing
to time_entry_id).
2. Group entries into invoice line items per the template:
- "Per project per week" (default): one line per project per
ISO week, description = project name + week range, qty =
hours total, unit price = effective rate (if all entries in
the group share the same hourly_rate_snapshot; if not, split
into multiple lines)
- "Per entry" (verbose): one line per time_entries row
- "Per project flat" (project total only)
3. Compute totals: subtotal, tax (per workspace tax setup), total.
4. For internal invoices: insert invoices + invoice_lines rows,
mark each time_entry as invoiced (link in invoice_lines), return
the invoice ID.
5. For external invoicing: build the appropriate API payload
(QuickBooks Items API, FreshBooks Invoices API), POST it, store
the external invoice id back on our side, mark entries invoiced.
Add an idempotency key (workspace_id + a hash of selected
time_entry_ids) so re-clicking the button doesn't double-invoice.
Output: the Postgres function for the internal path, a TypeScript
adapter pattern for the external invoicing APIs (so we can add new
ones later), and the idempotency check.
The industry default for time tracking is per-seat-per-month, and customers expect it:
For payments, time tracking is a clean Stripe subscription product. Lemon Squeezy works too (merchant-of-record handles VAT for international solo freelancers, which is a real feature for this audience). Our how to build a CRM SaaS guide covers a similar per-seat pricing model in more depth.
More niche options in our micro SaaS ideas roundup.
Generic time tracking is a settled market. The win is a profession-specific build (lawyers, agencies, dev consultants), one truly best-in-class feature (offline-first, AI categorization), or a deep integration with a tool the customer already lives in. Per-seat pricing remains the industry default and a clean Stripe subscription handles it.
The stack, prompts, pricing, and mistakes to avoid — for solo founders building with AI.