Lane 3: This week, the sessions list, and the clock D17 actually decided (#729) #767

Merged
claude-bot merged 4 commits from build/lane3-thisweek into main 2026-09-23 06:42:38 +00:00
Collaborator

Lane 3's second UI PR: CAMP-1 This week and CAMP-2 Sessions list — 47 coverage rows — plus a correction to the clock that reaches back into lane 1.

The clock, which is the part to read

D17 says one time, always the reader's own, carrying its zone abbreviation, with a line reconciling to the table's day only when the days differ. Its stated reason: the abbreviation is what tells a traveller their device has moved.

Lane 1 shipped the opposite — Home renders a session's time in the campaign's zone, under a comment saying "that is the night the table agreed on". That comment reads like a rule and is not one; it is a rationale written during the build, and it contradicts a decision already made, for a reason that directly answers it.

I then propagated it: in #762 I moved Needs you onto the campaign's zone so it would agree with the card beside it, and described that to the owner as following an existing rule. The disagreement was real; I resolved it in the direction D17 rules out.

This PR's delegate built This week the same wrong way, because my brief said to — and flagged that the CAMP-1 drawing disagreed, which is how it surfaced. The owner has confirmed D17 stands.

So, in one helper that this PR had already consolidated:

  • whenText(iso) formats in the reader's own zone with its abbreviation, and no longer takes a zone parameter at all — it had one, all four callers passed the campaign's, and a parameter that can reinstate a retired decision is a parameter that will.
  • The reconciliation line is D17's own sentence rather than a third wording: "The table calls this Monday's session — you are 6 hours ahead." The offset is computed from the two wall-clock readings of that one instant, not from the zones in general, because one of them may be on summer time and the other not.
  • All four consumers flip together: Home's campaign cards, Home's Needs you rows, This week's hero and the Sessions rows. Home's live row gains the abbreviation — I had written a comment last week excusing it as the one exception; under D17 there is no exception, and that comment is gone.
  • Home.jsx's old rationale is replaced by one citing D17. No trace of "the night the table agreed on" remains in the tree.

/api/me/attention's timezone from #762 stays — it is what the reconciliation is drawn from. Only its use changed.

Three tests were asserting the old direction, one of them with it in the title; all are rewritten, and the file now pins the reader to London against the fiction's Chicago table so the two can never accidentally be the same answer.

CAMP-1 and CAMP-2

/campaigns/:id is This week now, answering the three questions the drawing's caption names, off the single this-week endpoint #765 added. /campaigns/:id/sessions is the list, with its three tabs and six row states.

The old CampaignDetail.jsx moves to /campaigns/:id/manage — "Members & settings" in the Contents — because paths are contracts and the campaign's landing role had to move. It keeps members, characters, invites, the settings form, the new-session form and SeriesManager, each named in its header for the PR that takes it. That Contents entry is deliberately temporary and says so in vocabulary.js; the alternative was no door at all to two unbuilt surfaces.

The first-run checklist moves onto This week and keeps its old dismissal key, so a GM who dismissed it does not meet it again. This week also draws the three campaign-level banners (archived, no GM, Discord failing) that would otherwise have left the landing page when the old one moved.

Not built as drawn

  • "Flag a concern" (IS-205) and "Nudge on Discord" — neither a safety registry nor a nudge endpoint exists anywhere. D36: a target appears only when it can work, so the controls are absent rather than dead.
  • "four at the table" on the live header — presence is Table mode's (lane 5).
  • The "Prep this session" card — its content is the GM's own line about what the session is for, which nothing returns; drawn without it, it is a second button to where "Prep sheet in progress · Continue" already goes.
  • A confirmed row's RSVP tally and a voting row's progress — a request per row.
  • A cancelled row's reason — SessionResponse has no cancelled_reason.
  • "Session 14" — no session numbering (#760); a row is its title, and an untitled session is named once, in "Still needed", rather than twice.
  • CA-095/CA-096/CA-098 re-homed rather than rebuilt — D16 moved search to the app bar, which lane 1 already built, so the list's filter has no minimum, no button and no server call.

What the render found

  • The Sessions page crashed outright — the app-shell crash screen, not a banner — on a list body it did not expect. The rows are read outside every SectionBoundary, since the tab counts are computed from them, so a bad response took the whole page down. Guarded at both read sites. Reading the code I had assumed the boundary covered it.
  • "in 5 days, 0 hours" under the one time was the same fact twice, one line apart; moved to the meta row where the drawing has it.
  • The archived banner sat a gutter in and below the eyebrow — Banners pads itself to the frame's gutter because it is chrome, which is wrong inside a document.
  • "Members & settings" had no icon where every other Contents entry does, which reads as a fault rather than a choice.

Two things for whoever writes the next zone test

A zone's abbreviation is locale data, not a constant. Europe/London renders as "GMT+1" under en-US CLDR — which is what the CI image runs — and as "BST" under en-GB. The assertions are therefore that a zone marker is present and is not the table's, with the reason in a comment.

TZ=… does not reach a headless browser on Windows; it reads the OS zone, so the first render silently showed the machine's own zone and looked like the flip had failed. Move the table instead.

Left for lane 4, deliberately

utils/datetime.js's viewerSecondaryTime is IS-003 — the campaign-zone-plus-viewer-zone pair D17 retires. Its only callers are SessionDetail.jsx and VotingGrid.jsx, both of which lane 4 replaces, and it should be deleted there rather than converted. Noted on #730 so that lane starts from a list rather than a surprise.

Checks

lint 0 errors (234 warnings, all pre-existing in pages no lane has rebuilt; none in this PR's files, held at error). 1457 frontend tests in 124 files. Build clean. Both pages served from the built dist and looked at, desktop and ~500px, both themes, with the reader in and out of the table's zone.

🤖 Generated with Claude Code

Lane 3's second UI PR: **CAMP-1 This week** and **CAMP-2 Sessions list** — 47 coverage rows — plus a correction to the clock that reaches back into lane 1. ## The clock, which is the part to read **D17 says one time, always the reader's own**, carrying its zone abbreviation, with a line reconciling to the table's day only when the days differ. Its stated reason: *the abbreviation is what tells a traveller their device has moved.* Lane 1 shipped the opposite — Home renders a session's time in the **campaign's** zone, under a comment saying "that is the night the table agreed on". That comment reads like a rule and is not one; it is a rationale written during the build, and it contradicts a decision already made, for a reason that directly answers it. **I then propagated it**: in #762 I moved Needs you onto the campaign's zone so it would agree with the card beside it, and described that to the owner as following an existing rule. The disagreement was real; I resolved it in the direction D17 rules out. This PR's delegate built This week the same wrong way, because my brief said to — **and flagged that the CAMP-1 drawing disagreed**, which is how it surfaced. The owner has confirmed D17 stands. So, in one helper that this PR had already consolidated: - `whenText(iso)` formats in **the reader's own zone** with its abbreviation, and **no longer takes a zone parameter at all** — it had one, all four callers passed the campaign's, and a parameter that can reinstate a retired decision is a parameter that will. - The reconciliation line is D17's own sentence rather than a third wording: *"The table calls this **Monday's** session — you are 6 hours ahead."* The offset is computed from the two wall-clock readings of **that one instant**, not from the zones in general, because one of them may be on summer time and the other not. - All four consumers flip together: Home's campaign cards, Home's Needs you rows, This week's hero and the Sessions rows. Home's live row *gains* the abbreviation — I had written a comment last week excusing it as the one exception; under D17 there is no exception, and that comment is gone. - `Home.jsx`'s old rationale is replaced by one citing D17. No trace of "the night the table agreed on" remains in the tree. `/api/me/attention`'s `timezone` from #762 stays — it is what the reconciliation is drawn *from*. Only its use changed. **Three tests were asserting the old direction**, one of them with it in the title; all are rewritten, and the file now pins the reader to London against the fiction's Chicago table so the two can never accidentally be the same answer. ## CAMP-1 and CAMP-2 `/campaigns/:id` is **This week** now, answering the three questions the drawing's caption names, off the single `this-week` endpoint #765 added. `/campaigns/:id/sessions` is the list, with its three tabs and six row states. The old `CampaignDetail.jsx` moves to `/campaigns/:id/manage` — **"Members & settings"** in the Contents — because paths are contracts and the campaign's landing role had to move. It keeps members, characters, invites, the settings form, the new-session form and `SeriesManager`, each named in its header for the PR that takes it. That Contents entry is deliberately temporary and says so in `vocabulary.js`; the alternative was no door at all to two unbuilt surfaces. The first-run checklist moves onto This week and keeps its old dismissal key, so a GM who dismissed it does not meet it again. This week also draws the three campaign-level banners (archived, no GM, Discord failing) that would otherwise have left the landing page when the old one moved. ## Not built as drawn - **"Flag a concern" (`IS-205`)** and **"Nudge on Discord"** — neither a safety registry nor a nudge endpoint exists anywhere. D36: a target appears only when it can work, so the controls are absent rather than dead. - **"four at the table"** on the live header — presence is Table mode's (lane 5). - **The "Prep this session" card** — its content is the GM's own line about what the session is for, which nothing returns; drawn without it, it is a second button to where "Prep sheet in progress · Continue" already goes. - **A confirmed row's RSVP tally and a voting row's progress** — a request per row. - **A cancelled row's reason** — `SessionResponse` has no `cancelled_reason`. - **"Session 14"** — no session numbering (#760); a row is its title, and an untitled session is named once, in "Still needed", rather than twice. - **`CA-095`/`CA-096`/`CA-098` re-homed rather than rebuilt** — D16 moved search to the app bar, which lane 1 already built, so the list's filter has no minimum, no button and no server call. ## What the render found - **The Sessions page crashed outright** — the app-shell crash screen, not a banner — on a list body it did not expect. The rows are read *outside* every `SectionBoundary`, since the tab counts are computed from them, so a bad response took the whole page down. Guarded at both read sites. Reading the code I had assumed the boundary covered it. - **"in 5 days, 0 hours" under the one time was the same fact twice**, one line apart; moved to the meta row where the drawing has it. - **The archived banner sat a gutter in and below the eyebrow** — `Banners` pads itself to the *frame's* gutter because it is chrome, which is wrong inside a document. - **"Members & settings" had no icon** where every other Contents entry does, which reads as a fault rather than a choice. ## Two things for whoever writes the next zone test **A zone's abbreviation is locale data, not a constant.** `Europe/London` renders as **"GMT+1"** under en-US CLDR — which is what the CI image runs — and as "BST" under en-GB. The assertions are therefore that *a* zone marker is present and is **not** the table's, with the reason in a comment. **`TZ=…` does not reach a headless browser on Windows**; it reads the OS zone, so the first render silently showed the machine's own zone and looked like the flip had failed. Move the *table* instead. ## Left for lane 4, deliberately `utils/datetime.js`'s `viewerSecondaryTime` **is `IS-003`** — the campaign-zone-plus-viewer-zone pair D17 retires. Its only callers are `SessionDetail.jsx` and `VotingGrid.jsx`, both of which lane 4 replaces, and it should be **deleted** there rather than converted. Noted on #730 so that lane starts from a list rather than a surprise. ## Checks lint **0 errors** (234 warnings, all pre-existing in pages no lane has rebuilt; none in this PR's files, held at error). **1457 frontend tests** in 124 files. Build clean. Both pages served from the built `dist` and looked at, desktop and ~500px, both themes, with the reader in and out of the table's zone. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The two pages a campaign is actually used through, rebuilt in the new shell.

**This week (CAMP-1) is what `/campaigns/:id` is now.** Paths are contracts
(build plan §2.4), so the address every bookmark and every link already points
at still opens the campaign; what is read there is what changed. It answers
three questions in the order people ask them — when, who is coming, and what
still needs doing — out of the one read the server PR added, and it does not
work around what that read deliberately leaves out. The tally, the member count
and your own answer are the roster read one way rather than fields, because two
copies of a fact can disagree and one cannot. Whether the bot is recording is
`/live`'s, asked for on its own and only while a session is actually under way,
which is the only time the answer changes anything. The campaign's own name,
system and description are `GET /api/campaigns/{id}`, which the page loads
anyway.

Four of its decisions are not layout. **One clock:** the time is printed once,
in the campaign's zone with the zone named — the same string Home's cards print,
from the same helper, because a card reading "Mon, 8:00 PM CDT" beside a page
reading "Tue, 2:00 AM BST" is two clocks and D17 exists to stop that. The line
reconciling it to the reader's own appears only when the two fall on different
days. **Not answering is not saying no:** silence gets a grey disc, a grey dot
and the words "No answer", because a GM chasing an answer has to be able to tell
the two apart. **A player's pronouns are not their character's:** the bold name
on a roster row is the character for a player and the person for whoever runs
the table, and the pronouns follow whichever noun is on the line. And **the page
changes its mind once the session starts** — the answer buttons, the roster,
what is still needed and the calendar all go, since none of them is any use
after eight o'clock.

It has three shapes rather than one: a campaign with nothing scheduled becomes
the four-item getting-started checklist and says what the page will become
without naming a Monday it has never had; an archived campaign keeps its page,
reading what happened last instead of what happens next, and says who can bring
it back; and everything else is the session.

**The sessions list (CAMP-2) is a page of its own**, at
`/campaigns/:id/sessions`. Three tabs whose counts agree with the list under
them, a row per status, the GM's actions on the row rather than behind a hover
because a phone has no hover, and the merge-notes modal. It keeps a **filter** —
narrowing the list already on screen — and searching the campaign stays the app
bar's, which is D16 and #593's one-box rule. There is no session numbering
(#760): a row is its title, and one without a title says so rather than being
given an ordinal the product does not have.

**What was not replaced still works.** `CampaignDetail` keeps members and
characters, the nine settings sections, the new-session form and the milestone
timeline — four surfaces lane 3 has not reached — at `/campaigns/:id/manage`,
in the Contents as "Members & settings" until their own PRs split it up. The
doors the departing pages still need are `?open=timezone|invite|discord|session`
(with `?reuse=` for a cancelled session's title and description), so a checklist
item still lands on the control that completes it rather than near it. That file
is deleted, not refactored, when the last of the four is built.

Four things the drawing has are not built, each because nothing behind it exists
and D36 says a target is drawn only when it can work: "Nudge on Discord" (no
endpoint), the Table rules & safety card and its "Flag a concern" (no safety
registry at all — D2 is unbuilt on both sides), "four at the table" on the live
header (presence is Table mode's, lane 5), and the "Prep this session" card,
whose content is the GM's own line about the session and which without it is a
second button to where "Prep sheet in progress · Continue" already goes.

`FirstRunChecklist.jsx` is gone; This week is where the checklist lives now, on
the same per-campaign localStorage key, so a GM who dismissed it before this does
not meet it again. Home's four time helpers move to `utils/when.js` unchanged,
so the pages that print a session's time all print the same string.

Two things found by looking at the built pages rather than at the source:

- **The sessions page did not draw at all** against a list response it did not
  expect. The rows are read outside every section boundary — the tab counts and
  the lead are computed from them — so a body that is not the list this asked
  for now becomes an empty list rather than a page that will not draw.
- **"in 5 days" under the time was the same sentence twice.** The countdown
  belongs beside the chip, where the drawing has it; under the one time it read
  as a second reading of a fact already on the line above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(webapp): a session's time is the reader's own again (#729, D17)
Some checks failed
CI / Bot/backend version sync (pull_request) Successful in 1m32s
CI / Backend lint (ruff) (pull_request) Successful in 1m44s
CI / Feedback site tests (pull_request) Successful in 2m32s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 3m2s
CI / Frontend tests, audit, and build (pull_request) Successful in 3m44s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Failing after 3m45s
CI / Bot tests and audit (pull_request) Successful in 4m24s
CI / Docker image build (pull_request) Successful in 5m20s
CI / Backend migration, tests, and audit (pull_request) Successful in 9m54s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 5m30s
2e9f452cbf
D17 settled this and both sides of it were built backwards. It supersedes
`IS-003` — which asked for the campaign's zone and the reader's side by side —
with **one time, always the reader's own, always carrying its timezone
abbreviation**, and a line reconciling it to the table's only when the reader's
*day* differs. Lane 1 shipped the campaign's zone instead, with a comment
explaining why ("that is the night the table agreed on") that reads like a rule
and is not one: it is a rationale written while building, against a decision
already made for a stated reason. #762 then moved Home's Needs you rows onto the
campaign's zone so a row and the card beside it would agree. The disagreement
was real; it was resolved in the direction D17 rules out.

So the direction flips, in the one place lane 3 had just consolidated it.

`whenText` takes the reader's clock and **no zone parameter at all**. A
parameter that can reinstate a retired decision is a parameter that will, and
this one had four callers all passing the campaign's. The campaign's zone is
still on every payload carrying a session time, and is still needed — to be
reconciled *to*. Two new helpers read it: `tableDay` for the day the table
calls it, and `zoneShift` for how far off the reader is, computed from the two
wall-clock readings of that one instant rather than from the zones in general,
because one of them may be on summer time and the other not.

The reconciling line is D17's own sentence — "The table calls this **Monday's**
session — you are 6 hours ahead" — and it still appears only when the days
differ. Two zones three hours apart on the same date have nothing to reconcile,
and a line saying so on every visit is furniture on the most-opened page in the
app.

Everything that prints a session's time flips with the helper: Home's campaign
cards, Home's Needs you rows (including the live row's "started 8:14 PM", which
gains the abbreviation it was the one exception to), This week's hero and live
header, and the sessions list's rows. Nothing needed special-casing — every
consumer is "a session's time", which is exactly what D17 is about.

`utils/datetime.js` is **not** flipped. `formatSessionTime` and
`viewerSecondaryTime` are the old pages' pair — the latter *is* `IS-003`, the
side-by-side rendering D17 retires — and their only callers are `SessionDetail`
and `VotingGrid`, which lane 4 replaces. Changing them here would edit a page
this PR does not own, to a rule its own lane has to apply anyway.

The sessions list no longer reads `GET /api/campaigns/{id}` at all: the zone was
the only thing it ever wanted from it. One fewer request per visit, and one
fewer thing that can fail.

Three tests were asserting the old direction and now assert D17:

- `Home.test.jsx` "renders a Needs you row's time in the campaign's zone, not
  the reader's (#727)" — the name said it. It is the reader's zone now, and the
  file pins `TZ` to London against the fiction's Chicago table so the two are
  never accidentally the same answer.
- `ThisWeek.test.jsx` "prints one time, in the campaign's zone" and "reconciles
  it only when the reader's day is not the table's", which asserted the "Where
  you are, that is…" wording this replaces.
- `Sessions.test.jsx`'s row-time assertion held by luck — its reader was already
  in the table's zone — and its comment said the campaign's.

Added: the reader's own clock when the two zones differ; the reconciling line's
wording and its offset; **its absence when the day is the same** (two zones, one
date); and a campaign with no zone set, which still draws the time and draws no
half-written line.

One thing the tests had to be taught: **a zone's abbreviation is locale data,
not a constant.** `Europe/London` renders as "BST" where CLDR has a short name
for it and "GMT+1" where it does not — en-GB does, en-US does not, and the CI
image runs en-US. Both say the same thing to a traveller, which is the whole
point of printing it, so the assertions are on the marker being there and not
being the table's rather than on either string.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rbrooks force-pushed build/lane3-thisweek from 2e9f452cbf
Some checks failed
CI / Bot/backend version sync (pull_request) Successful in 1m32s
CI / Backend lint (ruff) (pull_request) Successful in 1m44s
CI / Feedback site tests (pull_request) Successful in 2m32s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 3m2s
CI / Frontend tests, audit, and build (pull_request) Successful in 3m44s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Failing after 3m45s
CI / Bot tests and audit (pull_request) Successful in 4m24s
CI / Docker image build (pull_request) Successful in 5m20s
CI / Backend migration, tests, and audit (pull_request) Successful in 9m54s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 5m30s
to 50f3c981c6
All checks were successful
CI / Feedback site tests (pull_request) Successful in 38s
CI / Bot/backend version sync (pull_request) Successful in 24s
CI / Backend lint (ruff) (pull_request) Successful in 1m28s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 3m9s
CI / Frontend tests, audit, and build (pull_request) Successful in 3m56s
CI / Bot tests and audit (pull_request) Successful in 5m24s
CI / Docker image build (pull_request) Successful in 5m11s
CI / Backend migration, tests, and audit (pull_request) Successful in 10m33s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 4m53s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 26m49s
2026-09-23 05:02:31 +00:00
Compare
Merge main into build/lane3-thisweek (#729)
Some checks failed
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 52s
CI / Feedback site tests (pull_request) Successful in 42s
CI / Bot/backend version sync (pull_request) Successful in 22s
CI / Bot tests and audit (pull_request) Successful in 1m45s
CI / Backend lint (ruff) (pull_request) Successful in 1m39s
CI / Frontend tests, audit, and build (pull_request) Failing after 6m22s
CI / Docker image build (pull_request) Successful in 4m47s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been cancelled
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Has been cancelled
CI / Backend migration, tests, and audit (pull_request) Has been cancelled
32964fa2ce
#766 landed the Storyline split underneath this branch. Three resolutions,
one of which git could not see:

- `CHANGELOG.md` — both entries kept, newest first.
- `useContents.js` — the campaign Contents carries This week, Sessions,
  Highlights, **Storyline** and Members & settings. Both sides added an entry
  to the same list and each side's comment said the other's page did not
  exist yet.
- `AppFrame.test.jsx` — **auto-merged without a conflict and was wrong**.
  This branch asserts Storyline is absent from the Contents ("a door that
  leads nowhere is worse than none"); #766 built it. The assertion is now
  that it is present and points at `/campaigns/:id/storyline`.

Full frontend suite run on the merged tree: 1526 tests in 126 files, green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test(frontend): give findBy the headroom the test timeout already has (#729)
All checks were successful
CI / Backend lint (ruff) (pull_request) Successful in 1m6s
CI / Bot/backend version sync (pull_request) Successful in 29s
CI / Feedback site tests (pull_request) Successful in 48s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 2m32s
CI / Docker image build (pull_request) Successful in 1m4s
CI / Frontend tests, audit, and build (pull_request) Successful in 3m53s
CI / Bot tests and audit (pull_request) Successful in 4m38s
CI / Backend migration, tests, and audit (pull_request) Successful in 10m21s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 7m1s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 13m14s
27886b68be
CI's frontend job failed on `Home.test.jsx` with a DOM dump showing the page
still reading "Loading your campaigns…", while the same tree passed the full
suite locally. It is not a wrong assertion and not a hang.

`vite.config.js` raises `testTimeout` to 15s for exactly this reason, but that
is the wrong clock: Testing Library keeps its own 1000ms `asyncUtilTimeout`, so
a `findByRole` can expire nine tests deep inside a 15s budget and fail as
though the element were never going to appear.

Home's first render measures ~570ms on a single core in isolation. The runner
was carrying ten jobs at the time, so that race was always going to be lost
occasionally — and it is the same hazard #755 fixed one instance of.

`configure({ asyncUtilTimeout: 5000 })` in the shared setup. This does not hide
a page that never resolves: that still fails on the test timeout, and says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign in to join this conversation.
No description provided.