feat(call-to-play): retain terminal outcomes

Keep complete running and cancelled histories visible for fifteen minutes so
peers retain the roster, chat, and outcome long enough to understand what
happened. Compact them to terminal tombstones afterward without charging
settled calls against the active-history limit.

Model running and cancelled as durable read-only frontend states, exclude
them from active badges, prune retired raw events, and document the lifecycle.
Add peer scenario S49 to prove a late joiner reconstructs a terminal call with
its roster and chat intact.

Test Plan:
- just fmt
- just clippy
- just test
- just frontend-test
- just build
- just peer-cli-tests S48 S49
- python3 -m py_compile crates/lanspread-peer-cli/scripts/run_extended_scenarios.py
- git diff --cached --check
This commit is contained in:
2026-07-23 18:03:57 +02:00
parent e5d70ae56f
commit 9c34efa705
13 changed files with 494 additions and 161 deletions
+29 -17
View File
@@ -98,7 +98,7 @@ The default screen. A grid of game cards over a dark, gradient-tinted background
- **Right zone (col 3, flex space-between with two sub-groups):**
- **Sort menu** (pinned left, hugging search) — 36 px button, same surface style as search. Label `Sort: <bold value>` plus 13 px sort-bars icon and 11 px chevron. Click reveals dropdown menu below. Options: `Name (AZ)`, `Size (largest)`, `Recently Played`, `Status`. This is the only thing on the *left* side of the right zone — it's part of the search cluster, so it hugs the search.
- **Kebab menu** (`⋮`, pinned far-right) — 36×36 button with same surface as search. Menu items: `Settings` (opens Settings dialog), `Refresh library`, separator, `Unpack logs`, `About SoftLAN`. This is the only "app-level" control left in the top bar; the game-folder picker has moved into Settings.
- **Call to Play button** (`.ctp-btn`, sits just left of the kebab in the far-right sub-group) — 36 px pill, flag icon + `Call to Play` label. Carries an accent-filled **badge** with the count of active (non-started) calls. Opens the Call to Play overlay. See the **"Call to Play"** section for the full feature. In variant B it lives in row 1's right group, between the storage meter and the kebab.
- **Call to Play button** (`.ctp-btn`, sits just left of the kebab in the far-right sub-group) — 36 px pill, flag icon + `Call to Play` label. Carries an accent-filled **badge** with the count of active, non-terminal calls. Opens the Call to Play overlay. See the **"Call to Play"** section for the full feature. In variant B it lives in row 1's right group, between the storage meter and the kebab.
**Narrow-window fallback** (container width < 1100 px): the grid is replaced by a single `display: flex; flex-wrap: nowrap; gap: 16px` row. All items align left-to-right in source order (brand → filter → search → sort → kebab). The search field becomes `flex: 1 1 auto` so it absorbs remaining slack. The geometric centering is abandoned at narrow widths because there isn't enough horizontal slack for it to read cleanly. Implement via container query (`@container launcher (max-width: 1100px)`) on the launcher root; a viewport media query is an acceptable fallback if you're not using container queries yet.
@@ -466,7 +466,7 @@ the call and its history expire as a unit.
### Three surfaces
1. **Top-bar button** (`CallToPlayButton` / `.ctp-btn`) — flag icon + label +
an accent badge counting active (non-`started`) calls. Opens the overlay.
an accent badge counting active, non-terminal calls. Opens the overlay.
2. **Quick bars** (`CallToPlayTicker` / `.ctp-ticker-stack`) — a persistent
stack rendered at the top of the grid area, **one row per active call**.
Sorted **ready → starting-soon → the rest**, ties broken by whichever
@@ -474,7 +474,8 @@ the call and its history expire as a unit.
then by `deadline`). Clicking a row opens the overlay focused on that call.
3. **Overlay** (`CallToPlayOverlay`) — a modal (same scrim/panel treatment as
the other dialogs) with a header, a **Call a new match** button, the create
form, and a list of **nomination cards** (started calls sink to the bottom).
form, and a list of **nomination cards** (Running and Cancelled calls sink
to the bottom).
### Status model
@@ -482,14 +483,16 @@ Two derived values drive everything (`calltoplay.jsx`):
- `phaseOf(call)``'now'` (no `scheduledFor`) · `'scheduled'` (>15 min out) ·
`'checkin'` (within the 15-min lead).
- `statusOf(call)` (quick-bar/label status) → `'started'` · `'expired'`
(deadline elapsed) · `'ready'` (`readyCount >= maxPlayers`, or state `done`) ·
`'soon'` (`deadline - now ≤ 15 min`) · `'scheduled'` (has a clock time) ·
`'call'` (a plain play-now call).
- `statusOf(call)` (quick-bar/label status) → `'running'` · `'cancelled'` ·
`'expired'` (deadline elapsed) · `'ready'` (`readyCount >= maxPlayers`, or
state `done`) · `'soon'` (`deadline - now ≤ 15 min`) · `'scheduled'` (has a
clock time) · `'call'` (a plain play-now call).
Quick-bar labels + LED colors: **SCHEDULED**, **CALL TO PLAY**, **STARTING
SOON**, **READY**, **TIME'S UP** (`TICKER_LABEL`), each with its own dot color via
`.ctp-ticker-dot[data-status]`.
SOON**, **READY**, **TIME'S UP**, **RUNNING**, **CANCELLED** (`TICKER_LABEL`),
each with its own dot color via `.ctp-ticker-dot[data-status]`. Running and
Cancelled receipts remain for 15 minutes, sort after actionable calls, and do
not contribute to the top-bar badge.
**Per-participant ready state:** `ready` (explicitly readied, or their `readyAt`
countdown has elapsed) · `in` (RSVP'd to a scheduled call but not checked in
@@ -502,7 +505,7 @@ as larger `AvatarChip`s in the nomination card roster.
Top to bottom:
1. **Header** — square game cover + title + a sub-line: `Called by <creator> · N/M peers have it installed` (or `Scheduled by <creator> · starts at HH:MM · …`), and a **timer** on the right: a live `M:SS` countdown for play-now/check-in, the **clock time + "in N min"** for a scheduled call, `Ready`, `Time's up`, or `Launching…`. If catalog data is temporarily unavailable, the card still renders the caller, game ID, roster, chat, and coordination actions with a clear `Game unavailable here` label. Countdown urgency (`data-urgency` high/mid/low) tints it as time runs low.
1. **Header** — square game cover + title + a sub-line: `Called by <creator> · N/M peers have it installed` (or `Scheduled by <creator> · starts at HH:MM · …`), and a **timer** on the right: a live `M:SS` countdown for play-now/check-in, the **clock time + "in N min"** for a scheduled call, `Ready`, `Time's up`, `Running`, or `Cancelled`. If catalog data is temporarily unavailable, the card still renders the caller, game ID, roster, chat, and coordination actions with a clear `Game unavailable here` label. Countdown urgency (`data-urgency` high/mid/low) tints it as time runs low.
2. **Check-in note** — only in the `checkin` phase: a clock icon + "Starting soon — check-in is open" (or a personalized nudge if you RSVP'd).
3. **Progress bar** — time remaining as a fill (accent, → green when done); hidden while a call is still in the far-out `scheduled` phase.
4. **Roster**`readyCount/maxPlayers ready` (scheduled shows `N in · up to M players`; check-in adds `· K not checked in yet`), then avatar chips for each participant plus empty slots up to `maxPlayers`.
@@ -510,6 +513,10 @@ Top to bottom:
6. **Chat** — the collapsible per-call chat panel.
7. **Cancel** — creators get a `Cancel this call` link with an inline confirm.
Running and Cancelled cards are read-only: the complete roster and chat remain
visible, but participant controls, creator controls, chat composition, and
cancel actions are disabled.
### Create form (`CreateNominationForm`)
Game search (typeahead over the catalog) → on pick, max-players defaults to the
@@ -546,8 +553,8 @@ type Nomination = {
readyAt?: number; // ms epoch a 'pending' buffer elapses
}>;
messages: { id: string; fromId: string; from: string; text: string; at: number }[];
state: 'open' | 'done' | 'started';
startedAt?: number;
state: 'open' | 'done' | 'running' | 'cancelled';
terminalAt: number | null;
};
```
@@ -570,11 +577,16 @@ create, response, RSVP, chat, leave, cancel, start, or deadline-extension action
is an immutable, uniquely identified event. Connected peers receive new events
immediately, while `Hello` / `HelloAck` exchange the bounded, deduplicated event
history so a late joiner reconstructs every event and chat message for active
calls. Started and cancelled calls compact to terminal tombstones; expired
calls are removed after the five-minute grace period. The frontend reducer
turns that event history into the `Nomination` state above and derives
time-based phase changes locally. Stable peer IDs identify actors and enforce
creator controls; `settings.username` is only the display name.
calls. Running and Cancelled calls retain their complete history for 15 minutes
so late joiners can see the outcome, roster, and chat, then compact to a Start
or Cancel tombstone for the rest of the peer session. Unresolved calls are
removed after the five-minute post-deadline recovery period. The frontend
reducer turns that event history into the `Nomination` state above, derives
time-based phase changes locally, and prunes retired raw events. Stable peer IDs
identify actors and enforce creator controls; `settings.username` is only the
display name. These deadlines use event wall-clock timestamps, so LAN clocks
are assumed to be reasonably close; no clock-synchronization protocol is
attempted.
---