← All work
0→1 · Working prototype Specced in Claude · Built with Claude Code

FleetServ: inspection first, invoice last

An app for a new fleet-services company with one operator on Oʻahu, scoped around the inspection, not the invoice.

Role
Product manager, discovery to launch
Client
New fleet-services company, one operator, Oʻahu
Built with
React, Supabase, Vercel, Claude Code

The story in three parts

01 · Problem

What he's paying for

A fleet manager pays for the maintenance of his trucks as a service: knowing their condition before one is sitting on the shoulder of the H-1. The back office is the operator's problem, not his customers'.

02 · Built

The commercial layer, end to end

Quoting, invoicing, service logging, and job history in one place, with service reports that carry the technician's photos through to the customer.

03 · Result

Handed over, not launched yet

No users and no usage data yet. Re-reading the code found the one schema decision that put a ceiling on the product, and undoing it is first on the roadmap.

Quote to invoice in 20 seconds

What the work turned up

10 to 0

text contrast failures in the shipped build, after measuring the real screens

104

text pairings re-measured on production, none below the minimum

1

schema decision found capping the product, now first on the roadmap

01 · The problem

The user and the problem

The operator's product is the inspection. Everything else is how he gets paid for it.

A mobile fleet mechanic going out on his own already knows the trade cold. The easy read is that his problem is the back office, and it partly is: quoting, invoicing, job records, and a client-ready PDF, all handled between jobs with no one to hand the admin to.

But that is his problem, not his customers'. A fleet manager does not pay for invoicing. What he pays for is the maintenance of his trucks: knowing their condition before one of them is sitting on the shoulder of the H-1 with a load on it.

I scoped FleetServ around that, with the capture happening in the yard on a phone, in the sun, sometimes with gloves on. Designing around how the real workflow runs, rather than how software usually models it, drove most of what follows.

Operational health is the product

Every truck has a condition, the condition changes, and somebody should be writing it down in a way that can be read back later.

An inspection is how the condition gets written down. Quoting, scheduling, and invoicing are what a finding turns into once the customer agrees the work is worth doing. Build them first and you get a billing tool that happens to carry a checklist. Build the inspection first and the billing falls out of it.

One job record, three audiences

Everything in FleetServ hangs off a single job record. The technician feeds it from the field, the admin runs the business from it, and the customer only ever sees the documents it generates. The record should start with a finding and end with a paid invoice, with every line pointing back at the finding that justified it. That is the product in one sentence, and it is the standard the rest of this page measures against.

A fleet yard below green mountain ridges at sunset: a technician holding a tablet stands at a box truck with its hood open, beside an unbranded service pickup, with a row of box trucks behind.
The work comes to the fleet. The record starts in the customer's own yard, not in a shop.
02 · What ships

What ships today, and where it stops

The commercial layer is done. On the inspection side, less.

What is done:

  • The job and quote pipeline, with status tracking.
  • Per-visit service logging with fields tailored to each job (oil grade and quantity, fluid and fuel type, fuel gallons, tire PSI and location).
  • A quote builder that recomputes each line as quantities and rates change.
  • Customer and vehicle records with service history per truck.
  • Print-ready PDF quotes, invoices, and service reports, with the invoice exported as a QuickBooks import file rather than payment collected in the app.

On the inspection side, an inspection is a pass or a fail across six fixed categories (lights, brakes, tires and wheels, belts and hoses, fluid leaks, mirrors and glass) plus a free-text note. Photos attach to the visit as a whole rather than to anything in particular.

One place the shipped product already does what the thesis asks: the service report PDF carries the technician's photos through to the customer, captioned, with an explicit empty state when none were taken. A fleet manager who gets one can see the truck the way the technician saw it. What he cannot see is which photo goes with which finding.

03 · The constraint

The constraint is one text column

Every structured value the operator enters is flattened into a sentence before it reaches the database.

The form knows that the brakes failed. The row that lands in the table looks like this:

service_type: "visual_inspection"
description:  "Inspection. Lights: Pass, Brakes: Fail, ..."
quantity:     1

The same happens to oil grade, tire PSI, and tire location. The line-items table has no column for any of them, only service type, description, quantity, unit, unit price, and subtotal. The structure exists in the form and dies at the database boundary.

