Skip to content

TurfOps

Field-service software for a lawncare, landscaping and snow-removal operation.

The same job’s information lives in three places that don’t talk.

Scope of work in a Word document. Money in QuickBooks. Property details on paper, in the truck. The gate code is in the cab, the price is on an invoice nobody in the field can see, and what was agreed to is in a file written weeks ago and never reopened. Every stage retypes the last one, so every stage can disagree with it.

The fix isn’t an integration between three systems. It’s one durable record per property — quote → scheduled work → completed work → invoice — where each stage inherits from the last instead of being retyped.

Try it

Two roles, one sign-in. The interesting difference is that the crew screen cannot show money — the redaction is enforced in the data layer, not by omitting a column from a template (ADR-0013).

Demo logins

Office

Emaildemo.office@turfops.example
Passworddemo-office-2026

The property record — scope, gate codes, standing crew notes, service history, and money.

Crew

Emaildemo.crew@turfops.example
Passworddemo-crew-2026

The day’s route on a phone-sized screen. It structurally cannot show money: financial fields are stripped server-side before the payload leaves.

Sign in to the demo

Three decisions worth reading about

Seven requested features were one record

Equipment right-sizing, arrival times, revenue per worker-hour, drive-time analysis, dual-service bundling, account keep/drop decisions, crew colour-coding. Read as a list, that’s seven screens and a year of work. Read carefully, it’s one record: who did what job, on what equipment, from when to when, for how much revenue.

Capture it well and all seven are queries over one dataset. Capture it badly and they’re seven half-features that don’t reconcile — the three-systems problem again, rebuilt inside one app. So the build order inverts the request: the data lands first, the dashboards last.

A portability budget with a dollar figure

Vercel’s Hobby tier forbids commercial use and free Postgres tiers pause on inactivity. Phase 2 is a real business, so the move off the free tier isn’t aspirational — it’s dated. The only open question is whether it’s a config change or a rewrite.

Naming it up front, with numbers attached: ~$0/mo in phase 1, and ~$8/mo self-hosted in phase 2 (~$95/yr on a small VPS running the Docker image). What that buys: plain Postgres, standard Node runtime, a Dockerfile maintained from day one, free-and-commercially- usable geocoding and weather, and every paid vendor behind an interface we own. Plus the rule that makes the other six real — reject any dependency that can’t be swapped without touching business logic. (ADR-0011.)

The review loop found bugs in its own fixes. Twice.

An adversarial review pass — read-only, and not allowed to fix what it finds — ran three times over the same code. Pass 1 found 4 severe bugs. Pass 2 found 5 defects introduced by pass 1’s fixes. Pass 3 found 3 more, in pass 2’s.

One of them: a green unit test suite certifying a 10,000x under-bill, because the test encoded the wrong money unit as the specification. Another: three successive attempts to decide by name whether a field means dollars, each of which broke a number the crew needs mid-storm. The fix was to stop guessing.

A fix deserves more suspicion than the original. It was written by someone who had just convinced themselves they understood the problem.

Where it stands

Slice 1’s spine is sound and demoable. Slice 1 is not done, and parts of the app are deliberately unbuilt.

1761

unit tests, 69 files

16

architecture decision records

10

migrations, no schema drift

71

seeded visits, idempotent

Working: real sign-in on Better Auth, customer and property records with full create/edit screens, the office day board, equipment tracking, the crew route screen with offline support, photo uploads that resize and compress on the way in, and a durable offline write queue — a crew taps Complete in a dead zone and the write survives a reload. Crews log arrival and departure times — the liability record on a snow push — and can correct them after the fact, plus daily odometer readings per truck, which is the mileage deduction.

Not built: no recurring agreements yet (the schema carries nullable agreement_id, not the UI), no invoicing or quotes, no crew-facing materials capture for snow jobs, and no profitability dashboards — those need a season of data to be anything but noise.

Not for sale, and not becoming a product: no multi-tenancy, no billing, no white-labeling, ever. It is one operation’s software that happens to be built well enough to show someone. TurfOps is a placeholder name; it lives in exactly one file.

Read next

docs/case-study.md
The long version of this page: both bugs in full, the money representation, and what I’d do differently.
docs/decisions/
Sixteen ADRs, each with a Rejected section — the highest-signal thing in the repository, because code shows what was built and an ADR shows what was declined. Start with ADR-0011 (the portability budget) or ADR-0013 (why a name-based money filter can’t work).