Compare commits

...
3 Commits
Author SHA1 Message Date
ddidderr d58307c328 fix(call-to-play): compact expired snapshots
Expired call histories were removed on the next store insertion, so an otherwise
idle peer could continue carrying stale payload through handshakes after the
five-minute UI retention ended.

Run the same inactive-call compaction before local and handshake snapshots. This
makes the expiry boundary exact without trimming any event from an active call.

Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed, 147 peer tests
- `git diff --cached --check` -- passed
2026-07-21 22:59:48 +02:00
ddidderr 383e8f4855 fix(call-to-play): resync after live delivery failure
A failed fire-and-forget event left one peer's active call state divergent until
some later discovery or reconnect happened. During a LAN party that could leave
different players looking at different rosters or chat.

Fall back to the existing bidirectional handshake whenever a live Call to Play
send fails. Its active-history exchange heals both sides immediately when the
failure was transient. Document the related wall-clock synchronization
assumption for deadline and countdown presentation.

Test Plan:
- `just fmt` -- passed
- `just clippy` -- passed
- `just test` -- passed, 147 peer tests
- `just peer-cli-tests S48` -- passed
- `git diff --cached --check` -- passed
2026-07-21 22:58:19 +02:00
ddidderr cc7dacf6c3 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
2026-07-21 22:56:02 +02:00
10 changed files with 204 additions and 46 deletions
+12 -1
View File
@@ -56,11 +56,18 @@ 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.
If a live Call to Play delivery fails, the sender immediately falls back to a
normal `Hello` / `HelloAck` exchange with that peer. The handshake carries the
full active history in both directions, so a transient request failure heals
without waiting for mDNS rediscovery or a later reconnect.
Actors are keyed by the peer's stable ID and carry a separate display name. The
origin peer overwrites the actor ID on local actions, and live-event envelopes
must match the known sending peer. This prevents duplicate default usernames
@@ -72,6 +79,10 @@ uses the project's trusted-LAN identity model.
join after a call was created reconstruct the same nominations, responses,
RSVPs, chat, and terminal actions. The launcher reducer sorts the event stream
deterministically and derives deadlines and check-in phases from timestamps.
Those phases compare creator-supplied wall-clock timestamps with each viewer's
local wall clock, so LAN machines are assumed to be synchronized closely enough
for human-scale minute countdowns; clock skew shifts the displayed boundary by
the same amount.
There is deliberately no compatibility path for older protocol versions.
### 4) Shutdown
+94 -13
View File
@@ -1,17 +1,27 @@
//! 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;
use crate::{PeerEvent, context::Ctx, events, network::send_call_to_play_events};
use crate::{
PeerEvent,
context::Ctx,
events,
network::send_call_to_play_events,
services::{HandshakeCtx, perform_handshake_with_peer},
};
const MAX_EVENTS: usize = 4_096;
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 {
@@ -20,7 +30,12 @@ pub(crate) struct CallToPlayStore {
}
impl CallToPlayStore {
pub(crate) fn snapshot(&self) -> Vec<CallToPlayEvent> {
pub(crate) fn snapshot(&mut self) -> Vec<CallToPlayEvent> {
self.snapshot_at(now_ms())
}
fn snapshot_at(&mut self, now: i64) -> Vec<CallToPlayEvent> {
self.compact_inactive_calls(now);
self.events.clone()
}
@@ -33,7 +48,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 +69,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 +88,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 +109,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 +152,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>,
@@ -124,15 +184,24 @@ pub(crate) async fn publish(
let peer_addresses = ctx.peer_game_db.read().await.get_peer_addresses();
let peer_id = ctx.peer_id.clone();
let handshake_ctx = HandshakeCtx::from_ctx(ctx, tx_notify_ui);
ctx.task_tracker.spawn(async move {
let deliveries = peer_addresses.into_iter().map(|peer_addr| {
let event = event.clone();
let peer_id = peer_id.clone();
let handshake_ctx = handshake_ctx.clone();
async move {
if let Err(err) =
send_call_to_play_events(peer_addr, peer_id.as_ref(), vec![event]).await
{
log::warn!("Failed to send Call to Play event to {peer_addr}: {err}");
if let Err(resync_err) =
perform_handshake_with_peer(handshake_ctx, peer_addr, None).await
{
log::warn!(
"Failed to resync Call to Play history with {peer_addr}: {resync_err}"
);
}
}
}
});
@@ -208,18 +277,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 +301,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 +431,14 @@ 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");
assert!(store.snapshot_at(TEST_NOW + 5 * 60_000 + 60_001).is_empty());
}
}
+1 -1
View File
@@ -510,7 +510,7 @@ async fn handle_peer_commands(
let _ = reply.send(result);
}
PeerCommand::GetCallToPlayEvents { reply } => {
let events = ctx.call_to_play.read().await.snapshot();
let events = ctx.call_to_play.write().await.snapshot();
events::send(tx_notify_ui, PeerEvent::CallToPlayEvents(events.clone()));
if let Some(reply) = reply {
let _ = reply.send(events);
@@ -67,7 +67,7 @@ pub(super) async fn build_hello_ack(ctx: &PeerCtx) -> eyre::Result<HelloAck> {
let library_guard = ctx.local_library.read().await;
build_library_snapshot(&library_guard)
};
let call_to_play_events = ctx.call_to_play.read().await.snapshot();
let call_to_play_events = ctx.call_to_play.write().await.snapshot();
Ok(HelloAck {
peer_id: ctx.peer_id.as_ref().clone(),
proto_ver: PROTOCOL_VERSION,
@@ -84,7 +84,7 @@ async fn build_hello_from_state(ctx: &HandshakeCtx) -> eyre::Result<Hello> {
let library_guard = ctx.local_library.read().await;
build_library_snapshot(&library_guard)
};
let call_to_play_events = ctx.call_to_play.read().await.snapshot();
let call_to_play_events = ctx.call_to_play.write().await.snapshot();
Ok(Hello {
peer_id: ctx.peer_id.as_ref().clone(),
proto_ver: PROTOCOL_VERSION,
@@ -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,
},
}
}
@@ -538,7 +538,7 @@ mod tests {
.expect("current protocol hello should be accepted");
assert_eq!(
ctx.call_to_play.read().await.snapshot(),
ctx.call_to_play.write().await.snapshot(),
[call_to_play_event()]
);
assert!(matches!(
@@ -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.
---