Close up at low sun: gloved hands press a dial pressure gauge onto the valve stem of a box truck's dual rear wheel, a work lamp glowing behind.
Tire PSI is one of the values a visit logs. It reaches the database as part of a sentence, not as a number.

That one decision explains everything the product cannot do. It cannot say which trucks failed brakes last quarter, because no row says so. It cannot trend tread depth across visits, because no depth was ever stored as a number. It cannot bind a photo to a finding, because a finding is not a row to bind to. It cannot seed a quote from findings, for the same reason.

So the next build is not an inspection screen. It is a data model: findings as rows, values with units, photos carrying a foreign key to the finding they document. The screen is the easy half. Naming that correctly is the difference between a roadmap item that takes a week and one that takes a month, and I would rather be right about which.

04 · Prioritization

What I prioritized, and what I cut

The prioritization log 4 decisions
  • Shipped the busiest path first. Quote, accept, invoice, PDF went live before fleets, service visits, or search. That got a working spine in front of real jobs sooner, and it was the right call for the launch. It was the wrong emphasis for the story, which is what this rewrite fixes.
  • One object, two states. A quote converts into an invoice in place, one history per job, so nothing is keyed twice. It cost some data-model flexibility and bought back a solo operator's day of double entry.
  • Designed for the field, not the desk. 44px tap targets and a 16px text floor for gloved hands in direct sun, with status shown as a labeled chip rather than color alone so it reads for colorblind users too. I set those numbers and checked the contrast numerically rather than trusting my eye. I checked them against the design system and not against the shipped build, and the gap between those two is its own section.
  • Flattened the line item, and would not again. Collapsing every service type into one description string kept the schema small and shipped the pipeline fast. It is also the single decision that put a ceiling on the product, and it is now the first thing on the roadmap to undo.

A judgment call on data I'd stand behind

A customer view wanted lifetime-billed and open-balance totals. The data layer was frozen and couldn't produce them honestly, so I left them out and documented why, rather than show a figure that might be wrong. Less on the screen, but every number on it is one the operator can defend to a customer.

Re-reading the code for this rewrite, that call and the constraint above turn out to be the same call seen twice. Both come from a schema that stores results rather than facts. I made the right small decision and missed the larger one sitting underneath it, which seems worth saying plainly rather than leaving as two separate anecdotes.

05 · Design system audit

The design system said one thing, the build did another

A spec that passes tells you the colors are right. Only measuring the built output tells you the colors shipped.

In July I checked contrast across the design system's small-text pairings, fixed the one that failed, and the decision log recorded a passing system. Nothing re-ran that check against the compiled app. When I measured the running build in September, ten text pairings fell below the 4.5:1 minimum, two of them visible only after signing in. Measured again on production after the fix: 104 pairings, no failures.

The useful part is not the fix, which took an afternoon. A verified design system is not a verified product, and I had been treating the first as evidence for the second.

The second lesson is narrower and I expect to reuse it more. Contrast is a property of a pairing, not of a color, so a token that passes on one surface can fail on another and no amount of reading the code will show you which. The two failures I missed were both found by measuring rendered screens while signed in. That check now belongs to the build, and it has to run against the real thing.

How the audit unfolded, and every finding 6 findings

In July I computed contrast ratios for thirty small-text pairings in the design system, found one that failed, and fixed it by adding a darker step to the neutral ramp. The decision log records a passing system, and it was right about the system.

That audit ran against the designed artboards. Nothing ever re-ran it against the compiled app, and by September the two had drifted. The redesign tokens and the older scale the product launched on were both still live, and the components a person actually touches, buttons, links, the sidebar, were still on the older one. Two palettes on one screen: two navies and two teals, close enough to each other that the mismatch reads as a rendering fault rather than a decision.

Re-measuring the running build found eight pairings below the 4.5:1 minimum before I had even signed in. Every one was the same mistake. The accent teal was doing work the token file rules out in a comment on the line that defines it: brand mark and active navigation on navy only. Teal carries white text at 2.58:1. It was backing the primary button anyway.

