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
This commit is contained in:
2026-07-21 22:56:02 +02:00
parent 640d81d919
commit cc7dacf6c3
9 changed files with 171 additions and 40 deletions
+3 -1
View File
@@ -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.
+74 -11
View File
@@ -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::<String, (i64, String, String)>::new();
fn compact_inactive_calls(&mut self, now: i64) {
let mut creators = HashMap::<String, (i64, String, String, i64)>::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::<String, (i64, String)>::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::<String, (i64, String, i64)>::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, &current.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::<HashSet<_>>();
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<PeerEvent>,
@@ -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());
}
}
@@ -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,
},
}
}
@@ -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: 'Times up',
} as const;
const RANK = { ready: 0, soon: 1, call: 2, scheduled: 2, started: 3 } as const;
type TickerStatus = Exclude<CallToPlayStatus, 'started'>;
const RANK: Record<TickerStatus, number> = {
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 (
<div className="ctp-ticker-stack">
{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
@@ -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
? <div className="ctp-card-timer" data-urgency="off">Launching</div>
: isExpired
? <div className="ctp-card-timer" data-urgency="high">Times up</div>
: isDone
? <div className="ctp-card-timer" data-urgency="off">Ready</div>
: isScheduled
@@ -128,7 +132,7 @@ export const NominationCard = ({
return (
<div
ref={cardRef}
className={`ctp-card ${isDone ? 'is-done' : ''} ${isStarted ? 'is-started' : ''} ${isCheckin ? 'is-checkin' : ''} ${focused ? 'is-focused' : ''}`}
className={`ctp-card ${isDone ? 'is-done' : ''} ${isExpired ? 'is-expired' : ''} ${isStarted ? 'is-started' : ''} ${isCheckin ? 'is-checkin' : ''} ${focused ? 'is-focused' : ''}`}
>
<div className="ctp-card-top">
<div className="ctp-card-cover">
@@ -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)',
}}
/>
</div>
@@ -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 (
<div className="ctp-note">
{readyCount >= nomination.maxPlayers
{isExpired
? `Times up — waiting for ${nomination.creator} to start or extend the call.`
: readyCount >= nomination.maxPlayers
? 'Everyones ready.'
: `Its time — waiting for ${nomination.creator} to start.`}
</div>
@@ -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);
@@ -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 {
@@ -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(),
+16 -9
View File
@@ -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 <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`, 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 <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.
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.
---