From cc7dacf6c3a6751965040856b40be5a75aad81a1 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Tue, 21 Jul 2026 22:56:02 +0200 Subject: [PATCH] fix(call-to-play): expire elapsed calls clearly Deadline completion previously shared the green Ready presentation with a full roster and remained visible forever. An abandoned call therefore looked ready to launch and required its creator to return and cancel it. Give elapsed calls a distinct Time's up state and a five-minute grace period in which the creator can start or extend them. After that, both the reducer and peer store remove the call as a unit. Filled calls remain ready until their deadline, and active calls continue to retain complete history for late joiners. Test Plan: - `just fmt` -- passed - `just clippy` -- passed - `just test` -- passed, 147 peer tests - `just frontend-test` -- passed, 22 tests - `just build` -- passed - `git diff --cached --check` -- passed --- crates/lanspread-peer/ARCHITECTURE.md | 4 +- crates/lanspread-peer/src/call_to_play.rs | 85 ++++++++++++++++--- .../lanspread-peer/src/services/handshake.rs | 4 +- .../calltoplay/CallToPlayTicker.tsx | 36 +++++--- .../components/calltoplay/NominationCard.tsx | 21 +++-- .../src/lib/callToPlay.ts | 9 +- .../src/styles/launcher.css | 13 ++- .../tests/callToPlay.test.ts | 14 +++ design/launcher/SPEC.md | 25 ++++-- 9 files changed, 171 insertions(+), 40 deletions(-) diff --git a/crates/lanspread-peer/ARCHITECTURE.md b/crates/lanspread-peer/ARCHITECTURE.md index e71418e..50ab449 100644 --- a/crates/lanspread-peer/ARCHITECTURE.md +++ b/crates/lanspread-peer/ARCHITECTURE.md @@ -56,7 +56,9 @@ or `Cancel` event replaces that terminal call with one small tombstone; this lets a peer that missed the live action heal on its next handshake without retaining the inactive call's full history. Active calls are never partially trimmed. If genuinely active history reaches the bound, local publishes return -an error to the caller instead of appearing to succeed. A +an error to the caller instead of appearing to succeed. A call whose deadline +elapses remains available for five minutes so the creator can start or extend +it, then its history is evicted as a unit. A local action is applied to that history, sent to the UI, and broadcast to every currently known peer. An incoming live event is applied once and sent to the UI without being rebroadcast, which prevents forwarding loops. diff --git a/crates/lanspread-peer/src/call_to_play.rs b/crates/lanspread-peer/src/call_to_play.rs index 10cd12e..8bf5dd2 100644 --- a/crates/lanspread-peer/src/call_to_play.rs +++ b/crates/lanspread-peer/src/call_to_play.rs @@ -1,6 +1,9 @@ //! Replicated event history for Call to Play coordination. -use std::collections::{HashMap, HashSet}; +use std::{ + collections::{HashMap, HashSet}, + time::{SystemTime, UNIX_EPOCH}, +}; use lanspread_proto::{CallToPlayAction, CallToPlayEvent}; use tokio::sync::mpsc::UnboundedSender; @@ -12,6 +15,7 @@ const MAX_ID_CHARS: usize = 128; const MAX_GAME_ID_CHARS: usize = 256; const MAX_USERNAME_CHARS: usize = 24; const MAX_MESSAGE_CHARS: usize = 500; +const EXPIRED_RETENTION_MS: i64 = 5 * 60_000; #[derive(Debug, Default)] pub(crate) struct CallToPlayStore { @@ -33,7 +37,7 @@ impl CallToPlayStore { let event_id = event.id.clone(); self.event_ids.insert(event_id.clone()); self.events.push(event); - self.evict_terminal_calls(); + self.compact_inactive_calls(now_ms()); if self.events.len() > MAX_EVENTS { self.events.retain(|event| event.id != event_id); self.event_ids.remove(&event_id); @@ -54,13 +58,13 @@ impl CallToPlayStore { accepted } - fn evict_terminal_calls(&mut self) { - let mut creators = HashMap::::new(); + fn compact_inactive_calls(&mut self, now: i64) { + let mut creators = HashMap::::new(); for event in &self.events { - if !matches!(event.action, CallToPlayAction::Create { .. }) { + let CallToPlayAction::Create { deadline, .. } = event.action else { continue; - } - let candidate = (event.at, event.id.clone(), event.actor_id.clone()); + }; + let candidate = (event.at, event.id.clone(), event.actor_id.clone(), deadline); creators .entry(event.call_id.clone()) .and_modify(|current| { @@ -73,7 +77,7 @@ impl CallToPlayStore { let mut terminal_events = HashMap::::new(); for event in &self.events { - let Some((created_at, create_id, creator_id)) = creators.get(&event.call_id) else { + let Some((created_at, create_id, creator_id, _)) = creators.get(&event.call_id) else { continue; }; if !matches!( @@ -94,7 +98,42 @@ impl CallToPlayStore { }) .or_insert(candidate); } + + let mut extensions = HashMap::::new(); + for event in &self.events { + let CallToPlayAction::AddTime { deadline } = event.action else { + continue; + }; + let Some((created_at, create_id, creator_id, _)) = creators.get(&event.call_id) else { + continue; + }; + if event.actor_id != *creator_id || (event.at, &event.id) <= (*created_at, create_id) { + continue; + } + let candidate = (event.at, event.id.clone(), deadline); + extensions + .entry(event.call_id.clone()) + .and_modify(|current| { + if (candidate.0, &candidate.1) > (current.0, ¤t.1) { + current.clone_from(&candidate); + } + }) + .or_insert(candidate); + } + let expired_calls = creators + .iter() + .filter_map(|(call_id, (_, _, _, original_deadline))| { + let deadline = extensions + .get(call_id) + .map_or(*original_deadline, |(_, _, deadline)| *deadline); + (now - deadline > EXPIRED_RETENTION_MS).then(|| call_id.clone()) + }) + .collect::>(); + self.events.retain(|event| { + if expired_calls.contains(&event.call_id) { + return false; + } terminal_events .get(&event.call_id) .is_none_or(|(_, terminal_id)| event.id == *terminal_id) @@ -102,6 +141,16 @@ impl CallToPlayStore { } } +fn now_ms() -> i64 { + i64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + ) + .unwrap_or(i64::MAX) +} + pub(crate) async fn publish( ctx: &Ctx, tx_notify_ui: &UnboundedSender, @@ -208,18 +257,20 @@ mod tests { use super::{CallToPlayStore, MAX_EVENTS}; + const TEST_NOW: i64 = 8_000_000_000_000; + fn create_event(id: &str) -> CallToPlayEvent { CallToPlayEvent { id: id.to_string(), call_id: "call-1".to_string(), actor_id: "peer-alice".to_string(), actor_name: "Alice".to_string(), - at: 1_000, + at: TEST_NOW, action: CallToPlayAction::Create { game_id: "game-1".to_string(), max_players: 4, scheduled_for: None, - deadline: 61_000, + deadline: TEST_NOW + 60_000, }, } } @@ -230,7 +281,7 @@ mod tests { call_id: call_id.to_string(), actor_id: "peer-alice".to_string(), actor_name: "Alice".to_string(), - at: 2_000, + at: TEST_NOW + 1_000, action, } } @@ -360,4 +411,16 @@ mod tests { ); assert_eq!(store.snapshot().len(), MAX_EVENTS); } + + #[test] + fn expired_call_history_is_evicted_as_a_unit() { + let mut store = CallToPlayStore::default(); + store + .insert(create_event("create")) + .expect("active call should fit"); + + store.compact_inactive_calls(TEST_NOW + 5 * 60_000 + 60_001); + + assert!(store.snapshot().is_empty()); + } } diff --git a/crates/lanspread-peer/src/services/handshake.rs b/crates/lanspread-peer/src/services/handshake.rs index 96f09d2..3a428d2 100644 --- a/crates/lanspread-peer/src/services/handshake.rs +++ b/crates/lanspread-peer/src/services/handshake.rs @@ -315,12 +315,12 @@ mod tests { call_id: "call-1".to_string(), actor_id: "peer-alice".to_string(), actor_name: "Alice".to_string(), - at: 1_000, + at: 8_000_000_000_000, action: CallToPlayAction::Create { game_id: "game".to_string(), max_players: 4, scheduled_for: None, - deadline: 61_000, + deadline: 8_000_000_060_000, }, } } diff --git a/crates/lanspread-tauri-deno-ts/src/components/calltoplay/CallToPlayTicker.tsx b/crates/lanspread-tauri-deno-ts/src/components/calltoplay/CallToPlayTicker.tsx index 65a4df5..548e479 100644 --- a/crates/lanspread-tauri-deno-ts/src/components/calltoplay/CallToPlayTicker.tsx +++ b/crates/lanspread-tauri-deno-ts/src/components/calltoplay/CallToPlayTicker.tsx @@ -10,6 +10,7 @@ import { isReady, readyCountOf, statusOf, + type CallToPlayStatus, } from '../../lib/callToPlay'; import { Game, Nomination } from '../../lib/types'; @@ -25,9 +26,23 @@ const LABEL = { call: 'Call to Play', soon: 'Starting soon', ready: 'Ready', + expired: 'Time’s up', } as const; -const RANK = { ready: 0, soon: 1, call: 2, scheduled: 2, started: 3 } as const; +type TickerStatus = Exclude; + +const RANK: Record = { + expired: 0, + ready: 1, + soon: 2, + call: 3, + scheduled: 3, +}; + +const tickerStatusOf = (nomination: Nomination, now: number): TickerStatus | null => { + const status = statusOf(nomination, now); + return status === 'started' ? null : status; +}; const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number }) => { const entries = Object.entries(nomination.participants); @@ -63,20 +78,19 @@ 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 - .filter(nomination => nomination.state !== 'started') - .sort((left, right) => - RANK[statusOf(left, now)] - RANK[statusOf(right, now)] - || left.deadline - right.deadline - ); + const active = nominations.flatMap(nomination => { + const status = tickerStatusOf(nomination, now); + return status === null ? [] : [{ nomination, status }]; + }).sort((left, right) => + RANK[left.status] - RANK[right.status] + || left.nomination.deadline - right.nomination.deadline + ); if (active.length === 0) return null; return (
- {active.map(nomination => { + {active.map(({ nomination, status }) => { const game = gameById.get(nomination.gameId); - const status = statusOf(nomination, now); - if (status === 'started') return null; const ready = readyCountOf(nomination, now); const total = Object.keys(nomination.participants).length; const remaining = Math.max(0, nomination.deadline - now); @@ -86,6 +100,8 @@ export const CallToPlayTicker = ({ nominations, games, accent, onOpen }: Props) : `${ready}/${nomination.maxPlayers} ready`; const time = status === 'ready' ? 'waiting to start' + : status === 'expired' + ? `${formatCountdownShort(now - nomination.deadline)} ago · waiting for caller` : status === 'scheduled' ? `${formatClock(nomination.scheduledFor!)} · ${formatUntil(nomination.scheduledFor! - now)}` : nomination.scheduledFor !== null diff --git a/crates/lanspread-tauri-deno-ts/src/components/calltoplay/NominationCard.tsx b/crates/lanspread-tauri-deno-ts/src/components/calltoplay/NominationCard.tsx index 20da08f..73c55d8 100644 --- a/crates/lanspread-tauri-deno-ts/src/components/calltoplay/NominationCard.tsx +++ b/crates/lanspread-tauri-deno-ts/src/components/calltoplay/NominationCard.tsx @@ -15,6 +15,7 @@ import { isReady, phaseOf, readyCountOf, + statusOf, } from '../../lib/callToPlay'; import { CallToPlayParticipant, Game, Nomination } from '../../lib/types'; @@ -82,9 +83,10 @@ export const NominationCard = ({ const isCreator = nomination.creatorId === actorId; const isDone = nomination.state === 'done'; const isStarted = nomination.state === 'started'; + const isExpired = statusOf(nomination, now) === 'expired'; const phase = phaseOf(nomination, now); - const isScheduled = phase === 'scheduled' && !isDone && !isStarted; - const isCheckin = phase === 'checkin' && !isDone && !isStarted; + const isScheduled = phase === 'scheduled' && !isDone && !isStarted && !isExpired; + const isCheckin = phase === 'checkin' && !isDone && !isStarted && !isExpired; const windowStart = nomination.scheduledFor === null ? nomination.createdAt : nomination.scheduledFor - CHECKIN_LEAD_MS; @@ -101,6 +103,8 @@ export const NominationCard = ({ const timer = isStarted ?
Launching…
+ : isExpired + ?
Time’s up
: isDone ?
Ready
: isScheduled @@ -128,7 +132,7 @@ export const NominationCard = ({ return (
@@ -165,7 +169,11 @@ export const NominationCard = ({ className="ctp-progress-fill" style={{ width: `${isStarted || isDone ? 100 : percentage}%`, - background: isStarted || isDone ? 'var(--ok)' : 'var(--accent)', + background: isExpired + ? 'var(--danger)' + : isStarted || isDone + ? 'var(--ok)' + : 'var(--accent)', }} />
@@ -276,6 +284,7 @@ const CardActions = ({ const isCreator = nomination.creatorId === actorId; const isDone = nomination.state === 'done'; const isStarted = nomination.state === 'started'; + const isExpired = statusOf(nomination, now) === 'expired'; const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !isStarted; const readyCount = readyCountOf(nomination, now); @@ -337,7 +346,9 @@ const CardActions = ({ if (isDone) { return (
- {readyCount >= nomination.maxPlayers + {isExpired + ? `Time’s up — waiting for ${nomination.creator} to start or extend the call.` + : readyCount >= nomination.maxPlayers ? 'Everyone’s ready.' : `It’s time — waiting for ${nomination.creator} to start.`}
diff --git a/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts b/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts index 4411a75..f2d62dd 100644 --- a/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts +++ b/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts @@ -7,9 +7,10 @@ import { export const CHECKIN_LEAD_MS = 15 * 60_000; export const STARTED_RETENTION_MS = 3_000; +export const EXPIRED_RETENTION_MS = 5 * 60_000; export type CallToPlayPhase = 'now' | 'scheduled' | 'checkin'; -export type CallToPlayStatus = 'started' | 'ready' | 'soon' | 'scheduled' | 'call'; +export type CallToPlayStatus = 'started' | 'expired' | 'ready' | 'soon' | 'scheduled' | 'call'; interface MutableNomination extends Nomination { cancelled: boolean; @@ -58,6 +59,7 @@ export const inCountOf = (nomination: Nomination, now: number): number => export const statusOf = (nomination: Nomination, now: number): CallToPlayStatus => { if (nomination.state === 'started') return 'started'; + if (now >= nomination.deadline) return 'expired'; if (nomination.state === 'done' || readyCountOf(nomination, now) >= nomination.maxPlayers) { return 'ready'; } @@ -123,6 +125,11 @@ export const reduceCallToPlayEvents = ( ) { continue; } + if (nomination.state !== 'started' + && now - nomination.deadline > EXPIRED_RETENTION_MS + ) { + continue; + } const { cancelled: _, messageIds: __, ...result } = nomination; nominations.push(result); diff --git a/crates/lanspread-tauri-deno-ts/src/styles/launcher.css b/crates/lanspread-tauri-deno-ts/src/styles/launcher.css index 45f84ab..c120950 100644 --- a/crates/lanspread-tauri-deno-ts/src/styles/launcher.css +++ b/crates/lanspread-tauri-deno-ts/src/styles/launcher.css @@ -1919,6 +1919,7 @@ border-radius: 12px; } .ctp-card.is-done { border-color: color-mix(in srgb, var(--ok) 45%, var(--bd-2)); } +.ctp-card.is-expired { border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2)); } .ctp-card.is-started { opacity: 0.6; } .ctp-card.is-focused { border-color: var(--accent); animation: ctp-cardflash 1.4s ease-out 1; } @keyframes ctp-cardflash { @@ -1956,6 +1957,7 @@ .ctp-card-timer[data-urgency="mid"] { color: var(--warn); } .ctp-card-timer[data-urgency="high"] { color: var(--danger); } .ctp-card.is-done .ctp-card-timer { color: var(--ok); font-size: 14px; text-transform: uppercase; letter-spacing: 0.04em; } +.ctp-card.is-expired .ctp-card-timer { color: var(--danger); } .ctp-cancel-link { display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; @@ -2210,7 +2212,8 @@ /* ─── Quick-bar status variants — LED + label + row tint per status: SCHEDULED (neutral) · CALL TO PLAY (accent, pulsing) · - STARTING SOON (amber, glowing) · READY (green, steady) ─── */ + STARTING SOON (amber, glowing) · READY (green, steady) · + TIME'S UP (red, steady) ─── */ .ctp-ticker[data-status="scheduled"] { background: var(--bg-2); border-color: var(--bd-2); @@ -2232,9 +2235,15 @@ box-shadow: 0 0 14px -2px color-mix(in srgb, var(--ok) 35%, transparent); } .ctp-ticker[data-status="ready"]:hover { background: color-mix(in srgb, var(--ok) 17%, var(--bg-2)); } +.ctp-ticker[data-status="expired"] { + background: color-mix(in srgb, var(--danger) 8%, var(--bg-2)); + border-color: color-mix(in srgb, var(--danger) 45%, var(--bd-2)); +} +.ctp-ticker[data-status="expired"]:hover { background: color-mix(in srgb, var(--danger) 13%, var(--bg-2)); } .ctp-ticker-dot[data-status="scheduled"] { background: var(--t-3); animation: none; box-shadow: none; } .ctp-ticker-dot[data-status="soon"] { background: var(--warn); animation: ctp-tickerpulse-warn 1.6s ease-out infinite; } .ctp-ticker-dot[data-status="ready"] { background: var(--ok); animation: none; box-shadow: 0 0 6px var(--ok); } +.ctp-ticker-dot[data-status="expired"] { background: var(--danger); animation: none; box-shadow: none; } @keyframes ctp-tickerpulse-warn { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 55%, transparent); } 70% { box-shadow: 0 0 0 6px transparent; } @@ -2243,8 +2252,10 @@ .ctp-ticker-label[data-status="scheduled"] { color: var(--t-2); } .ctp-ticker-label[data-status="soon"] { color: var(--warn); } .ctp-ticker-label[data-status="ready"] { color: var(--ok); } +.ctp-ticker-label[data-status="expired"] { color: var(--danger); } .ctp-ticker[data-status="soon"] .ctp-ticker-cta { color: var(--warn); } .ctp-ticker[data-status="ready"] .ctp-ticker-cta { color: var(--ok); } +.ctp-ticker[data-status="expired"] .ctp-ticker-cta { color: var(--danger); } /* ─── Quick-bar inline chat preview ─── */ .ctp-ticker-chat { diff --git a/crates/lanspread-tauri-deno-ts/tests/callToPlay.test.ts b/crates/lanspread-tauri-deno-ts/tests/callToPlay.test.ts index 2d9351a..512b34a 100644 --- a/crates/lanspread-tauri-deno-ts/tests/callToPlay.test.ts +++ b/crates/lanspread-tauri-deno-ts/tests/callToPlay.test.ts @@ -1,5 +1,6 @@ import { CHECKIN_LEAD_MS, + EXPIRED_RETENTION_MS, phaseOf, bumpTime, normalizeTimeInput, @@ -89,6 +90,19 @@ Deno.test('call resolves when the roster fills or its deadline elapses', () => { ); }); +Deno.test('elapsed calls show as expired briefly and then disappear', () => { + const deadline = NOW + 30 * 60_000; + const events = [create(null, deadline)]; + const [expired] = reduceCallToPlayEvents(events, deadline + 1); + assert(expired, 'freshly expired call remains visible'); + assertEquals(statusOf(expired, deadline + 1), 'expired', 'elapsed status'); + assertEquals( + reduceCallToPlayEvents(events, deadline + EXPIRED_RETENTION_MS + 1).length, + 0, + 'expired call retention', + ); +}); + Deno.test('creator-only controls cannot be forged by another participant', () => { const forged = [ create(), diff --git a/design/launcher/SPEC.md b/design/launcher/SPEC.md index 21bf1e8..0a0f393 100644 --- a/design/launcher/SPEC.md +++ b/design/launcher/SPEC.md @@ -457,6 +457,10 @@ flips into the `checkin` phase: it lights up everywhere and everyone who said "I'm in" is nudged to answer with the same `Ready now` / `+N minutes` states as a play-now call. A play-now call is effectively "always in its check-in window." +When the deadline passes, the call is labeled **Time's up** rather than Ready. +It remains visible for five minutes so the caller can start or extend it, then +the call and its history expire as a unit. + **Every call carries a small group chat** (see "Per-call chat" below). ### Three surfaces @@ -478,12 +482,13 @@ 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'` · `'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) → `'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). Quick-bar labels + LED colors: **SCHEDULED**, **CALL TO PLAY**, **STARTING -SOON**, **READY** (`TICKER_LABEL`), each with its own dot color via +SOON**, **READY**, **TIME'S UP** (`TICKER_LABEL`), each with its own dot color via `.ctp-ticker-dot[data-status]`. **Per-participant ready state:** `ready` (explicitly readied, or their `readyAt` @@ -497,7 +502,7 @@ as larger `AvatarChip`s in the nomination card roster. Top to bottom: -1. **Header** — square game cover + title + a sub-line: `Called by · N/M peers have it installed` (or `Scheduled by · 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`, or `Launching…`. Countdown urgency (`data-urgency` high/mid/low) tints it as time runs low. +1. **Header** — square game cover + title + a sub-line: `Called by · N/M peers have it installed` (or `Scheduled by · 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. 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`. @@ -564,10 +569,12 @@ The production launcher uses the peer's existing QUIC control channel. Each 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 the same calls. The frontend reducer turns -that event history into the `Nomination` state above and derives time-based phase -changes locally. The `username` that identifies "you" comes from -`settings.username` (the Profile setting), not a prop default. +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. ---