Topic 16.8
Build: The Live Operations Dashboard
In one line
Turn the accessible React dashboard into the live operations dashboard: key numbers with change and trend, a status box that updates itself, a state for every way a box can fail, export and print, and a test suite.
Before you start
You will learn
- Add the state, tile, live-feed and export modules to the project and wire them into
App.jsx - Watch the status box update on its own, then make a box fail and recover with Try again
- Run
npm testandnpm run build, and say what the dashboard still lacks
You write code in
In your Vite project lumen-dashboard/ (run npm run dev), and in the editor on this page
Files you touch
- src/parts/states.js
- src/parts/kpi.js
- src/data/live.js
- src/utils/export.js
- src/styles/production.css
- tests/scales.test.js
- src/App.jsx
- src/main.jsx
- package.json
Think of it like this
The control room wall at the end of a long project. The screen is on all day. The numbers move by themselves. A grey box with a sentence means one feed is late, a stale label tells you a number is old, and nobody has to touch anything to find out. That is what Tomás asked for.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- useKpis
- The hook in
App.jsxthat fills the#kpissection with grey tiles, then real tiles. - LiveStatus
- The component that starts the live feed on the
#statusbox and stops it when the page goes away. - makeSource
- The option that makes the message source. Today a simulated one, later
new EventSource(url). - AbortSignal
- A signal passed to the feed. Aborting it stops the feed, which React's StrictMode needs.
- Message
- The React component for a box's loading, empty and error states.
Step by step
01Start from your dashboard
You have lumen-dashboard/ running with npm run dev: the Phase 15 React app, with the accessible charts. This build adds to it; nothing from Phases 4 to 15 is removed.
02Add the four modules and the CSS
Create src/parts/states.js, src/parts/kpi.js, src/data/live.js, src/utils/export.js and src/styles/production.css from the files below. They are plain JavaScript and need no new package except D3, which you already have.
03Wire them in and add the tests
Replace src/App.jsx, add the production.css import to src/main.jsx, and replace package.json, then run npm install to fetch Vitest. Add tests/scales.test.js and run npm test: 15 tests pass in about a second.
04Look at the live box, then break a box
Open the dashboard. The station box shows a skeleton, then five bars that change every second or so, and a Show the numbers as a table list under it. Rename public/data/lumen/daily.csv to daily-old.csv: the key numbers go away and the error notice shows Could not load … with a Try again button, while the station box keeps updating. Rename it back and press Try again.
Try it yourself
- 1
Slow the status box down
In
LiveStatusaddredrawEvery: 3000to the options ofstartStatusFeed. The bars now change every three seconds, however fast the simulated messages arrive. - 2
Make the wrong file right
In the example change
stations-2026.csvtostations.csv. The stations box now loads (loadWrongFilereturns an empty list) and shows nothing at all: a box that is ready with no content is a blank box, which this phase avoids. (The example's own log lines that read that box's message now stop withCannot read properties of null (reading 'className'): the box has no state to read.) Give it aMessage kind="empty"like the late returns box. Change the name back to see the error and Try again. - 3
Pick dates, then export
In your project, press the Summer button and then Download CSV. The file name contains the dates (
lumen-rides-2025-06-01-2025-08-31.csv) and the file holds only those 92 days.
Code & diagrams
The preview cannot import your local files, so this example pastes states.js, kpi.js, live.js and export.js, and the Phase 16 parts of App.jsx (useLoad, Message, Box, the tiles, LiveStatus, ExportBar), into one file. Its own small App has a plain line instead of the full rides line, one wrong file on purpose and one empty box. tableTwin is a short stand-in for table-twin.js. StrictMode runs every effect twice, and the live box still has one chart and one table.
Preview
Console
Nothing logged yet.Expected console output
Error: Could not load stations-2026.csv (the server answered 404).
key numbers: Days of data 365 | Rides in the year 933,651 | Last 7 days 9,224 | Busiest day 18 Jul 2025 (4,380) | Quietest day 14 Aug 2025 (439) | Rows cleaned 0
change line: ▼ down 6.7% vs the 7 days before
tiles with a trend line: 2
live status: charts 1 | table twins 1 | rows drawn 5 | feeds running 1
stations box: state error | Could not load stations-2026.csv (the server answered 404).
late returns box: state empty | No late returns to show.
Error: Could not load stations-2026.csv (the server answered 404).
after Try again: error boxes 1
download: lumen-rides-2025.svg
download: lumen-rides-2025.png
download: lumen-rides-2025.csv
print dialog requested
export message: Saved lumen-rides-2025.csv (365 days)Your project files
Your lumen-dashboard project after this topic. Click a file to see it in full.
The whole file after this topic. If yours differs, you can copy this one and carry on.
// What a box shows while it waits, when it has nothing to show, and when something went wrong. Never a blank box.
/** The box with its first <h2> kept and everything else removed: the way to start a box over (a retry, or a new state). */
export function resetBox(box) {
const keep = box.querySelector(":scope > h2");
for (const child of [...box.children]) if (child !== keep) child.remove();
}
/** Removes the state message of a box (loading, empty or error), if it has one. */
export function clearState(box) {
for (const old of box.querySelectorAll(":scope > .state")) old.remove();
}
/**
* Shows one state message at the end of a box, replacing an older one. kind: "loading" (grey bars and a sentence),
* "empty" (a sentence that says why there is nothing) or "error" (a sentence and, with onRetry, a Try again button).
*/
export function showState(box, kind, text, onRetry) {
clearState(box);
const area = document.createElement("div");
area.className = "state " + kind;
area.setAttribute("role", kind === "error" ? "alert" : "status"); // a screen reader announces the change
if (kind === "loading") {
for (let i = 0; i < 3; i += 1) {
const bone = document.createElement("span");
bone.className = "bone";
bone.setAttribute("aria-hidden", "true"); // decoration: the sentence below says the same
area.append(bone);
}
}
const message = document.createElement("p");
message.textContent = text; // text only: an error message that came from a server is never markup
area.append(message);
if (kind === "error" && onRetry) {
const button = document.createElement("button");
button.type = "button";
button.textContent = "Try again";
button.onclick = () => {
button.disabled = true; // one click, one retry
onRetry();
};
area.append(button);
}
box.append(area);
return area;
}
/** The age of some data in words: "just now", "42 seconds ago", "3 minutes ago", "2 hours ago", "3 days ago". */
export function ageText(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return "at an unknown time"; // a clock that is wrong must not print "-5 seconds ago"
if (seconds < 5) return "just now";
if (seconds < 60) return Math.floor(seconds) + " seconds ago";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return minutes + (minutes === 1 ? " minute ago" : " minutes ago");
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + (hours === 1 ? " hour ago" : " hours ago");
const days = Math.floor(hours / 24);
return days + (days === 1 ? " day ago" : " days ago");
}
/** Marks a box as showing old numbers, with their age. Pass null when the data is fresh again. */
export function setStale(box, seconds) {
let badge = box.querySelector(":scope > .stale");
if (seconds === null) {
if (badge) badge.remove();
return;
}
if (!badge) {
badge = document.createElement("p");
badge.className = "stale";
badge.setAttribute("role", "status");
box.append(badge);
}
const words = "Showing old numbers. Last update " + ageText(seconds) + ".";
if (badge.textContent !== words) badge.textContent = words; // change the text only when the words change, so a screen reader is not interrupted every second
}
/**
* Runs task(attempt) and tries again when it fails, waiting wait, then 2 x wait, then 4 x wait (exponential backoff).
* A failure with an HTTP status from 400 to 499 is the caller's mistake, so it is not tried again.
* sleep and onRetry can be replaced, which is how the tests run without waiting.
*/
export async function withRetry(task, options = {}) {
const {
tries = 3,
wait = 500,
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
onRetry = () => {},
shouldRetry = (problem) => !(problem && problem.status >= 400 && problem.status < 500),
} = options;
let last;
for (let attempt = 1; attempt <= tries; attempt += 1) {
try {
return await task(attempt);
} catch (problem) {
last = problem;
if (attempt === tries || !shouldRetry(problem)) break;
const delay = wait * 2 ** (attempt - 1);
onRetry(attempt, delay, problem);
await sleep(delay);
}
}
throw last;
}Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget the abort in LiveStatus
In LiveStatus delete return () => controller.abort(); from the effect, and run the example.
Myth vs fact
Myth
A dashboard is finished when the charts look right.
Fact
It is finished when it survives a failed request, a stopped feed and a night on the wall. Those states are most of the work.
Myth
Production hardening means rewriting the charts.
Fact
Here no chart changed. The new behaviour sits around them: loaders, states, a feed, exports and tests.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
The simulated feed is a seam:
makeSourcetakes the snapshot and returns anything withonmessage,onerrorandclose. Tests and the preview pass a fake, and production passesnew EventSource(url). That is the cheapest way to keep a real-time dashboard testable. - ▸
useKpiswrites into a node React does not own. That is safe only because nothing else renders inside#kpis. If React rendered the tiles itself, the one-owner rule (Topic 14.1) would hold the same. - ▸
A wall display should also reload itself after a deploy and when it has been stale for a long time. A one-line
setTimeout(() => location.reload(), …)driven by the stale badge is often all it takes. - ▸
The test file imports
live.js, which importsd3: the tests run in Node, so the modules must not touchdocumentat import time. Keep browser calls inside functions.
Remember this
- 1
Tomás's need is met. The dashboard now runs on a wall without a person next to it: the key numbers show how the week went against the week before, the station box updates by itself, every box has a state for loading, empty, error and stale, and a failed box offers Try again while the rest keep working. People who want the numbers can download them, and the page prints cleanly.
- 2
Starting point: the Phase 15 dashboard. You have
lumen-dashboard/as a React app from Phase 14, with the accessible charts and table twins of Phase 15 (describe.js,table-twin.js, the changedRidesLine.jsxandStationsBar.jsx, the accessible D3 charts andcharts.css). Those files do not change. Six files are new:states.js,kpi.js,live.js,export.js,production.cssandtests/scales.test.js. Three change:App.jsx,main.jsx(one import) andpackage.json(Vitest andnpm test).index.htmldoes not change: the key numbers and the status box were already there. - 3
How it plugs in. The four plain modules know nothing about React, so they are tested as they are.
App.jsxcalls them:useKpisfills the#kpissection (which belongs toindex.html, not to React),LiveStatushands the#statusbox to the live feed and stops it in its cleanup,Messageis the React version ofshowState, andExportBarsits under the rides line.useLoadnow retries with backoff and returns aretryfunction for the Try again button. - 4
Phase 15 is kept. The live station chart has a
role="img"label that is rebuilt from the data at every draw, and a table twin that updates with it (makeTwinpasses intableTwin). The stale badge and error messages are live regions that speak politely or at once. The skeleton bars are hidden from screen readers and the sentence beside them is read. - 5
The checks inside. An empty rides file shows
The ride file has no days in it, so there is nothing to draw.instead of a crash. A tile without an earlier value says so, notInfinity%. A failed load is retried twice for a hiccup and not at all for a 404. Late messages are ignored, counts are clamped, and the feed stops when the page or the box goes away. - 6
What the dashboard can do now that it could not before: update a box by itself, show a number's change and trend, survive one failed request, say when data is old, export the picture and the numbers, print, and run a test suite in about a second. Still missing: a real server feed (the preview and your project use a simulated one; swap
makeSourcefor anEventSource), URL-stored filters (Topic 16.2 showed the pattern), the story page for the public (Phase 17), and the capstone steps: your own S3 bucket and publishing (Phase 18).
Explain it without notes
Which files changed in this build and which stayed the same, and why could the charts stay the same?
How does LiveStatus stop the feed, and why does React's StrictMode need that?
How do the new states keep one failed box from blanking the page?
Practice
Add a fifth tile, Rainy days (30 days), with higherIsGood: false, using rain_mm from the daily rows. Where does it go, and what does the change line say if the earlier 30 days are missing?
Make the status box show a stale badge when the feed has been quiet for 15 seconds. Which option and which function in live.js already do this?
Trade-offs
- ↔
Handing the status box to plain JavaScript keeps the feed testable and framework-free, at the cost of a node React does not manage. Rewriting it as a React component would let React own it, at the cost of rewriting
live.js's timers as hooks. - ↔
Retrying inside
useLoadhides short hiccups from the reader but delays the error message by up to 1.5 seconds. For a wall display that is the right trade.
Done when you can
My project has the four modules,
production.css, the test file, and the changedApp.jsx,main.jsxandpackage.json.The status box updates by itself, has a table twin, and stops when I leave the page.
I made the daily file fail on purpose, saw the Try again notice, and the rest of the page kept working.
npm testpasses andnpm run buildfinishes.I can say what the dashboard still lacks and which phase adds it.