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:
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user