Skip to content
AP.
03Public repository

Leads Master

One workspace for the person who sells websites by phone: the leads, the branching call playbooks, the logged calls, the follow-ups those calls create, and a quotation priced from the same catalog.

Scale / counted from the repository

pages, each reading D1 through one repository
26
route handlers, every write zod-validated
39
D1 migrations, money rules among them
8
tests in 20 files
257
starter call playbooks
10
offline writes allowed to replay
5
direct dependencies
12
owner_id columns, stated as a warning
0
Leads Master cover artwork: the product name over a laptop showing a dashboard of total leads, calls logged, follow-ups and deals, with a target and a ringing phone floating beside it.
Role
Author, design and build
Period
2026
Type
Outbound sales workspace
Stack
TypeScript, React 19, Next.js 15, Cloudflare D1, Cloudflare Workers, Tailwind CSS 4

01

Overview

Selling websites by phone runs on a spreadsheet of prospects, three PDFs of prices, a notebook of call-backs and one WhatsApp thread, and the thread is the only place the last conversation exists. Leads Master replaces all four with one lead record that a playbook, a call, a follow-up, a quotation and a report all key off. The screens are the smallest part. Most of the work is in the rules: what a quote may total, which status may follow which, and which offline write is safe to replay.

One lead id, from the first call to the quotation

  1. 01Lead
  2. 02Playbook
  3. 03Call
  4. 04Follow-up
  5. 05Quotation
  6. 06Report

02

The challenge

Cold-calling small businesses for website work is a bookkeeping problem wearing a sales costume. The script lives in one person's head and leaves with them. Prices come from a PDF, so two prospects get two quotes for the same work. Follow-ups go on paper, so the interested lead goes cold because nobody called back on Thursday. Call notes end up in four places, so nobody can answer how many conversations happened last month. None of that is a screen problem, which is why the rules went into SQL and the screens became a view of them.

03

My contribution

  • Designed the schema across eight D1 migrations: guides, playbooks, calls, leads, follow-ups, quotations, automation and offline receipts.
  • Wrote 39 route handlers with zod on every write path, behind one typed repository per domain so no page builds its own SQL.
  • Built all 26 pages, including the playbook graph editor, Call Mode, the quote builder and the analytics view.
  • Wrote the four places a record could be lost or doubled: playbook publish, call-path folding, the idempotency receipt and the offline queue.
  • Wrote the 257 tests, each database test building a throwaway SQLite file from the real migrations.

04

Inside the product

  1. 01

    A playbook is a graph, and publishing is a snapshot

    validateGraph() · published_snapshot_json · WHERE published_version = ?

    A message step owns its responses and each response points at the next step, so a loop is a warning rather than an error. Validation is one pure function called twice, live in the builder and again on the server before publishing, so the two cannot disagree. Publishing stores an immutable snapshot behind a version guard, and every call starts from a copy of it, which means editing a playbook cannot change a call that is already open.

  2. 02

    Position is folded, never stored as a pointer

    call_session_steps, append-only

    Going back in a call appends an event instead of rewinding a cursor. The current step is re-derived by folding the log, so a caller who changes their mind afterwards replaces the abandoned route without rewriting what happened.

  3. 03

    Money is an integer, and the server recalculates it

    quotes · quote_line_items · triggers in 0006_quotes.sql

    Totals are integer minor units with the currency exponent, discounts are basis points, and no float reaches the database. The browser never submits a total: both write paths re-read the price rows and run the same totals function the preview and the print view use. A trigger rejects any line whose total is not quantity times unit price, and a second one rejects a line in a different currency from its quote.

  4. 04

    An offline write lands once, or not at all

    src/lib/offline/policy.ts · mutation_receipts

    The service worker never touches the API. Writes the application knows are safe to replay queue in IndexedDB behind a five-pattern allowlist, each carrying an Idempotency-Key. The receipt is a reservation rather than a lookup: the first request claims it with a private nonce and re-reads it, so two simultaneous replays cannot both run the handler, and a refused write releases its key so a corrected retry still works.

  5. 05

    One follow-up rule, five screens

    src/lib/leads/follow-up-time.ts · follow-up-consistency.test.ts

    A follow-up is stored both as a calendar day and as a UTC instant, and comparing those directly is how a task ends up due on one screen and overdue on another. One module compares days in the reporting zone, and the dashboard counters, the lead badge, the list filters, analytics and the automation rules all call it. A cross-feature test fails if any screen drifts.

  6. 06

    Automation reads records and never writes to them

    automation_rules · automation_alerts · automation_runs

    Five rules, no cron, evaluated when you press run. Reminders upsert by dedupe key, so running twice opens nothing new and a reminder whose condition clears resolves itself. There is no analytics table either: the reporting page reads the same rows the list pages read, so a figure cannot disagree with the list it came from.

