# ADR: The Admin tab, as a product

Status: accepted, 2026-09-20. Asked for in one sentence, which is this record's whole specification:
"My goal is to make the admin portal very easy to understand and very graphically impressive, because
when I get a new job I want to advocate the admin portal we have at the new job, to make it easier
to maintain the site statuses."

## In plain words

This page decides how the operator's page (the Admin tab) is built so someone new can read it in seconds: a strip of status tiles across the top, and every card filed under the plain question it answers, such as "is it up?". Its charts are drawn by the site's own code, with no added library.

What that is worth: a developer gets charts and thresholds written as small tested files they can read or lift, and the organization gets a status page anyone on the team can open without an Azure sign-in.

## Context

The Admin tab grew a card at a time, each one answering the question of the week it was written in:
health, Azure's view of the container, traffic from Application Insights, errors, timing, the SQL the
application ran, what the document store ran, the comparison, the proof, site activity, the kept log,
every page checked, what the machines are doing. Every one of them is correct, and most of them are
paragraphs and tables. A person who built it reads it easily. A person who did not, which is the
person this record is for, meets several paragraphs before the first picture.

The bar is a reviewer looking at it and wanting one. That reader gives a page a few seconds,
and in those seconds the page has to answer four questions in the order they would be asked at
three in the morning: is it up, is it fast, is it costing anything, what broke.

## What was considered

| Option | What it buys | What it costs |
| --- | --- | --- |
| A component library, Material UI | Cards, tiles, icons and charts that look finished on day one | Priced before it was decided: about 300 KB on the wire for a site whose own script is about 100 KB compressed, and a contradiction of the decision the README states in its first paragraph on the stack, no component, icon, state or CSS libraries |
| A hosted dashboard, Azure Monitor workbooks or Grafana | Real charting, alerting, history without writing any | Not on the site: a reader would have to be given an Azure sign-in to see it, and the thing being advocated is a page a team can open, not a portal they need a role in |
| **The site's own CSS over the token sheet, and its own chart arithmetic** | Every number on the page comes from the thing it is about, the page stays inside the bundle it has, and the whole of it is readable source a team could lift | Every chart is drawn here, by hand, and has to be tested here |

**Decision: our own CSS over `src/styles/tokens.css`, and charts drawn by `src/lib/machineChart.ts`,
which imports nothing from React and is tested on its own.** Steve chose it over Material UI with the
price in front of him.

## The rules the charts follow

They are the rules the machine charts already followed (ADR: What the machines are doing), written
down because there are now enough charts for them to be a system.

- **One question a chart.** How busy, how fast, what failed, what it cost. A chart that answers two
  questions in two units is two charts.
- **One frame.** Every chart is the same 720 by 170 drawing with the same axis, the same three
  labels along the bottom and the same legend under it, so the eye learns it once.
- **A gap is a gap and a zero is a zero.** A stretch nobody measured breaks the line. A measured
  minute in which nobody asked for anything is zero requests, and has no median, because there is no
  median of nothing. The arithmetic that decides which is which is one function a window, tested,
  and the hour and the month go through the same one.
- **A percentage keeps a full axis.** A quiet hour drawn against its own maximum looks like a busy
  one, which is a chart lying with true numbers.
- **Colour means something or it means position.** Where a line is good news or bad news it takes
  the status colours the rest of the site already uses; where it is one store against the other it
  takes the two store colours; otherwise it takes its place in the legend.
- **A reading with no history behind it keeps its table.** The health checks, Azure's view, the SQL
  the application ran: these are lists of facts about now, and a line through them would be
  decoration. They stay tables.

## What is drawn, as of this record

**Traffic**, a new card above the machines: requests a minute, the median and the ninety-fifth in
milliseconds, and answers that were 5xx and 4xx, a minute at a time. The hour is the request ring;
the day, the week and the month are the minutes each site keeps (ADR: What the machines are doing,
the addendum on the windows), which is why a kept minute carries its traffic. One window is chosen
once, on this card, and the machines card under it follows, because the two are only worth reading
side by side over the same stretch.

**The proof**, as pairs of bars over its table: each path's two medians, each bar a share of the
longest median on the card. The table says most pairs are the same and the rest differ by a round
trip; the bars say it before anybody reads a number.

**Request units a minute** and **the machines** were already drawn, and **unique visitors a day** is
the graph whose shape the rest were matched to.

## What it costs

Nothing on the bill and nothing on the wire that is not this site's own. No dependency was added.

## Addendum, 2026-09-20 (1.0.0.161): the look

The charts were the second ship and this is the third: the page now opens on the four questions this
record started from, and everything under them is filed under the question it answers.

**A strip of tiles across the top.** Eight tiles, each a label, one number, a line saying what the
number is a number of, and where there is an hour behind the number, the hour drawn under it as a
line with no axis. Version, health and pages answer "is it up?"; the slowest ninety-fifth of the last
hour and today's visitors answer "is it fast?"; memory against its limit and the request units
charged answer "is it costing anything?"; errors answer "what broke?". A tile is a button, and it
goes to its question.

**The tiles ask the server for nothing.** They are made of what the cards below have already read, so
a tile and the card it points at cannot disagree, and the strip added no endpoint and no request. A
reading that has not arrived is a tile that says "waiting", not a zero.

**What makes a tile amber or red is a rule somebody can read.** `src/lib/statTiles.ts` has no React
in it, and the thresholds are constants with names:

| Tile | Amber, worth a look | Red, needs attention |
| --- | --- | --- |
| Health | the site calls itself healthy while a check fails, which is the fallback serving | the site does not call itself healthy |
| Pages | the sweep checked nothing | any address is down |
| Typical answer | the hour's ninety-fifth is 1,000 ms or more, over twenty requests at least | 3,000 ms or more |
| Memory | four fifths of the limit | nineteen twentieths of it |
| Errors | | anything answered 5xx in the hour, or anything reported |

They are this site's own, read off what it measures on a quiet day, and they are there to be argued
with. The tone is written on the tile as a word, "fine", "worth a look", "needs attention", as well
as a colour, because a colour alone says nothing to somebody who cannot see it.

**Four questions, as headings, with the cards under them.** Is it up: application health, Azure's
view, every page checked. Is it fast: traffic, the last hour as Application Insights recorded it,
timing, the two backends side by side, the proof. Is it costing anything: the machines, the partition
key, what each store ran. What broke: recent errors, the log, the kept log. The operator's desk,
which is site activity, the key and the reset link, comes last, because it is where somebody works
and the rest is where somebody looks. No card was removed and no number moved to a different card.

**The paragraph on each card is one tap away.** Twelve cards opened with a paragraph on what the card
is and how to read it. They are all still there, word for word, inside a closed "What this shows" on
the card, so the first thing on a card is its numbers. On a phone that was most of the scrolling.

**All of it is the token sheet.** The tiles, the headings and the soft shadow every card now carries
are `AdminPanel.module.css` over `src/styles/tokens.css`: the surfaces, the three status colours and
their soft grounds, the accent, the radius and the two shadows were already there, and no token was
added or changed. No dependency was added either.

**Checked at 375 px.** The strip is two tiles to a row on a phone and four from 720 px. The browser
suite opens the tab on a 375 by 812 screen and holds that the first two tiles sit side by side at the
same size, that the third is under the first, and that nothing on the tab makes the page wider than
the phone: charts and tables scroll inside their cards.

**What is still small on a phone** is the writing on the charts. A chart is a 720 wide drawing, and in
a 320 wide card its axis labels are drawn at under half size. The lines read and the labels do not,
and that is the next thing to fix on this tab, said here so that nobody finds it before the record
does.

**What it cost on the wire**, from the build's own output: the script went from 101.15 kB compressed
to 103.30 kB and the stylesheet from 8.74 kB to 9.26 kB.

## Addendum, 2026-09-20 (1.0.0.163): the first amber tile, investigated

The strip had been live for eight minutes when a tile went amber: "Slowest 95th, 1212 ms, worth a
look". Steve's instruction was that anything the dashboard marks is investigated, which is the
point of marking it. This is what the tile led to, in the order it was found.

**Where.** The machines endpoint's traffic, a minute at a time, on both sites: the slow minutes were
12:30 and 12:32 UTC on the SQL site and 12:30, 12:31, 12:36 and 12:37 on the Cosmos DB site. The
processes had started at 12:30 and 12:32: the roll of 1.0.0.161. The later pair had no roll
behind them.

**What.** The timing card's rows by path, same read: `/api/vehicles` at a median of 278 ms and a
ninety-fifth of 779 ms, 1,059 ms at worst, server-side, against 0 to 31 ms for every other API path.
One `/index.html` at 1,212 ms was the first request a cold process served.

**Why.** `InventoryService.Search` sorted every match and then took the page. An unfiltered listing
matches all 100,000 vehicles, so every landing page was a full stable sort of 100,000 rows to show
100. On a container group's own core that read 51 ms and nobody looked again. On the plan it shares
one core with the other site (ADR: One plan, two sites), it read 122 to 160 ms warm, and in the
minutes after a roll, with two processes starting on that core, it passed a second.

**The fix** is three lines in one method: count the matches, then `Skip` and `Take` straight off
the ordering, which lets the runtime sort only as far as the page asked for. The rows and their order
are the same, ties included, because the ordering is still the stable one, and
`A_page_is_the_rows_a_full_sort_would_have_given_ties_included` holds that for every sort and six
pages over forty vehicles at four prices. What it reads on the plan afterwards is measured after the
roll and goes in the changelog line that follows this one, not guessed at here.

**What the tile learned.** A tile that says "slow" and not "when" sends somebody through an hour of
rows, so the tile now names the minute: "74 requests in the last hour, slowest at 07:32". The
threshold did not move. A ninety-fifth over the ten requests of a quiet minute is that minute's
slowest request, which makes the tile quick to go amber after a roll, and that is left as it is on
purpose: it went amber over something real the first time it was looked at.

## Addendum, 2026-09-20 (1.0.0.165): one window for every chart

With the tab open, Steve asked for every graph to offer 30 days, 7 days and 24 hours, and for every
chart to reach back at least 30 days.

Every chart of something over time already could: the three traffic charts and the three machine
charts read the last hour, 24 hours, 7 days or 30 days from the minutes each site keeps for thirty-one
days, and the visitors graph reads 24 hours, 7 days or 30 days from counters kept for thirty-five.
What he found is that the page did not say so where he was looking. 1.0.0.161 had filed the traffic
card under "is it fast" and the machines card under "is it costing anything", a long scroll apart,
and left the buttons on the first with a sentence on the second saying to go and find them. And the
lines under the tiles were the last hour and nothing else.

**One window, and its buttons wherever a chart is:** over the tiles, on the traffic card and on the
machines card. They are one piece of state, so pressing a row presses all three, and the browser
suite holds that. The lines under the tiles follow it: over a kept window they are drawn from the
same buckets the charts are, one value a bucket, with a gap where the store holds nothing, by
`keptSparks`, which is tested on its own. A sentence beside the buttons says what the lines are lines
of, and that the number over a line is still now, because a line with no axis says nothing about its
own width. A change of window no longer sends the tiles back to "waiting": they are made of the last
answer that arrived.

**The chosen button looks chosen.** Every row of window buttons on the tab marked its choice with
`aria-pressed` and with nothing a sighted reader could see, which a picture of the new row taken by
the precheck showed at once. The pressed button is now filled with the accent colour, from the token
sheet, on every row on the tab.

**What is not on the window, and why.** The proof's bars are one run and not a stretch of time. The
visitors graph keeps its own three buttons, because its shortest window is a day and its card is the
operator's desk. And thirty days is what the page offers, not yet what the store holds: the minutes
began to be kept at 1.0.0.159, this morning, so a month drawn today is a gap with one day at the end
of it, which is what the charts draw and what the card's sentence counts.

## Addendum, 2026-09-20 (1.0.0.166): where a window starts, and an alarm that fired on every deploy

Two things the live tab showed an hour after 1.0.0.165, looked at in a headless browser from Steve's
machine and not asked about.

**A month drawn whole looked broken.** The minutes began to be kept this morning, so Last 30 days
held two four-hour buckets of 180, and the charts and the lines under the tiles drew them as one
sliver at the right-hand edge of an empty frame. It was correct, by the rule this record set, a gap
is a gap, and it read as a chart that had failed to load. The rule was too wide. A gap after the
first reading is the site not reporting, and that is information. The emptiness before the record
began is not a gap in anything, and drawing it tells nobody anything for the next twenty-nine days.
So a kept window is still asked for whole and still counted whole, "2 of 180 buckets hold a
reading", but the drawing starts at the first reading (`fromFirstReading`), never at fewer than a
dozen slots, and the sentence over the charts says so and gives the time. Every later gap is drawn.

