Lane 3: what the campaign's reading surfaces need (#729) #765

Merged
claude-bot merged 2 commits from build/lane3-reading-server into main 2026-09-23 02:40:16 +00:00
Collaborator

Lane 3's third and last server PR: the campaign timeline (D47), a This week aggregate (CAMP-1), the 12-month analytics figure (CA-239) and a read of a series' sessions (IS-368). Backend only, no migration — every field comes off a column that already exists.

The timeline (D47, IS-094/IS-358/IS-359)

Sessions, milestones and wiki events have all existed for a long time and had never been merged. GET /api/campaigns/{id}/timeline?kinds=… merges them, newest first, filterable by the drawing's four choices.

Visibility is the part that mattered. A wiki timeline event carries public, gm or player_private, and this surface is read by every member. The filtering goes through one door — lore_service.visible_timeline_events, wrapping the existing rule rather than restating it — and is tested from both sides.

Two ids that are not keys. GET /{id}/milestones synthesises "reappeared" entries for NPCs with id set to a session id; a wiki event's id is unique only within its entry. So a timeline id is a string, and composite where it has to be. Generated entries arrive editable: false, and editable is reader-aware — true only for a GM, and only for a stored milestone — which is D36's "a target appears only when it can work" rather than a button that can only answer 403.

A cross-campaign echo, closed on the way past. A wiki timeline event's session_id is client-written JSON, not a foreign key. Anything not in this campaign's own session set is now dropped rather than echoed back. Tested.

Ordering when a milestone has no date of its own: its session's time, then created_at. A GM picks a session, not a date, so a milestone about session 2 written today must not land at the top — and the tie-break is deterministic, so a reload never shuffles two lines.

This week (CAMP-1)

The campaign's most-visited surface had no endpoint and would otherwise be four or more client calls. GET /api/campaigns/{id}/this-week answers the three questions the drawing's caption names — when, who is coming, what still needs doing — and composes them from existing queries: Home's own this-week priority (live > next confirmed > open vote), the RSVP roster, evaluate_session_at_risk (the same evaluation the Discord warning is sent on), and the GM's outstanding bits.

What it deliberately does not carry is the more interesting half:

  • No tally, no member_count, no my_rsvp — all three are derivations of the roster. Two copies of a fact can disagree; one cannot.
  • No recording state. GET /{id}/live is polled every few seconds off a Redis cache; this endpoint is not, and a second answer would be stale half the time.
  • No About card — that is GET /campaigns/{id}, which the page loads anyway. D45 put the card on this page; it did not make it this endpoint's.
  • No nudge field. There is no RSVP-nudge endpoint anywhere in the backend, so a can_nudge would have been inventing a concept. Flagged rather than guessed.
  • An archived campaign gets session: null and keeps its roster and last time — #479's rule, and what the archived drawing shows.

Also

sessions_last_12_months on the authenticated analytics response, which public.py's docstring had already deferred to this rebuild; GET /api/series/{id}/sessions, which says whether each occurrence was individually rescheduled, since that is what a GM editing the rule needs to know; and milestone icon validated against the drawing's eight marks, so the timeline cannot be handed a glyph the page has none for.

A test-harness trap, written down

The signed-in client fixtures override auth on the module-level app, so a test asking for two of them gets two clients that are both whichever fixture ran last — and "the GM sees it, the player does not" then passes while proving nothing.

Nothing in the suite does this today (checked), which is why it has never bitten. It cost a red test while writing the timeline's visibility cases, where a vacuous pass would have been a leak nobody noticed, so the reason is now a comment beside the fixtures.

Checks

Ruff 0.4.4 clean. Full backend suite 4019 passed, 13 skipped; the three new/changed test files run 57 passed after the conftest note. No rate limits added — the neighbours (search-all, /me/home, /{id}/analytics) have none, and the timeline is bounded by load_only (never a transcript, never a wiki body) and a jsonb_array_length filter rather than by a limiter. Say the word if you want one anyway.

🤖 Generated with Claude Code

