Pass the effective nomination deadline into the Add time action and extend from whichever is later: that deadline or the current time. This preserves remaining time when a call becomes ready early while still giving an overdue call a fresh five-minute window. Test Plan: - just fmt - just frontend-test - just build - git diff --cached --check
402 lines
15 KiB
TypeScript
402 lines
15 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
|
||
import { Icon } from '../Icon';
|
||
import { GameCover } from '../grid/GameCover';
|
||
import { CtpChat } from './CtpChat';
|
||
import { CallToPlayActions } from '../../hooks/useCallToPlay';
|
||
import {
|
||
avatarColor,
|
||
CHECKIN_LEAD_MS,
|
||
formatClock,
|
||
formatCountdown,
|
||
formatCountdownShort,
|
||
formatUntil,
|
||
inCountOf,
|
||
isReady,
|
||
isTerminal,
|
||
phaseOf,
|
||
readyCountOf,
|
||
statusOf,
|
||
} from '../../lib/callToPlay';
|
||
import { CallToPlayParticipant, Game, Nomination } from '../../lib/types';
|
||
|
||
interface Props {
|
||
nomination: Nomination;
|
||
game: Game | null;
|
||
actorId: string | null;
|
||
actions: CallToPlayActions;
|
||
focused: boolean;
|
||
thumbnailUrl?: string | null;
|
||
totalPeerCount: number;
|
||
onLaunch: (game: Game) => void;
|
||
}
|
||
|
||
const AvatarChip = ({
|
||
name,
|
||
participant,
|
||
now,
|
||
}: {
|
||
name: string;
|
||
participant: CallToPlayParticipant;
|
||
now: number;
|
||
}) => {
|
||
const ready = isReady(participant, now);
|
||
const isIn = !ready && participant.status === 'in';
|
||
const remaining = Math.max(0, (participant.readyAt ?? now) - now);
|
||
const initials = name.replace(/[^a-z0-9]/gi, '').slice(0, 2).toUpperCase();
|
||
return (
|
||
<div
|
||
className={`ctp-avatar ${ready ? 'is-ready' : isIn ? 'is-in' : 'is-pending'}`}
|
||
title={`${name} — ${ready ? 'ready' : isIn ? 'in, not checked in' : `ready in ${formatCountdown(remaining)}`}`}
|
||
>
|
||
<span className="ctp-avatar-dot" style={{ background: avatarColor(name) }}>{initials}</span>
|
||
{ready
|
||
? <span className="ctp-avatar-check"><Icon.check /></span>
|
||
: isIn
|
||
? <span className="ctp-avatar-in">in</span>
|
||
: <span className="ctp-avatar-pending">{formatCountdownShort(remaining)}</span>}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export const NominationCard = ({
|
||
nomination,
|
||
game,
|
||
actorId,
|
||
actions,
|
||
focused,
|
||
thumbnailUrl,
|
||
totalPeerCount,
|
||
onLaunch,
|
||
}: Props) => {
|
||
const [confirmCancel, setConfirmCancel] = useState(false);
|
||
const cardRef = useRef<HTMLDivElement>(null);
|
||
useEffect(() => {
|
||
if (focused) cardRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||
}, [focused]);
|
||
|
||
const now = Date.now();
|
||
const entries = Object.entries(nomination.participants);
|
||
const readyCount = readyCountOf(nomination, now);
|
||
const inCount = inCountOf(nomination, now);
|
||
const myStatus = actorId === null ? undefined : nomination.participants[actorId];
|
||
const isMe = myStatus !== undefined;
|
||
const isCreator = nomination.creatorId === actorId;
|
||
const isDone = nomination.state === 'done';
|
||
const terminal = isTerminal(nomination);
|
||
const isRunning = nomination.state === 'running';
|
||
const isCancelled = nomination.state === 'cancelled';
|
||
const isExpired = statusOf(nomination, now) === 'expired';
|
||
const phase = phaseOf(nomination, now);
|
||
const isScheduled = phase === 'scheduled' && !isDone && !terminal && !isExpired;
|
||
const isCheckin = phase === 'checkin' && !isDone && !terminal && !isExpired;
|
||
const windowStart = nomination.scheduledFor === null
|
||
? nomination.createdAt
|
||
: nomination.scheduledFor - CHECKIN_LEAD_MS;
|
||
const remaining = Math.max(0, nomination.deadline - now);
|
||
const percentage = Math.max(
|
||
0,
|
||
Math.min(100, (remaining / Math.max(1, nomination.deadline - windowStart)) * 100),
|
||
);
|
||
const urgency = percentage < 20 ? 'high' : percentage < 50 ? 'mid' : 'low';
|
||
const installedCount = game === null
|
||
? 0
|
||
: (game.installed_peer_count ?? game.peer_count) + (game.installed ? 1 : 0);
|
||
const lanCount = Math.max(totalPeerCount + 1, installedCount);
|
||
|
||
const timer = isRunning
|
||
? <div className="ctp-card-timer" data-urgency="off">Running</div>
|
||
: isCancelled
|
||
? <div className="ctp-card-timer" data-urgency="off">Cancelled</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
|
||
? (
|
||
<div className="ctp-card-timer is-sched" data-urgency="off">
|
||
<span className="ctp-card-clock">{formatClock(nomination.scheduledFor!)}</span>
|
||
<span className="ctp-card-until">{formatUntil(nomination.scheduledFor! - now)}</span>
|
||
</div>
|
||
)
|
||
: isCheckin
|
||
? (
|
||
<div className="ctp-card-timer is-sched" data-urgency={urgency}>
|
||
<span className="ctp-card-clock">{formatCountdown(remaining)}</span>
|
||
<span className="ctp-card-until">starts {formatClock(nomination.scheduledFor!)}</span>
|
||
</div>
|
||
)
|
||
: <div className="ctp-card-timer" data-urgency={urgency}>{formatCountdown(remaining)}</div>;
|
||
|
||
const rosterLabel = terminal
|
||
? `${entries.length} players`
|
||
: isScheduled
|
||
? `${entries.length} in · up to ${nomination.maxPlayers} players`
|
||
: isCheckin && inCount > 0
|
||
? `${readyCount}/${nomination.maxPlayers} ready · ${inCount} not checked in yet`
|
||
: `${readyCount}/${nomination.maxPlayers} ready`;
|
||
|
||
return (
|
||
<div
|
||
ref={cardRef}
|
||
className={`ctp-card ${isDone ? 'is-done' : ''} ${isExpired ? 'is-expired' : ''} ${terminal ? 'is-terminal' : ''} ${isRunning ? 'is-running' : ''} ${isCancelled ? 'is-cancelled' : ''} ${isCheckin ? 'is-checkin' : ''} ${focused ? 'is-focused' : ''}`}
|
||
>
|
||
<div className="ctp-card-top">
|
||
<div className="ctp-card-cover">
|
||
{game
|
||
? <GameCover game={game} aspect="square" thumbnailUrl={thumbnailUrl} />
|
||
: <div className="ctp-card-cover-missing"><Icon.flag /></div>}
|
||
</div>
|
||
<div className="ctp-card-info">
|
||
<div className="ctp-card-title">{game?.name ?? 'Game unavailable here'}</div>
|
||
<div className="ctp-card-sub">
|
||
{nomination.scheduledFor === null ? 'Called' : 'Scheduled'} by{' '}
|
||
<strong>{nomination.creator}</strong>
|
||
{nomination.scheduledFor !== null && ` · starts at ${formatClock(nomination.scheduledFor)}`}
|
||
{game
|
||
? ` · ${installedCount}/${lanCount} peers have it installed`
|
||
: ` · this game is not in your current library (ID: ${nomination.gameId})`}
|
||
</div>
|
||
</div>
|
||
{timer}
|
||
</div>
|
||
|
||
{isCheckin && (
|
||
<div className="ctp-checkin-note">
|
||
<Icon.clock />
|
||
<span>{isMe && myStatus.status === 'in'
|
||
? 'Starting soon — you said you’re in. Check in below.'
|
||
: 'Starting soon — check-in is open.'}</span>
|
||
</div>
|
||
)}
|
||
|
||
{!isScheduled && (
|
||
<div className="ctp-progress">
|
||
<div
|
||
className="ctp-progress-fill"
|
||
style={{
|
||
width: `${terminal || isDone ? 100 : percentage}%`,
|
||
background: isExpired || isCancelled
|
||
? 'var(--danger)'
|
||
: terminal || isDone
|
||
? 'var(--ok)'
|
||
: 'var(--accent)',
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<div className="ctp-roster">
|
||
<div className="ctp-roster-count">{rosterLabel}</div>
|
||
<div className="ctp-avatars">
|
||
{entries.map(([participantId, participant]) => (
|
||
<AvatarChip
|
||
key={participantId}
|
||
name={participant.name}
|
||
participant={participant}
|
||
now={now}
|
||
/>
|
||
))}
|
||
{Array.from({ length: Math.max(0, nomination.maxPlayers - entries.length) })
|
||
.map((_, index) => (
|
||
<div key={index} className="ctp-avatar ctp-avatar-empty" />
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="ctp-actions">
|
||
<CardActions
|
||
nomination={nomination}
|
||
game={game}
|
||
actorId={actorId}
|
||
actions={actions}
|
||
onLaunch={onLaunch}
|
||
now={now}
|
||
/>
|
||
</div>
|
||
|
||
<CtpChat
|
||
nomination={nomination}
|
||
actorId={actorId}
|
||
disabled={terminal}
|
||
onSend={text => actions.sendMessage(nomination.id, text)}
|
||
/>
|
||
|
||
{isCreator && !terminal && (
|
||
confirmCancel
|
||
? (
|
||
<div className="ctp-cancel-confirm">
|
||
<span>Cancel this call for everyone?</span>
|
||
<div className="ctp-cancel-confirm-btns">
|
||
<button
|
||
className="ghost-btn ghost-danger"
|
||
onClick={() => actions.cancel(nomination.id)}
|
||
>Yes, cancel it</button>
|
||
<button className="ghost-btn" onClick={() => setConfirmCancel(false)}>
|
||
No, keep it
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
: (
|
||
<button className="ctp-cancel-link" onClick={() => setConfirmCancel(true)}>
|
||
<Icon.trash /><span>Cancel this call</span>
|
||
</button>
|
||
)
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
interface CardActionsProps {
|
||
nomination: Nomination;
|
||
game: Game | null;
|
||
actorId: string | null;
|
||
actions: CallToPlayActions;
|
||
onLaunch: (game: Game) => void;
|
||
now: number;
|
||
}
|
||
|
||
const ReadyButtons = ({ nomination, actions, includeThirty = false }: {
|
||
nomination: Nomination;
|
||
actions: CallToPlayActions;
|
||
includeThirty?: boolean;
|
||
}) => (
|
||
<>
|
||
<button className="act-btn act-play" onClick={() => actions.respond(nomination.id, 'ready')}>
|
||
<Icon.play /><span>Ready now</span>
|
||
</button>
|
||
<div className="ctp-buffer-group">
|
||
{[5, 10, 15, ...(includeThirty ? [30] : [])].map(minutes => (
|
||
<button
|
||
key={minutes}
|
||
className="ctp-buffer-btn"
|
||
onClick={() => actions.respond(nomination.id, minutes)}
|
||
>+{minutes}m</button>
|
||
))}
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
const CardActions = ({
|
||
nomination,
|
||
game,
|
||
actorId,
|
||
actions,
|
||
onLaunch,
|
||
now,
|
||
}: CardActionsProps) => {
|
||
const myStatus = actorId === null ? undefined : nomination.participants[actorId];
|
||
const isMe = myStatus !== undefined;
|
||
const isCreator = nomination.creatorId === actorId;
|
||
const isDone = nomination.state === 'done';
|
||
const terminal = isTerminal(nomination);
|
||
const isExpired = statusOf(nomination, now) === 'expired';
|
||
const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !terminal;
|
||
const readyCount = readyCountOf(nomination, now);
|
||
|
||
if (terminal) {
|
||
return (
|
||
<div className="ctp-note">
|
||
{nomination.state === 'running'
|
||
? game
|
||
? `${game.name} is running.`
|
||
: 'The match is running.'
|
||
: 'This call was cancelled.'}
|
||
</div>
|
||
);
|
||
}
|
||
if (scheduled) {
|
||
if (isCreator) {
|
||
return (
|
||
<div className="ctp-note">
|
||
Scheduled for {formatClock(nomination.scheduledFor!)} — check-in opens 15 min before start.
|
||
</div>
|
||
);
|
||
}
|
||
if (isMe) {
|
||
return (
|
||
<>
|
||
<div className="ctp-me-status">You’re in — we’ll nudge you when check-in opens</div>
|
||
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(nomination.id)}>
|
||
Can’t make it
|
||
</button>
|
||
</>
|
||
);
|
||
}
|
||
return (
|
||
<>
|
||
<button className="act-btn act-play" onClick={() => actions.rsvp(nomination.id)}>
|
||
<Icon.check /><span>I’m in</span>
|
||
</button>
|
||
<div className="ctp-note">Check-in opens 15 min before start</div>
|
||
</>
|
||
);
|
||
}
|
||
if (isCreator) {
|
||
if (isDone) {
|
||
return (
|
||
<>
|
||
<button
|
||
className="act-btn act-play"
|
||
onClick={() => void actions.startNow(nomination.id).then(accepted => {
|
||
if (accepted && game) onLaunch(game);
|
||
})}
|
||
><Icon.play /><span>{game ? 'Start now' : 'Mark as running'}</span></button>
|
||
<button
|
||
className="ghost-btn"
|
||
onClick={() => actions.addTime(nomination.id, nomination.deadline)}
|
||
>
|
||
Add 5 more minutes
|
||
</button>
|
||
</>
|
||
);
|
||
}
|
||
if (myStatus?.status === 'in') {
|
||
return <ReadyButtons nomination={nomination} actions={actions} />;
|
||
}
|
||
return <div className="ctp-note">Waiting for players to ready up…</div>;
|
||
}
|
||
if (isDone) {
|
||
return (
|
||
<div className="ctp-note">
|
||
{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>
|
||
);
|
||
}
|
||
if (isMe) {
|
||
if (myStatus.status === 'in') {
|
||
return (
|
||
<>
|
||
<div className="ctp-me-status">You said you’re in — check in:</div>
|
||
<ReadyButtons nomination={nomination} actions={actions} />
|
||
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(nomination.id)}>
|
||
Leave
|
||
</button>
|
||
</>
|
||
);
|
||
}
|
||
const ready = isReady(myStatus, now);
|
||
return (
|
||
<>
|
||
<div className="ctp-me-status">
|
||
{ready ? 'You’re in — ready' : `You: ready in ${formatCountdown((myStatus.readyAt ?? now) - now)}`}
|
||
</div>
|
||
{!ready && (
|
||
<button className="ghost-btn" onClick={() => actions.respond(nomination.id, 'ready')}>
|
||
I’m ready now
|
||
</button>
|
||
)}
|
||
<button className="ghost-btn ghost-danger" onClick={() => actions.leave(nomination.id)}>
|
||
Leave
|
||
</button>
|
||
</>
|
||
);
|
||
}
|
||
return <ReadyButtons nomination={nomination} actions={actions} includeThirty />;
|
||
};
|