Keep complete running and cancelled histories visible for fifteen minutes so peers retain the roster, chat, and outcome long enough to understand what happened. Compact them to terminal tombstones afterward without charging settled calls against the active-history limit. Model running and cancelled as durable read-only frontend states, exclude them from active badges, prune retired raw events, and document the lifecycle. Add peer scenario S49 to prove a late joiner reconstructs a terminal call with its roster and chat intact. Test Plan: - just fmt - just clippy - just test - just frontend-test - just build - just peer-cli-tests S48 S49 - python3 -m py_compile crates/lanspread-peer-cli/scripts/run_extended_scenarios.py - git diff --cached --check
156 lines
6.4 KiB
TypeScript
156 lines
6.4 KiB
TypeScript
import { CSSProperties } from 'react';
|
||
|
||
import { Icon } from '../Icon';
|
||
import {
|
||
avatarColor,
|
||
formatClock,
|
||
formatCountdown,
|
||
formatCountdownShort,
|
||
formatUntil,
|
||
isReady,
|
||
readyCountOf,
|
||
statusOf,
|
||
type CallToPlayStatus,
|
||
} from '../../lib/callToPlay';
|
||
import { Game, Nomination } from '../../lib/types';
|
||
|
||
interface Props {
|
||
nominations: ReadonlyArray<Nomination>;
|
||
games: ReadonlyArray<Game>;
|
||
accent: string;
|
||
onOpen: (callId: string) => void;
|
||
}
|
||
|
||
const LABEL = {
|
||
running: 'Running',
|
||
cancelled: 'Cancelled',
|
||
scheduled: 'Scheduled',
|
||
call: 'Call to Play',
|
||
soon: 'Starting soon',
|
||
ready: 'Ready',
|
||
expired: 'Time’s up',
|
||
} as const;
|
||
|
||
type TickerStatus = CallToPlayStatus;
|
||
|
||
const RANK: Record<TickerStatus, number> = {
|
||
expired: 0,
|
||
ready: 1,
|
||
soon: 2,
|
||
call: 3,
|
||
scheduled: 3,
|
||
running: 4,
|
||
cancelled: 4,
|
||
};
|
||
|
||
const tickerStatusOf = (nomination: Nomination, now: number): TickerStatus =>
|
||
statusOf(nomination, now);
|
||
|
||
const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number }) => {
|
||
const entries = Object.entries(nomination.participants);
|
||
return (
|
||
<span className="ctp-ticker-bubbles">
|
||
{entries.slice(0, 6).map(([participantId, participant]) => {
|
||
const name = participant.name;
|
||
const ready = isReady(participant, now);
|
||
const state = ready ? 'ready' : participant.status === 'in' ? 'in' : 'pending';
|
||
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
|
||
const remaining = (participant.readyAt ?? now) - now;
|
||
return (
|
||
<span
|
||
key={participantId}
|
||
className="ctp-mini"
|
||
data-state={state}
|
||
title={`${name} — ${ready ? 'ready' : state === 'in' ? 'in' : `ready ${formatCountdownShort(remaining)}`}`}
|
||
style={{ background: avatarColor(name) }}
|
||
>
|
||
{initials}
|
||
{ready && <span className="ctp-mini-check"><Icon.check /></span>}
|
||
{state === 'pending' && (
|
||
<i className="ctp-mini-tag">{formatCountdownShort(remaining)}</i>
|
||
)}
|
||
</span>
|
||
);
|
||
})}
|
||
{entries.length > 6 && <span className="ctp-mini ctp-mini-more">+{entries.length - 6}</span>}
|
||
</span>
|
||
);
|
||
};
|
||
|
||
export const CallToPlayTicker = ({ nominations, games, accent, onOpen }: Props) => {
|
||
const now = Date.now();
|
||
const gameById = new Map(games.map(game => [game.id, game]));
|
||
const rows = nominations.map(nomination => ({
|
||
nomination,
|
||
status: tickerStatusOf(nomination, now),
|
||
})).sort((left, right) =>
|
||
RANK[left.status] - RANK[right.status]
|
||
|| left.nomination.deadline - right.nomination.deadline
|
||
);
|
||
if (rows.length === 0) return null;
|
||
|
||
return (
|
||
<div className="ctp-ticker-stack">
|
||
{rows.map(({ nomination, status }) => {
|
||
const game = gameById.get(nomination.gameId);
|
||
const ready = readyCountOf(nomination, now);
|
||
const total = Object.keys(nomination.participants).length;
|
||
const remaining = Math.max(0, nomination.deadline - now);
|
||
const last = nomination.messages[nomination.messages.length - 1];
|
||
const count = status === 'running' || status === 'cancelled'
|
||
? `${total} players`
|
||
: status === 'scheduled'
|
||
? `${total} in`
|
||
: `${ready}/${nomination.maxPlayers} ready`;
|
||
const terminalElapsed = now - (nomination.terminalAt ?? now);
|
||
const terminalAge = terminalElapsed < 1_000
|
||
? 'just now'
|
||
: `${formatCountdownShort(terminalElapsed)} ago`;
|
||
const time = status === 'running'
|
||
? `started ${terminalAge}`
|
||
: status === 'cancelled'
|
||
? `cancelled ${terminalAge}`
|
||
: 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
|
||
? `starts ${formatClock(nomination.scheduledFor)} · ${formatCountdown(remaining)}`
|
||
: formatCountdown(remaining);
|
||
return (
|
||
<button
|
||
key={nomination.id}
|
||
className="ctp-ticker"
|
||
data-status={status}
|
||
style={{ '--accent': accent } as CSSProperties}
|
||
onClick={() => onOpen(nomination.id)}
|
||
>
|
||
<span className="ctp-ticker-dot" data-status={status} />
|
||
<span className="ctp-ticker-label" data-status={status}>{LABEL[status]}</span>
|
||
<span
|
||
className="ctp-ticker-game"
|
||
title={game ? undefined : `Game ID: ${nomination.gameId}`}
|
||
>{game?.name ?? 'Game unavailable here'}</span>
|
||
<span className="ctp-ticker-by">by {nomination.creator}</span>
|
||
<span className="ctp-ticker-ready">{count}</span>
|
||
<span className="ctp-ticker-time">{time}</span>
|
||
{last ? (
|
||
<span className="ctp-ticker-chat" title={`${last.from}: ${last.text}`}>
|
||
<Icon.chat />
|
||
<b style={{ color: avatarColor(last.from) }}>{last.from}:</b>
|
||
<span className="ctp-ticker-chat-text">{last.text}</span>
|
||
</span>
|
||
) : <span className="ctp-ticker-chat" />}
|
||
<MiniBubbles nomination={nomination} now={now} />
|
||
<span className="ctp-ticker-cta">
|
||
{status === 'soon' ? 'Check in' : 'View'}<Icon.chevron />
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
};
|