Case Study
Garage
Everything your motorcycle has been through, in one place. Fills, services, documents and every rupee spent, turned into economy trends, per-component health and reminders that arrive before the damage does.
02The One Idea
A motorcycle generates a stream of small facts. Fuel goes in, kilometres go by, a chain gets lubed, a service happens, insurance lapses. Every one of those facts is trivial on its own and worthless in isolation.
03The Outcome
- 29,500Lines of TypeScriptexcluding tests
- 366Tests, in 32 filesabout 3,650 lines
- 21Database tablesacross 17 migrations
- 99Catalogued models21 makes, 61 variants
04The Proof
Five digits with no context around them, which makes the odometer the easiest thing in the app to misread and the most expensive to get wrong: a bad reading corrupts every distance and economy figure derived after it. Four independent checks stand between a photograph and the log. Pick a way the read can go wrong and watch which one catches it.
Both renderings agree, and the reading sits where a fortnight of riding would put it.
- Last Entry
- 17,412 km
- Days Since
- 14
- This Rider Covers
- 38 km a day
- So the Dial Should Read
- ~17,944 km
- 01
Two Renderings
PassThe same photograph is read at two sizes concurrently. The leading 1 of a seven-segment display is a single thin bar with no enclosed shape, and resampling loses it. Two readings that disagree are evidence that one is wrong.
- 02
The Odometer Only Goes Up
PassA reading below the last entry is wrong by definition, and is almost always the trip meter.
- 03
Jump Size
PassMore than 20,000 kilometres on from the last entry is more likely a misread digit than a ride.
- 04
The Projection
PassBuilt from how much this rider actually rides. A reading at less than half of where the dial should be by now means either a missing digit or a bike that has been in a shed. Both are worth asking about.
OutcomeFilled in
A confident reading is filled in. Anything doubtful is offered with the reason in plain language, because silently entering a wrong odometer is worse than asking.
05The Work
06In Depth
The rest of it, if you want it. Nothing above depends on opening any of these.
The BriefA motorcycle makes a stream of small facts, each worthless alone and none of them worth the friction of writing down.
A motorcycle generates a stream of small facts. Fuel goes in, kilometres go by, a chain gets lubed, a service happens, insurance lapses. Every one of those facts is trivial on its own and worthless in isolation, which is why almost nobody keeps them. Together they answer questions an owner actually has, and the friction of capturing them is the whole problem: the moments worth logging happen at a pump or a service centre, which is exactly where phone signal is worst.
The MoveStore what was observed, compute the rest, and mark on the screen which is which.
A motorcycle generates a stream of small facts. Fuel goes in, kilometres go by, a chain gets lubed, a service happens, insurance lapses. Every one of those facts is trivial on its own and worthless in isolation.
Together they answer questions an owner actually has. Garage collects those facts with as little friction as possible, and then does the arithmetic that turns them into answers.
| The Question | What Answers It |
|---|---|
| Is my bike drinking more fuel than it used to? | Economy by the full-tank method, and a ranked list of what to check first |
| What does this machine cost me per kilometre, honestly? | Cost per kilometre across fuel, service, care and every other expense |
| Which part is closest to needing attention right now? | Twenty checkpoints and seven rituals, rolled up to eight hotspots on the bike |
| When does my insurance expire, and will I find out in time? | Staged warnings at 30 days, 7 days and 1 day |
| How far did that ride actually go? | The odometer where both ends were read, route and waypoints where they were not |
The SpineFuel, distance, care, service and paperwork, and the arithmetic that turns them into answers.
Logging
Every form is a bottom sheet, so logging never loses the context underneath.
- Quick log. One floating action, direct routes to fuel, odometer, chain, tyres, service, expense, care and trips.
- Scan a fuel bill. Station, brand, grade, ethanol blend, litres, rate, total, invoice number and date.
- Scan the odometer. The reading is extracted and the image is never stored.
- Log by sentence. "Filled 500 at HP on the way to work" opens a draft with the fields populated.
- Offline queue in IndexedDB, replayed in order. Anything the server actively rejected is not queued.
- Station recognition from your own history, not a map service. Silent within 10 metres, one tap out to 250.
The Insights Engine
Pure, synchronous, no React and no network. The same code runs online, offline and in the tests.
- Economy by the full-tank method. Partial fills accumulate; a missed fill voids the segment rather than inflating it.
- Provisional economy from the catalogue, labelled claimed rather than measured, with a count of tanks to go.
- Economy diagnosis. Below the baseline by 8 percent, a ranked list: free things, then consumables, then real work.
- Component health scored on the fraction of each interval consumed, rolled up to eight hotspots.
- Inspections reset when marked OK. Replacements do not, because brake pads wear whether or not somebody looked.
- Condition as a verdict. The score exists and is never shown, only its band, with what is dragging it down.
- Riding rate over a 90-day window that widens only when the narrow one has too little to say.
- Fill rhythm learned from your own history, in both days and kilometres, whichever comes first.
- One ask at a time. A single ranked prompt for the whole app, each carrying its effort: one tap, a photo, or a minute.
- Cost analysis, anomaly detection, year in review, and one ranked queue for maintenance and paperwork together.
Trips
- A layer over the ordinary log, not a copy. Deleting a trip removes the grouping and keeps every entry.
- Distance from the odometer where both ends were read.
- Where the closing reading was never entered, location fixes give a floor and the fills become waypoints.
- Every figure carries its source: odometer, route, waypoints or straight line, in descending order of trust.
Paperwork
- Insurance, PUC, registration, warranty and licence, each with its own renewal period.
- Expiry warnings staged at 30 days, 7 days and 1 day, with silence in between.
- The one thing the app will send email about.
Bikes and Instruments
- Any number of bikes per account, archivable and restorable with their full history.
- A stylised motorcycle assembled procedurally from three.js primitives. No asset to download, no licence to manage.
- Eight health pins anchored to the model's own coordinate space, so the engine pin sits on the engine.
- A cluster replacement is stored as its own event, splitting the bike's life into eras. Readings are never rewritten.
- Lifetime distance is derived at the one point raw data enters the engine, so everything downstream reads as one instrument.
Your Data, Back
- Google Drive backup, the whole account as one file in your own Drive. Manual button and nightly sweep, one code path.
- The Drive refresh token is AES-GCM encrypted at rest, so a copy of the database is useless on its own.
- Export and import in the same portable format.
- Deleting a bike writes and records the export first, and delivery is a separate retrying job.
- An account can be closed from inside the app, with history handed back on the way out.
Where AI Is Allowed to ActThree engines read the photograph. None of them is believed without four checks agreeing first.
Photographs are read by a three-stage chain, all of it behind the session cookie. Every stage passes through the same trust boundary afterwards, so the parser does not care which engine spoke.
| Where | What it does | The gate |
|---|---|---|
| Stage one, Cloud Vision | Dense-document text detection returns flat text and word boxes | Word boxes are what make an odometer readable at all: the total is marked out only by sitting beside the ODO caption |
| Stage two, Gemini 3 Flash | Multimodal, temperature zero, strict output schema | Started alongside Vision on a 1.2 second hedge. A healthy scan finishes first and never starts it |
| Stage three, Workers AI | Llama 4 Scout and Mistral Small, free, tried last | Same trust boundary as the two above it |
| Reading a fuel bill | Printed field names at the start of a line become fields | A normaliser reconciles litres, rate and total against each other |
| Implausible figures | Anything outside what a motorcycle can physically do | Dropped rather than written |
| Inference | Anything inferred rather than read off the page | Flagged in the interface, beside the figure |
| Reading an odometer | The same photograph read at two sizes concurrently | Four independent checks, then filled in or offered with the reason |
| Station lookup | An unfamiliar pump is matched on OpenStreetMap | Through the Worker, so it carries the service identity and not your IP, with coordinates rounded to about eleven metres first |
Under the HoodThe insights engine is a pure module with no React, no network and no clock, so every claim about a bike can be called from a test.
An edge-first build with no origin server: the API, the static assets, the database, the object store, the image models and the cron triggers all run on Cloudflare. The insights engine is a pure, synchronous TypeScript module with no React and no network, so the same code runs on fresh data, on cached data while offline, and inside the test suite against fixtures with known answers. Zod schemas shared by the browser and the Worker mean a field cannot be valid on one side and invalid on the other. Money is stored in paise and distance in whole kilometres, so no floating point goes near the arithmetic. The three-stage reading chain is tested against real API responses recorded once and committed, keyed by the hash of the image, so the parsers and the trust boundaries run against genuine model output offline and free on every test run.
worker/ | Hono API on Cloudflare Workers, Drizzle over D1, R2 for receipts |
|---|---|
shared/ | Zod schemas and types used by both sides. One definition of valid |
src/lib/ | The insights engine: pure, synchronous, no React, no network |
src/ | The app itself |
The insights engine is a pure module, and that is what makes it testable
No React, no network, no clock of its own. The same code runs on fresh data, on cached data while offline, and inside the test suite against fixtures with known answers. Everything the app claims about a bike comes out of one function that can be called from a test file.
One definition of valid
Zod schemas in the shared folder are imported by both the browser and the Worker, so a field cannot be valid on one side and invalid on the other.
Integers, plain dates, and sortable keys
Money in paise, distance in whole kilometres, volume in millilitres, so no floating point goes anywhere near the arithmetic. Log dates are YYYY-MM-DD and are treated as UTC noon for day arithmetic, so a fill logged at 11pm does not become yesterday in a chart. UUIDv7 throughout, so primary keys sort by creation time.
Edge first, and one request per screen
The API, the assets, the database, the object store, the image models and the cron triggers all run on Cloudflare. There is no origin server. A bike bundle endpoint returns everything the engine needs for one motorcycle in a single response, cached and recomputed client-side.
The CraftEvery derived figure carries a tilde and a reason. No evidence yet shows as a dash, never as zero.
Approximation has a convention
Every figure the app derived rather than observed carries a tilde, so ~396 km reads as approximate at a glance, in a table, on a chart axis, with no legend. Each one also carries a plain-language reason for the caption beside it.
Unknown is not zero
No distance evidence yet shows as a dash, never as 0 km. Nothing spent yet is never a cost of zero per kilometre. A number the app has not earned is worse than no number, because the reader cannot tell which one they are looking at.
Two themes on two axes
A default theme built around a warm ember accent, and Void, built around indigo. Each renders in dark or light, with system following the device. The two axes are independent attributes on the document root, so the stylesheet can express "Void, in light" as a plain two-attribute selector.
Contrast is tested, not assumed
A test reads the token stylesheet itself and checks every theme and mode against WCAG AA. It found real failures the day it was written: fifty-odd pairs below 4.5:1 in the light theme, and two below 3.0.
Sheets, not pages
Every form is a bottom sheet over the screen that opened it, so logging never loses context. The whole app is meant to be used one-handed at a fuel pump.
Motion with respect, and empty states that were drawn
Animated sheets, rolling numbers, count-ups and a scanning shimmer over a receipt thumbnail, all of which stand down when the operating system asks for reduced motion. Every screen that can be empty has a custom illustration rather than a grey box.
| What is guarded | How |
|---|---|
| The insights engine | Fixtures with known answers, covering economy, health, costs and projections |
| The reading chain | Recorded real-world API output, keyed by the hash of the image |
| Colour contrast | A test reads the token stylesheet and checks every theme and mode against WCAG AA |
| Migrations | A guard script refuses to start the local Worker when migrations have not been applied |
| Types | The build runs a full TypeScript check before it bundles |
| Scans | Structured logging with the parser's output and the model's token usage, so an unmet bill format arrives as evidence |
This page follows the convention it describes. The one figure on it that was derived rather than observed, the reading the dial should show by now, carries its tilde.
Store what was observed, compute the rest, and mark what was computed.