# How to build a live logistics dashboard: a simulated fleet, MapLibre and one source of truth

> How Wayline's logistics admin works: a company generated around now, a route's day as a function of time, trucks on precomputed roads in MapLibre, and 16 lessons from building it.

Behind the build, by [Ramin Valadan](https://upfork.dev/developers/ramin-valadan), about [Wayline — Logistics & Fleet Management Admin Template for Next.js & shadcn/ui (Live Map, Routes, Inventory)](https://upfork.dev/products/nextjs-logistics-fleet-management-template) (Landing page, comes with the Pro plan). Published 2026-10-26. 17 min read. Written with AI: Claude Opus 5.5, through Claude.

## What it is

Wayline is a logistics admin for Next.js 16, React 19, Tailwind CSS 4 and shadcn/ui: a live map of trucks on real roads, an exceptions inbox with one-click fixes, a route planner, a dispatch board, orders, shipments with proof of delivery, fleet, inventory, warehouses and reports — 76 pages that all read the same data. There is no backend. A whole regional delivery company (four warehouses in New York and New Jersey, 34 vehicles, 36 drivers, 24 clients, 120 products, 75 days of history and 29 routes on the road today) is generated in the browser around the moment you open it, and its day plays out in real time. The live demo is at [wayline.upfork.dev](https://wayline.upfork.dev).

![Wayline's live map with the delayed truck TRK-208 selected: its route, the stops at risk and the inspector](/article-images/01a1773f-b7c4-72ed-b6b0-98ffbd74e0bf)

This article is for anyone — person or AI — about to build a dashboard like this. It goes through the architecture in the order you'd build it, shows the real code for each hard part, and puts every surprise we hit right next to the part where it happened, with the fix.

## The architecture in four pieces

Each piece has one job, and nothing on screen is stored state:

1. **A generator** builds the company from a fixed seed and a timestamp ("now"): warehouses, vehicles, drivers, clients, history and today's routes.
2. **A clock** advances simulated time (×1, ×5, ×20 or paused). It ticks once a second; the map interpolates between ticks every frame.
3. **Selectors** compute what every screen shows — a route's progress, a vehicle's position, each KPI, each exception — as pure functions of the data and a time `t`.
4. **A change log** records what the user does (confirm an order, reassign a stop, receive a purchase order) as mutations replayed over the generated data, with Undo.

Because everything is a function of `(data, t)`, "live", "replay the last four hours" and "what last week's report says" can't disagree: they are the same functions called at different times.

## 1. Generate the company around "now"

A demo that opens on a finished, quiet day is a bad demo. So the generator doesn't put today's routes at fixed clock times: it places them relative to now. Some trucks left an hour ago, one is 25 minutes behind on the expressway right now, a refrigerated truck crept above 4 °C a quarter of an hour ago, two products ran out at one warehouse. These story beats are constants in the generator, and a test checks each one.

Outside the network's operating hours the demo pins "now" to 11:20 local time, so there is always an operation in progress:

```ts
export function resolveDemoNow(real: number, tz = DEFAULT_TZ): { now: number; pinned: boolean } {
  const env = typeof process !== "undefined" ? process.env.NEXT_PUBLIC_DEMO_NOW : undefined
  if (env) {
    const t = Date.parse(env)
    if (!Number.isNaN(t)) return { now: t, pinned: true }
  }
  const m = localMinutes(real, tz)
  if (m >= 7.5 * 60 && m < 20 * 60) return { now: real, pinned: false }
  return { now: atLocal(dayStart(real, tz), 11 * 60 + 20, tz), pinned: true }
}
```

Everything else is deterministic: one seeded random generator, forked per concern (so adding a field to vehicles doesn't reshuffle every order), IDs given out in time order at the very end, and the same `now` gives the same company. A test generates it twice and compares.

> **Lesson: A demo seed anchored to 'now' gives different IDs at different hours, so reloads break links** (My own mistake; learned by the AI (Claude Opus 5.5))
> **I assumed:** a deterministic generator with a fixed seed gives the same records on every reload of the same day.
> **What happened:** routes and new orders are placed relative to now, so the order in which records were created — and with it every ID and tracking code — changed with the hour of the reload. A link to an order opened a different order three hours later, and changes saved in localStorage landed on the wrong records.
> **The fix:** store the anchor (the "now" the company was built for) in localStorage and reuse it on reload for 75 minutes, continuing the clock from it. After that, build a fresh company and clear the change log, because old changes point at old records. Not longer than 75 minutes: the routes are generated mid-flow, so about two hours after the anchor the day winds down and the demo goes quiet.
> **Evidence:** generating the same day for 07:35, 09:00, 11:20 and 15:00 gave different route IDs and tracking codes (11:20 and 11:25 matched); `sessionAnchor()` in lib/data/store.ts.

The network runs on New York time whatever the viewer's zone, so every "today", delivery window and chart bucket goes through a time-zone conversion — hundreds of thousands of them while 75 days of history are generated. `zonedSlow()` is the plain `Intl.DateTimeFormat#formatToParts` conversion; everything else uses a cached offset:

```ts
// Offsets only change on the hour (DST), so cache them per zone per UTC hour; Intl is slow.
const offsetCache = new Map<string, number>()

export function zoneOffset(ms: number, tz = DEFAULT_TZ): number {
  const hour = Math.floor(ms / HOUR)
  const k = `${tz}|${hour}`
  let off = offsetCache.get(k)
  if (off === undefined) {
    const at = hour * HOUR
    const z = zonedSlow(at, tz)
    off = Date.UTC(z.year, z.month - 1, z.day, z.hour, z.minute, z.second) - at
    if (offsetCache.size > 50_000) offsetCache.clear()
    offsetCache.set(k, off)
  }
  return off
}
```

> **Lesson: Intl formatToParts in a hot loop made a data generator take 3 s; caching offsets per UTC hour cut it to 0.25 s** (My own mistake; learned by the AI (Claude Opus 5.5))
> **I assumed:** converting timestamps to a named time zone with `Intl.DateTimeFormat(...).formatToParts()` is cheap enough to call for every event a generator places.
> **What happened:** generating 75 days of history took about 3 seconds in Chrome, almost all of it in formatToParts.
> **Why:** each call formats a whole date and allocates its parts, while a zone's offset only changes on the hour, so nearly every call recomputed the same answer.
> **The fix:** cache the offset per zone per UTC hour and do the rest with `Date.UTC` arithmetic (above). The whole company now generates in about 0.25 s.
> **Evidence:** `zoneOffset()` in lib/data/time.ts.

## 2. Make the data tell the truth, and test it

The promise of a template like this is that every number agrees with every other: the KPI on the dashboard, the badge in the sidebar and the row in the table. That only holds if the generated data is relationally valid, so the generator has its own tests:

- every order points at a real client and one of that client's own delivery sites;
- shipments and route stops reference records that exist;
- on-hand stock equals the sum of the stock ledger everywhere — except one deliberate discrepancy, which the Reconcile screen is there to find;
- no stock balance ever goes negative, and nothing in the ledger happens in the future;
- the dormant client really has no orders in 60 days;
- the story: the delayed truck is 24–26 minutes late with three stops at risk, one warehouse is at 91 %, one delivery failed today, one document expires in six days.

> **Lesson: Simulating warehouse stock per day in aggregate drove balances negative; it has to be one time-ordered event queue** (My own mistake; learned by the AI (Claude Opus 5.5))
> **I assumed:** stock could be simulated day by day in aggregate: add the day's receipts, subtract the day's picks.
> **What happened:** the integrity tests found ledger balances below zero and on-hand totals that didn't match the ledger: within a day, picks used stock before that day's receipts had arrived, which adding and subtracting whole days hides.
> **The fix:** one event queue per day, in time order: picks, receipts, transfers dispatched at 18–19 h that land the next morning, adjustments in the evening, with a rush replenishment when a pick would go below zero. Then open orders are trimmed to what the shelves really hold, except the two stock-outs the story needs.
> **Evidence:** tests/seed.test.ts ("no stock balance ever goes negative", "on-hand equals the ledger sum everywhere but the one deliberate discrepancy"); lib/data/seed/stock.ts.

## 3. A route's day as a pure function of time

A route is a list of segments: drive to stop 1, dwell there, drive to stop 2 and so on, with a 30-minute break after the first stop that finishes past the driver's break time, and an optional drive back to the warehouse. Delays are events: at time `a`, `m` minutes of delay begin, spread over `s` minutes (a jam on the expressway). The delay accumulated by time `t` is

`D(t) = Σ m · min(1, max(0, (t − a) / s))` (minutes)

and the vehicle's position in its plan is the plan-equivalent time `τ = t − D(t)`. Finding where a route is reduces to finding the segment that contains `τ`:

```ts
export function delayAt(delays: DelayEvent[], t: number): number {
  let d = 0
  for (const e of delays) {
    if (t <= e.at) continue
    const f = Math.min(1, (t - e.at) / (e.spreadMin * MINUTE))
    d += e.minutes * f
  }
  return d
}

export function momentAt(route: Route, tl: Timeline, t: number): RouteMoment {
  const delayMin = delayAt(route.delays, t)
  const tau = t - delayMin * MINUTE
  // …before departure, after the end, or inside a drive / dwell / break segment:
  for (const s of tl.segs) {
    if (tau < s.from || tau >= s.to) continue
    if (s.kind === "drive") return { phase: "drive", leg: s.leg, legT: (tau - s.from) / (s.to - s.from), /* … */ }
    // …
  }
}
```

The ETA of a future stop goes the other way: find the real time `t'` at which `τ` reaches the stop's planned time `p`, that is `t' − D(t') = p`. Iterating `x ← p + D(x)` converges in a few steps because `D` grows slower than time: its slope is the slowdown rate, capped at 0.95. While a jam is in progress, the same rate slows the vehicle on the map, so the delayed truck's inspector shows a crawl (4 mph when we recorded the demo).

Because all of this is a function of `t`, the map's "Replay last 4 h" is the same code with an older `t`, and the route page, the driver app, the customer's tracking page and the dispatch board all show the same ETA.

![The same simulation on three phones: the driver app, the live map and the customer's tracking page](/article-images/01a1773f-be79-76fb-b6a3-86bfbbd65b87)

## 4. Trucks on real roads, with no routing calls at runtime

Road geometry is computed once, at build time, with OSRM (`npm run geometry`): every route leg, every warehouse-to-site pair and the lanes between warehouses, with `steps=true` so each step's duration and road name come back. It's stored as encoded polylines with step breakpoints; `data/geometry.json` is 170 KB. At runtime a leg becomes vertices with cumulative metres `d[]` and cumulative free-flow seconds `t[]`, so "this fraction of the leg's time" maps to a point with a binary search on `t[]`:

```ts
export function pointAtTime(leg: LegPath, f: number): { p: LngLat; i: number } {
  const target = Math.max(0, Math.min(1, f)) * leg.total
  let lo = 0
  let hi = leg.t.length - 1
  while (lo < hi - 1) {
    const mid = (lo + hi) >> 1
    if (leg.t[mid] <= target) lo = mid
    else hi = mid
  }
  const span = leg.t[hi] - leg.t[lo] || 1
  return { p: lerp(leg.pts[lo], leg.pts[hi], (target - leg.t[lo]) / span), i: lo }
}
```

Following the time profile rather than the distance makes trucks slow down in the city and speed up on highways by themselves. Speed is the distance over the time across three vertices either side (one short segment would otherwise read as a standstill), heading comes from the next vertex, and the road name ("I-278 · Brooklyn-Queens Expressway") from the step breakpoints.

The map is MapLibre GL with a style built in code on OpenFreeMap's free vector tiles, so there is no API key to manage. Two setup surprises came first:

> **Lesson: maplibre-gl 6 under Next.js 16 with Turbopack: use a namespace import and serve its worker from your own origin** (Not in the docs; learned by the AI (Claude Opus 5.5); maplibre-gl@6.15, next@16.4)
> **I assumed:** `import maplibregl from "maplibre-gl"` works, and the bundler ships MapLibre's worker as it did in older webpack setups.
> **What happened:** the default import failed, and once that was fixed the maps stayed empty because the worker script never loaded.
> **The fix:** `import * as maplibregl from "maplibre-gl"`; copy `node_modules/maplibre-gl/dist/maplibre-gl-worker.mjs` into `public/maplibre/` on install, dev and build (a small script in `postinstall`, `dev` and `build`); call `maplibregl.setWorkerUrl("/maplibre/maplibre-gl-worker.mjs")` before the first map is created.
> **Evidence:** scripts/copy-map-worker.mjs and the first lines of components/map/fleet-map.tsx.

> **Lesson: maplibre-gl.css outranks Tailwind 4 utilities: unlayered CSS beats @layer utilities, so the map's box gets 0 height** (Not in the docs; learned by the AI (Claude Opus 5.5); tailwindcss@4, maplibre-gl@6.15)
> **I assumed:** `className="absolute inset-0"` on the map's container sizes it.
> **What happened:** the container was 0 px high and the map invisible: `maplibre-gl.css` sets `.maplibregl-map { position: relative }`.
> **Why:** Tailwind 4 puts utilities in the `utilities` cascade layer; a stylesheet imported outside any layer outranks every layered rule, whatever the specificity.
> **The fix:** position the map's element with an inline style (`style={{ position: "absolute", inset: 0 }}`) inside a sized, `relative` wrapper, and call `map.resize()` from a ResizeObserver.
> **Evidence:** the comment above the map container in components/map/fleet-map.tsx.

## 5. Drawing 34 vehicles at 60 frames a second

Vehicles are DOM markers, not a symbol layer: each one is a button with a glyph, a heading wedge, a status ring, a label and a delay badge, and it can take keyboard focus. Every animation frame moves them to their position at the interpolated simulation time:

`simTime = baseSim + (performance.now() − baseReal) × speed`

The expensive parts don't run every frame. Each vehicle's status (its colour family, label and delay) is recomputed once a second; route lines (driven and ahead) are GeoJSON sources updated once a second; below zoom 10.3, nearby vehicles are clustered in screen space twice a second — a greedy pass that groups markers within 44 px — and each cluster shows its members' status mix as a conic-gradient ring.

> **Lesson: A CSS animation on a MapLibre marker's element pins the marker to the map's top-left corner** (Not in the docs; learned by the AI (Claude Opus 5.5); maplibre-gl@6.15)
> **I assumed:** a short entrance animation (opacity plus `translateY`) on a cluster marker's element was harmless.
> **What happened:** every cluster sat in the map's top-left corner instead of over its vehicles. Its style attribute still said `transform: translate(-50%, -50%) translate(805px, 368px)`, but it was drawn at 0,0. A pulsing "next stop" ring that animated `transform: scale()` did the same.
> **Why:** MapLibre positions a marker by writing `transform` on the element you give it. An animation that animates `transform` (with fill mode `both`) overrides it for as long as it applies.
> **The fix:** never animate the marker's own element; animate a child (`.wl-cluster span`). And key clusters by their members, so a stable group keeps its marker instead of being rebuilt — and re-animated — twice a second.
> **Evidence:** Wayline commit 4a8798c ("Animate the inside only" in app/globals.css, clusters keyed by members in components/map/fleet-map.tsx).

> **Lesson: map.isStyleLoaded() stays false until tiles arrive, so checking it after another async step can wait forever** (Not in the docs; learned by the AI (Claude Opus 5.5); maplibre-gl@6.15)
> **I assumed:** after loading my own data, `if (map.isStyleLoaded()) start(); else map.once("style.load", start)` covers both cases.
> **What happened:** on a slow connection the live map stayed on "Loading map", then showed its own "couldn't load" message after 20 seconds, although the style had loaded.
> **Why:** `isStyleLoaded()` also waits for every source's tiles. `style.load` had already fired while the road data was loading, so the listener waited for an event that never came again.
> **The fix:** subscribe when the map is created — `const styleLoaded = new Promise((r) => map.once("style.load", () => r()))` — and start on `Promise.all([loadGeometry(), styleLoaded])`. Show the failure message only if the style itself never loads.
> **Evidence:** Wayline commit 4a8798c (components/map/fleet-map.tsx).

> **Lesson: A once-a-second throttle initialised to 0 skips the first frame when performance.now() is under 1000** (My own mistake; learned by the AI (Claude Opus 5.5))
> **I assumed:** `if (performance.now() - lastData > 1000) recompute()` with `let lastData = 0` always recomputes on the first frame.
> **What happened:** recording a video of the app on a virtual clock (where `performance.now()` starts near 0) crashed the map with "Cannot read properties of undefined (reading 'status')": the per-vehicle status cache was still empty on the first frame.
> **The fix:** start the throttle at `-Infinity`. Real browsers rarely hit it because the page is more than a second old when the map starts, which is why it hid until a recording tool replaced the clock.
> **Evidence:** Wayline commit 59c9334.

## 6. Exceptions: rules, stable IDs and one-click fixes

The exceptions inbox is a rules engine run over the data at `now`: delayed routes, deliveries whose ETA passes the promised window, refrigerated loads above range, stock-outs with orders waiting, expired compliance documents, late inbound trucks, warehouses over 90 %, approvals and review backlogs. Each rule returns an exception with a severity, the records involved, a place on the map, a due time and a suggested fix:

```ts
export interface Suggestion {
  text: string
  action: "reassign" | "reattempt" | "transfer" | "notify" | "renew" | "call_driver" | "rebalance" | "approve" | "review" | "chase"
  label: string
}
```

The suggestion is computed, not canned. For deliveries at risk, the rule looks for the nearest vehicle of the same class (no bikes for pallets, no refrigerated trucks for dry freight) that is on the road, not itself late, with at least 150 kg spare, and words it: "Reassign ORD-6858 to VAN-012 — 6.4 km away with 989 kg spare; saves about 21 min."

The important design choice is the ID. Exceptions are recomputed every 15 seconds of simulated time, so their IDs are derived from what they are about (`EX-SLA-${route.id}`), and what the user did with them — acknowledged, assigned, snoozed until a time, resolved — is stored separately, keyed by that ID, and merged in:

```ts
export function withState(list: OpsException[], overrides: Record<string, ExceptionOverride>, now: number) {
  return list.map((x) => {
    const o = overrides[x.id]
    let state: ExceptionState = "open"
    if (o) state = o.state === "snoozed" && o.until && o.until <= now ? "open" : o.state
    return { ...x, state, owner: o?.owner ?? x.owner, override: o ?? null }
  })
}
```

A snooze ends by itself when `now` passes it, and an acknowledgement survives the rule firing again.

## 7. One source of truth for every number

Every metric lives once, in `lib/data/selectors/`, and every screen calls it: the dashboard tile, the sidebar badge, the report chart and the CSV export. Each metric's definition sits in a tooltip next to it.

![Operations: what needs attention, the network pulse and the KPIs, all read from the same selectors](/article-images/01a1773f-c744-70cc-a543-ceb618f43f61)

That rule was learned the hard way:

> **Lesson: Two screens computed 'fleet utilisation' with different formulas: 17 % on one, 82 % on the other** (My own mistake; learned by the AI (Claude Opus 5.5))
> **I assumed:** giving a metric one name in the UI was enough to keep it consistent.
> **What happened:** Reports computed fleet utilisation as vehicle-hours on route ÷ (vehicles × 10 h) and showed about 17 %; Operations and Vehicles used vehicles with a dispatched route ÷ vehicles in service and showed 82–88 %. The sidebar's exception count also left out info-level exceptions while the Exceptions page counted them (14 against 15).
> **The fix:** one definition per metric, in one selector, its definition shown beside it; reports apply the same rule per day.
> **Evidence:** Wayline commits 4a8798c and 59b8999.

> **Lesson: Day arithmetic as `today − n × 24 h` drops a day from any period that crosses a DST change** (My own mistake; learned by the AI (Claude Opus 5.5))
> **I assumed:** the first day of a 30-day period starts at `todayMidnight − 29 × 86 400 000`.
> **What happened:** for a period crossing the end of daylight saving time (1 November 2026 in New York), that lands at 01:00 on the first day, so the first day's midnight failed the `day >= from` test and the "30-day" report covered 29 days.
> **The fix:** step through noon and snap back to midnight: `dayStart(end − (days − 1) × DAY + 12 h)`.
> **Evidence:** for 15 November 2026 the old formula gives a period starting 2026-10-17T05:00Z with 29 days in it, the new one 2026-10-17T04:00Z with 30; `periodStats()` in lib/data/selectors/metrics.ts.

## 8. Writes, Undo, and a mock API you can swap out

Every write goes through `lib/api.ts` (and its siblings `lib/api-fleet.ts`, `lib/api-inventory.ts`, `lib/api-network.ts`), which behave like a real API. The core: it applies the change at once (optimistically), waits a moment, sometimes fails on purpose (add `?fail=1` to the URL) and then rolls back, and offers Undo on success:

```ts
async function write(list: Mutation[], opts: WriteOpts = {}) {
  const store = useData.getState()
  store.commit(list)
  await sleep(LATENCY)
  if (shouldFail()) {
    useData.getState().revert(list)
    toast.error("Couldn't save the change", { description: "The server didn't respond (demo failure mode: remove ?fail from the URL)." })
    throw new Error("mock failure")
  }
  if (!opts.silent && opts.success)
    toast.success(opts.success, {
      description: opts.description,
      action: opts.undo ? { label: "Undo", onClick: () => useData.getState().revert(list) } : undefined,
    })
}
```

A mutation is a small serialisable record (`{ t: "order.status", at, id, to }`), and the store keeps the list in localStorage and replays it over the generated company on load. Records the user creates get IDs from 90001 up, so they never collide with generated ones. To connect a real backend, keep the function names in the `lib/api*.ts` files and make them call your server; the screens never touch the store's internals.

## 9. Themes that reach the map

Colours are CSS variables in oklch; presets are `[data-preset]` blocks; the user's choice is a cookie the root layout reads, so the server renders the right theme on the first paint. Blue is the default; the neutral greys take a hue variable (`--nh`) from the preset, so a green theme doesn't sit on blue-grey surfaces.

> **Lesson: MapLibre paint properties can't read CSS variables: a var(--color) line is drawn black** (Not in the docs; learned by the AI (Claude Opus 5.5); maplibre-gl@6.15)
> **I assumed:** `"line-color": "var(--info-line)"` follows the theme like the rest of the UI.
> **What happened:** every route line was drawn black.
> **Why:** MapLibre parses colours itself and only accepts literal CSS colours; it never asks the browser to resolve a custom property.
> **The fix:** resolve the token in the page — `getComputedStyle(document.documentElement).getPropertyValue("--signal")`, converted from oklch to `rgba()` — pass that, and call `setPaintProperty` again when the theme or preset changes.
> **Evidence:** `tokenRgba()` in lib/color.ts and the preset effect in components/map/fleet-map.tsx (Wayline commit 70cb52a).

> **Lesson: prefers-color-scheme read during render breaks hydration; useSyncExternalStore with a server snapshot fixes it** (My own mistake; learned by the AI (Claude Opus 5.5); react@19.3, next@16.4)
> **I assumed:** `typeof window === "undefined" ? mode === "dark" : matchMedia("(prefers-color-scheme: dark)").matches` was a safe way to read the effective theme in a component.
> **What happened:** with the theme on "system" and the computer in dark mode, React reported "A tree hydrated but some attributes of the server rendered HTML didn't match the client properties": the theme toggle's aria-label read "Switch to dark mode" on the server and "Switch to light mode" in the browser.
> **The fix:** `useSyncExternalStore(subscribe, () => matchMedia(q).matches, () => false)`. React hydrates with the server's answer and switches right after, with no mismatch.
> **Evidence:** `useIsDark()` in lib/ui-store.ts (Wayline commit 4a8798c).

## 10. Shipping it

Three more surprises came while preparing the release, the screenshots and the deploy:

> **Lesson: An onClick in a Next.js server component passes next build and fails only when the page is served** (The tools and the machine; learned by the AI (Claude Opus 5.5); next@16.4)
> **I assumed:** `next build` refuses a server component that passes an event handler to an element.
> **What happened:** the build succeeded, and the 500 page crashed when it was opened: "Event handlers cannot be passed to Client Component props. <button type="button" onClick={function onClick} …>".
> **The fix:** `"use client"` at the top of the shared error screen (app/global-error.tsx uses it too and must be a client component anyway), and opening every page once on the production build before shipping.
> **Evidence:** components/system/server-error.tsx.

> **Lesson: eslint-config-next 16 reports React Compiler rules as errors even when the compiler is off** (Newer than what I knew; learned by the AI (Claude Opus 5.5); eslint-config-next@16.4, eslint-plugin-react-hooks@7.1)
> **I assumed:** without the React Compiler enabled in next.config, `eslint-config-next` only reports the classic hooks rules.
> **What happened:** lint failed on ordinary components with react-hooks/preserve-manual-memoization ("Compilation Skipped: Existing memoization could not be preserved"), react-hooks/set-state-in-effect and react-hooks/incompatible-library.
> **The fix:** turn those three advisory rules off in eslint.config.mjs, with a comment saying why. Keep react-hooks/refs, purity and immutability: refs flagged a ref written during render, which we fixed.
> **Evidence:** wayline/eslint.config.mjs.

> **Lesson: Headless screenshots of a MapLibre page must wait for tile requests, not for network idle** (The tools and the machine; learned by the AI (Claude Opus 5.5); playwright-core, maplibre-gl@6.15)
> **I assumed:** `page.goto(url, { waitUntil: "networkidle" })` plus two seconds is enough before a map screenshot.
> **What happened:** maps came out grey, with markers and route lines but no streets.
> **Why:** MapLibre's worker requests tiles after the page has gone idle, and on a slow link they keep arriving for several seconds.
> **The fix:** count requests to the tile host with `context.on("request")`, `requestfinished` and `requestfailed`, and shoot only when none is in flight and none started in the last 1.5 s.
> **Evidence:** the listing screenshots of this product; the counter is in scripts/listing-shots.mjs in the Wayline repository (kept out of the download).

> **Lesson: A deploy piped into tail reported success after scp was cut off mid-file** (The tools and the machine; learned by the AI (Claude Opus 5.5))
> **I assumed:** running `bash deploy.sh 2>&1 | tail -40` would fail if the upload failed.
> **What happened:** uploading a 16 MB release over a throttled connection stopped with "Connection reset by peer" and "lost connection", yet the command exited 0 and nothing had changed on the server. On the same line, 0.5–0.7 MB uploads went through while a 9 MB video upload failed twice.
> **Why:** a pipeline's exit status is the last command's (`tail`), not the script's.
> **The fix:** `set -o pipefail` (or no pipe), and send big files in 200 KB pieces over ssh with retries, then compare sha256 on both ends before unpacking.
> **Evidence:** UpFork's release of 25 October 2026: the cut attempt left a partial 1.5 MB archive on the server; the chunked upload passed its checksum and went live.

## Make it yours

- **Your data:** keep the selectors and replace the generator with your API. `docs/BACKEND.md` explains the swap; each screen only calls the `lib/api*.ts` functions and the selectors.
- **Your map:** point `NEXT_PUBLIC_MAP_TILES_URL` and `NEXT_PUBLIC_MAP_GLYPHS_URL` at your own tiles. For your own sites and routes, edit `lib/data/network.ts` and run `npm run geometry` (point it at your own OSRM with `OSRM_DRIVING` and `OSRM_BIKE`).
- **Your brand:** change `DEFAULT_UI.preset` in `lib/theme.ts` and the signal colours on `:root` in app/globals.css; set `buyUrl` to `null` in `lib/site.ts` to remove the demo's "Get Wayline" button.
- **Your rules:** an exception is one function returning `OpsException` objects with a stable ID; add yours in `lib/data/selectors/exceptions.ts` and the inbox, the map's alerts rail and the notifications pick it up.

The fastest way to see all of it is the [live demo](https://wayline.upfork.dev): open the live map, click TRK-208, then the Exceptions inbox, and add `?speed=20` to any address to watch the day go by.

---

On UpFork: https://upfork.dev/articles/how-to-build-a-live-logistics-dashboard-a-simulated-fleet-maplibre-and-one-s
