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.

The crowded reality of time tracking

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.

Where solo founders can still win

Three viable wedges in 2026:

  • A specific profession. A time tracker for lawyers (built around case + client + matter, with billing-rate hierarchies and trust-account integration), for dev consultants (with GitHub commit auto-attribution and Linear/Jira integration), for design agencies (with Figma file linking and project-based retainers). Generic Toggl is fine for the laptop sitting on the kitchen table; a profession-specific version is what a 20-person agency pays $30/seat/month for.
  • One killer feature. Offline-first sync (most trackers fail when WiFi drops), AI-categorization of entries (you describe roughly what you did, the model assigns project + tags), or a calendar-import flow (drag a Google Calendar event, it becomes a billable entry). One feature done well, marketed clearly, can carve out paying customers from the incumbents.
  • A tighter integration. Time trackers that ship a great native integration with one CRM (HubSpot, Pipedrive) or one project tool (Notion, Linear) win over generic competitors when the customer’s workflow already lives in that tool.

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.

Step 1 — The time tracking data model

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.

Prompt 1 — Time tracker data model with overlap-detection constraint
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.

Step 2 — The timer state machine

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.

Prompt 2 — Timer state machine + edit + gap-fill
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.

Step 3 — The weekly timesheet UI

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.

Prompt 3 — Weekly timesheet UI (Next.js + Tailwind + React Query)
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.

Step 4 — Billable hours reports

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.

Prompt 4 — Billable hours report query + dashboard
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.

Step 5 — Invoicing handoff

Time tracking that doesn’t turn into invoices is half a product. You have two paths:

  • Build invoicing in. See our how to build an invoicing SaaS with Claude guide for the full breakdown. The win: one tool, full revenue capture. The cost: another full module to build and maintain.
  • Integrate with an existing invoicing tool. QuickBooks, FreshBooks, Xero, Wave, Invoice Ninja. Build a one-click “Generate invoice from this week” button that pushes line items to their tool of choice. Faster to ship, but you lose the recurring touchpoint that good invoicing creates.
Prompt 5 — Generate invoice from time entries
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.

Pricing and monetization

The industry default for time tracking is per-seat-per-month, and customers expect it:

  • Free tier — one user, unlimited entries, no reports. Solo freelancer onboarding.
  • Starter — $9–$12/user/month for 1–3 users. Small freelancer pods.
  • Team — $19–$29/user/month, all features, integrations, admin reports. The mass-market price point.
  • Business — $39–$59/user/month, SSO, audit logs, custom fields, API access. Where margins live.

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.

Niche time tracker ideas worth building

  • For lawyers — matter-aware tracking, ABA-compliant time codes, trust account integration, conflict checks. High-priced niche, sticky customers.
  • For dev consultants — GitHub commit attribution, IDE plugin (track time per Cursor/VS Code project), Linear/Jira ticket linking.
  • For agencies — retainer caps, project profitability reports, utilization dashboards, budget alerts.
  • For accountants — client-engagement-aware tracking, year-end summary exports, integration with QuickBooks Online.
  • Offline-first for remote workers — sync-conflict-free local DB, hard mode for spotty WiFi.

More niche options in our micro SaaS ideas roundup.

Time tracker SaaS, in one paragraph
Niche, killer feature, or tighter integration. Pick one.

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.

Related guides

Get one SaaS build breakdown every week

The stack, prompts, pricing, and mistakes to avoid — for solo founders building with AI.