CASE STUDY 02 · QUICKLITRACK

Every skip on the street, tracked to the day.

Our own app for hire firms: where every hire is, when it is due, and who still owes.

Quicklitrack · demo data
Quicklitrack’s morning dashboard in demo mode: 3 overdue, 4 due soon, 8 unpaid, 6 unassigned, then today’s schedule and a button to build today’s route
The morning dashboardDemo data · desktop
Quicklitrack’s jobs list at phone width, demo data: each hire with its address, skip size, paid or unpaid, driver and days left
The jobs listDemo data · phone
PRODUCT
Quicklitrack, our own
FOR
Skip-hire and equipment-hire firms
BUILT FIRST FOR
Leo’s Skip Hire, Milton Keynes
PLATFORMS
Web, iOS and Android
ROLE
Product, design, build and release
21 daysV1.0 TO V1.3, MARCH 2026
17TAGGED RELEASES
3PLATFORMS, ONE CODEBASE
01 · THE PROBLEM

Skips stand all over town. Each one is a question.

Skips sit at customers’ addresses on their own hire periods; the answers lived in phone calls and memory.

  1. 01

    Where is it, and who is driving to it?

    Every hire pinned to its address and assigned to a driver.

    ANSWERED BY THE JOBS LIST AND THE MAP
  2. 02

    Is it due back, or already late?

    Due soon three days before the hire ends; overdue the day after.

    ANSWERED BY THE DATES, SEE 02
  3. 03

    Has it been paid?

    Paid and unpaid on every hire, and the outstanding total on the dashboard.

    ANSWERED BY THE CASH-FLOW RING
02 · HOW IT WORKS

The dates decide the state.

Nobody types a status: the app works it out from the end date. Only “collected” is set by hand.

  1. HIRE STARTSACTIVEtoday < end − 3 days

    On the street, nothing to do yet.

  2. END − 3 DAYSDUE SOONend − 3 days ≤ today ≤ end

    Message the customer: collect, or extend.

  3. END DATEOVERDUEtoday > end

    Top of the morning list until it is back.

COLLECTEDhire.collected === true

The one state a person sets: the driver taps Mark as collected, at any point in the hire.

The hire lifecycle, from the app’s status rule (data.js) · the status is computed, never stored
03 · THE FLOWS

From the morning list to the last collection.

Real screens from the app in demo mode. Pick a step.

Quicklitrack · demo data
The dashboard, demo data: what needs attention this morning and today’s schedule
The jobs list, demo data: every hire as a card with address, size, payment, driver and days left
One hire opened, demo data: address, phone, skip size, hire period, driver, photo proof, price, then WhatsApp, edit, collect, exchange
The route planner, demo data: drivers, depot, truck capacity, the overdue stops chosen, then navigate

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.

04 · ON THE ROAD

The same app in the driver’s pocket.

One web app, wrapped for iOS and Android. Drivers join with a team code and see only their hires.

One hire at phone width, demo data: address, phone, skip size, hire period, driver, photo proof and price
01The hireEverything the driver needs at the door.
The WhatsApp sheet at phone width, demo data: day 5 check-in, extension offer, collection today or a custom message
02The messageFour WhatsApp templates, pre-filled. Just press send.
The route planner at phone width, demo data: three overdue stops selected, mark collected or navigate
03The routePick the stops, then hand over to the maps app.

Screens from the app in demo mode at phone width · sample customers, no real data

05 · WHAT ELSE IS INSIDE

Small things that save a phone call.

Built around three people: the owner, the driver and the customer waiting for a collection.

01
THE OWNER

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
02
THE DRIVER

On the road

  • Every hire pinned on a map, grouped when they share an addressLEAFLET · OPENSTREETMAP
  • A short briefing for the day’s route, written by AIGEMINI, THROUGH A SERVER PROXY
  • Photo proof from the camera on every hireSTORED PER COMPANY
03
THE CUSTOMER

Kept in the loop

  • WhatsApp messages from templates, one tapCHECK-IN · EXTENSION · COLLECTION
  • An exchange swaps the skip and keeps the historyEXCHANGE · UNPAID TOTAL CARRIED
  • Consent asked before any analyticsGDPR BANNER · ESSENTIAL ONLY
06 · RESULTS

Shipped in three weeks, then hardened.

What the repository shows, with where each figure comes from.

  1. 21 days

    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 TAGS
  2. 45

    Automated 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)
  3. 27 Mar

    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 2026
  4. 17 Oct

    A security audit, every finding closed; hosting now publishes only an allow-listed build, never the repository.

    SOURCE · GIT HISTORY, 17 OCTOBER 2026
07 · THE DECISIONS

Four choices, and what each one cost.

Every choice was ours to get wrong. The first one was, and we fixed it in twelve days.

Four decisions, what was chosen and what it cost later
ChoiceWHAT WE CHOSEWHAT IT COST LATER
01One web app, wrapped for the storesMARCH 2026CapacitorWe 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 2026JavaScriptPlain 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 2026FirebaseThere 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 2026The 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.
08 · UNDER THE HOOD

What the app sends, keeps and counts.

Three real pieces, plain words first and code second. Demo data; no key or token appears.

A · THE REQUEST

Asking the AI for a route briefing

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.

1

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.

2

POST, not GET. The stops travel in the body, never in the address.

3

