Lane 3: the story, the timeline, the lines and the numbers (#729) #766

Merged
claude-bot merged 3 commits from build/lane3-story into main 2026-09-23 04:59:24 +00:00
Collaborator

Lane 3's first UI PR: CAMP-5 Highlights, CAMP-6 Storyline and CAMP-7 Analytics — 48 coverage rows. Frontend, plus one backend hunk explained below.

D47: Storyline was four things and is now two

The story (chapters, generated, GM-editable) and the timeline — sessions, milestones and wiki events, one line each, filterable. The journal leaves (each session's notes live on the session, D30) and the wiki pieces go to the wiki.

The timeline reads the endpoint #765 added. Three things it gets right because the server told it so: an entry's id is never treated as a primary key (a generated NPC reappearance is npc:{id}:{session}), editable is taken from the server rather than recomputed (D36 — a target appears only when it can work), and visibility is not re-filtered, because it is already done by the reader on the server.

The file split, which needed care

CampaignStoryline.jsx was the wiki's front page — /campaigns/:id/wiki, storyline body plus category sidebar, search and suggestions. The build plan says that file splits: Storyline here, the wiki landing in lane 8 (WIKI-1, WIKI-2, WIKI-10).

So it was moved, not overwritten — git mv to WikiFront.jsx, with only the docstring and export renamed. The wiki landing keeps working at its unchanged address for lane 8 to replace, and Storyline gets the file name and an address of its own. Rewriting in place would have deleted a page nothing replaces yet and broken the Contents' "Front page & search" door.

The one backend hunk: D14 was decoration, not a rule

D14 — a player sees the campaign-wide numbers and their own row only. The GM keeps the full table.

CAMP-7 draws exactly that, and this PR built the page that way. But GET /api/campaigns/{id}/analytics was require_campaign_member and returned every member's row to every member — so the whole league table of who turns up and who answers was one look at the network tab away, on a page any member can open.

A page that hides a row the server sent is not a rule. The filter is on the endpoint now, with a test for each half so it cannot later be widened to everyone or narrowed onto the GM by accident. The campaign-wide figures stay everybody's — D14 keeps the page for every member and takes away only the table.

That is why a frontend PR has a backend hunk: implementing D14 only in the page would have been implementing it wrongly.

Not built as drawn

  1. CA-239's "3 h 38 m a session, on average" — the endpoint reports no average session length and nothing else on the page can stand in for one. The stat is absent rather than invented; it wants a server field.
  2. CA-182's count on the Contents' Highlights entry — would cost the frame a read of every campaign's quotes on every screen.
  3. A filter that empties the timeline has a line of its own ("Nothing on the timeline of that kind yet"), which the drawing does not cover — it draws only the whole-timeline empty, and showing "sessions arrive here as they are played" over a merely-filtered list would be wrong.
  4. The timeline's own failure banner is likewise not drawn, but the rule that every async call fails in words with a next action requires one.
  5. Highlights' lead says "the GM approved" where the drawing says "Dana approved" — the name would be another request, and the drawing's Dana is the fiction's instance of a variable.
  6. A milestone's date is a day stored as an instant, so the form writes the chosen day at local noon; midnight local lands on the previous day in half the world once it has been through UTC.
  7. manually_edited_at is not surfaced, though the model's comment suggests it should be — built as drawn, reported rather than invented.
  8. sessions_last_12_months is not shown here. CAMP-7 draws the 30-day figure; the twelve-month sentence is the public share page's (D89). Worth saying because #765 added the field.

Two things only the render found

A short rebuilt page left a band of bare colour under it. AppFrame still paints the Outlet's wrapper for pages no lane has rebuilt, so the Book's paper stopped where the document did. Lane 1 hit this and wrote the fix down in Home.css; all three pages now claim the height. Not findable by reading the new files — it needed a screenshot of the built page and then a second of /dashboard to prove it was not the shell.

A milestone with no mark wore the wrong glyph — defaulted to storyline, which at row size is nearly the wiki rows' book shape, so a generated NPC reappearance read as a wiki line. The drawing's own map says milestone → xp.

Also fixed, found by looking for it once the pages were real: Storyline and Analytics read their data once on mount, and the route keeps the component alive when only :id changes — so walking between two campaigns' Storylines showed the first campaign's story under the second's name. Both read on the id in the address now, with a test that navigates between two campaigns.

Checks

lint 0 errors (238 warnings, all in pages no lane has rebuilt; none in this PR's files, which are held at error). 1467 frontend tests in 125 files. Build clean. Backend 4037 passed, 13 skipped after the D14 filter. All three pages served from the built dist and looked at, desktop and ~500px, both themes.

🤖 Generated with Claude Code

Lane 3's first UI PR: **CAMP-5 Highlights**, **CAMP-6 Storyline** and **CAMP-7 Analytics** — 48 coverage rows. Frontend, plus one backend hunk explained below. ## D47: Storyline was four things and is now two The story (chapters, generated, GM-editable) and the timeline — sessions, milestones and wiki events, one line each, filterable. The journal leaves (each session's notes live on the session, D30) and the wiki pieces go to the wiki. The timeline reads the endpoint #765 added. Three things it gets right because the server told it so: an entry's `id` is **never treated as a primary key** (a generated NPC reappearance is `npc:{id}:{session}`), `editable` is taken from the server rather than recomputed (D36 — a target appears only when it can work), and visibility is **not** re-filtered, because it is already done by the reader on the server. ### The file split, which needed care `CampaignStoryline.jsx` **was** the wiki's front page — `/campaigns/:id/wiki`, storyline body plus category sidebar, search and suggestions. The build plan says that file splits: Storyline here, the wiki landing in lane 8 (WIKI-1, WIKI-2, WIKI-10). So it was **moved, not overwritten** — `git mv` to `WikiFront.jsx`, with only the docstring and export renamed. The wiki landing keeps working at its unchanged address for lane 8 to replace, and Storyline gets the file name and an address of its own. Rewriting in place would have deleted a page nothing replaces yet and broken the Contents' "Front page & search" door. ## The one backend hunk: D14 was decoration, not a rule > **D14** — a player sees the campaign-wide numbers and **their own row only**. The GM keeps the full table. CAMP-7 draws exactly that, and this PR built the page that way. But `GET /api/campaigns/{id}/analytics` was `require_campaign_member` and returned **every member's row to every member** — so the whole league table of who turns up and who answers was one look at the network tab away, on a page any member can open. A page that hides a row the server sent is not a rule. The filter is on the endpoint now, with a test for each half so it cannot later be widened to everyone or narrowed onto the GM by accident. The campaign-wide figures stay everybody's — D14 keeps the page for every member and takes away only the table. That is why a frontend PR has a backend hunk: implementing D14 only in the page would have been implementing it wrongly. ## Not built as drawn 1. **`CA-239`'s "3 h 38 m a session, on average"** — the endpoint reports no average session length and nothing else on the page can stand in for one. **The stat is absent rather than invented**; it wants a server field. 2. **`CA-182`'s count on the Contents' Highlights entry** — would cost the frame a read of every campaign's quotes on every screen. 3. **A filter that empties the timeline** has a line of its own ("Nothing on the timeline of that kind yet"), which the drawing does not cover — it draws only the whole-timeline empty, and showing "sessions arrive here as they are played" over a merely-filtered list would be wrong. 4. **The timeline's own failure banner** is likewise not drawn, but the rule that every async call fails in words with a next action requires one. 5. **Highlights' lead says "the GM approved"** where the drawing says "Dana approved" — the name would be another request, and the drawing's Dana is the fiction's instance of a variable. 6. **A milestone's date** is a day stored as an instant, so the form writes the chosen day at **local noon**; midnight local lands on the previous day in half the world once it has been through UTC. 7. **`manually_edited_at` is not surfaced**, though the model's comment suggests it should be — built as drawn, reported rather than invented. 8. **`sessions_last_12_months` is not shown here.** CAMP-7 draws the 30-day figure; the twelve-month sentence is the *public* share page's (D89). Worth saying because #765 added the field. ## Two things only the render found **A short rebuilt page left a band of bare colour under it.** `AppFrame` still paints the Outlet's wrapper for pages no lane has rebuilt, so the Book's paper stopped where the document did. Lane 1 hit this and wrote the fix down in `Home.css`; all three pages now claim the height. Not findable by reading the new files — it needed a screenshot of the built page and then a second of `/dashboard` to prove it was not the shell. **A milestone with no mark wore the wrong glyph** — defaulted to `storyline`, which at row size is nearly the wiki rows' book shape, so a generated NPC reappearance read as a wiki line. The drawing's own map says `milestone → xp`. Also fixed, found by looking for it once the pages were real: **Storyline and Analytics read their data once on mount**, and the route keeps the component alive when only `:id` changes — so walking between two campaigns' Storylines showed the first campaign's story under the second's name. Both read on the id in the address now, with a test that navigates between two campaigns. ## Checks lint **0 errors** (238 warnings, all in pages no lane has rebuilt; none in this PR's files, which are held at error). **1467 frontend tests** in 125 files. Build clean. Backend **4037 passed, 13 skipped** after the D14 filter. All three pages served from the built `dist` and looked at, desktop and ~500px, both themes. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
CAMP-5, CAMP-6 and CAMP-7 — Highlights, Storyline and Analytics — rebuilt in
the new frame, from lane 2c's drawing.

**Storyline is D47's reduction, and that is why this touches the wiki.** The
page was four things: a generated story, milestones on a rail, a journal of
everyone's session notes, and a Wiki tab with a suggested-updates row. It is
two: the story, and the timeline. The journal goes — each session keeps its own
notes (D30) — and the wiki pieces stay with the wiki, which is the half of
`CampaignStoryline.jsx` this lane does *not* own (build plan §4: its wiki
landing, search and suggestions are lane 8's WIKI-1/2/10). So the file splits
rather than being rewritten over: `WikiFront.jsx` is the old page, unchanged,
still at `/campaigns/:id/wiki` — an address in people's history and in the
Contents — and `CampaignStoryline.jsx` is now the page its name says it is, at
`/campaigns/:id/storyline`, with an entry in the campaign's Contents.

Three things about the timeline are the server's answers and are not re-derived
here, which is the whole reason `GET /campaigns/{id}/timeline` exists:
`editable` decides whether a row carries Edit and Remove — true only for a GM
and only for a stored milestone, so a generated NPC reappearance and every wiki
line are read-only without this page knowing why (D36); an entry's `id` is a
string and sometimes composite, so it is a React key and never a thing to write
back to; and visibility is filtered by the reader on the server, so nothing that
arrives wants hiding again. The list is fetched once and the four filter buttons
narrow it in the browser: they are four views of one answer, so pressing one is
instant and cannot fail. Removing a milestone is D12's lightest tier — it
happens, and the Undo sits where it happened, writing the row back at the
instant it sat at.

The story keeps the distinction it is most likely to lose (WK-028): a **missing**
story is "No story yet" — in the GM-with-recaps, GM-with-nothing and player
wordings — and any other failure is an error, said in words with a way to try
again. Writing a chapter and rewriting the whole thing are queued work the page
cannot wait on, so each says what it is doing and asks again until the answer
changes, rather than telling the reader to refresh.

Analytics follows D14: a player reads the campaign-wide numbers and their own
line; the table of everyone and the public share link are the GM's. Every rate
prints the percentage **and** the fraction it came from, the shape the public
share page settled (`PublicAnalytics.jsx`), so the colour band is redundant with
text already in the cell (IS-295); the one cell with no rate keeps the drawing's
dash with its meaning said in words behind it. Making a new link and turning the
link off each break an address somebody may be holding, so each says so first;
making the first link breaks nothing and asks nothing.

Highlights groups the lines under the session they were said in and dates that
group from the **session**, not from the day the quote was kept — a page whose
whole job is to say which night a line came from should not print the morning
after. The speaker filter's choices are built from the unfiltered answer and
kept, so picking a name never shrinks the list of names.

No page invents a session number (#760): where the drawing reads "S13" or "after
Session 13", the build reads the session's own title.

Two things the drawing shows are not built, for want of a server that reports
them, and are absent rather than faked: Analytics' "3 h 38 m a session, on
average" — `GET /campaigns/{id}/analytics` has no average session length — and
the count on the Contents' Highlights entry, which would cost the frame a read
of every campaign's quotes on every screen.

Each page claims the Book's remaining height (`flex: 1`), the workaround lane 1
wrote down in `Home.css`: the frame still paints the Outlet's wrapper white for
the pages it has not rebuilt, and a short document left a band of it underneath.
Looking at the built pages is what found that; reading them did not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Storyline and Analytics asked for their data once, on mount. The route keeps
the same component alive when only `:id` under it changes, so walking from one
campaign's Storyline to another's would have left the first campaign's story
and timeline on the second campaign's page — with the Contents beside it saying
the other name. Highlights already read on the id; these two now do the same.

`useAsync`'s `run` is stable and reads the latest function, so an effect keyed
on the campaign is all it takes, and Try again still asks about the campaign
you are on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(backend): the analytics table is the GM's on the wire too (D14, #729)
All checks were successful
CI / Bot/backend version sync (pull_request) Successful in 39s
CI / Feedback site tests (pull_request) Successful in 50s
CI / Backend lint (ruff) (pull_request) Successful in 1m23s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 3m4s
CI / Bot tests and audit (pull_request) Successful in 4m45s
CI / Docker image build (pull_request) Successful in 4m53s
CI / Frontend tests, audit, and build (pull_request) Successful in 6m25s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 5m45s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 13m18s
CI / Backend migration, tests, and audit (pull_request) Successful in 17m55s
84b1ae62a4
D14: "a player sees the campaign-wide numbers and their own row only. The GM
keeps the full table." CAMP-7 draws it that way and this lane built the page
that way — but `GET /{id}/analytics` was `require_campaign_member` and returned
every member's row to all of them.

A page that hides a row the server sent is a decoration, not a rule. The whole
league table of who turns up and who answers was one look at the network tab
away, on a page every member can open, which is precisely what D14 says it
must not be.

So the filter is on the endpoint. The campaign-wide figures stay everybody's —
D14 keeps the page for every member and takes away only the table — and both
halves have a test, so the filter cannot later be widened to everyone or
narrowed onto the GM by accident.

Found while building CAMP-7 (the page was already correct; the API was not).

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