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:
ddidderr committed 2026-07-21 22:56:02 +02:00
1 parent 640d81d919
commit cc7dacf6c3
9 files changed
+171 -40

No files matched your search

@@ -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<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">Time’s 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
? `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.`}
</div>