Lane 3's third and last server PR: the campaign **timeline** (D47), a **This week** aggregate (CAMP-1), the 12-month analytics figure (`CA-239`) and a read of a **series' sessions** (`IS-368`). Backend only, no migration — every field comes off a column that already exists. ## The timeline (D47, `IS-094`/`IS-358`/`IS-359`) Sessions, milestones and wiki events have all existed for a long time and had **never been merged**. `GET /api/campaigns/{id}/timeline?kinds=…` merges them, newest first, filterable by the drawing's four choices. **Visibility is the part that mattered.** A wiki timeline event carries `public`, `gm` or `player_private`, and this surface is read by *every* member. The filtering goes through one door — `lore_service.visible_timeline_events`, wrapping the existing rule rather than restating it — and is tested from both sides. **Two ids that are not keys.** `GET /{id}/milestones` synthesises "reappeared" entries for NPCs with `id` set to a session id; a wiki event's id is unique only within its entry. So a timeline `id` is a string, and composite where it has to be. Generated entries arrive `editable: false`, and `editable` is reader-aware — true only for a GM, and only for a stored milestone — which is D36's "a target appears only when it can work" rather than a button that can only answer 403. **A cross-campaign echo, closed on the way past.** A wiki timeline event's `session_id` is client-written JSON, not a foreign key. Anything not in this campaign's own session set is now dropped rather than echoed back. Tested. **Ordering when a milestone has no date of its own**: its session's time, then `created_at`. A GM picks a session, not a date, so a milestone about session 2 written today must not land at the top — and the tie-break is deterministic, so a reload never shuffles two lines. ## This week (CAMP-1) The campaign's most-visited surface had no endpoint and would otherwise be four or more client calls. `GET /api/campaigns/{id}/this-week` answers the three questions the drawing's caption names — **when**, **who is coming**, **what still needs doing** — and composes them from existing queries: Home's own this-week priority (live > next confirmed > open vote), the RSVP roster, `evaluate_session_at_risk` (the same evaluation the Discord warning is sent on), and the GM's outstanding bits. **What it deliberately does not carry** is the more interesting half: - **No tally, no `member_count`, no `my_rsvp`** — all three are derivations of the roster. Two copies of a fact can disagree; one cannot. - **No recording state.** `GET /{id}/live` is polled every few seconds off a Redis cache; this endpoint is not, and a second answer would be stale half the time. - **No About card** — that is `GET /campaigns/{id}`, which the page loads anyway. D45 put the card on this page; it did not make it this endpoint's. - **No nudge field.** There is no RSVP-nudge endpoint anywhere in the backend, so a `can_nudge` would have been inventing a concept. Flagged rather than guessed. - An **archived** campaign gets `session: null` and keeps its roster and last time — #479's rule, and what the archived drawing shows. ## Also `sessions_last_12_months` on the authenticated analytics response, which `public.py`'s docstring had already deferred to this rebuild; `GET /api/series/{id}/sessions`, which says whether each occurrence was individually rescheduled, since that is what a GM editing the rule needs to know; and milestone `icon` validated against the drawing's eight marks, so the timeline cannot be handed a glyph the page has none for. ## A test-harness trap, written down The signed-in client fixtures override auth on the **module-level** `app`, so a test asking for two of them gets two clients that are both whichever fixture ran last — and "the GM sees it, the player does not" then passes while proving nothing. Nothing in the suite does this today (checked), which is why it has never bitten. It cost a red test while writing the timeline's visibility cases, where a vacuous pass would have been a leak nobody noticed, so the reason is now a comment beside the fixtures. ## Checks Ruff 0.4.4 clean. Full backend suite **4019 passed, 13 skipped**; the three new/changed test files run **57 passed** after the conftest note. No rate limits added — the neighbours (`search-all`, `/me/home`, `/{id}/analytics`) have none, and the timeline is bounded by `load_only` (never a transcript, never a wiki body) and a `jsonb_array_length` filter rather than by a limiter. Say the word if you want one anyway. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Four reads the redesign's campaign pages have no way to make, and one figure
the authenticated analytics page was left without.

**The timeline (D47, `IS-094`/`IS-358`/`IS-359`).** Sessions, milestones and
the wiki's own timeline events have existed separately since they were built
and have never been on one line. Merging them on the client would be three
requests, three sort keys and three ideas of what a date is, so
`GET /api/campaigns/{id}/timeline` is a read model: one list, newest first,
one shape per line, `?kinds=` to narrow it, and a 400 on an unknown kind in
the same shape `search-all` uses — a filter that silently does nothing is
worse than one that says it cannot.

