How to Build a Self-Hosted Harvest & Toggl Replacement with Your AI Coding Agent
Most AI-generated time trackers break in production the moment a browser tab goes to sleep, two admins generate an invoice at the same second, or floating-point math corrupts a client total. This battle-tested blueprint gives Claude Code, Cursor, Windsurf, and Devin the exact PostgreSQL DDL schema, 200-line modular file tree, and Two-Gate regression suite to build an agency-grade Harvest & Toggl alternative in under 30 minutes.
Live Blueprint Demo // B2B Time Tracker & Sequential Invoicing
1280×720 • Port 3000
Task: API Concurrency & Advisory Lock Audit → Auto-converted into sequential invoice INV-2026-001 via PostgreSQL FOR UPDATE transaction lock.
pnpm install && pnpm run db:push && pnpm run dev
Gate 1 & Gate 2 Vitest Verified
pnpm install && pnpm run db:push && pnpm run dev
1. Why Naive Prompts Fail at Building a Harvest Replacement
If you ask an AI agent to "build a time tracking and invoicing app like Harvest," it will generate a prototype that looks great for 5 minutes and corrupts billing data in production. This blueprint enforces four non-negotiable engineering invariants:
Zero setInterval Counter Drift
Browsers throttle background tabs, causing naive seconds++ timers to lose 20+ minutes per hour. Elapsed time is strictly computed as Math.floor((Date.now() - startedAtUTC) / 1000) and re-synced immediately on visibilitychange.
Row-Locked Invoice Numbers (INV-2026-001)
Invoice creation locks the tenant workspace row via SELECT next_invoice_number FROM workspaces WHERE id = $1 FOR UPDATE inside a DB transaction, preventing duplicate invoice IDs under concurrent clicks.
Zero Floating-Point Math Bugs
All hourly rates, line-item subtotals, tax calculations, and invoice totals are stored as integer cents (10000 = $100.00) to prevent classic JavaScript 0.1 + 0.2 billing discrepancies.
Database-Enforced Active Timer Guard
A partial index WHERE stopped_at IS NULL and transactional check guarantee a contractor can never accidentally run two overlapping billable timers at once.
2. Complete PostgreSQL Relational Schema (DDL)
Multi-tenant schema with UTC TIMESTAMPTZ enforcement, generated duration columns, and composite invoice constraints.
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Workspaces (Tenant Isolation)
CREATE TABLE workspaces (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
default_currency VARCHAR(3) DEFAULT 'USD',
invoice_prefix VARCHAR(10) DEFAULT 'INV',
next_invoice_number INT DEFAULT 1,
tax_rate_percent NUMERIC(5,2) DEFAULT 0.00,
created_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()),
updated_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now())
);
-- Users / Members
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL,
full_name VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL CHECK (role IN ('owner', 'admin', 'contractor', 'viewer')),
default_hourly_rate_cents INT DEFAULT 10000, -- in integer cents ($100.00/hr)
created_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now())
);
CREATE INDEX idx_users_workspace ON users(workspace_id);
-- Clients & Projects
CREATE TABLE clients (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
billing_email VARCHAR(255),
address_line1 VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now())
);
CREATE INDEX idx_clients_workspace ON clients(workspace_id);
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
hourly_rate_cents INT NOT NULL DEFAULT 10000,
is_archived BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now())
);
CREATE INDEX idx_projects_workspace ON projects(workspace_id);
-- Time Entries (Live Timers & Manual Logs)
CREATE TABLE time_entries (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
started_at TIMESTAMPTZ NOT NULL,
stopped_at TIMESTAMPTZ, -- NULL while timer is actively running
duration_seconds INT GENERATED ALWAYS AS (
CASE WHEN stopped_at IS NOT NULL THEN EXTRACT(EPOCH FROM (stopped_at - started_at))::INT ELSE NULL END
) STORED,
hourly_rate_cents INT NOT NULL,
notes TEXT,
is_invoiced BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now())
);
CREATE INDEX idx_time_entries_workspace ON time_entries(workspace_id);
CREATE INDEX idx_time_entries_user_active ON time_entries(user_id) WHERE stopped_at IS NULL;
CREATE INDEX idx_time_entries_started_at ON time_entries(started_at);
-- Invoices & Sequential Locks
CREATE TABLE invoices (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE RESTRICT,
invoice_number VARCHAR(50) NOT NULL, -- e.g. INV-2026-001
status VARCHAR(20) NOT NULL CHECK (status IN ('draft', 'sent', 'paid', 'void')),
issue_date DATE NOT NULL,
due_date DATE NOT NULL,
subtotal_cents INT NOT NULL DEFAULT 0,
tax_percent NUMERIC(5,2) NOT NULL DEFAULT 0.00,
tax_cents INT NOT NULL DEFAULT 0,
total_cents INT NOT NULL DEFAULT 0,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()),
updated_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()),
CONSTRAINT uq_workspace_invoice_number UNIQUE (workspace_id, invoice_number)
);
CREATE INDEX idx_invoices_workspace ON invoices(workspace_id);
-- Invoice Line Items (Mapping time ranges)
CREATE TABLE invoice_line_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
time_entry_id UUID REFERENCES time_entries(id) ON DELETE SET NULL,
description TEXT NOT NULL,
quantity_hours NUMERIC(7,2) NOT NULL,
unit_rate_cents INT NOT NULL,
amount_cents INT NOT NULL
);
CREATE INDEX idx_invoice_line_items ON invoice_line_items(invoice_id);
3. Modular File System Architecture (Hard 200-Line Cap)
To prevent AI context degradation and monolithic 1,500-line React files, every presentation component, hook, and server action is strictly capped under 200 lines:
src/
├── app/
│ ├── (auth)/
│ │ ├── login/page.tsx # < 90 lines
│ │ └── layout.tsx # < 50 lines
│ ├── (dashboard)/
│ │ ├── timer/page.tsx # < 120 lines
│ │ ├── projects/page.tsx # < 140 lines
│ │ ├── invoices/page.tsx # < 160 lines
│ │ └── invoices/[id]/page.tsx # < 180 lines
│ └── api/
│ ├── timer/route.ts # < 110 lines
│ └── invoices/route.ts # < 170 lines
├── components/
│ ├── timer/
│ │ ├── timer-display.tsx # < 95 lines
│ │ ├── timer-controls.tsx # < 85 lines
│ │ └── idle-prompt-dialog.tsx # < 120 lines
│ ├── invoices/
│ │ ├── invoice-generator-modal.tsx # < 190 lines
│ │ ├── invoice-preview-sheet.tsx # < 175 lines
│ │ └── invoice-status-badge.tsx # < 60 lines
│ └── ui/
│ ├── button.tsx, badge.tsx, modal.tsx # Atomic UI primitives
├── hooks/
│ ├── use-active-timer.ts # < 140 lines (drift delta logic)
│ └── use-idle-detector.ts # < 110 lines (activity listener)
├── server/
│ ├── actions/
│ │ ├── timer-actions.ts # < 160 lines
│ │ └── invoice-actions.ts # < 190 lines (atomic sequence lock)
│ └── db/
│ ├── client.ts # < 60 lines (Port 6543 pooler)
│ └── schema.ts # < 190 lines (Drizzle definitions)
└── lib/
├── currency.ts # < 50 lines (cents conversion)
├── invoice-sequence.ts # < 90 lines (atomic counter generator)
└── time-math.ts # < 130 lines (UTC drift & DST math)
4. Phase-by-Phase Prompts for Claude Code, Cursor & Windsurf
Open your agent in Planning Mode, attach harvest-time-tracker.plan.md, and feed these 5 gated prompts sequentially:
I have attached the complete PromptScale technical blueprint for the Harvest & Toggl Replacement. We are entering Strict Planning Mode. Review the PostgreSQL schema, 200-line modular file limits, and Two-Gate test requirements. Do not write any code until you ask me up to 5 clarifying questions and confirm Phase 1 scope.
Execute Phase 1: Set up Next.js 14 App Router with TypeScript Strict Mode and Drizzle ORM connecting to PostgreSQL on Port 6543 pooler. Create the database schema migrations matching the DDL in the blueprint: workspaces, users, clients, projects, time_entries, invoices, invoice_line_items. Add seed data with 1 test workspace, 2 clients, 3 projects, and initial rate tiers. Verify with Vitest that database connections and migrations execute cleanly. Keep all files under 200 lines.
Execute Phase 2: Build the live timer engine. Implement `useActiveTimer` hook calculating duration strictly via UTC timestamp delta against `time_entries.started_at`. Wire the `visibilitychange` window event to recalculate elapsed time on tab return. Implement `useIdleDetector` to surface the idle dialog after 15 minutes of inactivity. Ensure only one active timer can exist per user. Write Vitest unit tests simulating background tab sleep and idle resolution.
Execute Phase 3: Build the projects page and daily/weekly timesheet views. Calculate billable totals using integer cents arithmetic. Enforce role boundaries: 'contractor' role sees only their assigned entries; 'admin' sees team-wide billable hours. Write test coverage for daylight saving time (DST) transitions and midnight boundary crossing.
Execute Phase 4: Implement the customizable invoice generation engine. Create server action `generateInvoice(workspaceId, clientId, startDate, endDate, taxPercent)`: 1. Atomically increment workspace `next_invoice_number` inside a transaction lock (`FOR UPDATE`). 2. Generate formatted sequential invoice number (e.g. `INV-2026-001`). 3. Query billable `time_entries` in range, calculate itemized subtotals, compute integer tax, and mark entries `is_invoiced = true`. 4. Render printable HTML/PDF invoice preview sheet. Write concurrency tests in Vitest ensuring two simultaneous requests never generate identical invoice numbers.
Execute Phase 5: Implement the Two-Gate regression script in package.json (`npm run test:gate1` and `npm run test:gate2`). Add Playwright end-to-end smoke test covering: Start timer -> Stop timer -> Generate Invoice -> Verify line item total. Ensure 100% of tests pass before declaring Phase 5 complete.
Frequently Asked Questions: Building a Harvest & Toggl Replacement
How can I build a Harvest or Toggl replacement using AI coding tools?
Start in Planning Mode inside Claude Code, Cursor, or Windsurf using PromptScale's Harvest Replacement Blueprint. Feed the PostgreSQL DDL schema first (enforcing UTC TIMESTAMPTZ, integer cents for hourly rates, and a partial index on time_entries(user_id) WHERE stopped_at IS NULL), then execute the 5 modular phases with a strict 200-line file cap and Vitest regression gates.
How do you prevent browser tab sleep from breaking a web-based time tracker?
Never increment a local counter variable inside setInterval(() => seconds++, 1000), because Chrome, Edge, and Safari throttle background tabs. Instead, persist started_at as a UTC TIMESTAMPTZ in PostgreSQL and calculate elapsed seconds dynamically as Math.floor((Date.now() - startedAtUTC) / 1000), triggered both on a 1-second render tick and on the window.addEventListener('visibilitychange') wake event.
How do you prevent duplicate invoice numbers when multiple admins generate invoices concurrently?
Wrap invoice creation inside a PostgreSQL transaction that locks the tenant row via SELECT next_invoice_number FROM workspaces WHERE id = $1 FOR UPDATE, increments the integer counter, formats INV-2026-001, and enforces a database-level composite constraint UNIQUE (workspace_id, invoice_number).
Explore More SaaS Replacement Blueprints
Multi-Timezone Booking Engine →
PostgreSQL advisory locks & RFC 5545 .ics calendar invites.
Replaces Mermaid.ai & Eraser.ioVisual Flowchart Canvas →
Sandboxed Web Worker rendering & 300 DPI PNG/SVG export.
Replaces Typeform & TallyConversational Form Engine →
100% keyboard state machine & JSON conditional logic jumps.