The day and the money
- Overdue, due soon, unpaid and unassigned on one screenDASHBOARD
- Paid against outstanding, as a percentage collectedCASH-FLOW RING · LIVE RENTALS
- Co-admins invited by email, drivers by team codeROLES CHECKED BY SECURITY RULES
Our own app for hire firms: where every hire is, when it is due, and who still owes.


Skips sit at customers’ addresses on their own hire periods; the answers lived in phone calls and memory.
Every hire pinned to its address and assigned to a driver.
ANSWERED BY THE JOBS LIST AND THE MAPDue soon three days before the hire ends; overdue the day after.
ANSWERED BY THE DATES, SEE 02Paid and unpaid on every hire, and the outstanding total on the dashboard.
ANSWERED BY THE CASH-FLOW RINGNobody types a status: the app works it out from the end date. Only “collected” is set by hand.
today < end − 3 daysOn the street, nothing to do yet.
end − 3 days ≤ today ≤ endMessage the customer: collect, or extend.
today > endTop of the morning list until it is back.
hire.collected === trueThe one state a person sets: the driver taps Mark as collected, at any point in the hire.
Real screens from the app in demo mode. Pick a step.




The morning screen: what is overdue, due soon, unpaid or without a driver, today’s numbers, and one button to build the route.
Every hire as a card: customer, address, size, paid or unpaid, the driver and the days left. Filter by state, or by unpaid and no driver.
One skip, all of it: address, phone, hire period and price. Assign a driver, add photo proof, mark it paid, message the customer, then collect or exchange it.
Choose the driver, the truck’s capacity and the stops. Navigate opens the route in Google Maps, Apple Maps or Waze.
One web app, wrapped for iOS and Android. Drivers join with a team code and see only their hires.



