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
144 lines
6.0 KiB
TypeScript
144 lines
6.0 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 = {
|
||
scheduled: 'Scheduled',
|
||
call: 'Call to Play',
|
||
soon: 'Starting soon',
|
||
ready: 'Ready',
|
||
expired: 'Time’s up',
|
||
} 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);
|
||
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 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, 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 === 'scheduled'
|
||
? `${total} in`
|
||
: `${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
|
||
? `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>
|
||
);
|
||
};
|