# ADR: Ten minutes, seven stops

Status: accepted, 2026-11-03, at 1.0.4.130. The site has a page at `/?view=review` that walks a visitor with ten minutes through it in a fixed order: seven stops, each one live thing to look at, one line on what it shows and one thing to do, ending at the resume. The place is kept on the browser. The page is a row in the rail directly under Home and a banner on the landing page beside the resume's, it is drawn on the server, `/review` is a permanent redirect to it that keeps the query, and a tagged link that opens it is counted under its own path.

## Reading list

1. [ADR: The landing page and the site map](https://theyard.stevenstout.biz/?doc=adr-landing-page): the one list the rail and the landing page are drawn from, which this record adds an action and a banner to.
2. [ADR: Tagged links, who opened what](https://theyard.stevenstout.biz/?doc=adr-tagged-links-who-opened-what): the six-character code a link carries and the page's own report of its open, which this page reports under a third page view.
3. [ADR: A rendering service beside the API](https://theyard.stevenstout.biz/?doc=adr-a-rendering-service-beside-the-api): why a view is drawn on the server, so a crawler and a link preview read the page and not a blank frame.
4. [ADR: Every LinkedIn post is also a blog post](https://theyard.stevenstout.biz/?doc=adr-every-linkedin-post-is-also-a-blog-post): the newest view added before this one, and the template this one was added on.

## In plain words

The site holds a lot: a live auction, an Admin tab of readings, more than a hundred decision records, eighteen diagrams, the gate's own results. A visitor with ten minutes does not know where to start, and the landing page shows everything at once. This page picks seven things in the order a reviewer usually asks about them, says in one line what each one shows, and says what to do when you get there. Each stop is the live site, not a screenshot of it, so every number on the way is read from the running system.

The page remembers where you were on this browser, so you can open a stop, look around, come back and pick up at the next one.

What that is worth to a developer: a new stop, a changed address or a reworded line is one edit to one list (`src/lib/reviewStops.ts`), which the server and the browser both draw from and a test holds to addresses the site serves. What it is worth to the organization: the ten minutes a visitor gives the site are spent on the things that prove it, in an order that builds, instead of on whatever the first click happened to find.

## Context

- **The landing page shows everything.** It was drawn for a reader who explores (ADR: The landing page and the site map): two large tiles, the resume, the evidence strip, three groups of sections. A reader with a short window has to choose, and the choice is the reader's guess about what matters.
- **The links sent out land on the home page.** `make_ref.py` mints a code and the Site link opens `/`. The Admin tab can say that a code opened the site, and nothing about what the reader looked at.
- **The views follow one pattern.** Admin, Account and the blog each live at `?view=`, are mirrored into the address bar by the one hook that writes it (`useAddressBar.ts`), and are drawn by the rendering service. A new page that broke the pattern would be the one page a link could not carry.

## Decision

1. **One list, seven stops.** `REVIEW_STOPS` in `src/lib/reviewStops.ts` holds each stop's key, title, address, minutes, what it shows and what to do. The minutes add up to ten. Every address is the site's own: the home page, a view, an Admin card, a record, or the resume's door. The only link that leaves the site is the GitHub link on the gate's card, stop 5.
2. **A view like every other.** The page is `/?view=review`, read by `readFirstAddress`, mirrored by `useAddressBar`, opened by `useReviewNavigation` as a history entry so Back returns to where it was opened from, and drawn by `ReviewPage` in the frame the blog is drawn in.
3. **The place is kept on the browser, never per code.** `src/lib/reviewProgress.ts` keeps the current stop and the stops reached under two keys of the browser's storage (`theyard-review-stop`, `theyard-review-reached`), every read and write in a try, because a private window throws. The page is drawn at the first stop on the server and moves to the kept place once it is on screen, so the server's page and the browser's first draw agree.
4. **Three states on a stop.** Done is a green disc with a tick. Current is the only stop that shows "Do this", a filled "Open this stop" and "Mark done". Next shows its number in a ring and a small "Open". Next stop marks the stop on screen done and moves on; Previous moves back and leaves what was done. Mark done on the last stop goes to the finish panel, which reads its two counts (the records and the diagrams) from the sidebar's own lists.
5. **A row and a banner, from the site map.** The action `review` sits directly after Home, so the rail reads Home, Review in ten minutes, Blog. It is drawn on the landing page as a banner, and so is the resume: `banner: true` on both, `landingTiles()` hands them out apart from the photo tiles, the resume first. The top row is Inventory and Author, the two photographs, and the two banners sit in one row under them from 640 px of column and stacked below that. The review banner opens the view in place; the resume's opens the PDF in a new tab.
6. **Drawn on the server, with no read.** The rendering service draws the page from the list; `loadFirst` reads nothing for it beyond the build every page reads. A crawler, a link preview and a reader without JavaScript get the title and all seven stops.
7. **A short door.** `GET /review` answers 301 to `/?view=review` with the rest of the request's query carried in its order, beside `/resume` in the Docs slice, so `/review?r=<code>` works on a card or in a message. The page sweep checks it as a page wherever the app is served.
8. **A tagged open counts under its own path.** `TaggedRequests.PageViews` gains `/review`, so the page's one report on load (`/api/links/open?r=<code>&path=/review`) is kept under it, read during the first render like the home page's. The door itself is not an open: the page it lands on reports one, so a visit through the door counts once.

```ts
export const REVIEW_STOPS: readonly ReviewStop[] = [
  {
    key: 'first-screen',
    title: 'The first screen',
    address: '/',
    minutes: 1,
    shows:
      'The running system reporting on itself: the answer time, the Azure bill, the time since the last roll and the people who have visited, read live.',
    doThis: 'Watch the four readouts fill in. Each one links to the page that explains it.',
  },
  {
    key: 'room',
    title: 'Bid in the room',
    address: '/?view=inventory',
    minutes: 2,
    shows:
      '100,000 vehicles filtered, sorted and paged by the store, the auction rules on the server, and a room that bids back.',
    doThis:
      'Open any live lot and watch the room bid against itself. Sign in and place the minimum bid if you have two more minutes; the room answers within twenty seconds and your bid survives a reload.',
  },
  {
    key: 'spend',
    title: 'What Azure charges',
    address: '/?view=admin&card=spend',
    minutes: 1,
    shows:
      "The bill by resource and the month's forecast, read from Azure once an hour and shown in public. The whole platform runs on one small plan.",
    doThis: 'Open the cost card. Every resource name links to its place in Azure.',
  },
  {
    key: 'decision',
    title: 'One decision, with its code',
    address: '/?doc=adr-rules',
    minutes: 2,
    shows:
      'Seventeen standing rules, each written beside the test that holds it. The code on the page is read from the running build, not pasted in.',
    doThis: 'Scroll to the table; click any file name to land on it in GitHub.',
  },
  {
    key: 'gate',
    title: 'The gate that shipped this version',
    address: '/?view=admin&card=tests',
    minutes: 1,
    shows:
      'Every push through one gate: xUnit, Vitest, Playwright with axe, and the architecture rules, on both stores, then a blue-green roll with no downtime.',
    doThis:
      'Read the suites and their times for the build you are looking at. The link on the card opens the run on GitHub.',
  },
  {
    key: 'stores',
    title: 'One codebase, two stores',
    address: '/?doc=sql-vs-cosmos',
    minutes: 2,
    shows:
      'The same build on Azure SQL Database and on Azure Cosmos DB, side by side, with the proof run that shows the same performance from both.',
    doThis:
      'Read the first table, then press the store toggle at the top of the page and watch the readouts switch.',
  },
  {
    key: 'resume',
    title: 'The resume',
    address: '/resume',
    minutes: 1,
    shows: 'One page. Everything above is what it claims.',
    doThis: 'Open the PDF. The top line is the title the site was built to prove.',
  },
];
```
*Live from [`src/lib/reviewStops.ts`](https://github.com/SteveStout/TheYard/blob/8003063/src/lib/reviewStops.ts#L26-L92), region `review-stops`, read from this build at 8003063.*

```ts
// Directly under Home: the site in ten minutes, seven live stops ending at the resume
// (ADR: Ten minutes, seven stops). On the landing page it is the banner beside the resume's.
{
  key: 'review',
  label: 'Review in ten minutes',
  icon: 'review',
  blurb: 'Seven stops, each one live, ending at the resume.',
  featured: true,
  featuredRank: 4,
  banner: true,
  inRail: true,
  onLanding: true,
},
```
*Live from [`src/lib/siteMapEntries.ts`](https://github.com/SteveStout/TheYard/blob/8003063/src/lib/siteMapEntries.ts#L148-L160), region `review-action`, read from this build at 8003063.*

## Consequences

- The resume leaves the top row: it was the third large tile and is a banner now. The top row is two tiles across the width the strip's three share.
- A stop that moves (a card renamed, a record renumbered) fails `reviewStops.test.ts` before it ships, because the test asks the catalogue and the Admin card list whether each address is served.
- The place is per browser: the same visitor on a phone and a laptop starts twice, and a cleared browser starts again. Nothing on the server knows where a visitor is until a later version reports it.
- The stops open in the same tab, as ordinary links, so Back returns to the route; a middle click opens one beside it.

## Where it sits

All of it is the front end's, in the rings the front end keeps: the list and the place are `src/lib` (no React), the hook over the place is `src/hooks`, the four components are `src/components/review`, and the shell's part is one hook in `src/app/hooks` and a line each in the address bar, the navigation and `App.tsx`. On the API side the door is one route in the Docs slice (`Features/Docs/DocumentationEndpoints.cs`) and the page view is one entry in the Links slice's rule (`Application/Features/Links/LinkOpens.cs`). Nothing in Domain knows the route exists.

## Files

- [`src/lib/reviewStops.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/reviewStops.ts) and [`src/lib/reviewStops.test.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/reviewStops.test.ts) - the seven stops, and the test that holds them to addresses the site serves
- [`src/lib/reviewProgress.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/reviewProgress.ts) and [`src/hooks/useReviewProgress.ts`](https://github.com/SteveStout/TheYard/blob/main/src/hooks/useReviewProgress.ts) - the place, kept on the browser
- [`src/components/review/ReviewPage/ReviewPage.tsx`](https://github.com/SteveStout/TheYard/blob/main/src/components/review/ReviewPage/ReviewPage.tsx) - the page, with [`ReviewProgress`](https://github.com/SteveStout/TheYard/tree/main/src/components/review/ReviewProgress), [`ReviewStop`](https://github.com/SteveStout/TheYard/tree/main/src/components/review/ReviewStop) and [`ReviewFinish`](https://github.com/SteveStout/TheYard/tree/main/src/components/review/ReviewFinish)
- [`src/lib/siteMapEntries.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/siteMapEntries.ts) and [`src/components/landing/LandingBanners/LandingBanners.tsx`](https://github.com/SteveStout/TheYard/blob/main/src/components/landing/LandingBanners/LandingBanners.tsx) - the rail row and the landing page's banners
- [`src/app/hooks/useReviewNavigation.ts`](https://github.com/SteveStout/TheYard/blob/main/src/app/hooks/useReviewNavigation.ts) - opening the view
- [`api/TheYard.Api/Features/Docs/DocumentationEndpoints.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Features/Docs/DocumentationEndpoints.cs) and [`api/TheYard.Tests/ReviewAddressTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/ReviewAddressTests.cs) - the door, and the test that holds the redirect and its query
- [`api/TheYard.Application/Features/Links/LinkOpens.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Application/Features/Links/LinkOpens.cs) - the third page view
- [`tests/e2e/review.spec.ts`](https://github.com/SteveStout/TheYard/blob/main/tests/e2e/review.spec.ts) - the page in a browser at 390 and 1280
- [`api/TheYard.Application/Features/Review/`](https://github.com/SteveStout/TheYard/tree/main/api/TheYard.Application/Features/Review) - the reach: its ports, `ReviewFolding`, the command, the query and the memory keeper
- [`api/TheYard.Infrastructure.Cosmos/Features/Review/CosmosReviewStore.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Infrastructure.Cosmos/Features/Review/CosmosReviewStore.cs) - the reach in the activity container
- [`api/TheYard.Api/Features/Review/`](https://github.com/SteveStout/TheYard/tree/main/api/TheYard.Api/Features/Review) - the two routes, the collector and the module
- [`src/components/admin/ReviewCard/ReviewCard.tsx`](https://github.com/SteveStout/TheYard/blob/main/src/components/admin/ReviewCard/ReviewCard.tsx) and [`src/lib/review.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/review.ts) - the card and its words
- [`api/TheYard.Tests/ReviewFoldingTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/ReviewFoldingTests.cs), [`ReviewHandlerTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/ReviewHandlerTests.cs), [`ReviewEndpointTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/ReviewEndpointTests.cs) and [`CosmosReviewStoreTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/CosmosReviewStoreTests.cs) - the rule, the handlers, the routes and the store

## Addendum, 2026-11-03 (version B, 1.0.4.131): the reach rows and the card

What stopped being true: "Nothing on the server knows where a visitor is" in the Consequences above. From this version the page reports how far a visitor got, and the Admin tab's desk question has a card that says it.

1. **The page reports each stop once a browser.** `useReviewProgress` posts `{ "stop": n, "r": "<code>" }` to `POST /api/review/reached`: stop 0 when the route opens, and a stop from 1 to 7 the first time it is reached on this browser (the reached set it already keeps), by Next stop, Mark done or opening the stop. The code is the `?r=` one read during the first render, as the tagged open is. A send that fails is let go; `keepalive` lets one made as a stop's link is followed finish. `reachBody` and `newlyReached` in `reviewProgress.ts` build it, and a test holds both.
2. **One row a visitor a day, never a name.** The endpoint asks the shared visitor tokens for the stable keyed hash (`VisitorTokens.PersonFor`, ADR-116) and never makes one; a request the site does not count as a person (its own tools, the operator's cookie, the loopback address, a scanner's agent) is answered 204 and not kept. `RecordReviewReachCommand` refuses a stop outside 0 to 7 with a 400 problem, drops a code that is not one rather than refusing it, and hands the reach to `ReviewReachCollector`, which drains once a minute, folds what it took by the one rule and writes.
3. **The one rule.** `ReviewFolding`: a higher stop replaces a lower one and never the reverse, the first time and the first code of the day stay, and the last time moves. The memory keeper and the document store both fold through it, and the document store replaces a row only under the ETag it read, so two containers cannot lower each other's rows.
4. **The fourth kind in the activity container.** `CosmosReviewStore` keeps `kind = "review"` documents with the id `review:{day}:{hash}`, partitioned on the day like the link opens, with no expiry, and reads them with `kind = @kind AND day >= @day`. No container, setting or role changed; a host with no document store keeps the rows in memory (`MemoryReviewStore`).
5. **The card counts people, not rows.** `ReadReviewFunnelQuery` reads the rows of the window (24 h, 7 d, 30 d or 90 d) and folds each visitor's days to one, so a visitor on two days is one visitor at the highest stop reached; `reached_by_stop` has eight places, opened and then the seven stops, each the visitors whose highest stop is at least that one, and `by_code` is one row a code with its highest stop, the minutes from its first report to its last, and its last day. `counted_from` is the first day a row is kept, and the card says it: "Counting from <day>, the day version B shipped." It never reads a bare count as all time.
6. **The card.** "Review route, how far they got", under the desk question right after Who opened what (`CARD_SLUGS`, `bench.ts`): the four window pills, the funnel as eight bars drawn with the activity card's `PathBars` in the first series' teal, the by-code table (Tagged link, Reached, Took, Last seen) with `DataTable`, the counting line and "A code says which link was sent, never who received it". Nothing on it is a status colour. Its code is a chunk of its own; the list of chunks moved to `AdminBenchCard/cardChunks.ts` because `AdminBenchCard.tsx` was at the cap.

What differed from the plan, and why: the reading port hands out rows (`RowsSinceAsync`, `FirstDayAsync`) rather than a finished funnel, so the counting is written once, in the query, and not once in each adapter; the funnel carries `available` and `window` beside the five fields, so a store that cannot be read says so on the card; `KeepAsync` takes the rows the collector already folded. And `LinkCodes`, the rule for what a code is, moved from the Links slice to `Application/Shared/Links`, because a slice may not use another (ADR-107, rule 1) and the review route reads it too; the type and the rule are unchanged.

```csharp
/// <summary>Reports folded to a row a visitor a day, and a row folded into what is kept.</summary>
public static class ReviewFolding
{
    /// <summary>The last stop on the route; a report past it, or below opened, is not one.</summary>
    public const int LastStop = 7;

    /// <summary>Whether a number is a stop a report may carry: 0 for opened, 1 to 7.</summary>
    /// <param name="stop">The number reported.</param>
    public static bool IsStop(int stop) => stop is >= 0 and <= LastStop;

    /// <summary>A batch of reports as one row a visitor a day, oldest day first.</summary>
    /// <param name="reaches">The batch.</param>
    public static IReadOnlyList<ReviewRow> Fold(IReadOnlyList<ReviewReach> reaches) =>
        [.. reaches
            .Where(reach => IsStop(reach.Stop))
            .GroupBy(reach => (reach.Day, reach.Hash))
            .Select(day => day.OrderBy(reach => reach.At).Select(Row).Aggregate(Merge))
            .OrderBy(row => row.Day, StringComparer.Ordinal)
            .ThenBy(row => row.Hash, StringComparer.Ordinal)];

    /// <summary>One report as a row of its own.</summary>
    /// <param name="reach">The report.</param>
    public static ReviewRow Row(ReviewReach reach) => new(reach.Hash, reach.Day, reach.Stop, reach.Code, reach.At, reach.At);

    /// <summary>
    /// A row folded into the one kept for the same visitor and day: the higher stop, the first code
    /// (a code is kept once per visitor and day), the earlier first and the later last.
    /// </summary>
    /// <param name="kept">The row as kept.</param>
    /// <param name="added">The row the batch adds.</param>
    public static ReviewRow Merge(ReviewRow kept, ReviewRow added) => kept with
    {
        Stop = Math.Max(kept.Stop, added.Stop),
        Code = kept.Code ?? added.Code,
        First = added.First < kept.First ? added.First : kept.First,
        Last = added.Last > kept.Last ? added.Last : kept.Last,
    };
}
```
*Live from [`api/TheYard.Application/Features/Review/ReviewFolding.cs`](https://github.com/SteveStout/TheYard/blob/8003063/api/TheYard.Application/Features/Review/ReviewFolding.cs#L8-L45), region `review-folding`, read from this build at 8003063.*

```csharp
// The document store keeps the reach beside the site's activity wherever it is configured, as the
// link opens are kept; a container without one keeps it in memory, which is what the tests run on.
services.AddSingleton<IReviewStore>(provider => provider.GetRequiredService<DocumentStore>().Store is { } cosmos
    ? new CosmosReviewStore(cosmos)
    : new MemoryReviewStore());
services.AddSingleton<IReviewReader>(provider => provider.GetRequiredService<IReviewStore>());
services.AddSingleton(provider => new ReviewReachCollector(provider.GetRequiredService<IReviewStore>(), provider.GetRequiredService<ILogger<ReviewReachCollector>>()));
services.AddHostedService(provider => provider.GetRequiredService<ReviewReachCollector>());
services.AddSingleton<IReviewReachOffers>(provider => provider.GetRequiredService<ReviewReachCollector>());
services.AddScoped(provider => new RecordReviewReachHandler(provider.GetRequiredService<IReviewReachOffers>(), TimeProvider.System));
services.AddScoped(provider => new ReadReviewFunnelHandler(provider.GetRequiredService<IReviewReader>(), TimeProvider.System));
```
*Live from [`api/TheYard.Api/Features/Review/Module.cs`](https://github.com/SteveStout/TheYard/blob/8003063/api/TheYard.Api/Features/Review/Module.cs#L18-L28), region `review-wiring`, read from this build at 8003063.*

`ReviewFoldingTests` holds the rule, `ReviewHandlerTests` the command, the query over the memory keeper, the four windows and the collector's fold, `ReviewEndpointTests` the 204, the 400 problem, a lower stop leaving the row where it was and a scanner never counted, and `CosmosReviewStoreTests` the same rule against the real account in the gate's live pass. `admin.spec.ts` draws the card.