**The speed tile went amber after every roll.** It went amber over something real the first time
(the addendum above on the first amber tile), and then again after 1.0.0.164 and 1.0.0.165, each
time naming the minute the process started: a cold process answers its first request in a second or
more, and a ninety-fifth over a quiet minute is that minute's slowest request. An alarm that fires
on every deploy teaches people to look past it, which is worse than no alarm. The tile now reads the
hour without the first three minutes after a start (`afterColdStart`, and the three is a named
constant), and says on its face that it did: "slowest at 07:41; the start at 07:30 is left out".
Nothing is hidden: every request and every error in those minutes is still counted on the tile, the
line under it still draws them, and so does the traffic card. When the process started is its newest
sample's time less its uptime, both from the one answer.

**Corrected the same morning, 1.0.0.167.** The look that found these two was run again after the
roll, and found the fix's own sentence wrong: with a dozen four-hour slots kept, a month's drawing
starts two days back, and the page called that time "its first reading", two days before anything was
kept. The sentence now gives the first reading and the start of the drawing as the two things they
are. The same look found a site whose only requests in the hour were its own cold start reading
"quiet" beside "67 requests"; it reads "warming". Both were true defects of a fix, live for twenty
minutes, and both were found by looking at the page and not by a test, which is the argument for
looking at the page.

## Addendum, 2026-09-21 (1.0.0.168): the traffic card in plain words, and a colour that raised an alarm

Reading the tab as the person it is for, Steve found the traffic section unclear about what it was
doing. On the first drawing of a plainer one, he pointed out that red on the slowest response time
reads as something wrong and raises a warning while scanning.

**The colour rule, restated, with the defect named.** This record said "colour means something or it
means position", and then let the traffic card break it: the median was drawn in the success colour
and the ninety-fifth in the warning colour, as though one were the good line and the other the bad
one. They are two series. A slow-requests line is slow by definition, on a good day and a bad one,
so a warning colour on it is an alarm that never stops, and the eye of somebody scanning the page
lands on it every time. The same chart family had the second half of the defect: a turned-away
request, a wrong address or a visitor who is not signed in, was drawn in the warning colour, and
nothing is wrong with the site when it turns one away. So the rule, in the form the code now holds:

- **A series takes an identity colour.** On the traffic card those are two tokens of their own,
  `--color-series-1` and `--color-series-2`, which today hold the accent's value and the heading's:
  neither a store's colour nor a status (ADR: The palette, the addendum on the series colours).
- **The status colours are for states.** A tile, a pill, a row of the kept log: things that are
  fine, worth a look, or wrong.
- **The one state a line can be is a server error**, which is something wrong whenever it is above
  zero, so that line alone takes the danger colour.
- **The alarm for slow answers lives on the speed tile**, which goes amber at 1,000 ms and red at
  3,000 and knows about a cold start. The chart draws the series and leaves the judgement there.

`MachineChart` lost its "good" and "warn" tones, so the defect cannot be written again by passing a
prop, and the browser suite reads the stroke of every line on the card and holds that none of them
is a status colour except the server errors line, which has to be.

**Every other chart on the tab was checked for the same defect, and none has it.** The three machine
charts over the hour and the three over a kept window pass no tones and take their colours by
position. The visitors graph and the proof's bars take the two store colours, which is what they
are comparing. The line under a tile takes the tile's tone, and a tile's tone is a state. The kept
log's rows keep the warning and danger colours on their edge, because a warning there is a warning.

**"Did anything fail?" comes first, and zero is said in words.** It was an untitled chart with a
legend reading "Answered 5xx (a minute)". It is now the first section on the card: the question as
its title, one sentence saying that a server error is the site's fault and should be zero and that
a turned-away request is the visitor's, a legend with the plain word first and the term in brackets,
and "errors / min" on the axis. Most hours that chart is a flat line at zero, and a flat line reads
as a chart that did not load. So the card says it: "No server errors in the last hour", beside the
same good pill the health card uses. When there are any, the sentence counts them and the pill says
so.

**Four numbers in place of one sentence.** "33 requests in the last hour, 0 answered 5xx and 7
answered 4xx, and the slowest stretch answered its ninety-fifth in 139 ms" was correct and written
for the person who wrote it. The card now opens on four blocks that wear the tiles' look, two to a
row on a phone: requests; the typical answer, "half of requests were faster"; slow answers, "19 in
20 requests beat this"; and server errors, with the turned-away count on its detail line. A block
takes a tone only for a state: no server errors is good and any is bad, and an answer under the
speed tile's own amber line is called fast. Over that line the block says nothing, because the
warning is the tile's to give and this block does not know about a cold start.

**One number is new, and it says what it is.** The slots the charts are drawn from hold a median a
minute, or a median a bucket over a kept window, and a median of every request in the window cannot
be made from those. The typical answer is the middle one of the medians the slots hold, by nearest
rank, and its detail line says "in the typical minute", or "stretch" over a kept window. Requests,
server errors, turned away and the slowest ninety-fifth are the numbers the sentence carried, from
the same function. No endpoint changed and no C# did.

**A question a chart, as its title.** "How busy is it?", requests per minute, with the sentence that
a flat line at zero means the site was up and nobody asked. "How fast does it answer?",
milliseconds, lower is better, "Typical request (median)" and "Slow requests (95th percentile)",
and one sentence on what 19 in 20 means. Every axis on the card carries its unit. The sentence that
a record is younger than its window is still under the blocks, word for word, and the folded "What
this shows" is unchanged.

**Where the words live.** `src/lib/trafficCard.ts` has no React in it and decides what the four
blocks say, the three titles and their sentences, and the sentence over the fail chart, and Vitest
holds them, including that no block's words contain a status code or a percentile.

## Addendum, 2026-09-21 (1.0.0.169): the tab in the new look

The Admin tab moved with the rest of the site to teal, dark green and gold, and to glass panels over
a soft watermark (ADR: The glass look; ADR: The palette, the addendum on teal, dark green and gold).
What that changed on this tab, and what it did not:

- **The rules this record set did not move.** One question a chart, one frame, a gap is a gap, and
  colour means something or it means position. The series are dark green, bright teal and a neutral
  grey, in that order on every chart, and the status colours are still only states. A turned-away
  request is the third series' grey.
- **Three tiles carry a ring**: health, pages and memory, because each of those numbers is a share
  of a known whole. The speed tile and the request units tile carry none, because milliseconds and
  request units have no whole, and a ring drawn for looks is a gauge that measures nothing. Which
  tiles have one is decided in `statTiles.ts` with the rest of the tile rules, and tested there.
- **A plain tile is the deep teal and never the accent teal**, which sits 1.09 from the status
  green. Plain, fine, worth a look and needs attention read as four different things.
- **A chart has a fine grid and a readout**: a rule at the slot under the pointer, the slot's time,
  and each line's reading in the unit of the axis, or "not measured" where a gap is. The arithmetic
  and the words are in `machineChart.ts` and tested there. On a phone the writing on a chart is
  still small, as the addendum on the look said it was, and the readout is a desk feature in
  practice.
- **The unit on an axis keeps a halo of the card's ground** and is drawn over the lines, because the
  live look after 1.0.0.168 showed a line that starts at the top of the axis passing through the
  word.

## Addendum, 2026-09-22 (1.0.1.4): red needs a busy minute

Steve saw "Slowest 95th: 6355 ms" and "needs attention" on a morning nothing was wrong, and asked for it to be checked against the last 30 days: it might be a deployment or wake-up blip, and such blips should not raise an alarm. The kept minutes only reach back to 2026-09-20, about 50 hours, so that is what was read, on both sites, at five-minute grain for the last 24 hours. Nine readings were red. None was a restart: the sampler kept every minute and the uptime never reset. Seven were quiet stretches after an idle spell, 10 to 28 requests in five minutes and no database work at all, and the 6355 ms was one of them. Two came within a minute of a ship commit, under a couple of hundred requests; their cause was not found.

The tile's number is the worst minute's ninety-fifth, and over a handful of requests a ninety-fifth is that minute's one slowest request. So **red now needs a minute of at least 20 requests (`RED_NEEDS_REQUESTS`)**; a slower minute with fewer is amber at most, the number still shows, and the line under it says the minute had too few requests to call red. Over the same 24 hours that leaves 2 of the 9 red. The three minutes after a start are still left out (the cold-start region), and every request and error in the hour is still counted.

## Addendum, 2026-09-23 (1.0.3.4): the typical answer, and the ninety-fifth beside it

The next morning Steve noted that the "is it fast" tile kept showing slow, and asked whether the reading was off and whether it should use the average instead of the 95th. Measured before anything changed, over the plan's first three days (2026-09-20 to 09-23): every request the two sites answered from Application Insights, about 30,000; the kept minutes, which are what the tile reads; and 48 process starts read off the after-ship logs plus one platform restart on the 23rd. The tile was sampling wrong in three ways, and the site has a small real tail.

**The tile headlined the slowest minute of the hour.** Its number was the largest of sixty minutes' ninety-fifths, so one slow request coloured the tile for the sixty minutes that minute stayed in the hour. On the SQL site 1.5 per cent of minutes had a ninety-fifth of a second or more, and the tile read amber 45 per cent of the time (Cosmos DB: 1.1 against 35). **Starts were most of the slow requests.** Of the 131 visitor requests over a second on the SQL site, 98 fell in the first three minutes of a process and 15 in minutes three to ten; a roll is a cold catalogue. **And the Admin tab's own reads were the slowest routes on the site**: 751 of the 882 requests over a second were `/api/admin/azure`, `experiment`, `activity`, `telemetry` and `/api/health`, which the ring already leaves out but the request log does not, and which no visitor makes. The real tail is `/api/vehicles` when warm: 19 of 808 calls over a second on SQL (median 70 ms, ninety-fifth 594), 16 of 123 on Cosmos DB; none of them waited on a database, and each coincided with other requests on the one B1 core the two sites share.

