Command Palette

Search for a command to run...

Hectal
PHASE 16Advanced ~63 min· topic 8 of 8

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 test and npm 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.jsx that fills the #kpis section with grey tiles, then real tiles.
LiveStatus
The component that starts the live feed on the #status box 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.

terminal
$ npm install
npm test
npm run build
── expected output ──
RUN v5.0.5
 
Test Files 1 passed (1)
Tests 15 passed (15)
 
vite v8.3.4 building client environment for production...
✓ 986 modules transformed.
dist/index.html 6.49 kB │ gzip: 2.33 kB
dist/assets/index-BUajVF3k.css 6.88 kB │ gzip: 1.96 kB
dist/assets/index-C-X5yBCy.js 711.63 kB │ gzip: 220.63 kB
Captured on 16 October 2026 with Vitest 5.0.5 and Vite 8.3.4 in the assembled project (every phase's files from 0 to 16). The tests do not need a browser. The bundle grew from 687.88 kB in Phase 14; the later phases added the rest. Your sizes will differ by a few kilobytes. Vite also prints its usual warning about chunks over 500 kB.

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.

Look at the live box, then break a boxdiagram
Rendering diagram…

Try it yourself

  1. 1

    Slow the status box down

    In LiveStatus add redrawEvery: 3000 to the options of startStatusFeed. The bars now change every three seconds, however fast the simulated messages arrive.

  2. 2

    Make the wrong file right

    In the example change stations-2026.csv to stations.csv. The stations box now loads (loadWrongFile returns 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 with Cannot read properties of null (reading 'className'): the box has no state to read.) Give it a Message kind="empty" like the late returns box. Change the name back to see the error and Try again.

  3. 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 Phase 16 part of the dashboard, with the modules inlined New tab

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

Press Run to see the page.

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.

src/parts/states.jsjs

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.

terminal
$ Console
── what you'll see ──
live status: charts 1 | table twins 1 | rows drawn 5 | feeds running 2
(the page looks right, but two feeds are running)

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: makeSource takes the snapshot and returns anything with onmessage, onerror and close. Tests and the preview pass a fake, and production passes new EventSource(url). That is the cheapest way to keep a real-time dashboard testable.

  • ▸

    useKpis writes 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 imports d3: the tests run in Node, so the modules must not touch document at import time. Keep browser calls inside functions.

Remember this

  1. 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. 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 changed RidesLine.jsx and StationsBar.jsx, the accessible D3 charts and charts.css). Those files do not change. Six files are new: states.js, kpi.js, live.js, export.js, production.css and tests/scales.test.js. Three change: App.jsx, main.jsx (one import) and package.json (Vitest and npm test). index.html does not change: the key numbers and the status box were already there.

  3. 3

    How it plugs in. The four plain modules know nothing about React, so they are tested as they are. App.jsx calls them: useKpis fills the #kpis section (which belongs to index.html, not to React), LiveStatus hands the #status box to the live feed and stops it in its cleanup, Message is the React version of showState, and ExportBar sits under the rides line. useLoad now retries with backoff and returns a retry function for the Try again button.

  4. 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 (makeTwin passes in tableTwin). 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. 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, not Infinity%. 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. 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 makeSource for an EventSource), 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

01

Which files changed in this build and which stayed the same, and why could the charts stay the same?

02

How does LiveStatus stop the feed, and why does React's StrictMode need that?

03

How do the new states keep one failed box from blanking the page?

Practice

01

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?

02

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 useLoad hides 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 changed App.jsx, main.jsx and package.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 test passes and npm run build finishes.

  • I can say what the dashboard still lacks and which phase adds it.