05

Technical approach

Invariants in SQL, not in the form

A rule can live in the database, in one shared server path, or in the browser. Anything that decides money, a status transition or what may replay went as far down that list as it could, because the browser is only allowed to be optimistic if the answer is re-derived anyway.

Triggers, CHECK constraints and foreign keys carry the arithmetic and the integrity. Route handlers carry the lifecycle. The optimistic UI carries nothing that matters.

A refusal is cheaper than a wrong number

Niche pricing invites interpolation, and interpolation invents a price nobody quoted. A niche row wins only when the source guide supplies one, otherwise the standard price is shown and labelled as a reference. The same instinct ran through analytics: a rate with an empty denominator reports N/A rather than zero.

src/lib/guides/pricing.ts, and the gap list on /analytics

Write the limitation into the product

Every gap the app cannot close is on screen, in the README and in the type that carries it. Analytics stops at 20,000 events per period and says the figures are partial past that. The offline queue replays your own writes and never invents a result. The page says automation runs on demand instead of implying background work.

The refusals and gaps list in the README, mirrored in the UI

Built with

Framework
Next.js 15.5, App RouterReact 19TypeScript, strict
Interface
Tailwind CSS 4Radix primitives@xyflow/react 12
Data
Cloudflare D1 (SQLite)SQL migrationsnode:sqlite for dev and test
Runtime
Cloudflare WorkersOpenNextWrangler 4
Validation
zod on every write path
Offline
Hand-written service workerIndexedDB outboxIdempotency-Key receipts

06

Where it stands

  • 01

    There is no authentication

    No owner_id, no login, no per-user isolation. Anyone who can reach a deployment can read and edit every lead, call, quotation and playbook. Cloning the repository is safe; exposing it is not, until Cloudflare Access or real authentication sits in front. The README carries that warning above the fold rather than in a footnote.

  • 02

    No CSV in or out

    Bringing a spreadsheet in, or handing data to a CRM, is manual or a script you write. The duplicate check runs on the way in only if the write comes through the app.

  • 03

    A fourth market is a migration

    The three seeded markets are editable in the UI. A new one needs SQL, because the market codes sit in constraints rather than in a form.

  • 04

    Thin coverage in two places

    The analytics composition has one cross-feature test, the follow-up rule. The automation open, re-open and resolve cycle has none, which is the next thing to pin.

  • 05

    No background scheduler

    Nothing runs while the app is closed and nothing is sent anywhere. No cron trigger is configured, so automation is a button rather than a queue.

  • 06

    A quotation is printed, not downloaded

    It leaves the building through the browser print pipeline and a print stylesheet. There is no generated PDF and no read-only share link.

07

Outcome

The loop closes: plan a session, run a published playbook, log the outcome, and send a quotation priced from the catalog the session was planned against. What the repository deliberately does not publish is business impact. It keeps no analytics on its own use, so the number of leads added, calls logged and quotes sent stays blank rather than being estimated. The code is the evidence, and it is the only thing claimed here.

Next project

PageScope →