So the tile reads the hour the way a visitor felt it. **The number is the hour's median over every request in its warm minutes, and the ninety-fifth stands beside it**, both from the durations each traffic minute now carries sorted (`hourTiming`), because a percentile of an hour cannot be had from sixty minutes' percentiles. **Under twenty requests the hour reads "quiet" and shows the count with both numbers** (`QUIET_BELOW_REQUESTS`, which replaces the busy-minute rule above: over fewer, a ninety-fifth is one request's time). Amber is an hour whose ninety-fifth is a second or more, red three seconds or more, and neither needs the extra rule now, because the floor is on the hour rather than the minute. The three minutes after a start are still left out and still said. Replayed over the same three days the tile reads good 48 per cent of the time on the SQL site and quiet the rest, amber under 1 per cent on either site, red never. The line under the tile is the median a minute, the same reading as the number over it; the traffic card keeps drawing every request, the slow line included.

## Addendum, 2026-09-24: the workbench, one card at a time, the address naming it

Steve chose the third of three drawings for the tab (a workbench, one card large), with each card reached by its address. The tab had grown to 19 cards on one scrolling page, and every card fetched when the tab opened, which is how the tab's own reads became most of the slow requests on the site (the addendum above on the typical answer). So the tab is a workbench now. **The address names the card**: `/?view=admin&card=timing` opens the timing card, `?view=admin` alone opens the first card of the first question (health), and a name that is no card opens health and says so in the rail, and the address stops naming it. **A card is a place**: opening one pushes an entry, so Back returns to the card before it, and the view's own back button still leaves the tab in one step, because each entry carries how deep into the bench it is. **The rail** down the left is the five questions in the order somebody asks them, every card under the question it answers, the open one marked, with a search box that filters it by name; it scrolls on its own when the window is shorter than it. **The strip stays** across the top and each tile is a link to the card that answers it (Health to health, Pages to pages, the typical answer to timing, visitors to the activity card, memory and request units to the machines, errors to errors, the version to the tests). **j and k** walk the rail's order wherever focus is, except in a field being typed in. **A pin** keeps a second card beside whichever one the rail opens next, so the comparison the long page gave for free is one choice away: `&pin=errors` in the address, one pin at most, unpinned from either column. Each card's own markup is what it was; what moved is which of them is on the page. The strip keeps its own reads of the page sweep and of today's people, because the cards that show those in full are not on the page unless they are open.

## Addendum, 2026-09-28 (1.0.3.35): the stylesheet by file

`AdminPanel.module.css` held every class the Admin tab draws, 1,766 lines and 164 classes, for the workbench and all nineteen cards. With a folder per component (ADR: One folder per component) the classes moved to the file that uses them, read off the source rather than a plan: a class one file uses lives beside that file, and a class several use lives in a shared file named for what it is.

- **`admin/AdminPanel/AdminPanel.module.css`**, the workbench only: the bench, the rail and its finder, the drawer, the pin, the crumb, the hour's heading, the loading card, the title.
- **`admin/shared/card.module.css`**, what every card is built from: the card and its title, the quiet text, the wide card, the back pill and the window pills, the filter row, the check lists and status rows, the mono type, the pills, the About block.
- **`admin/shared/stat-strip.module.css`**, the stat strip and every tile on it, drawn by the workbench and by the traffic card, with the strip's sparklines.
- **`admin/charts/charts.module.css`**, the plot, its axes and Mark VII marks, the lines and swatches, the readout, the gauges and the summary list under a chart.
- **`admin/DataTable/DataTable.module.css`**, the table, its sortable headers, its day rows, its stacked form, and the tone of a warning or error row (the kept logs pass those into the table, and the stacked form styles them).
- **One sheet per card where a card has classes of its own:** the activity card (22), the proof (7), the traffic card (6), the tests card (6), the kept logs (1) and the errors card (1).

Every rule kept its exact text; a `@media` block whose rules went to two files became one block in each. Three rules decided a placement against the first plan: the telemetry card's error list shares its rules with the check list, so both are in `card.module.css`; the kept logs' warning and error rows are styled by the table's stacked form, so they are in `DataTable.module.css`; and the workbench's `statStrip` and its sparklines are styled with the tiles, so they are in `stat-strip.module.css`. A mono cell in a table wears the card sheet's `.mono` and the table's own, since the table's rules for it read `.table td.mono`. One class, `grid`, was declared and never used: no component named it, as `styles.grid`, in a template string or by a computed key, and no rule composed it, so it went.

Two things change at run time and nothing else does. A card whose sheet is its own now loads that sheet with its own chunk, the first time the card opens, instead of inside the workbench's sheet. And where two sheets style one element (the traffic card's stat blocks are tiles with a look of their own), the card's sheet loads after the strip's, the same order the two rules had in the one file. Built, the tab's CSS was one file of 25,548 bytes and is nine files of 25,210 bytes in all, 338 fewer: the workbench's file, 12,979, which carries the card and stat strip sheets the workbench itself imports, then the table 4,064, the charts 3,911, the activity card 2,103, the traffic card 751, the tests card 664, the proof 597, the kept logs 104 and the errors card 37, each loaded with its own chunk. The page's first sheet is byte for byte the same file.

## Addendum, 2026-10-15 (1.0.3.127): the days of the week on a daily chart

Steve flagged the Site activity chart over a month: its axis read a date every sixth day from 14 Sep, and nothing on it said which day was a Monday. The question that chart answers most is whether a quiet stretch was a weekend or an outage, and the 30-day read on 14 October had the answer in it (people visitor-days of 58 on Saturday 26 September and 49 on the Sunday, against 95 to 138 on the weekdays either side), but only for somebody who counted days. So a chart whose slots are days or parts of days now says it. **Every Monday and Friday carries its name and date** along the bottom, "Mon 14 Sep, Fri 18, Mon 21", the month written once on the first label, and **the weekends are washed** in a faint band of the heading colour. The hour and the day keep their three clock labels, because every slot of those falls on one date and shading it all would say nothing. A week on the activity card already names every day, so it takes the wash and not the labels.

Which slot falls on which day is decided in `src/lib/dayMarks.ts`, React-free and tested: the UTC calendar day, which is how the server keeps its days, a date like `2026-10-12` read at noon UTC so it is that day in every zone, a label only on the first slot of its day so a day of four-hour buckets is named once, and a slot whose time cannot be read skipped rather than guessed. `DayMarks` draws it, one component that the activity chart and the machine charts both place under their lines. Built, it adds about 0.77 kB compressed across the Admin chunks and leaves the page's first chunk byte for byte the same.

