fix(call-to-play): confirm early starts inline
Starting an underfilled or scheduled call immediately sent Start. Ask "Start this call early?" inline, with Start now and Keep waiting choices, before publishing an early start. Ready and elapsed calls retain one-click starting, and local launch still waits for core acceptance. Match the existing cancel confirmation layout, reset pending confirmation on call or actor changes, and test the real action callbacks for dismissal, retry, deferred acceptance, normal starts, and missing local games. Test Plan: - `just frontend-test` -- passed, 113 tests - `just test` -- passed on rerun, 797 tests; the initial run hit an unchanged scoped-process test's two-second child-start timeout - `just clippy` -- passed with warnings denied - `just fmt` -- passed - `deno task build` -- passed TypeScript and Vite production build - `git diff --cached --check` -- passed
This commit is contained in:
1 parent
37899da18a
commit
dc326a0e3f
4 files changed
+197
-14
No files matched your search
@@ -70,10 +70,14 @@ export const NominationCard = ({
|
||||
onLaunch,
|
||||
}: Props) => {
|
||||
const [confirmCancel, setConfirmCancel] = useState(false);
|
||||
const [confirmStart, setConfirmStart] = useState(false);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (focused) cardRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}, [focused]);
|
||||
useEffect(() => {
|
||||
setConfirmStart(false);
|
||||
}, [nomination.id, nomination.state, actorId]);
|
||||
|
||||
const now = Date.now();
|
||||
const entries = Object.entries(nomination.participants);
|
||||
@@ -212,6 +216,8 @@ export const NominationCard = ({
|
||||
actions={actions}
|
||||
onLaunch={onLaunch}
|
||||
now={now}
|
||||
confirmStart={confirmStart}
|
||||
onStartConfirmationChange={setConfirmStart}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -255,6 +261,8 @@ interface CardActionsProps {
|
||||
actions: CallToPlayActions;
|
||||
onLaunch: (game: Game) => void;
|
||||
now: number;
|
||||
confirmStart: boolean;
|
||||
onStartConfirmationChange: (confirm: boolean) => void;
|
||||
}
|
||||
|
||||
const ReadyButtons = ({ nomination, actions, includeThirty = false }: {
|
||||
@@ -278,13 +286,15 @@ const ReadyButtons = ({ nomination, actions, includeThirty = false }: {
|
||||
</>
|
||||
);
|
||||
|
||||
const CardActions = ({
|
||||
export const CardActions = ({
|
||||
nomination,
|
||||
game,
|
||||
actorId,
|
||||
actions,
|
||||
onLaunch,
|
||||
now,
|
||||
confirmStart,
|
||||
onStartConfirmationChange,
|
||||
}: CardActionsProps) => {
|
||||
const myStatus = actorId === null ? undefined : nomination.participants[actorId];
|
||||
const isMe = myStatus !== undefined;
|
||||
@@ -294,6 +304,11 @@ const CardActions = ({
|
||||
const isExpired = statusOf(nomination, now) === 'expired';
|
||||
const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !terminal;
|
||||
const readyCount = readyCountOf(nomination, now);
|
||||
const startCall = async () => {
|
||||
onStartConfirmationChange(false);
|
||||
const accepted = await actions.startNow(nomination.id);
|
||||
if (accepted && game) onLaunch(game);
|
||||
};
|
||||
|
||||
if (terminal) {
|
||||
return (
|
||||
@@ -310,12 +325,33 @@ const CardActions = ({
|
||||
if (isCreator) {
|
||||
return (
|
||||
<>
|
||||
{confirmStart && !isDone
|
||||
? (
|
||||
<div className="ctp-start-confirm" role="group" aria-label="Start this call early?">
|
||||
<span>Start this call early?</span>
|
||||
<div className="ctp-start-confirm-btns">
|
||||
<button className="act-btn act-play" onClick={startCall}>
|
||||
<Icon.play /><span>Start now</span>
|
||||
</button>
|
||||
<button
|
||||
className="ghost-btn"
|
||||
onClick={() => onStartConfirmationChange(false)}
|
||||
>Keep waiting</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<button
|
||||
className="act-btn act-play"
|
||||
onClick={() => void actions.startNow(nomination.id).then(accepted => {
|
||||
if (accepted && game) onLaunch(game);
|
||||
})}
|
||||
onClick={() => {
|
||||
if (!isDone) {
|
||||
onStartConfirmationChange(true);
|
||||
return;
|
||||
}
|
||||
return startCall();
|
||||
}}
|
||||
><Icon.play /><span>{game ? 'Start now' : 'Mark as running'}</span></button>
|
||||
)}
|
||||
{isDone && (
|
||||
<button
|
||||
className="ghost-btn"
|
||||
|
||||
@@ -2201,26 +2201,38 @@
|
||||
.ctp-cancel-link:hover {
|
||||
color: #fca5a5;
|
||||
}
|
||||
.ctp-cancel-confirm {
|
||||
.ctp-cancel-confirm,
|
||||
.ctp-start-confirm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(239,68,68,0.08);
|
||||
border: 1px solid rgba(239,68,68,0.35);
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px;
|
||||
color: #fca5a5;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ctp-cancel-confirm-btns {
|
||||
.ctp-cancel-confirm {
|
||||
background: rgba(239,68,68,0.08);
|
||||
border: 1px solid rgba(239,68,68,0.35);
|
||||
color: #fca5a5;
|
||||
}
|
||||
.ctp-start-confirm {
|
||||
flex: 1 1 100%;
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--bd-2));
|
||||
color: var(--t-1);
|
||||
}
|
||||
.ctp-cancel-confirm-btns,
|
||||
.ctp-start-confirm-btns {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ctp-cancel-confirm-btns .ghost-btn {
|
||||
.ctp-cancel-confirm-btns .ghost-btn,
|
||||
.ctp-actions .ctp-start-confirm-btns .act-btn,
|
||||
.ctp-actions .ctp-start-confirm-btns .ghost-btn {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createElement } from 'react';
|
||||
import { Children, createElement, isValidElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { NominationCard } from '../src/components/calltoplay/NominationCard.tsx';
|
||||
import { CardActions, NominationCard } from '../src/components/calltoplay/NominationCard.tsx';
|
||||
import { type CallToPlayActions } from '../src/hooks/useCallToPlay.ts';
|
||||
import { reduceCallToPlayEvents } from '../src/lib/callToPlay.ts';
|
||||
import {
|
||||
@@ -16,6 +16,10 @@ function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
const assertEquals = <T>(actual: T, expected: T, message: string): void => {
|
||||
if (actual !== expected) throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
||||
};
|
||||
|
||||
const game: Game = {
|
||||
id: 'game-1',
|
||||
name: 'Test game',
|
||||
@@ -171,3 +175,130 @@ Deno.test('running and cancelled calls cannot be started again', () => {
|
||||
Deno.test('caller can mark an underfilled call running when the game is unavailable locally', () => {
|
||||
assertStartAvailable(renderCard(call(Date.now(), 6), 'Alice', null), 'Mark as running');
|
||||
});
|
||||
|
||||
type ClickHandler = () => void | Promise<void>;
|
||||
|
||||
const findClickHandler = (node: ReactNode, label: string): ClickHandler | undefined => {
|
||||
if (!isValidElement<{ children?: ReactNode; onClick?: ClickHandler }>(node)) return;
|
||||
if (node.type === 'button' && buttonNamed(renderToStaticMarkup(node), label)) {
|
||||
return node.props.onClick;
|
||||
}
|
||||
for (const child of Children.toArray(node.props.children)) {
|
||||
const handler = findClickHandler(child, label);
|
||||
if (handler) return handler;
|
||||
}
|
||||
};
|
||||
|
||||
// CardActions is controlled by NominationCard. Exercise its real button
|
||||
// callbacks and rerender with the resulting confirmation state.
|
||||
const startHarness = (
|
||||
nomination: Nomination,
|
||||
publication: Promise<boolean> = Promise.resolve(true),
|
||||
localGame: Game | null = game,
|
||||
) => {
|
||||
let confirming = false;
|
||||
const published: string[] = [];
|
||||
const launched: Game[] = [];
|
||||
const view = () => CardActions({
|
||||
nomination,
|
||||
game: localGame,
|
||||
actorId: 'Alice',
|
||||
actions: {
|
||||
...actions,
|
||||
startNow: callId => {
|
||||
published.push(callId);
|
||||
return publication;
|
||||
},
|
||||
},
|
||||
now: Date.now(),
|
||||
onLaunch: launchedGame => launched.push(launchedGame),
|
||||
confirmStart: confirming,
|
||||
onStartConfirmationChange: value => { confirming = value; },
|
||||
});
|
||||
return {
|
||||
published,
|
||||
launched,
|
||||
markup: () => renderToStaticMarkup(view()),
|
||||
click: (label: string) => {
|
||||
const handler = findClickHandler(view(), label);
|
||||
assert(handler, `${label} must have a click handler`);
|
||||
return handler();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Deno.test('early start asks for confirmation without publishing or launching', async () => {
|
||||
const now = Date.now();
|
||||
for (const nomination of [
|
||||
call(now, 6),
|
||||
call(now),
|
||||
call(now, 1, now + 60 * 60_000),
|
||||
call(now, 1, now + 10 * 60_000),
|
||||
]) {
|
||||
const harness = startHarness(nomination);
|
||||
await harness.click('Start now');
|
||||
assert(harness.markup().includes('Start this call early?'), 'early-start question appears');
|
||||
assert(buttonNamed(harness.markup(), 'Keep waiting'), 'waiting remains an explicit choice');
|
||||
assert(harness.published.length === 0, 'first click must not publish Start');
|
||||
assert(harness.launched.length === 0, 'first click must not launch');
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('keeping a call waiting dismisses confirmation and requires it again next time', async () => {
|
||||
const harness = startHarness(call(Date.now(), 6));
|
||||
await harness.click('Start now');
|
||||
await harness.click('Keep waiting');
|
||||
assert(!harness.markup().includes('Start this call early?'), 'confirmation closes');
|
||||
assertStartAvailable(harness.markup());
|
||||
await harness.click('Start now');
|
||||
assert(harness.markup().includes('Start this call early?'), 'a later early start asks again');
|
||||
assert(harness.published.length === 0, 'keeping the call waiting publishes nothing');
|
||||
assert(harness.launched.length === 0, 'keeping the call waiting launches nothing');
|
||||
});
|
||||
|
||||
Deno.test('confirming an early start launches only after core acceptance', async () => {
|
||||
let accept!: (accepted: boolean) => void;
|
||||
const publication = new Promise<boolean>(resolve => { accept = resolve; });
|
||||
const nomination = call(Date.now(), 6);
|
||||
const harness = startHarness(nomination, publication);
|
||||
await harness.click('Start now');
|
||||
const confirmation = harness.click('Start now');
|
||||
assert(harness.published.length === 1, 'confirmation publishes exactly one Start');
|
||||
assert(harness.published[0] === nomination.id, 'Start targets this call');
|
||||
assertEquals(harness.launched.length, 0, 'launch waits for core acceptance');
|
||||
accept(true);
|
||||
await confirmation;
|
||||
assert(harness.launched.at(0) === game, 'accepted Start launches the local game');
|
||||
assert(harness.launched.length === 1, 'the game launches exactly once');
|
||||
});
|
||||
|
||||
Deno.test('rejected early starts never launch and require confirmation on retry', async () => {
|
||||
const harness = startHarness(call(Date.now(), 6), Promise.resolve(false));
|
||||
await harness.click('Start now');
|
||||
await harness.click('Start now');
|
||||
assert(harness.published.length === 1, 'confirmation reaches the peer');
|
||||
assert(harness.launched.length === 0, 'rejection cannot launch the game');
|
||||
await harness.click('Start now');
|
||||
assert(harness.markup().includes('Start this call early?'), 'retry requires confirmation');
|
||||
assert(harness.published.length === 1, 'retry first click does not publish');
|
||||
});
|
||||
|
||||
Deno.test('ready and elapsed calls still start with one click', async () => {
|
||||
const now = Date.now();
|
||||
for (const nomination of [call(now, 8), call(now, 1, null, now - 1_000)]) {
|
||||
const harness = startHarness(nomination);
|
||||
await harness.click('Start now');
|
||||
assert(harness.published.length === 1, 'normal start publishes on the first click');
|
||||
assert(harness.launched.length === 1, 'accepted normal start launches');
|
||||
assert(!harness.markup().includes('Start this call early?'), 'normal start needs no confirmation');
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('confirmed early start without a local game only marks the call running', async () => {
|
||||
const harness = startHarness(call(Date.now(), 6), Promise.resolve(true), null);
|
||||
await harness.click('Mark as running');
|
||||
assertEquals(harness.published.length, 0, 'marking early also requires confirmation');
|
||||
await harness.click('Start now');
|
||||
assert(harness.published.length === 1, 'confirmation publishes Start');
|
||||
assert(harness.launched.length === 0, 'an unavailable game cannot launch locally');
|
||||
});
|
||||
@@ -834,8 +834,12 @@ The caller can use **Start now** at any time while the call is visible and has
|
||||
not been started or cancelled, including before scheduled check-in. The player
|
||||
count and deadline guide coordination; they do not gate starting. An
|
||||
eight-player call can start with six ready players, or even the caller alone.
|
||||
Starting marks the call Running and then invokes the caller's local game action
|
||||
only after the peer accepts it.
|
||||
Before the roster is ready or the deadline has elapsed, clicking **Start now**
|
||||
opens a small inline confirmation: **Start this call early?**, with **Start
|
||||
now** and **Keep waiting** buttons. Keeping the call waiting publishes no
|
||||
action. Ready and Time's-up calls start with one click. Starting marks the call
|
||||
Running and then invokes the caller's local game action only after the peer
|
||||
accepts it.
|
||||
|
||||
**Every call carries a small group chat** (see "Per-call chat" below).
|
||||
|
||||
|
||||
Reference in new issue
Block a user