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
@@ -22,6 +22,8 @@ interface Props {
}
const LABEL = {
running: 'Running',
cancelled: 'Cancelled',
scheduled: 'Scheduled',
call: 'Call to Play',
soon: 'Starting soon',
@@ -29,7 +31,7 @@ const LABEL = {
expired: 'Times up',
} as const;
type TickerStatus = Exclude<CallToPlayStatus, 'started'>;
type TickerStatus = CallToPlayStatus;
const RANK: Record<TickerStatus, number> = {
expired: 0,
@@ -37,12 +39,12 @@ const RANK: Record<TickerStatus, number> = {
soon: 2,
call: 3,
scheduled: 3,
running: 4,
cancelled: 4,
};
const tickerStatusOf = (nomination: Nomination, now: number): TickerStatus | null => {
const status = statusOf(nomination, now);
return status === 'started' ? null : status;
};
const tickerStatusOf = (nomination: Nomination, now: number): TickerStatus =>
statusOf(nomination, now);
const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number }) => {
const entries = Object.entries(nomination.participants);
@@ -78,27 +80,37 @@ const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number
export const CallToPlayTicker = ({ nominations, games, accent, onOpen }: Props) => {
const now = Date.now();
const gameById = new Map(games.map(game => [game.id, game]));
const active = nominations.flatMap(nomination => {
const status = tickerStatusOf(nomination, now);
return status === null ? [] : [{ nomination, status }];
}).sort((left, right) =>
const rows = nominations.map(nomination => ({
nomination,
status: tickerStatusOf(nomination, now),
})).sort((left, right) =>
RANK[left.status] - RANK[right.status]
|| left.nomination.deadline - right.nomination.deadline
);
if (active.length === 0) return null;
if (rows.length === 0) return null;
return (
<div className="ctp-ticker-stack">
{active.map(({ nomination, status }) => {
{rows.map(({ nomination, status }) => {
const game = gameById.get(nomination.gameId);
const ready = readyCountOf(nomination, now);
const total = Object.keys(nomination.participants).length;
const remaining = Math.max(0, nomination.deadline - now);
const last = nomination.messages[nomination.messages.length - 1];
const count = status === 'scheduled'
const count = status === 'running' || status === 'cancelled'
? `${total} players`
: status === 'scheduled'
? `${total} in`
: `${ready}/${nomination.maxPlayers} ready`;
const time = status === 'ready'
const terminalElapsed = now - (nomination.terminalAt ?? now);
const terminalAge = terminalElapsed < 1_000
? 'just now'
: `${formatCountdownShort(terminalElapsed)} ago`;
const time = status === 'running'
? `started ${terminalAge}`
: status === 'cancelled'
? `cancelled ${terminalAge}`
: status === 'ready'
? 'waiting to start'
: status === 'expired'
? `${formatCountdownShort(now - nomination.deadline)} ago · waiting for caller`