```ts
/**
 * Every slot's day of the week, for a chart whose slots are days or parts of
 * days: the weekend ones to shade and the Mondays and Fridays to label. A
 * window shorter than a day gets no marks, because every slot is the same
 * day and shading it all would say nothing. A slot whose time cannot be read
 * is skipped rather than guessed.
 */
export function dayMarks(slots: { at: string }[], windowMinutes: number): DayMark[] {
  if (windowMinutes < 24 * 60 || slots.length === 0) return [];
  const marks: DayMark[] = [];
  let previousDay = -1;
  slots.forEach((slot, index) => {
    const when = instantOf(slot.at);
    if (Number.isNaN(when.getTime())) return;
    const day = when.getUTCDay();
    const weekend = day === 0 || day === 6;
    // A label sits on the first slot of its day, so a day of four-hour buckets is labelled once.
    const labelled = LABELLED_DAYS.has(day) && day !== previousDay;
    marks.push({ index, day: UTC_DAY_NAMES[day], weekend, labelled });
    previousDay = day;
  });
  return marks;
}
```
*Live from [`src/lib/dayMarks.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/lib/dayMarks.ts#L33-L55), region `day-marks`, read from this build at 1d6b50b.*

## Addendum, 2026-10-15 (1.0.3.128): the Overview, and one table of normal ranges

Steve asked for the tab to "bring as much server and code health information to the surface as possible" and for every section to show him exactly what he needs. Read on 14 October, the strip had eight tiles and none of them said where normal ends: 27% memory said nothing about 80% amber and 95% red, and "under 1 ms" said nothing about the second that turns the speed tile amber. The cost of running the site was two clicks away on the spend card with no line it could cross. Three of the eight tiles have no tone by design, so they never rolled anything up.

**The tab opens on the Overview now**, the first card on the rail, and `?view=admin` with no card names it. It is four rows, the four questions in the order somebody on call asks them, each with its number and its normal range under it, and the tone on the row's edge and in a word beside the number. Up counts the checks and the pages and says when the process started and when the site last rolled, at which version, both read off what the API already answered (the metrics read's start time and the page sweep's trigger). Fast is the speed tile's reading. Cost is the month so far against Steve's number for the subscription, **$50 a month: red over it**, amber from $40 or when Azure forecasts more than the month's own pace would reach, measured to the newest day on the bill because Azure reports a day late. Broke is the errors tile plus any kept-warm read that failed. The rows are built in `src/lib/overview.ts` from what the strip already holds plus reads the API already served, so the Overview and the tiles cannot disagree. No endpoint was added for it.

**Every line now comes from one table.** `src/lib/ranges.ts` holds each judged number's amber and red lines in its own unit, with where they come from, from memory and the ninety-fifth through the month's cost and the gate's seconds, plus two for the collection still to come (the certificate's days and the SQL storage). The tile rules import their thresholds from it instead of keeping their own. Seven cards print their range under the title, so a card and its tile say the same thing about where amber starts.

Two signals that were on the wire and on no card are drawn now. The Backends card names this site's build beside the other site's: the peer read asks the other site's public `/api/version` inside the same two and a half seconds of patience, because two sites on different builds explain most of the differences between them. Under the table it says when this process started and what the runtime compiled for itself. The Azure card says when Azure answered, since the answer is kept for a minute.

```ts
/** Steve's monthly budget for the whole subscription: over it the cost reads red ("Red at this is over 50$", in his words). */
export const MONTHLY_BUDGET_DOLLARS = 50;

/** Every judged number, by key. A share is written from 0 to 1 and said as a percentage. */
export const RANGES = {
  memory: {
    name: 'Memory of its limit',
    unit: '%',
    amber: 0.8,
    red: 0.95,
    source: 'the container is restarted by the platform at its limit; 80% leaves room for a burst',
  },
  p95: {
    name: 'Ninety-fifth answer time',
    unit: 'ms',
    amber: 1_000,
    red: 3_000,
    source: 'ADR-080: a second is slow to a visitor, three seconds is broken',
  },
  coldStart: {
    name: 'Cold start, ready',
    unit: 'ms',
    amber: 30_000,
    red: 60_000,
    source:
      'measured at 26,677 ms on the live site; past a minute the platform warm-up probe gives up',
  },
  ruMinute: {
    name: 'Busiest minute of the free request units',
    unit: '%',
    amber: 0.5,
    red: 0.9,
    source: 'the free tier carries 1,000 RU a second; past 90% a burst is billed or throttled',
  },
  sqlDtu: {
    name: 'Azure SQL busiest share',
    unit: '%',
    amber: 0.6,
    red: 0.9,
    source: 'the serverless tier scales at its ceiling; 60% is the early look',
  },
  keptWarm: {
    name: 'Slowest kept-warm read',
    unit: 'ms',
    amber: 1_000,
    red: 3_000,
    source: 'the same lines as an answer a visitor waits for',
  },
  cost: {
    name: 'The month, forecast',
    unit: '$',
    amber: MONTHLY_BUDGET_DOLLARS * 0.8,
    red: MONTHLY_BUDGET_DOLLARS,
    source:
      "Steve's budget: red over $50 a month; amber from $40, or when Azure forecasts more than the month's own pace",
  },
  clientErrorsHour: {
    name: 'Turned away (4xx) in the hour',
    unit: '',
    amber: 50,
    red: 200,
    source:
      'a quiet hour turns away a handful of scanners; 50 is a crawl, 200 is an attack or a broken link',
  },
  gateSeconds: {
    name: 'The gate, start to verdict',
    unit: 's',
    amber: 300,
    red: 600,
    source: 'ADR-068: every check in under five minutes',
  },
  certificateDays: {
    name: 'Days left on the certificate',
    unit: 'days',
    amber: 30,
    red: 7,
    lowIsBad: true,
    source: 'the platform renews at 30 days; under a week a renewal has failed',
  },
  sqlStorage: {
    name: 'Azure SQL storage of its tier',
    unit: '%',
    amber: 0.8,
    red: 0.95,
    source: 'the tier holds 2 GB and refuses writes when full',
  },
} satisfies Record<string, Range>;
```
*Live from [`src/lib/ranges.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/lib/ranges.ts#L30-L116), region `ranges`, read from this build at 1d6b50b.*

```ts
/** The four rows, in the order somebody asks the questions. */
export function overviewRows(readings: OverviewReadings): OverviewRow[] {
  return [upRow(readings), fastRow(readings), costRow(readings), brokeRow(readings)];
}

/** Up: every check and every page, and when the process last started and the site last rolled. */
function upRow({ health, pages, startedAt }: OverviewReadings): OverviewRow {
  const question = 'Is it up?';
  const range = 'red on one failing check or one page down';
  if (health === null) {
    return { key: 'up', question, value: '…', detail: 'not read yet', range, tone: 'waiting' };
  }
  const passing = health.checks.filter((check) => check.status === 'pass').length;
  const checksFine = passing === health.checks.length;
  const pagesFine = pages === null || pages.up === pages.checked;
  const restart =
    (startedAt === null ? 'restarted' : `restarted ${dayClock(startedAt)}`) +
    `, ${uptimeWords(health.uptime_seconds)} ago`;
  const rolled =
    pages === null
      ? ''
      : pages.trigger === 'roll'
        ? `; rolled ${dayClock(pages.at)} at v${pages.version}`
        : `; pages checked ${dayClock(pages.at)}`;
  return {
    key: 'up',
    question,
    value:
      `${passing} of ${health.checks.length} checks` +
      (pages === null ? '' : `, ${pages.up} of ${pages.checked} pages`),
    detail: restart + rolled,
    range,
    tone: checksFine && pagesFine ? 'good' : 'bad',
  };
}

/** Fast: the speed tile's reading, with the ninety-fifth's range under it. */
function fastRow({ tiles }: OverviewReadings): OverviewRow {
  const tile = tiles.find((entry) => entry.key === 'speed');
  return {
    key: 'fast',
    question: 'Is it fast?',
    value: tile?.value ?? '…',
    detail: tile?.detail ?? 'not read yet',
    range: `the ninety-fifth: ${rangeWords('p95')}`,
    tone: tile?.tone ?? 'waiting',
  };
}

/** Cost: the month so far against Steve's budget, and what Azure forecasts. */
function costRow({ cost }: OverviewReadings): OverviewRow {
  const question = 'Is it costing anything?';
  const range = `the month against the $${MONTHLY_BUDGET_DOLLARS} budget: ${rangeWords('cost')}`;
  if (cost === null) {
    return { key: 'cost', question, value: '…', detail: 'not read yet', range, tone: 'waiting' };
  }
  if (!cost.available || cost.month === null) {
    return {
      key: 'cost',
      question,
      value: 'no bill to show',
      // The reader's own reason, so a throttled read does not say this site never reads the bill.
      detail: cost.note ?? 'this site does not read the bill',
      range,
      tone: 'plain',
    };
  }
  // The pace runs to the newest day on the bill, not to today: Azure reports a day late.
  const day = cost.newest_day === null ? 0 : Number(cost.newest_day.slice(8, 10));
  const days = cost.month_days;
  return {
    key: 'cost',
    question,
    value: money(cost.month_to_date, cost.currency),
    detail:
      `${monthWords(cost.month)} so far` +
      (cost.forecast_month === null
        ? '; no forecast yet'
        : `; Azure forecasts ${money(cost.forecast_month, cost.currency)}`),
    range,
    tone: costTone(cost.month_to_date, cost.forecast_month, day, days),
  };
}

/** Broke: the errors tile, and a kept-warm pass that failed. */
function brokeRow({ tiles, health }: OverviewReadings): OverviewRow {
  const tile = tiles.find((entry) => entry.key === 'errors');
  const failedWarm = health?.kept_warm?.failed ?? 0;
  const tone: TileTone = failedWarm > 0 ? 'bad' : (tile?.tone ?? 'waiting');
  return {
    key: 'broke',
    question: 'What broke?',
    value: tile?.value ?? '…',
    detail:
      (tile?.detail ?? 'not read yet') +
      (failedWarm > 0 ? `; ${failedWarm} kept-warm reads failed` : ''),
    range: 'red on any 5xx answer, listed error or failed kept-warm read',
    tone,
  };
}
/**
 * A time today as its clock, and a time on any other day with the day and month in front, so a
 * roll from last week does not read as this morning's.
 */
function dayClock(at: string): string {
  const when = new Date(at);
  if (Number.isNaN(when.getTime())) return '';
  if (when.toDateString() === new Date().toDateString()) return clockLabel(at);
  return `${when.getDate()} ${when.toLocaleString('en-US', { month: 'short' })} ${clockLabel(at)}`;
}
```
*Live from [`src/lib/overview.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/lib/overview.ts#L57-L166), region `overview-rows`, read from this build at 1d6b50b.*

## Addendum, 2026-10-15 (1.0.3.129): one way to say loading, and the long tables folded

The audit of 14 October counted 19 hand-written "Loading..." paragraphs and ten cards using the shared failed sentence, while seven wrote their own and four of those dropped when the next try would come. **One component, `CardState`, now says all three states**: loading, failed with the next try, and read but empty, each as one status line a screen reader hears and a test finds by its id on any card. The shared `failed()` is that component's failed state.

The longest cards were long because of their tables. The machines card drew three charts and then three twenty-row tables, 4,433 pixels tall at a desk, and the log card printed 80 rows, 22,306 characters, with no count first. **Nothing is deleted, only folded behind More**: the machines card keeps its charts and folds its three tables, the log card says how many lines of each level the window holds and folds the table under that line, and an empty error list is one line with how the list is kept behind More.

The cards also take their type sizes and their spacing from tokens in the token sheets now, instead of the same numbers written on every card.

```ts
/** "76 information lines", or "80 lines: 1 error, 3 warnings, 76 information": every level present, worst first, each with its count. */
export function levelLine(entries: { level: string }[]): string {
  if (entries.length === 0) return 'No lines in the window.';
  const counts = new Map<string, number>();
  for (const entry of entries) counts.set(entry.level, (counts.get(entry.level) ?? 0) + 1);
  const rank = (level: string) => {
    const at = ORDER.indexOf(level);
    return at === -1 ? ORDER.length : at;
  };
  const levels = [...counts.keys()].sort((a, b) => rank(a) - rank(b));
  const total = entries.length;
  const ending = total === 1 ? '' : 's';
  if (levels.length === 1) {
    return `${total.toLocaleString('en-US')} ${levels[0].toLowerCase()} line${ending}`;
  }
  const each = levels
    .map((level) => {
      const n = counts.get(level) ?? 0;
      const word = level.toLowerCase();
      const plural = n === 1 || word === 'information' || word === 'debug' || word === 'trace';
      return `${n.toLocaleString('en-US')} ${plural ? word : `${word}s`}`;
    })
    .join(', ');
  return `${total.toLocaleString('en-US')} line${ending}: ${each}`;
}
```
*Live from [`src/lib/logLevels.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/lib/logLevels.ts#L11-L35), region `level-line`, read from this build at 1d6b50b.*

## Addendum, 2026-10-15 (1.0.3.130): the tiles first on a phone

Shot at 390 by 844 on 14 October, the tab put the Admin paragraph, What this shows, the window row and a caption before any number, so the first tiles started about 540 pixels down and the second row of tiles was below the fold. On a phone the paragraph and its fold now follow the strip, and the first tiles are on the first screen with the words one scroll away. A desk keeps the order it had. The Overview is scanned by axe as the tab's first card, and the tone words are held to 4.5:1 on their soft grounds by `colors.test.ts`; the amber one is the closest of the three, at 4.51.

## Addendum, 2026-10-15 (1.0.3.131): CODE HEALTH on the tests card

The gate already measured things about the code that nobody could see: its build warnings were counted and thrown away once the verdict read them, and the size of the built page was in nobody's file. The tests card now has a CODE HEALTH section under its headline. Build warnings are red over zero, since the gate fails on one anyway. The gate's seconds are read against the five minutes of ADR-068. The page's built size and the number of npm and NuGet packages with a newer release are shown plainly, with no tone, because neither has a line Steve has drawn.

The numbers are measured on the gate's runner, in a step of their own that writes `code.result` and turns nothing red; `GateWorkflowTests` still requires every check the workflow records to feed the verdict and now names that file as the one that holds measurements. `scripts/test_results.mjs` carries them into `data/test-results.json` with the last thirty gates before this one, carried forward from the file it replaces, so the trend ships in the same three files the ship already writes. The first gate run after this change is the proof that the step works; a number the gate did not measure reads as not measured, never as zero.

```ts
/** The rows the section draws; a number the gate did not measure says so rather than reading as zero. */
export function codeRows(code: CodeNumbers | undefined, gateSeconds: number): CodeRow[] {
  const unread = 'not measured by this gate';
  const counted = (n: number | null | undefined, unit: string) =>
    n === null || n === undefined || n < 0 ? unread : `${n.toLocaleString('en-US')} ${unit}`;
  return [
    {
      key: 'warnings',
      label: 'Build warnings',
      value: counted(code?.warnings, code?.warnings === 1 ? 'warning' : 'warnings'),
      // Any warning is red: the gate already fails on one (ADR: The gate runs where the memory is).
      tone:
        code?.warnings === null || code?.warnings === undefined
          ? 'plain'
          : code.warnings > 0
            ? 'bad'
            : 'good',
    },
    {
      key: 'gate',
      label: 'The gate, start to verdict',
      value: `${gateSeconds.toLocaleString('en-US')} s`,
      tone: toneOf('gateSeconds', gateSeconds),
    },
    {
      key: 'bundle',
      label: 'The page, built',
      value:
        code?.distJsBytes === null || code?.distJsBytes === undefined
          ? unread
          : `${kb(code.distJsBytes)} of script, ${kb(code.distCssBytes ?? 0)} of styles`,
      tone: 'plain',
    },
    {
      key: 'outdated',
      label: 'Packages with a newer release',
      value:
        code?.outdatedNpm === null || code?.outdatedNpm === undefined
          ? unread
          : `${counted(code.outdatedNpm, 'npm')}, ${counted(code.outdatedNuget, 'NuGet')}`,
      tone: 'plain',
    },
  ];
}
```
*Live from [`src/lib/codeHealth.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/lib/codeHealth.ts#L36-L79), region `code-rows`, read from this build at 1d6b50b.*

## Addendum, 2026-10-15 (1.0.3.132): the certificate and the storage

Two numbers that change by the day were not collected anywhere: when the certificate the site answers with runs out, and how full the relational store is. Both are read once an hour now, outside any request, and shown on the Azure card with their ranges. The certificate comes from one TLS handshake with the site's own public address, which only a container with an https address of its own makes; it is amber under 30 days, when the platform should already have renewed it, and red under a week. The storage is the data files' size from `sys.database_files` beside the tier's ceiling, which any user of the database may read; it is amber at 80% and red at 95%, because the tier refuses writes when it is full. A container without either says so in a sentence.

```csharp
/// <summary>
/// Two readings that change by the day, not the minute, taken once an hour and
/// shown on the Azure card (ADR: The Admin tab, as a product, the addendum on
/// certificate and storage): when the certificate the site answers with runs
/// out, read from one TLS handshake with its own public address, and how full
/// the relational store is. Neither is asked on a request. A container with no
/// public address (a developer's machine, the test host) reads no certificate,
/// and one with no Azure SQL Database reads no storage; the card says so.
/// </summary>
public sealed class HourlyReadings(Uri? site, Func<CancellationToken, Task<StorageReading?>> storage, TimeProvider clock) : BackgroundService
{
    public static readonly TimeSpan Interval = TimeSpan.FromHours(1);

    private object? _view;

    /// <summary>The last readings as the card shows them, or null before the first pass.</summary>
    public object? View => Volatile.Read(ref _view);

    /// <summary>The certificate's last day and the days left from <paramref name="now"/>, whole days, rounded down.</summary>
    public static int DaysLeft(DateTimeOffset notAfter, DateTimeOffset now) =>
        (int)Math.Floor((notAfter - now).TotalDays);

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await PassAsync(stoppingToken);
            try
            {
                await Task.Delay(Interval, clock, stoppingToken);
            }
            catch (OperationCanceledException)
            {
                return;
            }
        }
    }

    /// <summary>One pass: both readings, each on its own, so one that fails leaves the other.</summary>
    public async Task PassAsync(CancellationToken cancellation)
    {
        var now = clock.GetUtcNow();
        DateTimeOffset? notAfter = site is null ? null : await NotAfterAsync(site, cancellation);
        var stored = await storage(cancellation);
        Volatile.Write(ref _view, new
        {
            read_at = now,
            certificate = notAfter is { } until
                ? new { host = site!.Host, not_after = until, days_left = DaysLeft(until, now) }
                : null,
            sql_storage = stored is { MaxMb: > 0 } s
                ? new { used_mb = Math.Round(s.UsedMb, 1), max_mb = Math.Round(s.MaxMb, 1), share = Math.Round(s.UsedMb / s.MaxMb, 4) }
                : null,
        });
    }

    /// <summary>The leaf certificate's last day, from one handshake with the site's own address; null when it cannot be read.</summary>
    private static async Task<DateTimeOffset?> NotAfterAsync(Uri site, CancellationToken cancellation)
    {
        DateTimeOffset? notAfter = null;
        using var handler = new SocketsHttpHandler
        {
            SslOptions = new SslClientAuthenticationOptions
            {
                // Read the leaf and keep the platform's own verdict: an invalid chain still fails.
                RemoteCertificateValidationCallback = (_, certificate, _, errors) =>
                {
                    if (certificate is X509Certificate2 leaf)
                    {
                        notAfter = new DateTimeOffset(leaf.NotAfter.ToUniversalTime(), TimeSpan.Zero);
                    }
                    return errors == SslPolicyErrors.None;
                },
            },
        };
        using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) };
        try
        {
            using var response = await client.GetAsync(new Uri(site, "/healthz"), cancellation);
        }
        catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException)
        {
            // No answer is no reading; the card says it was not read.
        }
        return notAfter;
    }
}
```
*Live from [`api/TheYard.Api/HourlyReadings.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/HourlyReadings.cs#L8-L94), region `hourly-readings`, read from this build at 1d6b50b.*

## Addendum, 2026-10-15 (1.0.3.137): the late store

The hourly storage reading took the relational store's quiet context once, when the services were registered. A store that refuses at startup has no context then; the second chance attaches it a few minutes later, but the reading kept the empty one and said "not read" until the next restart. The reading now asks for the context each hour, the same way the machine recorder already did, so a late store shows its size at the next hourly read.

