Lane 3: what the campaign's reading surfaces need (#729) #765
Loading…
Reference in a new issue
No description provided.
Delete branch "build/lane3-reading-server"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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,gmorplayer_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}/milestonessynthesises "reappeared" entries for NPCs withidset to a session id; a wiki event's id is unique only within its entry. So a timelineidis a string, and composite where it has to be. Generated entries arriveeditable: false, andeditableis 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_idis 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-weekanswers 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:
member_count, nomy_rsvp— all three are derivations of the roster. Two copies of a fact can disagree; one cannot.GET /{id}/liveis polled every few seconds off a Redis cache; this endpoint is not, and a second answer would be stale half the time.GET /campaigns/{id}, which the page loads anyway. D45 put the card on this page; it did not make it this endpoint's.can_nudgewould have been inventing a concept. Flagged rather than guessed.session: nulland keeps its roster and last time — #479's rule, and what the archived drawing shows.Also
sessions_last_12_monthson the authenticated analytics response, whichpublic.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 milestoneiconvalidated 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 byload_only(never a transcript, never a wiki body) and ajsonb_array_lengthfilter rather than by a limiter. Say the word if you want one anyway.🤖 Generated with 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>