Two things in that merge are load-bearing.

A wiki event carries a `visibility`, and a `gm` one must never reach a player.
The filtering happens on the server, by the reader, through the same
`lore_service` predicate the wiki applies to its own page — a second
implementation of a three-way rule is how one of them ends up wrong, and this
surface is read by every member of the campaign. It is tested from both
sides, and so is `player_private`, which is the entry's primary owner's.

And `GET /{id}/milestones` never only returned `Milestone` rows: an NPC's
repeat appearance is a milestone with no row behind it, and that endpoint
hands it back with the *session's* id in the `id` field, which is why
`MilestoneResponse.id` is `UUID | str`. Those are `IS-359`'s generated
milestones, so the timeline carries them — with `editable: false` and an
obviously synthetic `npc:{npc}:{session}` id. An id that cannot be written
back to should not be shaped like one that can. The synthesis itself moves to
`milestone_service.list_reappearances` so the two surfaces cannot drift.

Placement is the third decision. A milestone's `at` is its own date, else the
time of the session it was recorded against, else when it was written: a GM
adding one picks a session, not a date, and a fallback to `created_at` would
file a milestone about session 2 at the top of the list. `mark` is validated
against the eight glyphs the page has, on write, so the timeline cannot be
handed an icon it cannot draw; a row written before that reads as no mark
rather than breaking the line.

**This week (CAMP-1).** The campaign's most-visited page had no endpoint and
would have been five or six requests before it could say anything. Home was
given the same treatment in lane 1, so `GET /api/campaigns/{id}/this-week` is
the house pattern, not a novelty: when, who is coming, and what still needs
doing, in that order. Every part comes from something a member of the
campaign can already read — the session Home's own `pick_this_week` chooses,
the RSVP roster driven by the membership so the un-answered are visible, the
at-risk evaluation the Discord warning is sent on, the vote's options, the
last session played. Nothing derivable is repeated: the tally and the
reader's own answer are the roster read one way. The GM's parts — the prep
sheet and the ideas waiting behind Name it — are in a `gm` block that is
absent, not empty, for everybody else. The campaign itself, the full
"Previously on", and whether the bot is recording stay on the three endpoints
that already own them.

Three of `home_service`'s helpers become public rather than being copied.
"Which session is this week's" is a priority between three candidates, not a
query, and a card on Home that says a campaign is voting while the campaign's
own page says it is confirmed is worse than either answer.

**A series' sessions (`IS-368`).** `series.py` returned the rule and never its
occurrences, so CAMP-4 had nothing to list. `GET /api/series/{id}/sessions` is
readable by any member — a player sees those evenings on the Sessions list
either way — and says which occurrences were individually rescheduled, on the
same comparison `update_series` makes before it moves anything. That is the
one thing a GM editing the rule cannot work out from the row, and it is
exactly the set `scope=future_all` will not touch.

**Twelve months (D89).** The shared analytics page has counted over twelve
months since #728 and the authenticated one had not, so the two pages said
different things about the same campaign — and this is the one the GM reads.
The service has computed it since lane 2; it is passed through beside the
30-day figure rather than instead of it.

No migration: every field here is read off a column that exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test(backend): say why a test may only ask for one signed-in client (#729)
All checks were successful
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 53s
CI / Docker image build (pull_request) Successful in 35s
CI / Bot/backend version sync (pull_request) Successful in 52s
CI / Backend lint (ruff) (pull_request) Successful in 54s
CI / Feedback site tests (pull_request) Successful in 1m16s
CI / Bot tests and audit (pull_request) Successful in 1m54s
CI / Frontend tests, audit, and build (pull_request) Successful in 3m52s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 4m57s
CI / Backend migration, tests, and audit (pull_request) Successful in 9m50s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 16m55s
af30d91780
The client fixtures override auth on the module-level `app`, so the overrides
are global rather than per-client. A test that asks for two gets two clients
that are both whichever fixture ran last — and "the GM sees it and the player
does not" then passes while proving nothing.

Nothing in the suite does it today, which is why it has never bitten. It cost
a red test while writing the timeline's visibility cases, where a vacuous pass
would have been a leak nobody noticed, so the reason is written down beside
the fixtures rather than left to be rediscovered.

Comment only.

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