Who is asking. The signed-in user’s Firebase token. The server checks it before anything else, or answers 401.

4

Three fields only. The server refuses an empty prompt or one over 5,000 characters (400), and caps the length of the answer.

5

One field back. The server has already unpacked Gemini’s nested reply into { text }.

The requestBuilt by the app’s code from three demo stops
METHOD
POST
ENDPOINT
/api/gemini → Cloud Function gemini
HEADERS
Content-Type: application/json
Authorization: Bearer ‹Firebase ID token, redacted›
BODY
{
  "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
}
RESPONSE
200 { "text": "‹the briefing›" }
401 Missing or invalid token · 429 Too many requests
400 Invalid prompt · 502 Gemini failed

Prompt trimmed (…). Replies as the server’s code writes them; no live call was made for this page.

js/gemini.js · the app · excerpt
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() ?? '';                  5
gemini-proxy.js · the server · excerpt
const 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 });
Here the auth key is a query parameter, 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.
B · THE RECORD

One hire, as the database keeps it

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.

1

The old single field stays, holding the first size, for older versions of the app.

2

The items: sizes from the catalogue. A list, not one value.

3

No status field. The state is worked out from rentalEnd every time a screen draws, see 02.

4

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.

companies/{company}/hires/{hire} · Firestore · demo record
{
  "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›
}
The field names and order the app writes (data.js, createHire). A demo customer from the app’s demo data, dated from 11 November 2026; the phone field is left out here.
js/data.js · normaliseItems · complete
export function normaliseItems(hire) {
  if (Array.isArray(hire.items) && hire.items.length) return hire.items;   a
  return [hire.itemSize || '6 yard'];                                     b
}
aA newer hire has a list of items: use it as it is.bAn older hire stored one size as text: wrap it in a list, with 6 yard if even that is missing.The jobs list, the hire form, the WhatsApp messages and the AI prompt all read items through this one function, so old and new records look the same there.
C · THE EVENTS

What it counts, and only after a yes

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.

01

The choice is saved in the browser as qt_consent: analytics yes or no, error tracking yes or no.

02

A yes fires qt:consent:analytics, and only then does Firebase Analytics start.

03

Every event goes through one function, which does nothing while Analytics is off.

js/analytics.js · excerpt
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 });
}
EVENTWITHWHEN
hire_createditem_count, is_firstA hire is saved
route_generatedstop_countA route is built
hire_status_changednew_statusA skip is collected
driver_invited–A driver joins the team
onboarding_completedsteps_completedSet-up is finished
screen_viewfirebase_screenAny screen opens
map_viewed · settings_changed · export_triggeredfield, formatThe map, settings, a CSV export
09 · DEBUGGING

The map that said everything was fine.

Locally, every map showed “API KEY REQUIRED” while the browser reported nothing wrong. Found and fixed; it ships next release.

01 · THE CONSOLE

No map errors, and every tile “loaded”

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.

Console · the app in demo mode · 11 November 2026
> 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 }
02 · THE NETWORK

A success that is really a warning

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.

REQUEST
GET https://b.basemaps.cartocdn.com/light_all/12/2038/1351.png
STATUS
200
CONTENT-TYPE
image/png
CONTENT-LENGTH
2049
ETAG
"wm-da89c20e77c1-light"
CACHE-CONTROL
public, max-age=2592000, immutable
The tile the server sent back: a blank square with the words API KEY REQUIRED and carto.com/basemaps/apikey written across it
The response body, as received

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.

03 · THE CAUSE

The backup waits for an error that never comes

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.

js/map.js · before the fix · excerpt
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);
});
04 · THE LIVE SITE

Saved by accident

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.

Console · quicklitrack.com, demo · 11 November 2026 · once per tile, 16 times
Failed to load resource: net::ERR_FAILED
05 · THE FIX

One map source that needs no key

Both 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.

js/map.js · after the fix · Quicklitrack commit c86dae6
export function createBaseTileLayer() {
    return L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
        attribution: '&copy; <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.

10 · BEHIND IT

Designed, built and released by us.

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.

APP
HTML, CSS and JavaScript modules, no build step
DATA
Firebase Auth and Firestore; security rules check every new record field by field
MAPS
Leaflet and OpenStreetMap; routes open in Google Maps, Apple Maps or Waze
PHONES
Capacitor wrappers for iOS and Android, with push notifications
AI
Gemini behind a server proxy that checks the sign-in and allows ten requests a minute

NEXT CASE Nomad Community

Read the case
11 · CONTACT

Two doors. Pick yours.

Whether you are hiring or building, the first step is the same: one conversation with Rikki Chandler.

DOOR 1 · FOR HIRING TEAMSHiring for integration, solutions or implementation?

Rikki connects customers to systems, fixes what breaks and explains it so it sticks.

  • Technical integration and onboarding
  • Solutions and implementation
  • Customer engineering and support
Read the CVThe full story
DOOR 2 · FOR CLIENTSA business that should learn from its own data?

Tell us the one thing that should change. We start from that question and a written test of how we will know.

  • AI systems that run your operations
  • Product search and recommendations
  • Design, film and training for your team
Start a projectSee the work
STUDIO
ArtiFxn Design
FOUNDER
Rikki Chandler
BASED
Florianópolis, Brazil
TIME
UTC−3, working with teams across time zones

Every door leads to the same person.