```csharp
// The reading is a delegate, which is the port for a one-call reading (ADR-080, the addendum
// on the delegate). The quiet context is looked up at each reading, not here: a relational store that
// refused at startup gets one only when the second chance attaches it later.
var sqlBackend = host.SqlBackend;
builder.Services.AddSingleton(new HourlyReadings(site, cancellation => StorageStats.ReadAsync(sqlBackend?.QuietContexts, cancellation), TimeProvider.System));
```
*Live from [`api/TheYard.Api/Composition/AdminRegistration.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/Composition/AdminRegistration.cs#L51-L55), region `hourly-readings-wiring`, read from this build at 1d6b50b.*

## Addendum, 2026-10-15 (1.0.3.138): the date in front

The Overview's first row said "rolled 14:02" for a roll of any day, so a site that had not shipped in a week read as if it rolled this morning. A restart or a roll today still shows its clock; any other day shows the day and month in front of it, "rolled 9 Oct 14:02". The live pass after the eleven ships also found Sarah Sebestyen's line on the landing page cut at "Steve consistently", which dropped the clause she opened it with; it is now quoted from her first word. The resume's doors had no page-name test of their own, so `activity.test.ts` holds `/resume/linkedin` to its name.

```ts
/** The four rows, in the order somebody asks the questions. */
export function overviewRows(readings: OverviewReadings): OverviewRow[] {
  return [upRow(readings), fastRow(readings), costRow(readings), brokeRow(readings)];
}

/** Up: every check and every page, and when the process last started and the site last rolled. */
function upRow({ health, pages, startedAt }: OverviewReadings): OverviewRow {
  const question = 'Is it up?';
  const range = 'red on one failing check or one page down';
  if (health === null) {
    return { key: 'up', question, value: '…', detail: 'not read yet', range, tone: 'waiting' };
  }
  const passing = health.checks.filter((check) => check.status === 'pass').length;
  const checksFine = passing === health.checks.length;
  const pagesFine = pages === null || pages.up === pages.checked;
  const restart =
    (startedAt === null ? 'restarted' : `restarted ${dayClock(startedAt)}`) +
    `, ${uptimeWords(health.uptime_seconds)} ago`;
  const rolled =
    pages === null
      ? ''
      : pages.trigger === 'roll'
        ? `; rolled ${dayClock(pages.at)} at v${pages.version}`
        : `; pages checked ${dayClock(pages.at)}`;
  return {
    key: 'up',
    question,
    value:
      `${passing} of ${health.checks.length} checks` +
      (pages === null ? '' : `, ${pages.up} of ${pages.checked} pages`),
    detail: restart + rolled,
    range,
    tone: checksFine && pagesFine ? 'good' : 'bad',
  };
}

/** Fast: the speed tile's reading, with the ninety-fifth's range under it. */
function fastRow({ tiles }: OverviewReadings): OverviewRow {
  const tile = tiles.find((entry) => entry.key === 'speed');
  return {
    key: 'fast',
    question: 'Is it fast?',
    value: tile?.value ?? '…',
    detail: tile?.detail ?? 'not read yet',
    range: `the ninety-fifth: ${rangeWords('p95')}`,
    tone: tile?.tone ?? 'waiting',
  };
}

/** Cost: the month so far against Steve's budget, and what Azure forecasts. */
function costRow({ cost }: OverviewReadings): OverviewRow {
  const question = 'Is it costing anything?';
  const range = `the month against the $${MONTHLY_BUDGET_DOLLARS} budget: ${rangeWords('cost')}`;
  if (cost === null) {
    return { key: 'cost', question, value: '…', detail: 'not read yet', range, tone: 'waiting' };
  }
  if (!cost.available || cost.month === null) {
    return {
      key: 'cost',
      question,
      value: 'no bill to show',
      // The reader's own reason, so a throttled read does not say this site never reads the bill.
      detail: cost.note ?? 'this site does not read the bill',
      range,
      tone: 'plain',
    };
  }
  // The pace runs to the newest day on the bill, not to today: Azure reports a day late.
  const day = cost.newest_day === null ? 0 : Number(cost.newest_day.slice(8, 10));
  const days = cost.month_days;
  return {
    key: 'cost',
    question,
    value: money(cost.month_to_date, cost.currency),
    detail:
      `${monthWords(cost.month)} so far` +
      (cost.forecast_month === null
        ? '; no forecast yet'
        : `; Azure forecasts ${money(cost.forecast_month, cost.currency)}`),
    range,
    tone: costTone(cost.month_to_date, cost.forecast_month, day, days),
  };
}

/** Broke: the errors tile, and a kept-warm pass that failed. */
function brokeRow({ tiles, health }: OverviewReadings): OverviewRow {
  const tile = tiles.find((entry) => entry.key === 'errors');
  const failedWarm = health?.kept_warm?.failed ?? 0;
  const tone: TileTone = failedWarm > 0 ? 'bad' : (tile?.tone ?? 'waiting');
  return {
    key: 'broke',
    question: 'What broke?',
    value: tile?.value ?? '…',
    detail:
      (tile?.detail ?? 'not read yet') +
      (failedWarm > 0 ? `; ${failedWarm} kept-warm reads failed` : ''),
    range: 'red on any 5xx answer, listed error or failed kept-warm read',
    tone,
  };
}
/**
 * A time today as its clock, and a time on any other day with the day and month in front, so a
 * roll from last week does not read as this morning's.
 */
function dayClock(at: string): string {
  const when = new Date(at);
  if (Number.isNaN(when.getTime())) return '';
  if (when.toDateString() === new Date().toDateString()) return clockLabel(at);
  return `${when.getDate()} ${when.toLocaleString('en-US', { month: 'short' })} ${clockLabel(at)}`;
}
```
*Live from [`src/lib/overview.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/lib/overview.ts#L57-L166), region `overview-rows`, read from this build at 1d6b50b.*

## Addendum, 2026-10-15 (1.0.3.139): the reason for no bill

The live pass on 1.0.3.138 found the Overview's cost row saying "not read here, this site does not read the bill" on both sites, while the What Azure charges card beside it said the true thing: Cost Management had answered the bill reader with 429 and asked it to wait an hour. The site restarted once per version that afternoon, and every start reads the bill. The row now shows the reader's own note, the same words the card shows, and keeps "this site does not read the bill" for a site that has no note to give.

```ts
/** The four rows, in the order somebody asks the questions. */
export function overviewRows(readings: OverviewReadings): OverviewRow[] {
  return [upRow(readings), fastRow(readings), costRow(readings), brokeRow(readings)];
}

/** Up: every check and every page, and when the process last started and the site last rolled. */
function upRow({ health, pages, startedAt }: OverviewReadings): OverviewRow {
  const question = 'Is it up?';
  const range = 'red on one failing check or one page down';
  if (health === null) {
    return { key: 'up', question, value: '…', detail: 'not read yet', range, tone: 'waiting' };
  }
  const passing = health.checks.filter((check) => check.status === 'pass').length;
  const checksFine = passing === health.checks.length;
  const pagesFine = pages === null || pages.up === pages.checked;
  const restart =
    (startedAt === null ? 'restarted' : `restarted ${dayClock(startedAt)}`) +
    `, ${uptimeWords(health.uptime_seconds)} ago`;
  const rolled =
    pages === null
      ? ''
      : pages.trigger === 'roll'
        ? `; rolled ${dayClock(pages.at)} at v${pages.version}`
        : `; pages checked ${dayClock(pages.at)}`;
  return {
    key: 'up',
    question,
    value:
      `${passing} of ${health.checks.length} checks` +
      (pages === null ? '' : `, ${pages.up} of ${pages.checked} pages`),
    detail: restart + rolled,
    range,
    tone: checksFine && pagesFine ? 'good' : 'bad',
  };
}

/** Fast: the speed tile's reading, with the ninety-fifth's range under it. */
function fastRow({ tiles }: OverviewReadings): OverviewRow {
  const tile = tiles.find((entry) => entry.key === 'speed');
  return {
    key: 'fast',
    question: 'Is it fast?',
    value: tile?.value ?? '…',
    detail: tile?.detail ?? 'not read yet',
    range: `the ninety-fifth: ${rangeWords('p95')}`,
    tone: tile?.tone ?? 'waiting',
  };
}

/** Cost: the month so far against Steve's budget, and what Azure forecasts. */
function costRow({ cost }: OverviewReadings): OverviewRow {
  const question = 'Is it costing anything?';
  const range = `the month against the $${MONTHLY_BUDGET_DOLLARS} budget: ${rangeWords('cost')}`;
  if (cost === null) {
    return { key: 'cost', question, value: '…', detail: 'not read yet', range, tone: 'waiting' };
  }
  if (!cost.available || cost.month === null) {
    return {
      key: 'cost',
      question,
      value: 'no bill to show',
      // The reader's own reason, so a throttled read does not say this site never reads the bill.
      detail: cost.note ?? 'this site does not read the bill',
      range,
      tone: 'plain',
    };
  }
  // The pace runs to the newest day on the bill, not to today: Azure reports a day late.
  const day = cost.newest_day === null ? 0 : Number(cost.newest_day.slice(8, 10));
  const days = cost.month_days;
  return {
    key: 'cost',
    question,
    value: money(cost.month_to_date, cost.currency),
    detail:
      `${monthWords(cost.month)} so far` +
      (cost.forecast_month === null
        ? '; no forecast yet'
        : `; Azure forecasts ${money(cost.forecast_month, cost.currency)}`),
    range,
    tone: costTone(cost.month_to_date, cost.forecast_month, day, days),
  };
}

/** Broke: the errors tile, and a kept-warm pass that failed. */
function brokeRow({ tiles, health }: OverviewReadings): OverviewRow {
  const tile = tiles.find((entry) => entry.key === 'errors');
  const failedWarm = health?.kept_warm?.failed ?? 0;
  const tone: TileTone = failedWarm > 0 ? 'bad' : (tile?.tone ?? 'waiting');
  return {
    key: 'broke',
    question: 'What broke?',
    value: tile?.value ?? '…',
    detail:
      (tile?.detail ?? 'not read yet') +
      (failedWarm > 0 ? `; ${failedWarm} kept-warm reads failed` : ''),
    range: 'red on any 5xx answer, listed error or failed kept-warm read',
    tone,
  };
}
/**
 * A time today as its clock, and a time on any other day with the day and month in front, so a
 * roll from last week does not read as this morning's.
 */
function dayClock(at: string): string {
  const when = new Date(at);
  if (Number.isNaN(when.getTime())) return '';
  if (when.toDateString() === new Date().toDateString()) return clockLabel(at);
  return `${when.getDate()} ${when.toLocaleString('en-US', { month: 'short' })} ${clockLabel(at)}`;
}
```
*Live from [`src/lib/overview.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/lib/overview.ts#L57-L166), region `overview-rows`, read from this build at 1d6b50b.*

## Addendum, 2026-10-16 (1.0.3.143): the reading order

1.0.3.130 put the tiles above the words on a phone with CSS `order`, which moves what the eye sees and leaves the document as it was: a keyboard still met the paragraph and its What this shows before the first tile (WCAG 1.3.2 and 2.4.3). The panel now draws the block after the strip on a phone and before it on a desk, so the two orders agree. A server draws the desk order, and the browser moves the block once on arrival, the same way the workbench already reads the phone width. The phone test holds both orders: the tile above the words on the screen, and before them in the document.

```tsx
// On a phone the words follow the tiles in the DOM itself, not by CSS order, so a keyboard
// reaches the tiles first the way the eye does (WCAG 1.3.2 and 2.4.3; ADR-080, the addendum
// on the reading order). A server draws the desk order and the browser moves the block once.
const phone = useMediaQuery(PHONE);
const intro = (
  <div className={styles.intro} data-testid="admin-intro">
    <p className={styles.blurb}>
      The running system reporting on itself, in the order somebody asks: is it up, is it fast, is
      it costing anything, what broke.
    </p>
    <About>
      One card at a time, the one the address names: the rail lists every card under the question
      it answers, a tile opens the card behind its number, j and k walk the rail, and a pinned
      card stays beside whichever one is open. Every statistic the page shows for one store it
      shows for the other, and where a number has no meaning on one side the page says so in
      words. Refreshes every 30 seconds. Public on purpose; the reasoning is in the Best Practices
      menu.
    </About>
  </div>
);
```
*Live from [`src/components/admin/AdminPanel/AdminPanel.tsx`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/components/admin/AdminPanel/AdminPanel.tsx#L120-L139), region `admin-panel-order`, read from this build at 1d6b50b.*

## Addendum, 2026-10-16 (1.0.3.144): measured only on a green build

The measuring step ran under `if: always()`, so a run whose build failed still wrote `dist_js_bytes=0`, and the card would have shown 0 kB where the addendum of 1.0.3.131 promised "not measured". The step now runs only when both builds passed; when it does not run, the file is absent and the card says so. The verdict test used to set aside every workflow line that named `code.result`; it now sets aside the code-numbers region alone, so a real check written elsewhere cannot hide behind that name.

## Addendum, 2026-10-16 (1.0.3.145): the delegate is the port

The pre-ship review of 15 October noted that the hourly storage read reaches `StorageStats` in Infrastructure through a delegate rather than an interface in Application. That is the pattern the admin readings already use: the machine recorder takes its relational reading as a `Func<CancellationToken, Task<StoreLoad>>` the composition root hands it, and `HourlyReadings` takes its storage reading the same way. A one-method interface would add a file and a name and no seam the delegate does not already give: the test passes a lambda, the composition root passes the Infrastructure call, and the onion tests see no reference from the reading to Infrastructure. The rule, recorded here so the next reader does not reopen it: an admin reading with one input and one output is a delegate; a port with more than one method is an interface.

## Addendum, 2026-10-16 (1.0.3.146): one way of writing a number

A read of the live page at 1440 found the same fact written two ways a few lines apart: the container line said `2466.1 MB` over a gauge that said `2,466.1 MB`, the gauge's own reading said `25 %` under a tile that said `25%`, and the document store's sentence allowed `1000` request units above a gauge marked `1,000 / s free`. Every number with a unit now goes through `formatNumber`, as src/lib/format.ts always said it should, and a percentage sign closes up to its figure everywhere. Two quiet lines went with it: the log card's first line says what it counts (`80 information lines`, or `5 lines: 1 error, 2 warnings, 2 information`) instead of a bare `80 information`, the Azure card names the mono text beside it as the container image, and a Normal line reads as one sentence, `Normal: memory of its limit, amber at 80%`, lower case after the colon unless the name opens with Azure.

## Addendum, 2026-10-16 (1.0.3.147): six small things from a read of the live page

A look at the live site at 1440 and 390, screen by screen, found six things no test had a line for. The traffic card's four blocks put their word (`fast`, `none`) under the last line of a detail that is two lines in one block and five in the next, so the words sat at four heights across one row; the word now sits at the foot of every block, and the figure is one step smaller so `under 1 ms` keeps to one line. The activity card's bars stopped at 560 px while the table under them ran to the card's edge; they run to the edge now. The tests table's last row kept a short rule under its name cell, because the name is a `th` and the rule that cleared the last row named `td` only. The activity chart's peak touched the frame, since the axis's top was the peak itself; the top is now the next round number with a tenth of headroom over it (`headroom()` in src/lib/activityChart.ts, so 50 draws against 60 and 10 against 12). Outside the Admin tab, the sidebar's pinned block casts a shade over the list that scrolls under it, so a row cut by its edge reads as more below, and the landing page's What colleagues say row keeps a thin scrollbar under it from the first paint, so the card its right edge cuts reads as a row that scrolls rather than a card the page clipped (a fade at the edge would have masked the row's focus ring with it).

## Addendum, 2026-10-16 (1.0.3.150): a second reader on the reading order

A fresh reader of 1.0.3.143 before it shipped found the cost of dropping the CSS `order` rule: a server draws the desk order, and a phone shows that until the browser has read its width, so the words stood above the tiles for one frame and then jumped under them, a shift the old rule never had. The rule is back for that first paint alone; once the block has moved in the document it changes nothing, and the keyboard follows the document, which is what 1.0.3.143 fixed. The same reader found that the test's desk read waited on nothing (the words are visible in both layouts) and raced the move; it polls the document order now. The gate's measuring step (1.0.3.144) names the two builds it reads, the API's and the page's, rather than "both" of the three the job runs.

## Addendum, 2026-10-16 (1.0.3.151): the bars at their real width

1.0.3.147 let the activity card's bars run to the card's edge by dropping the 560 px cap on the drawing, and the close read of the live page at 1440 showed the words beside the bars a size and a half: `fitBox` only ever narrows a drawing to the width it is given, so the 560-wide drawing was scaled up to the card, its text with it. The drawing now starts wider than any card (1,600 units), so it is always narrowed to the real width and its words keep the chart text size, which is what the cap had been doing by accident.

## Where it sits

This is mostly front end: machineChart.ts, statTiles.ts and workbench.ts are React-free code in src/lib, and the cards in src/components/admin draw what those files compute. Single responsibility (one reason to change) is the point of that split: the tile thresholds are named constants in statTiles.ts, and a chart's arithmetic is tested without a browser. The one Application change was InventoryService.Search sorting only as far as the page. It cost every chart drawn and tested by hand, with no library to borrow from. A team that already pays for Grafana or Azure Monitor workbooks, and can hand out sign-ins, would have less reason to build its own.

## Files

- [`src/lib/machineChart.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/machineChart.ts): the arithmetic for every chart on the tab, React-free: axes, paths with their gaps, the kept windows' timelines, traffic as slots, and the proof's bars.
- [`src/lib/machineChart.test.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/machineChart.test.ts): what a gap is, what a zero is, and what a bar is a share of.
- [`src/lib/statTiles.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/statTiles.ts): what each tile says and what makes it amber or red, React-free, and the line under a tile as the points of a polyline.
- [`src/lib/statTiles.test.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/statTiles.test.ts): every threshold, a reading that has not arrived, and a gap in the line.
- [`api/TheYard.Application/InventoryService.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Application/InventoryService.cs): the page sorted only as far as the page, which is what the first amber tile found.
- [`src/lib/dayMarks.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/dayMarks.ts) and [`src/components/admin/DayMarks/DayMarks.tsx`](https://github.com/SteveStout/TheYard/blob/main/src/components/admin/DayMarks/DayMarks.tsx): which slot of a daily chart is a weekend and which carries a Monday or Friday label, and the band and the labels drawn from it.
- [`src/lib/ranges.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/ranges.ts) and [`src/lib/overview.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/overview.ts): every normal range on the tab in one table, and the Overview's four rows built from it; [`src/components/admin/OverviewCard/OverviewCard.tsx`](https://github.com/SteveStout/TheYard/blob/main/src/components/admin/OverviewCard/OverviewCard.tsx) draws them.
- [`src/lib/workbench.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/workbench.ts): the workbench's addresses, React-free: the cards by slug and what an address opens, the only part of the workbench the page's first chunk carries.
- [`src/lib/bench.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/bench.ts): the rest of it, loaded with the Admin tab's chunk: the cards under their questions, where j and k lead, what the rail's search finds, which card a tile opens and the hour at a glance.
- [`src/lib/workbench.test.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/workbench.test.ts): every card once, a wrong name, the walk round the rail, and every tile's card.
- [`src/lib/trafficCard.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/trafficCard.ts): what the traffic card says, React-free: the four blocks, the three questions, and zero said in words.
- [`src/lib/trafficCard.test.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/trafficCard.test.ts): the words, the one new number, and what makes a block good or bad.
- [`src/components/admin/TrafficCard/TrafficCard.tsx`](https://github.com/SteveStout/TheYard/blob/main/src/components/admin/TrafficCard/TrafficCard.tsx) and [`src/components/admin/ProofCard/ProofCard.tsx`](https://github.com/SteveStout/TheYard/blob/main/src/components/admin/ProofCard/ProofCard.tsx): the traffic card and the proof's bars; [`src/components/admin/AdminPanel/AdminPanel.tsx`](https://github.com/SteveStout/TheYard/blob/main/src/components/admin/AdminPanel/AdminPanel.tsx): the window both cards share.
- [`src/components/admin/AdminPanel/AdminPanel.module.css`](https://github.com/SteveStout/TheYard/blob/main/src/components/admin/AdminPanel/AdminPanel.module.css): the workbench's styles, over the token sheet; [`src/components/admin/shared/card.module.css`](https://github.com/SteveStout/TheYard/blob/main/src/components/admin/shared/card.module.css), [`src/components/admin/shared/stat-strip.module.css`](https://github.com/SteveStout/TheYard/blob/main/src/components/admin/shared/stat-strip.module.css), [`src/components/admin/charts/charts.module.css`](https://github.com/SteveStout/TheYard/blob/main/src/components/admin/charts/charts.module.css) and [`src/components/admin/DataTable/DataTable.module.css`](https://github.com/SteveStout/TheYard/blob/main/src/components/admin/DataTable/DataTable.module.css): what the cards share, and each card's own sheet in its folder.
- [`api/TheYard.Api/Machines.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Machines.cs): the request ring folded into minutes, once, for the hour on the card and for the minute that is kept.
- [`docs/decisions/ADR-078-what-the-machines-are-doing.md`](https://github.com/SteveStout/TheYard/blob/main/docs/decisions/ADR-078-what-the-machines-are-doing.md): the first charts, and the windows these are drawn over.

```ts
/**
 * Traffic, in one shape whatever the window (ADR: The Admin tab, as a
 * product). The hour comes from the request ring a minute at a time; a kept
 * window comes from buckets of kept minutes. Both become the same slots, so
 * the four traffic charts are drawn by one piece of code and cannot disagree
 * about what a gap or a zero means: a slot nobody measured is null, and a
 * measured slot in which nobody asked for anything is zero requests with no
 * median, because there is no median of nothing.
 */
export type TrafficMinute = {
  at: string;
  requests: number;
  p50_ms: number;
  p95_ms: number;
  server_errors: number;
  client_errors: number;
  /** Every request's time in the minute, sorted, for the hour's own percentiles (statTiles.ts, hourTiming). */
  durations_ms?: number[];
  /**
   * True on the oldest minute of a full request ring: the ring pushed out what
   * came before it, so its counts are short and nothing older is held. The ring
   * is a number of requests, not an hour, and this is where it stops reaching.
   */
  clipped?: boolean;
};

/** One slot of a traffic chart, from a minute of the ring or a kept bucket; null wherever nothing was measured. */
export type TrafficSlot = {
  at: string;
  /** Requests a minute, or null where nothing was measured. */
  requests: number | null;
  p50_ms: number | null;
  p95_ms: number | null;
  server_errors: number | null;
  client_errors: number | null;
  /** The minute's request times, sorted; absent on a kept bucket, which holds only its percentiles. */
  durations_ms?: number[];
  /** The minute the request ring stops reaching back at, when it is full (TrafficMinute.clipped). */
  clipped?: boolean;
};

/**
 * The hour the ring holds: every minute from the oldest request it still has,
 * or an hour ago if that is later, to now. Minutes inside that span with no
 * request are true zeros, because the process was up and nobody asked.
 */
export function hourOfTraffic(minutes: TrafficMinute[], asOf: Date): TrafficSlot[] {
  const step = 60_000;
  const end = Math.floor(asOf.getTime() / step) * step;
  const held = new Map<number, TrafficMinute>();
  let oldest = end;
  for (const minute of minutes) {
    const at = Math.floor(new Date(minute.at).getTime() / step) * step;
    if (Number.isNaN(at)) continue;
    held.set(at, minute);
    if (at < oldest) oldest = at;
  }
  const start = Math.max(oldest, end - 59 * step);
  const slots: TrafficSlot[] = [];
  for (let at = start; at <= end; at += step) {
    const minute = held.get(at);
    slots.push({
      at: new Date(at).toISOString(),
      requests: minute?.requests ?? 0,
      p50_ms: minute?.p50_ms ?? null,
      p95_ms: minute?.p95_ms ?? null,
      server_errors: minute?.server_errors ?? 0,
      client_errors: minute?.client_errors ?? 0,
      durations_ms: minute?.durations_ms ?? [],
      ...(minute?.clipped === true ? { clipped: true } : {}),
    });
  }
  return slots;
}

/** A kept window's slots as traffic: counts become a rate a minute over the minutes the bucket holds. */
export function keptTraffic(slots: { at: string; bucket: KeptBucket | null }[]): TrafficSlot[] {
  const rate = (count: number, minutes: number) =>
    minutes <= 0 ? null : Math.round((count / minutes) * 100) / 100;
  return slots.map(({ at, bucket }) =>
    bucket === null
      ? { at, requests: null, p50_ms: null, p95_ms: null, server_errors: null, client_errors: null }
      : {
          at,
          requests: rate(bucket.requests, bucket.minutes),
          p50_ms: bucket.p50_ms,
          p95_ms: bucket.p95_ms,
          server_errors: rate(bucket.server_errors, bucket.minutes),
          client_errors: rate(bucket.client_errors, bucket.minutes),
        }
  );
}

/**
 * The lines under the tiles over a kept window (ADR: The Admin tab, as a
 * product, the addendum on one window for every chart): the same four
 * readings the hour's lines are drawn from, one value a bucket, and null
 * where the store holds no bucket, so a day the site was down is a gap under
 * the tile exactly as it is on the chart.
 */
export function keptSparks(slots: { at: string; bucket: KeptBucket | null }[]): {
  speed: (number | null)[];
  memory: (number | null)[];
  charged: (number | null)[];
  errors: (number | null)[];
} {
  return {
    // The line under the speed tile is the typical answer, the same reading as the number over it.
    speed: slots.map(({ bucket }) => bucket?.p50_ms ?? null),
    memory: slots.map(({ bucket }) => bucket?.working_set_mb ?? null),
    charged: slots.map(({ bucket }) => bucket?.request_units ?? null),
    errors: slots.map(({ bucket }) => bucket?.server_errors ?? null),
  };
}

/**
 * What the hour's minutes add up to, from the slots themselves. A slot is one
 * minute of the ring, so `minutesPerSlot` is 1. A kept window's totals do not
 * come from here: its slots are rates over the minutes each bucket holds, and a
 * rate times the bucket's width overstates the newest bucket, which holds only
 * the minutes kept so far, so the server adds the counts up (keptWindowTotals).
 *
 * `ring_minutes` is how many minutes the figures reach back when the request
 * ring stops short of the hour, counted from the minute the server marked, and
 * null when the slots are the whole hour or everything since the process began.
 */
export function trafficTotals(slots: TrafficSlot[], minutesPerSlot: number) {
  let requests = 0;
  let serverErrors = 0;
  let clientErrors = 0;
  let slowest: number | null = null;
  let slowestAt: string | null = null;
  for (const slot of slots) {
    requests += (slot.requests ?? 0) * minutesPerSlot;
    serverErrors += (slot.server_errors ?? 0) * minutesPerSlot;
    clientErrors += (slot.client_errors ?? 0) * minutesPerSlot;
    if (slot.p95_ms !== null && (slowest === null || slot.p95_ms > slowest)) {
      slowest = slot.p95_ms;
      slowestAt = slot.at;
    }
  }
  const clippedAt = slots.findIndex((slot) => slot.clipped === true);
  return {
    requests: Math.round(requests),
    server_errors: Math.round(serverErrors),
    client_errors: Math.round(clientErrors),
    slowest_p95_ms: slowest,
    // Where to look: a tile that says "slow" and not "when" sends somebody through an hour of rows.
    slowest_at: slowestAt,
    ring_minutes: clippedAt < 0 ? null : (slots.length - clippedAt) * minutesPerSlot,
  };
}

/** A kept window's totals as the endpoint sends them, counted on the server from the buckets' own counts. */
export type KeptTotals = {
  requests: number;
  server_errors: number;
  client_errors: number;
  /** The worst single minute's 95th percentile in the window, or null when no minute had a request. */
  worst_minute_p95_ms: number | null;
};

/**
 * The totals over a kept window's charts, from what the server counted, in the
 * shape the hour's come in. Null when the answer carries none, which a window
 * the store could not read never does.
 */
export function keptWindowTotals(history: { window: string; totals?: KeptTotals | null }) {
  const totals = history.totals;
  if (totals === undefined || totals === null) return null;
  return {
    requests: totals.requests,
    server_errors: totals.server_errors,
    client_errors: totals.client_errors,
    slowest_p95_ms: totals.worst_minute_p95_ms,
    ring_minutes: null,
  };
}

/**
 * The proof's paired medians as bars (ADR: Same performance, proven): one
 * pair a path, each bar a share of the longest median on the card, so the
 * eye reads what the table says, that most pairs are the same length and the
 * ones that are not differ by a round trip.
 */
export type PairedBar = { label: string; bars: { store: string; ms: number; share: number }[] };

/** The proof's rows as paired bars: rows with no samples are left off, and each bar is a share of the longest median. */
export function pairedBars(
  rows: { label: string; cells: { store: string; samples: number; p50_ms: number }[] }[]
): PairedBar[] {
  const measured = rows
    .map((row) => ({ label: row.label, cells: row.cells.filter((cell) => cell.samples > 0) }))
    .filter((row) => row.cells.length > 0);
  const longest = measured.reduce(
    (most, row) => Math.max(most, ...row.cells.map((cell) => cell.p50_ms)),
    0
  );
  return measured.map((row) => ({
    label: row.label,
    bars: row.cells.map((cell) => ({
      store: cell.store,
      ms: cell.p50_ms,
      // A bar for a zero is still drawn, one per cent wide, so a path both
      // stores answer in no time reads as two equal bars and not as nothing.
      share: longest <= 0 ? 1 : Math.max(1, Math.round((cell.p50_ms / longest) * 100)),
    })),
  }));
}
```
*Live from [`src/lib/trafficSlots.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/lib/trafficSlots.ts#L13-L221), region `traffic`, read from this build at 1d6b50b.*

```csharp
/// <summary>
/// The request ring, a minute at a time: how many requests, their median and
/// ninety-fifth, and how many answered 4xx and 5xx (ADR: The Admin tab, as a
/// product). The ring already leaves out the Admin tab watching itself and the
/// page sweep, so a minute here is a visitor's minute. One function, used
/// twice: the hour on the card is every minute the ring still holds, and the
/// minute the recorder keeps is the same arithmetic over one of them, so the
/// hour and the month are drawn in one unit by construction.
/// </summary>
public static class TrafficMinutes
{
    /// <summary>The given requests grouped by the minute they were answered in, oldest minute first, each with its counts and sorted durations.</summary>
    public static IReadOnlyList<TrafficMinute> From(IReadOnlyList<RequestEntry> requests) =>
        requests
            .GroupBy(request => request.At.UtcMinute)
            .OrderBy(group => group.Key)
            .Select(group =>
            {
                long[] durations = group.Select(request => request.DurationMs).ToArray();
                // Sorted once here, so the page can take any percentile of the
                // hour without sorting five hundred numbers on every refresh.
                Array.Sort(durations);
                return new TrafficMinute(
                    group.Key,
                    durations.Length,
                    Percentiles.Of(durations, 50),
                    Percentiles.Of(durations, 95),
                    group.Count(request => request.Status >= 500),
                    group.Count(request => request.Status is >= 400 and < 500),
                    group.Count(request => request.Status is >= 300 and < 400),
                    group.Count(request => request.Status < 300),
                    durations);
            })
            .ToList();

    /// <summary>
    /// The request ring as the card's hour, with the oldest minute marked when
    /// the ring is full. The ring holds a fixed number of requests, not an hour,
    /// so on a busy hour it reaches back only part of the way: a full ring has
    /// pushed out what came before its oldest request, that minute's counts are
    /// short, and nothing older is held. The mark is how the page knows to say
    /// how many minutes its figures cover instead of calling them the hour.
    /// </summary>
    public static IReadOnlyList<TrafficMinute> OfRing(IReadOnlyList<RequestEntry> requests, int capacity)
    {
        var minutes = From(requests);
        if (minutes.Count == 0 || requests.Count < capacity)
        {
            return minutes;
        }

        return [minutes[0] with { Clipped = true }, .. minutes.Skip(1)];
    }
}

/// <summary>
/// One minute of answered requests. A minute with none is not in the list: there is no median of nothing.
/// The durations ride along, sorted, because the speed tile reads the hour's own median and ninety-fifth
/// over every request in its warm minutes (ADR: The Admin tab, as a product, the addendum on the
/// typical answer): a percentile of an hour cannot be had from sixty minutes' percentiles, and headlining
/// the worst minute's ninety-fifth instead would keep one slow request on the tile for an hour.
/// The ring holds five hundred requests, so this is at most five hundred numbers on an answer the tab
/// reads every thirty seconds.
/// </summary>
/// <param name="At">The start of the minute, UTC.</param>
/// <param name="Requests">How many requests were answered in the minute.</param>
/// <param name="P50Ms">The median duration, in milliseconds.</param>
/// <param name="P95Ms">The 95th percentile duration, in milliseconds.</param>
/// <param name="ServerErrors">How many requests answered with a 5xx status.</param>
/// <param name="ClientErrors">How many requests answered with a 4xx status.</param>
/// <param name="Redirects">How many requests answered with a 3xx status.</param>
/// <param name="Ok">How many requests answered with a status below 300.</param>
/// <param name="DurationsMs">Every request duration in the minute, sorted, in milliseconds.</param>
/// <param name="Clipped">True on the oldest minute of a full ring: requests earlier in it were pushed out, so its counts are short and nothing before it is held.</param>
public sealed record TrafficMinute(
    DateTimeOffset At,
    int Requests,
    long P50Ms,
    long P95Ms,
    int ServerErrors,
    int ClientErrors,
    int Redirects,
    int Ok,
    IReadOnlyList<long> DurationsMs,
    bool Clipped = false);
```
*Live from [`api/TheYard.Api/TrafficMinutes.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/TrafficMinutes.cs#L9-L93), region `traffic-minutes`, read from this build at 1d6b50b.*

```ts
/**
 * The colour rules, all in one place. Amber means "worth a look". Red means
 * "someone should be looking". A failing check or a page that is down is red.
 * A slow 95th percentile (p95: 95 of every 100 requests were faster), memory
 * past 80% of its limit, or a refused request is amber. The thresholds are
 * this site's own, set from what it measures on a quiet day, and open to change;
 * the lines themselves live in src/lib/ranges.ts, one table for the tab.
 */
export const SLOW_P95_MS = RANGES.p95.amber;
/** A 95th percentile at or above this many milliseconds turns the speed tile red. */
export const VERY_SLOW_P95_MS = RANGES.p95.red;
/**
 * The speed tile only takes a colour when the hour has at least this many warm
 * requests. With fewer, the 95th percentile is really just the one slowest
 * request, so the tile stays plain and shows the request count instead.
 */
export const QUIET_BELOW_REQUESTS = 20;

/**
 * The tiles whose number is a share of a known whole. Only these reserve room
 * for a ring, so the other tiles keep their full width for text on a phone.
 */
const RINGED = new Set(['health', 'pages', 'memory']);
/** The share of its limit at which memory turns the tile amber. */
export const MEMORY_WARN_SHARE = RANGES.memory.amber;
/** The share of its limit at which memory turns the tile red. */
export const MEMORY_BAD_SHARE = RANGES.memory.red;

/** Builds every tile, in display order, from the readings the tab holds. */
export function tilesFrom(readings: TileReadings): StatTile[] {
  const { health, pages, traffic, memory, charged, errors, visitorsToday, sparks } = readings;
  const tiles: Omit<StatTile, 'ringed'>[] = [];

  if (health === null) {
    tiles.push(waiting('version', 'up', 'Version'), waiting('health', 'up', 'Health'));
  } else {
    const passing = health.checks.filter((check) => check.status === 'pass').length;
    tiles.push({
      key: 'version',
      question: 'up',
      label: 'Version',
      value: health.version,
      detail: `${health.commit}, up ${uptimeWords(health.uptime_seconds)}`,
      tone: 'plain',
    });
    tiles.push({
      key: 'health',
      question: 'up',
      label: 'Health',
      value: health.status === 'healthy' ? 'Healthy' : health.status,
      detail: `${passing} of ${health.checks.length} checks pass`,
      tone:
        passing === health.checks.length ? 'good' : health.status === 'healthy' ? 'warn' : 'bad',
      ring: ringOf(passing, health.checks.length, `${passing}/${health.checks.length}`),
    });
  }

  tiles.push(
    pages === null
      ? waiting('pages', 'up', 'Pages')
      : {
          key: 'pages',
          question: 'up',
          label: 'Pages',
          value: `${pages.up} of ${pages.checked}`,
          detail:
            pages.up === pages.checked
              ? 'every address it serves is up'
              : `${pages.checked - pages.up} down`,
          tone: pages.checked === 0 ? 'warn' : pages.up === pages.checked ? 'good' : 'bad',
          ring: ringOf(
            pages.up,
            pages.checked,
            `${Math.floor((pages.up / Math.max(1, pages.checked)) * 100)}%`
          ),
        }
  );

  // Speed, as a visitor felt it. The median (p50) is the big number and the 95th
  // percentile goes in the line under it, both over the warm requests. Below
  // QUIET_BELOW_REQUESTS the hour is too quiet to colour, and the tile says so.
  const busy = traffic !== null && traffic.warm_requests >= QUIET_BELOW_REQUESTS;
  const percentiles = (t: NonNullable<TileReadings['traffic']>) =>
    t.p50_ms === null || t.p95_ms === null
      ? ''
      : `typical ${millisecondsWords(t.p50_ms)}, 95th ${millisecondsWords(t.p95_ms)}`;
  tiles.push(
    traffic === null
      ? waiting('speed', 'fast', 'Typical answer')
      : {
          key: 'speed',
          question: 'fast',
          label: 'Typical answer',
          // With no median to show, the hour is "quiet", unless the only requests
          // came during the cold start, in which case the process is "warming".
          value:
            busy && traffic.p50_ms !== null
              ? millisecondsWords(traffic.p50_ms)
              : traffic.warm_requests === 0 && traffic.requests > 0 && traffic.cold_start_label
                ? 'warming'
                : 'quiet',
          ...speedLine(traffic, busy, percentiles(traffic)),
          tone:
            !busy || traffic.p95_ms === null
              ? 'plain'
              : traffic.p95_ms >= VERY_SLOW_P95_MS
                ? 'bad'
                : traffic.p95_ms >= SLOW_P95_MS
                  ? 'warn'
                  : 'good',
          spark: sparks?.speed,
        }
  );

  if (memory === null || memory.limit_mb <= 0) {
    tiles.push(waiting('memory', 'cost', 'Memory'));
  } else {
    const share = memory.working_set_mb / memory.limit_mb;
    tiles.push({
      key: 'memory',
      question: 'cost',
      label: 'Memory',
      value: `${Math.round(share * 100)}%`,
      detail: `${formatNumber(Math.round(memory.working_set_mb))} of ${formatNumber(Math.round(memory.limit_mb))} MB`,
      tone: share >= MEMORY_BAD_SHARE ? 'bad' : share >= MEMORY_WARN_SHARE ? 'warn' : 'good',
      spark: sparks?.memory,
      ring: ringOf(memory.working_set_mb, memory.limit_mb, `${Math.round(share * 100)}%`),
    });
  }

  tiles.push(
    charged === null
      ? waiting('charged', 'cost', 'Request units')
      : charged === 'none'
        ? {
            key: 'charged',
            question: 'cost',
            label: 'Request units',
            value: 'none',
            detail: 'no document store is answering here',
            tone: 'plain',
          }
        : {
            key: 'charged',
            question: 'cost',
            label: 'Request units',
            value: `${Math.round(charged.request_units * 10) / 10}`,
            // This is a total, not a rate, so it is never coloured against the free
            // allowance per second. The machines card compares the busiest minute.
            // A total says the stretch it covers, which the server measures.
            ...(charged.span_minutes === null || charged.span_minutes === undefined
              ? {
                  detail: `RU in memory; ${formatNumber(charged.free_per_second)} a second is free`,
                }
              : {
                  detail: `RU over the last ${formatNumber(charged.span_minutes)} min`,
                  more: `; the free tier includes ${formatNumber(charged.free_per_second)} a second`,
                }),
            tone: 'plain',
            spark: sparks?.charged,
          }
  );

  // Errors: two counts over two different windows, so each is named with its
  // own. The big number is the error list the tile links to, the server's and
  // the browser's most recent errors; the 5xx answers are the traffic's
  // stretch. Before the list is read, the big number is the 5xx count, and the
  // line says so. Any error at all is red.
  if (errors === null && traffic === null) {
    tiles.push(waiting('errors', 'broke', 'Errors'));
  } else {
    const failing = traffic?.server_errors ?? 0;
    const reported = errors ?? 0;
    const stretch = ringStretch(traffic?.ring_minutes);
    const clean = failing === 0 && reported === 0;
    tiles.push({
      key: 'errors',
      question: 'broke',
      label: 'Errors',
      value: `${errors === null ? failing : reported}`,
      detail: clean
        ? 'no 5xx answered and none listed'
        : errors === null
          ? `${failing} answered 5xx`
          : `${reported} listed, ${failing} answered 5xx`,
      more:
        (errors === null ? '' : '; the list is the most recent from the server and the browser') +
        (traffic === null ? '' : `${errors === null ? ';' : ','} the 5xx are from the ${stretch}`),
      tone: clean ? 'good' : 'bad',
      spark: sparks?.errors,
    });
  }

  tiles.push(
    visitorsToday === null
      ? waiting('visitors', 'fast', 'Visitors today')
      : {
          key: 'visitors',
          question: 'fast',
          label: 'Visitors today',
          value: `${visitorsToday}`,
          detail: 'since midnight UTC, bots left out',
          more: '; counted by a daily hash, the day the server keeps',
          tone: 'plain',
        }
  );

  return tiles.map((tile) => ({ ...tile, ringed: RINGED.has(tile.key) }));
}
```
*Live from [`src/lib/statTiles/tileRules.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/lib/statTiles/tileRules.ts#L59-L267), region `tile-rules`, read from this build at 1d6b50b.*

```ts
/**
 * The hour's median and 95th percentile, over every request in the minutes
 * passed in (usually the warm ones from afterColdStart). You cannot get an
 * hour's percentile from its minutes' percentiles, so this pools every request
 * duration first. It uses the nearest-rank method, the same arithmetic as the
 * API's Percentiles.Of, so the two agree on an hour that has one minute.
 */
export function hourTiming(
  slots: { at: string; requests?: number | null; durations_ms?: number[] }[]
): {
  requests: number;
  p50_ms: number | null;
  p95_ms: number | null;
  /** The minute the slowest request was in, or null on an hour with none. */
  slowest_at: string | null;
} {
  const all: number[] = [];
  let slowest: number | null = null;
  let slowestAt: string | null = null;
  for (const slot of slots) {
    const durations = slot.durations_ms ?? [];
    for (const ms of durations) {
      all.push(ms);
      if (slowest === null || ms > slowest) {
        slowest = ms;
        slowestAt = slot.at;
      }
    }
  }
  all.sort((a, b) => a - b);
  // Nearest rank: the value at position ceil(p% of n), counting from 1.
  const rank = (percentile: number) =>
    all.length === 0 ? null : all[Math.max(0, Math.ceil((percentile / 100) * all.length) - 1)];
  return { requests: all.length, p50_ms: rank(50), p95_ms: rank(95), slowest_at: slowestAt };
}
```
*Live from [`src/lib/statTiles/hourTiming.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/lib/statTiles/hourTiming.ts#L9-L43), region `hour-timing`, read from this build at 1d6b50b.*

```ts
/** The three charts, each titled with the question it answers and one sentence on how to read it. */
export const TRAFFIC_CHARTS = {
  errors: {
    title: 'Did anything fail?',
    read: 'Errors per minute. A server error is the site’s fault and should be zero. A turned-away request is the visitor’s: a wrong address, or not signed in.',
    unit: 'errors / min',
    server: 'Server errors (5xx, the site’s fault)',
    turnedAway: 'Turned away (4xx, the visitor’s)',
  },
  requests: {
    title: 'How busy is it?',
    read: 'Requests per minute. A flat line at zero means the site was up and nobody asked for anything.',
    unit: 'requests / min',
    requests: 'Requests',
  },
  timing: {
    title: 'How fast does it answer?',
    read: 'Milliseconds to answer, lower is better. The typical request, and the slow ones: 19 in 20 requests were faster than the upper line.',
    /** A kept window's point is a stretch of minutes, and its two readings are folded from theirs. */
    readKept:
      'Milliseconds to answer, lower is better. Each point is a stretch of minutes: the lower line is their typical request, the upper line their worst minute, in which 19 in 20 requests were faster.',
    unit: 'ms',
    typical: 'Typical request (median)',
    slow: 'Slow requests (95th percentile)',
    typicalKept: 'Typical request (mean of the minutes’ medians)',
    slowKept: 'Slow requests (worst minute’s 95th percentile)',
  },
} as const;

/**
 * The typical answer over the stretch: the middle one of the medians the
 * slots hold. A median of every request in the window is not something the
 * slots can give, and the card does not pretend to it: the detail line says
 * "in the typical" minute or stretch.
 */
export function typicalMs(slots: TrafficSlot[]): number | null {
  const medians = slots
    .map((slot) => slot.p50_ms)
    .filter((value): value is number => value !== null)
    .sort((a, b) => a - b);
  if (medians.length === 0) return null;
  // Nearest rank: the middle one of an odd count, the lower of the two middle ones of an even count.
  return medians[Math.ceil(medians.length / 2) - 1];
}

const count = (value: number, one: string, many: string) =>
  `${value.toLocaleString('en-US')} ${value === 1 ? one : many}`;

/**
 * The four blocks over the charts. `stretch` is the window in words, lower
 * case, "last hour", or the minutes the request ring reaches when it stops
 * short of the hour. The slow block is the worst minute's 95th percentile,
 * for the hour and for a kept window alike, and says so: a percentile over
 * the whole window is not something either can give. Only a state takes a
 * status colour: no server errors is good news and any is bad news; an answer
 * under the speed tile's own amber line is fast. A slow stretch is left plain here, because the alarm is the
 * speed tile's, which knows about a cold start and this block does not.
 */
export function trafficBlocks(
  slots: TrafficSlot[],
  totals: TrafficTotals,
  stretch: string,
  hourly: boolean
): TrafficBlock[] {
  const typical = typicalMs(slots);
  const slow = totals.slowest_p95_ms;
  const fast = (value: number | null) => value !== null && value < SLOW_P95_MS;
  const each = hourly ? 'minute' : 'stretch';
  const turnedAway =
    totals.client_errors === 0
      ? 'none were turned away'
      : `${count(totals.client_errors, 'more was', 'more were')} turned away (bad address or not signed in)`;
  return [
    {
      key: 'requests',
      label: 'Requests',
      value: totals.requests.toLocaleString('en-US'),
      detail: `${totals.requests === 1 ? 'request' : 'requests'} in the ${stretch}: pages and API calls`,
      tone: 'plain',
      word: null,
    },
    {
      key: 'typical',
      label: 'Typical answer',
      value: typical === null ? 'none' : millisecondsWords(typical),
      detail:
        typical === null
          ? 'nobody asked for anything'
          : `half of requests were faster, in the typical ${each}`,
      tone: fast(typical) ? 'good' : 'plain',
      word: fast(typical) ? 'fast' : null,
    },
    {
      key: 'slow',
      label: 'Slow answers',
      value: slow === null ? 'none' : millisecondsWords(slow),
      detail:
        slow === null
          ? 'nobody asked for anything'
          : '19 in 20 requests beat this, in the worst minute',
      tone: fast(slow) ? 'good' : 'plain',
      word: fast(slow) ? 'fast' : null,
    },
    {
      key: 'errors',
      label: 'Server errors',
      value: totals.server_errors.toLocaleString('en-US'),
      detail: turnedAway,
      tone: totals.server_errors === 0 ? 'good' : 'bad',
      word: totals.server_errors === 0 ? 'none' : 'needs attention',
    },
  ];
}

/**
 * The sentence over the fail chart. Most hours that chart is a flat line at
 * zero, and a flat line reads as "nothing loaded"; said in words, zero reads
 * as the good news it is.
 */
export function failSentence(
  totals: TrafficTotals,
  stretch: string
): { good: boolean; text: string } {
  return totals.server_errors === 0
    ? { good: true, text: `No server errors in the ${stretch}.` }
    : {
        good: false,
        text: `${count(totals.server_errors, 'server error', 'server errors')} in the ${stretch}.`,
      };
}
```
*Live from [`src/lib/trafficCard.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/lib/trafficCard.ts#L36-L165), region `traffic-words`, read from this build at 1d6b50b.*

```csharp
// The count needs every match and the page needs a hundred of them.
// Sorting every match and then taking the page would be a full sort
// of a hundred thousand rows on every unfiltered listing, and on the
// plan's one shared core that is the slowest read the site has (ADR: The
// Admin tab, as a product, the addendum on the first amber tile). Skip and Take straight off the ordering let the
// runtime sort only as far as the page: the same rows in the same
// order, ties included, because the ordering is still the stable one.
var page = VehicleOrdering.Sort(matched, sort, clock).Skip(offset).Take(limit).ToList();
return new SearchResult(matched.Count, page);
```
*Live from [`api/TheYard.Application/InventoryService.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Application/InventoryService.cs#L121-L129), region `page`, read from this build at 1d6b50b.*
