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 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 retaining the inactive call's full history. Active calls are never partially
trimmed. If genuinely active history reaches the bound, local publishes return 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 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 currently known peer. An incoming live event is applied once and sent to the UI
without being rebroadcast, which prevents forwarding loops. without being rebroadcast, which prevents forwarding loops.
+74 -11
View File
@@ -1,6 +1,9 @@
//! Replicated event history for Call to Play coordination. //! 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 lanspread_proto::{CallToPlayAction, CallToPlayEvent};
use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::UnboundedSender;
@@ -12,6 +15,7 @@ const MAX_ID_CHARS: usize = 128;
const MAX_GAME_ID_CHARS: usize = 256; const MAX_GAME_ID_CHARS: usize = 256;
const MAX_USERNAME_CHARS: usize = 24; const MAX_USERNAME_CHARS: usize = 24;
const MAX_MESSAGE_CHARS: usize = 500; const MAX_MESSAGE_CHARS: usize = 500;
const EXPIRED_RETENTION_MS: i64 = 5 * 60_000;
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub(crate) struct CallToPlayStore { pub(crate) struct CallToPlayStore {
@@ -33,7 +37,7 @@ impl CallToPlayStore {
let event_id = event.id.clone(); let event_id = event.id.clone();
self.event_ids.insert(event_id.clone()); self.event_ids.insert(event_id.clone());
self.events.push(event); self.events.push(event);
self.evict_terminal_calls(); self.compact_inactive_calls(now_ms());
if self.events.len() > MAX_EVENTS { if self.events.len() > MAX_EVENTS {
self.events.retain(|event| event.id != event_id); self.events.retain(|event| event.id != event_id);
self.event_ids.remove(&event_id); self.event_ids.remove(&event_id);
@@ -54,13 +58,13 @@ impl CallToPlayStore {
accepted accepted
} }
fn evict_terminal_calls(&mut self) { fn compact_inactive_calls(&mut self, now: i64) {
let mut creators = HashMap::<String, (i64, String, String)>::new(); let mut creators = HashMap::<String, (i64, String, String, i64)>::new();
for event in &self.events { for event in &self.events {
if !matches!(event.action, CallToPlayAction::Create { .. }) { let CallToPlayAction::Create { deadline, .. } = event.action else {
continue; 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 creators
.entry(event.call_id.clone()) .entry(event.call_id.clone())
.and_modify(|current| { .and_modify(|current| {
@@ -73,7 +77,7 @@ impl CallToPlayStore {
let mut terminal_events = HashMap::<String, (i64, String)>::new(); let mut terminal_events = HashMap::<String, (i64, String)>::new();
for event in &self.events { 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; continue;
}; };
if !matches!( if !matches!(
@@ -94,7 +98,42 @@ impl CallToPlayStore {
}) })
.or_insert(candidate); .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| { self.events.retain(|event| {
if expired_calls.contains(&event.call_id) {
return false;
}
terminal_events terminal_events
.get(&event.call_id) .get(&event.call_id)
.is_none_or(|(_, terminal_id)| event.id == *terminal_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( pub(crate) async fn publish(
ctx: &Ctx, ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>, tx_notify_ui: &UnboundedSender<PeerEvent>,
@@ -208,18 +257,20 @@ mod tests {
use super::{CallToPlayStore, MAX_EVENTS}; use super::{CallToPlayStore, MAX_EVENTS};
const TEST_NOW: i64 = 8_000_000_000_000;
fn create_event(id: &str) -> CallToPlayEvent { fn create_event(id: &str) -> CallToPlayEvent {
CallToPlayEvent { CallToPlayEvent {
id: id.to_string(), id: id.to_string(),
call_id: "call-1".to_string(), call_id: "call-1".to_string(),
actor_id: "peer-alice".to_string(), actor_id: "peer-alice".to_string(),
actor_name: "Alice".to_string(), actor_name: "Alice".to_string(),
at: 1_000, at: TEST_NOW,
action: CallToPlayAction::Create { action: CallToPlayAction::Create {
game_id: "game-1".to_string(), game_id: "game-1".to_string(),
max_players: 4, max_players: 4,
scheduled_for: None, scheduled_for: None,
deadline: 61_000, deadline: TEST_NOW + 60_000,
}, },
} }
} }
@@ -230,7 +281,7 @@ mod tests {
call_id: call_id.to_string(), call_id: call_id.to_string(),
actor_id: "peer-alice".to_string(), actor_id: "peer-alice".to_string(),
actor_name: "Alice".to_string(), actor_name: "Alice".to_string(),
at: 2_000, at: TEST_NOW + 1_000,
action, action,
} }
} }
@@ -360,4 +411,16 @@ mod tests {
); );
assert_eq!(store.snapshot().len(), MAX_EVENTS); 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(), call_id: "call-1".to_string(),
actor_id: "peer-alice".to_string(), actor_id: "peer-alice".to_string(),
actor_name: "Alice".to_string(), actor_name: "Alice".to_string(),
at: 1_000, at: 8_000_000_000_000,
action: CallToPlayAction::Create { action: CallToPlayAction::Create {
game_id: "game".to_string(), game_id: "game".to_string(),
max_players: 4, max_players: 4,
scheduled_for: None, scheduled_for: None,
deadline: 61_000, deadline: 8_000_000_060_000,
}, },
} }
} }
@@ -10,6 +10,7 @@ import {
isReady, isReady,
readyCountOf, readyCountOf,
statusOf, statusOf,
type CallToPlayStatus,
} from '../../lib/callToPlay'; } from '../../lib/callToPlay';
import { Game, Nomination } from '../../lib/types'; import { Game, Nomination } from '../../lib/types';
@@ -25,9 +26,23 @@ const LABEL = {
call: 'Call to Play', call: 'Call to Play',
soon: 'Starting soon', soon: 'Starting soon',
ready: 'Ready', ready: 'Ready',
expired: 'Times up',
} as const; } 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 MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number }) => {
const entries = Object.entries(nomination.participants); 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) => { export const CallToPlayTicker = ({ nominations, games, accent, onOpen }: Props) => {
const now = Date.now(); const now = Date.now();
const gameById = new Map(games.map(game => [game.id, game])); const gameById = new Map(games.map(game => [game.id, game]));
const active = nominations const active = nominations.flatMap(nomination => {
.filter(nomination => nomination.state !== 'started') const status = tickerStatusOf(nomination, now);
.sort((left, right) => return status === null ? [] : [{ nomination, status }];
RANK[statusOf(left, now)] - RANK[statusOf(right, now)] }).sort((left, right) =>
|| left.deadline - right.deadline RANK[left.status] - RANK[right.status]
|| left.nomination.deadline - right.nomination.deadline
); );
if (active.length === 0) return null; if (active.length === 0) return null;
return ( return (
<div className="ctp-ticker-stack"> <div className="ctp-ticker-stack">
{active.map(nomination => { {active.map(({ nomination, status }) => {
const game = gameById.get(nomination.gameId); const game = gameById.get(nomination.gameId);
const status = statusOf(nomination, now);
if (status === 'started') return null;
const ready = readyCountOf(nomination, now); const ready = readyCountOf(nomination, now);
const total = Object.keys(nomination.participants).length; const total = Object.keys(nomination.participants).length;
const remaining = Math.max(0, nomination.deadline - now); const remaining = Math.max(0, nomination.deadline - now);
@@ -86,6 +100,8 @@ export const CallToPlayTicker = ({ nominations, games, accent, onOpen }: Props)
: `${ready}/${nomination.maxPlayers} ready`; : `${ready}/${nomination.maxPlayers} ready`;
const time = status === 'ready' const time = status === 'ready'
? 'waiting to start' ? 'waiting to start'
: status === 'expired'
? `${formatCountdownShort(now - nomination.deadline)} ago · waiting for caller`
: status === 'scheduled' : status === 'scheduled'
? `${formatClock(nomination.scheduledFor!)} · ${formatUntil(nomination.scheduledFor! - now)}` ? `${formatClock(nomination.scheduledFor!)} · ${formatUntil(nomination.scheduledFor! - now)}`
: nomination.scheduledFor !== null : nomination.scheduledFor !== null
@@ -15,6 +15,7 @@ import {
isReady, isReady,
phaseOf, phaseOf,
readyCountOf, readyCountOf,
statusOf,
} from '../../lib/callToPlay'; } from '../../lib/callToPlay';
import { CallToPlayParticipant, Game, Nomination } from '../../lib/types'; import { CallToPlayParticipant, Game, Nomination } from '../../lib/types';
@@ -82,9 +83,10 @@ export const NominationCard = ({
const isCreator = nomination.creatorId === actorId; const isCreator = nomination.creatorId === actorId;
const isDone = nomination.state === 'done'; const isDone = nomination.state === 'done';
const isStarted = nomination.state === 'started'; const isStarted = nomination.state === 'started';
const isExpired = statusOf(nomination, now) === 'expired';
const phase = phaseOf(nomination, now); const phase = phaseOf(nomination, now);
const isScheduled = phase === 'scheduled' && !isDone && !isStarted; const isScheduled = phase === 'scheduled' && !isDone && !isStarted && !isExpired;
const isCheckin = phase === 'checkin' && !isDone && !isStarted; const isCheckin = phase === 'checkin' && !isDone && !isStarted && !isExpired;
const windowStart = nomination.scheduledFor === null const windowStart = nomination.scheduledFor === null
? nomination.createdAt ? nomination.createdAt
: nomination.scheduledFor - CHECKIN_LEAD_MS; : nomination.scheduledFor - CHECKIN_LEAD_MS;
@@ -101,6 +103,8 @@ export const NominationCard = ({
const timer = isStarted const timer = isStarted
? <div className="ctp-card-timer" data-urgency="off">Launching</div> ? <div className="ctp-card-timer" data-urgency="off">Launching</div>
: isExpired
? <div className="ctp-card-timer" data-urgency="high">Times up</div>
: isDone : isDone
? <div className="ctp-card-timer" data-urgency="off">Ready</div> ? <div className="ctp-card-timer" data-urgency="off">Ready</div>
: isScheduled : isScheduled
@@ -128,7 +132,7 @@ export const NominationCard = ({
return ( return (
<div <div
ref={cardRef} 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-top">
<div className="ctp-card-cover"> <div className="ctp-card-cover">
@@ -165,7 +169,11 @@ export const NominationCard = ({
className="ctp-progress-fill" className="ctp-progress-fill"
style={{ style={{
width: `${isStarted || isDone ? 100 : percentage}%`, width: `${isStarted || isDone ? 100 : percentage}%`,
background: isStarted || isDone ? 'var(--ok)' : 'var(--accent)', background: isExpired
? 'var(--danger)'
: isStarted || isDone
? 'var(--ok)'
: 'var(--accent)',
}} }}
/> />
</div> </div>
@@ -276,6 +284,7 @@ const CardActions = ({
const isCreator = nomination.creatorId === actorId; const isCreator = nomination.creatorId === actorId;
const isDone = nomination.state === 'done'; const isDone = nomination.state === 'done';
const isStarted = nomination.state === 'started'; const isStarted = nomination.state === 'started';
const isExpired = statusOf(nomination, now) === 'expired';
const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !isStarted; const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !isStarted;
const readyCount = readyCountOf(nomination, now); const readyCount = readyCountOf(nomination, now);
@@ -337,7 +346,9 @@ const CardActions = ({
if (isDone) { if (isDone) {
return ( return (
<div className="ctp-note"> <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.' ? 'Everyones ready.'
: `Its time — waiting for ${nomination.creator} to start.`} : `Its time — waiting for ${nomination.creator} to start.`}
</div> </div>
@@ -7,9 +7,10 @@ import {
export const CHECKIN_LEAD_MS = 15 * 60_000; export const CHECKIN_LEAD_MS = 15 * 60_000;
export const STARTED_RETENTION_MS = 3_000; export const STARTED_RETENTION_MS = 3_000;
export const EXPIRED_RETENTION_MS = 5 * 60_000;
export type CallToPlayPhase = 'now' | 'scheduled' | 'checkin'; 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 { interface MutableNomination extends Nomination {
cancelled: boolean; cancelled: boolean;
@@ -58,6 +59,7 @@ export const inCountOf = (nomination: Nomination, now: number): number =>
export const statusOf = (nomination: Nomination, now: number): CallToPlayStatus => { export const statusOf = (nomination: Nomination, now: number): CallToPlayStatus => {
if (nomination.state === 'started') return 'started'; if (nomination.state === 'started') return 'started';
if (now >= nomination.deadline) return 'expired';
if (nomination.state === 'done' || readyCountOf(nomination, now) >= nomination.maxPlayers) { if (nomination.state === 'done' || readyCountOf(nomination, now) >= nomination.maxPlayers) {
return 'ready'; return 'ready';
} }
@@ -123,6 +125,11 @@ export const reduceCallToPlayEvents = (
) { ) {
continue; continue;
} }
if (nomination.state !== 'started'
&& now - nomination.deadline > EXPIRED_RETENTION_MS
) {
continue;
}
const { cancelled: _, messageIds: __, ...result } = nomination; const { cancelled: _, messageIds: __, ...result } = nomination;
nominations.push(result); nominations.push(result);
@@ -1919,6 +1919,7 @@
border-radius: 12px; border-radius: 12px;
} }
.ctp-card.is-done { border-color: color-mix(in srgb, var(--ok) 45%, var(--bd-2)); } .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-started { opacity: 0.6; }
.ctp-card.is-focused { border-color: var(--accent); animation: ctp-cardflash 1.4s ease-out 1; } .ctp-card.is-focused { border-color: var(--accent); animation: ctp-cardflash 1.4s ease-out 1; }
@keyframes ctp-cardflash { @keyframes ctp-cardflash {
@@ -1956,6 +1957,7 @@
.ctp-card-timer[data-urgency="mid"] { color: var(--warn); } .ctp-card-timer[data-urgency="mid"] { color: var(--warn); }
.ctp-card-timer[data-urgency="high"] { color: var(--danger); } .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-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 { .ctp-cancel-link {
display: inline-flex; align-items: center; gap: 6px; display: inline-flex; align-items: center; gap: 6px;
align-self: flex-start; align-self: flex-start;
@@ -2210,7 +2212,8 @@
/* ─── Quick-bar status variants — LED + label + row tint per status: /* ─── Quick-bar status variants — LED + label + row tint per status:
SCHEDULED (neutral) · CALL TO PLAY (accent, pulsing) · 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"] { .ctp-ticker[data-status="scheduled"] {
background: var(--bg-2); background: var(--bg-2);
border-color: var(--bd-2); border-color: var(--bd-2);
@@ -2232,9 +2235,15 @@
box-shadow: 0 0 14px -2px color-mix(in srgb, var(--ok) 35%, transparent); 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="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="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="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="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 { @keyframes ctp-tickerpulse-warn {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 55%, transparent); } 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 55%, transparent); }
70% { box-shadow: 0 0 0 6px 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="scheduled"] { color: var(--t-2); }
.ctp-ticker-label[data-status="soon"] { color: var(--warn); } .ctp-ticker-label[data-status="soon"] { color: var(--warn); }
.ctp-ticker-label[data-status="ready"] { color: var(--ok); } .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="soon"] .ctp-ticker-cta { color: var(--warn); }
.ctp-ticker[data-status="ready"] .ctp-ticker-cta { color: var(--ok); } .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 ─── */ /* ─── Quick-bar inline chat preview ─── */
.ctp-ticker-chat { .ctp-ticker-chat {
@@ -1,5 +1,6 @@
import { import {
CHECKIN_LEAD_MS, CHECKIN_LEAD_MS,
EXPIRED_RETENTION_MS,
phaseOf, phaseOf,
bumpTime, bumpTime,
normalizeTimeInput, 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', () => { Deno.test('creator-only controls cannot be forged by another participant', () => {
const forged = [ const forged = [
create(), 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 "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." 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). **Every call carries a small group chat** (see "Per-call chat" below).
### Three surfaces ### Three surfaces
@@ -478,12 +482,13 @@ Two derived values drive everything (`calltoplay.jsx`):
- `phaseOf(call)``'now'` (no `scheduledFor`) · `'scheduled'` (>15 min out) · - `phaseOf(call)``'now'` (no `scheduledFor`) · `'scheduled'` (>15 min out) ·
`'checkin'` (within the 15-min lead). `'checkin'` (within the 15-min lead).
- `statusOf(call)` (quick-bar/label status) → `'started'` · `'ready'` - `statusOf(call)` (quick-bar/label status) → `'started'` · `'expired'`
(`readyCount >= maxPlayers`, or state `done`) · `'soon'` (`deadline - now ≤ (deadline elapsed) · `'ready'` (`readyCount >= maxPlayers`, or state `done`) ·
15 min`) · `'scheduled'` (has a clock time) · `'call'` (a plain play-now call). `'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 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]`. `.ctp-ticker-dot[data-status]`.
**Per-participant ready state:** `ready` (explicitly readied, or their `readyAt` **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: 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). 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. 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`. 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 create, response, RSVP, chat, leave, cancel, start, or deadline-extension action
is an immutable, uniquely identified event. Connected peers receive new events is an immutable, uniquely identified event. Connected peers receive new events
immediately, while `Hello` / `HelloAck` exchange the bounded, deduplicated event immediately, while `Hello` / `HelloAck` exchange the bounded, deduplicated event
history so a late joiner reconstructs the same calls. The frontend reducer turns history so a late joiner reconstructs every event and chat message for active
that event history into the `Nomination` state above and derives time-based phase calls. Started and cancelled calls compact to terminal tombstones; expired
changes locally. The `username` that identifies "you" comes from calls are removed after the five-minute grace period. The frontend reducer
`settings.username` (the Profile setting), not a prop default. 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.
--- ---