Screens from the app in demo mode at phone width · sample customers, no real data
Built around three people: the owner, the driver and the customer waiting for a collection.
What the repository shows, with where each figure comes from.
From v1.0 on 9 March 2026 to v1.3.0 on 30 March 2026: web app, landing page, roles, routes, WhatsApp and the store builds.
SOURCE · CHANGELOG AND GIT TAGSAutomated tests on what must not break: the route builder, the security rules, the escaping of user text and the hosting allow-list.
SOURCE · TESTS (VITEST)App Store privacy details and Google Play data-safety answers written, with the privacy policy and terms brought in line.
SOURCE · APP PRIVACY DOCUMENTS, 27 MARCH 2026A security audit, every finding closed; hosting now publishes only an allow-listed build, never the repository.
SOURCE · GIT HISTORY, 17 OCTOBER 2026Every choice was ours to get wrong. The first one was, and we fixed it in twelve days.
| Choice | WHAT WE CHOSE | WHAT IT COST LATER |
|---|---|---|
| 01One web app, wrapped for the storesMARCH 2026Capacitor | We began rebuilding every screen as a native app in Flutter. Twelve days in, the native copy had to match the web app screen for screen, by hand. We stopped and wrapped the working web app with Capacitor, so one fix ships to web, iOS and Android. | 154 files and 18,269 lines of Flutter deleted on 23 March. The wrapper brought its own faults: the offline cache that helps in a browser breaks inside an app’s web view, so it is switched off there. |
| 02No build stepMARCH 2026JavaScript | Plain HTML, CSS and JavaScript modules. Pages load fast and there is no toolchain to maintain. The rule is written into the project: no bundlers. | Nothing renames a file when it changes, so phones held on to old code. The cache version went from 10 to 28, and on 31 March the app learned to reload itself after a release, so nobody clears a cache by hand. |
| 03Rules in the database, not a serverMARCH 2026Firebase | There is no server of our own to run. The database’s security rules decide who may read or write each record, and a driver joins a team with a code. | Queries had to be rewritten to satisfy the rules. An audit in October found a signed-out visitor could page through team names and codes. The codes moved to a place where one can only be fetched by someone who already knows it. |
| 04AI only where it earns its placeMARCH 2026 | The first versions had voice fill, a smart assistant, an AI insight on the dashboard and AI message drafts. We took all four out and kept one: the route briefing, behind a server proxy so the key never reaches a phone. | Less to show off. The dashboard shows plain numbers and the WhatsApp messages are templates, which are instant and never wrong. |
Three real pieces, plain words first and code second. Demo data; no key or token appears.
When a driver builds a route, the app asks Google’s Gemini for a few lines to read before setting off. The phone never talks to Gemini itself. It sends the stops to our own server with proof of who is signed in; the server holds the Gemini key, checks the sign-in, allows ten requests a minute, then asks Google.
One address on our own site. Hosting forwards /api/gemini to a small server function, so there is no key in the app to steal.
POST, not GET. The stops travel in the body, never in the address.
Who is asking. The signed-in user’s Firebase token. The server checks it before anything else, or answers 401.
Three fields only. The server refuses an empty prompt or one over 5,000 characters (400), and caps the length of the answer.
One field back. The server has already unpacked Gemini’s nested reply into { text }.
POST/api/gemini → Cloud Function geminiContent-Type: application/json
Authorization: Bearer ‹Firebase ID token, redacted›{
"prompt": "You are a logistics assistant for a hire company called Quicklitrack. … Route stops:\nStop 1: Tony Singh, 9 Stacey Bushes (8 yard item, overdue)\nStop 2: Wayne Dixon, 30 Heelands (4 yard item, overdue)\nStop 3: Kevin Lawson, 44 Coffee Hall (12 yard item, overdue)\n\nWrite 2-4 sentences. …",
"temperature": 0.7,
"maxTokens": 200
}200 { "text": "‹the briefing›" }
401 Missing or invalid token · 429 Too many requests
400 Invalid prompt · 502 Gemini failedPrompt trimmed (…). Replies as the server’s code writes them; no live call was made for this page.
const res = await fetch('/api/gemini', { 1
method: 'POST', 2
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${idToken}`, 3
},
body: JSON.stringify({ prompt, temperature, maxTokens }), 4
});
if (res.status === 429) throw new Error('Rate limited — try again shortly.');
if (!res.ok) throw new Error(`Gemini API error: ${res.status}`);
const data = await res.json();
return data.text?.trim() ?? ''; 5const GEMINI_API_KEY = defineSecret('GEMINI_API_KEY');
…
const url = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY.value()}`;
…
const text = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ?? '';
return res.json({ text });key=, but only on the server’s own call to Google. Its value comes from Firebase’s secret store when the function runs: it is not in the code, the repository or the app.Each hire is one document in Firestore, filed under its company: companies/{company}/hires/{hire}. The company keeps its own catalogue: one kind of item, the skip, in the sizes it hires out. Eight sizes come as standard, from 2 yard to RoRo 20, and each firm picks the ones it offers. A hire lists the sizes it took, so one job can carry two skips.
The old single field stays, holding the first size, for older versions of the app.
The items: sizes from the catalogue. A list, not one value.
No status field. The state is worked out from rentalEnd every time a screen draws, see 02.
Money and the driver are plain fields, so the dashboard can total them without another lookup.
No categories yet: other hire types are on the list for what’s next.
{
"customer": "Tom Briggs",
"address": "3 Campbell Park, Milton Keynes, MK9 4AH",
"lat": 52.0406, "lng": -0.7594,
"itemSize": "4 yard", 1
"items": ["4 yard"], 2
"rentalStart": "2026-11-09",
"rentalDays": 14,
"rentalEnd": "2026-11-23", 3
"price": 160, 4
"paid": true,
"collected": false,
"collectedAt": null,
"driverId": "demo-driver-1",
"notes": "",
"createdAt": ‹server timestamp›
}export function normaliseItems(hire) {
if (Array.isArray(hire.items) && hire.items.length) return hire.items; a
return [hire.itemSize || '6 yard']; b
}The app counts a few business moments, such as a hire created or a route built, to see which features firms really use. Nothing is counted until the person says yes. The privacy banner offers Accept all, Essential only or Manage; Essential only leaves analytics off.
The choice is saved in the browser as qt_consent: analytics yes or no, error tracking yes or no.
A yes fires qt:consent:analytics, and only then does Firebase Analytics start.
Every event goes through one function, which does nothing while Analytics is off.
export function logEvent(name, params) {
if (!_analytics) return;
try {
fbLogEvent(_analytics, name, params);
} catch { /* analytics is non-critical */ }
}
export function logRouteGenerated(stopCount = 0) {
logEvent('route_generated', { stop_count: stopCount });
}hire_createditem_count, is_firstA hire is savedroute_generatedstop_countA route is builthire_status_changednew_statusA skip is collecteddriver_invited–A driver joins the teamonboarding_completedsteps_completedSet-up is finishedscreen_viewfirebase_screenAny screen opensmap_viewed · settings_changed · export_triggeredfield, formatThe map, settings, a CSV exportLocally, every map showed “API KEY REQUIRED” while the browser reported nothing wrong. Found and fixed; it ships next release.
The Console said nothing about the map; its one error, about a page transition, was unrelated. Asking the map library itself: 16 tiles on the map, all 16 marked as loaded, none from the backup map. The app believed every picture had arrived.
> const i = [...document.querySelectorAll('img.leaflet-tile')];
({ tilesOnMap: i.length,
loaded: i.filter(x => x.classList.contains('leaflet-tile-loaded')).length,
osmTiles: i.filter(x => /openstreetmap/.test(x.src)).length })
< { tilesOnMap: 16, loaded: 16, osmTiles: 0 }One tile request, opened up. The status is 200 OK and the file is a real PNG, but every tile is the same 2,049-byte picture, and its ETag starts with wm: a watermark. The dark map does the same, and the referring site made no difference. It only shows when you preview the image itself: Network, Img, then Preview.
GET https://b.basemaps.cartocdn.com/light_all/12/2038/1351.png200image/png2049"wm-da89c20e77c1-light"public, max-age=2592000, immutable
The browser’s network log did not list the page’s own image requests, so the tile was opened at its own address and its headers read with curl. Captured 11 November 2026.
The map server, CARTO, now needs an API key, and without one it answers with a watermark and a 200. The app already had a backup: if a tile fails, swap to OpenStreetMap. A 200 with a valid picture is not a failure, so the swap never ran.
const tile = theme === 'light' ? makeLightTile() : makeDarkTile();
tile.addTo(mapInstance);
tile.on('tileerror', () => {
if (!mapInstance.hasLayer(tile)) return; // already swapped
tile.removeFrom(mapInstance);
makeOsmTile().addTo(mapInstance);
});On quicklitrack.com the maps looked fine, for the wrong reason. The site’s security policy did not list the map server, so the requests made by its offline helper, the service worker, were blocked. Those real failures tripped the backup, and OpenStreetMap appeared. Without that worker, as in local demo mode, the watermark stayed.
Failed to load resource: net::ERR_FAILEDBoth maps now use OpenStreetMap’s own tiles, credited to its contributors, and the security policy allows that server and no longer lists CARTO. The dark look is a filter on the map layer, so switching theme no longer reloads a single tile. Checked on desktop and at phone width, light and dark, with the live site’s security headers; all 45 tests pass. The lesson: a backup that waits for errors cannot catch a server that says yes.
export function createBaseTileLayer() {
return L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19
});
}
css/tokens.css and css/layout.css · the same commit · excerpts/* OSM base map, muted so the status pins carry the colour */
--map-tile-filter: saturate(0.45);
/* OSM base map, inverted to a dark map; pins sit in another pane and keep their colours */
--map-tile-filter: invert(1) hue-rotate(180deg) brightness(0.95) contrast(0.9) saturate(0.35);
.leaflet-tile-pane {
filter: var(--map-tile-filter, none);
}Fixed in Quicklitrack commit c86dae6, 11 November 2026. Shipping with the next release.
Built first around a skip-hire firm in Milton Keynes, Leo’s Skip Hire, whose website we also made.
Rikki Chandler, our founder, designed the product and directed the AI coding agents that wrote it, reviewing every change before it shipped: the app, the website at quicklitrack.com, the security rules, the store privacy documents and the releases.
NEXT CASE Nomad Community
Read the caseWhether you are hiring or building, the first step is the same: one conversation with Rikki Chandler.
Rikki connects customers to systems, fixes what breaks and explains it so it sticks.
Tell us the one thing that should change. We start from that question and a written test of how we will know.
Every door leads to the same person.