
Loading the page…
Loading the page…
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.
Written with AI: Claude Opus 5.5, through Claude
16 lessons learned building it
The product
Wayline — Logistics & Fleet Management Admin Template for Next.js & shadcn/ui (Live Map, Routes, Inventory)Pro plan · Landing page


The product
Pro plan · Landing page
A complete logistics admin for Next.js and shadcn/ui: a live map of trucks on real roads, an exceptions inbox with one-click fixes, route planner, dispatch board, orders, shipments with proof of delivery, fleet, drivers, inventory, warehouses and reports. Light and dark.
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.
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.
Each piece has one job, and nothing on screen is stored state:
t.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.
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:
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.
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:
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:
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 τ:
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.
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; is 170 KB. At runtime a leg becomes vertices with cumulative metres and cumulative free-flow seconds , so "this fraction of the leg's time" maps to a point with a binary search on :
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.
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:
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:
A snooze ends by itself when now passes it, and an acknowledgement survives the rule firing again.
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.
That rule was learned the hard way:
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:
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.
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.
Three more surprises came while preparing the release, the screenshots and the deploy:
docs/BACKEND.md explains the swap; each screen only calls the lib/api*.ts functions and the selectors.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).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.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: open the live map, click TRK-208, then the Exceptions inbox, and add ?speed=20 to any address to watch the day go by.
Written by
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork
Full-stack developer and founder of UpFork · .NET, React/Next.js and WebGL
This version is signed in UpFork’s ledger as entry #21, so anyone can check it hasn’t changed since it was published. Check it.
Something wrong in it, or against the rules? Report it.

Behind the build · Dynamic 3D Carousel for React & Next.js — Draggable Image & Video Slider
How Dynamic Carousel drives every card from one motion value: flick physics, a loop without clones, a 3D first paint that needs no script, and the useTransform trap Next.js hid from us.
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork11 min readAI
6 lessons


Behind the build · Sponsor Bot — Telegram Uploader Bot with Forced Channel Join, Ad Shop and Admin Panel
How Sponsor Bot runs many Telegram bots on one PostgreSQL database, no Redis: inbox, outbox and leases, a forced-join gate, TRON payment checks, and a load test to 200 updates a second.
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork17 min readAI
19 lessons


Behind the build · Maple — Steakhouse & Restaurant Hero Section for React with an Interactive Mascot
How Maple turns a 720p AI video into a keyed, recolourable cow that follows the cursor at 60 fps: the frame pipeline, gaze mapping, one-spring motion, WebGL drawing, and what surprised us.
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork22 min readAI
19 lessons
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.
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.
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.
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.
zoneOffset() in lib/data/time.ts.
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.
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.
data/geometry.jsond[]t[]t[]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:
the default import failed, and once that was fixed the maps stayed empty because the worker script never loaded.
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.
scripts/copy-map-worker.mjs and the first lines of components/map/fleet-map.tsx.
the container was 0 px high and the map invisible: maplibre-gl.css sets .maplibregl-map { position: relative }.
Tailwind 4 puts utilities in the utilities cascade layer; a stylesheet imported outside any layer outranks every layered rule, whatever the specificity.
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.
the comment above the map container in components/map/fleet-map.tsx.
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.
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.
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.
Wayline commit 4a8798c ("Animate the inside only" in app/globals.css, clusters keyed by members in components/map/fleet-map.tsx).
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.
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.
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.
Wayline commit 4a8798c (components/map/fleet-map.tsx).
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.
Wayline commit 59c9334.
one definition per metric, in one selector, its definition shown beside it; reports apply the same rule per day.
Wayline commits 4a8798c and 59b8999.
step through noon and snap back to midnight: dayStart(end − (days − 1) × DAY + 12 h).
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.
MapLibre parses colours itself and only accepts literal CSS colours; it never asks the browser to resolve a custom property.
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.
tokenRgba() in lib/color.ts and the preset effect in components/map/fleet-map.tsx (Wayline commit 70cb52a).
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.
useSyncExternalStore(subscribe, () => matchMedia(q).matches, () => false). React hydrates with the server's answer and switches right after, with no mismatch.
useIsDark() in lib/ui-store.ts (Wayline commit 4a8798c).
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} …>".
"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.
components/system/server-error.tsx.
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.
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.
wayline/eslint.config.mjs.
maps came out grey, with markers and route lines but no streets.
MapLibre's worker requests tiles after the page has gone idle, and on a slow link they keep arriving for several seconds.
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.
the listing screenshots of this product; the counter is in scripts/listing-shots.mjs in the Wayline repository (kept out of the download).
a pipeline's exit status is the last command's (tail), not the script's.
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.
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.
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 }
}// 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
}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), /* … */ }
// …
}
}export interface Suggestion {
text: string
action: "reassign" | "reattempt" | "transfer" | "notify" | "renew" | "call_driver" | "rebalance" | "approve" | "review" | "chase"
label: string
}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 }
})
}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,
})
}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 }
}
Comments
0 comments
No comments yet. Questions, what you built with it, what could be better: its developer reads them.
Sign in to comment.
Sign in to comment