Signing in found two more, and those were a different mistake worth more than the first eight. A tertiary text color added in July to fix a failure on white measures 4.54:1 there and 4.11:1 on the app's own background, which is the ground most of the app actually stands on. It was carrying an empty state and a breadcrumb separator. Neither came from this migration and neither was visible in the code, because whether that color passes depends on what sits behind it, and a class name does not say.

  • Ten text pairings sat below AA in the shipped build. The worst was a button hover state at 2.95:1, and the primary button itself measured 4.16:1.
  • The primary button, the destructive button, the sidebar's active row, the account avatar, and eighteen inline links all used the accent as a text or label color.
  • Two of the ten only appeared behind the login, on the app background rather than on a white card. Both predate the migration.
  • Measured again on production afterward: five screens, 104 pairings, no failures, lowest 4.54:1.
  • The 44px tap floor had been wired into form inputs and never into buttons, which sat near 36px. It now applies to both.
  • The source carries one palette: 440 uses of the redesign tokens, and none of the old scale.
06 · How I'll know

How I'll know it's working

The business isn't open yet, so there's no usage data. The signals I'll track as it ramps now start with the inspection rather than the invoice: inspections logged per week, the share of quoted work that traces back to a documented finding, and whether a customer ever asks for a copy of the service report unprompted. That last one matters most, because it is the only signal that says the evidence was worth something to the person receiving it.

The clearest failure signal is still the simplest, whether the operator ever falls back to a spreadsheet.

First in line on the roadmap is the data model described above, because nothing else can be built until findings are rows. Behind it sit the client-facing inspection report as its own document, a reusable line-item catalog to speed quoting, and per-truck trending once there are values worth trending.

Where it stands

  • Concept to a working product: brand, quoting, invoicing, service logging, and job history in one place instead of four.
  • Built and handed to the owner, who intends to run the business on it. He hasn't started yet, so there are no users and no usage data. The business hasn't launched or named itself, so FleetServ is the working name.
  • Inspection capture ships as a pass or a fail across six categories, with the technician's photos reaching the customer in a service report PDF. The structured inspection module is specced, not built, and the constraint section says why.
How it was built 3 notes
  • Specced and built 0-to-1 with Claude and Claude Code, including a custom design system (tokens, components, brand mark) built alongside the product, not retrofitted.
  • A React single-page app on Supabase for data, auth, and photo storage, with PDFs via react-pdf, deployed on Vercel.
  • Access control is the shipped kind, not a placeholder: password sign-in, invite-only accounts with email confirmation, and a password reset that completes, with every application route behind a session guard. Row-level security backs it in the database, so the seven tables and the photo bucket refuse unauthenticated reads instead of trusting the client to hide them.
07 · Screens

Screens

Select any screen to open it larger; use the arrows to page through and the close button to come back here. These are the commercial layer, which is the part that shipped. The inspection surface is a checklist inside the visit form, and is not worth a screenshot until it is the thing described above.

Quote builder on a phone: line-item steppers with a line total and running total updating as items are added.
Building a quote in the yard. Steppers sized for gloved hands; the line total and running total recompute as you go.

The inspection module, as designed

These three are drawn on the app's own tokens and chrome, and they are not screenshots: nothing behind them runs. They are here because the roadmap above is easier to argue with a picture than a paragraph, and each one maps to a piece of the data model that has to exist first. Every frame carries a mono banner saying so, in the frame rather than only in the caption, so the label survives if the image travels without this page.

Designed inspection overview on a phone: a progress bar reading 18 of 29, and five sections (tires and wheels, brakes, fluids and leaks, lights and electrical, undercarriage) each with a labeled pass, fail, or to-do chip.
Section by section, with progress counted in items rather than screens. Status is a labeled chip here too, never color alone.
Designed item capture on a phone: a slack adjuster stroke recorded as 2.4 inches against a 2.0 maximum, marked fail, with two photos and a technician's note attached to that item.
The part that does not exist today: a measured value with its unit and limit, and photos attached to the item rather than to the visit.
Designed findings summary on a phone: two flagged findings each with its own photo and measurement, a quote built from them, and the truck's tread depth across the last four visits.
Findings become the quote, and the values become history. Both are only possible once a finding is a row.