feat(call-to-play): retain terminal outcomes
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
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Icon } from '../Icon';
|
||||
import { activeCallCount } from '../../lib/callToPlay';
|
||||
import { Nomination } from '../../lib/types';
|
||||
|
||||
interface Props {
|
||||
@@ -7,7 +8,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const CallToPlayButton = ({ nominations, onClick }: Props) => {
|
||||
const activeCount = nominations.filter(nomination => nomination.state !== 'started').length;
|
||||
const activeCount = activeCallCount(nominations);
|
||||
return (
|
||||
<button className="ctp-btn" onClick={onClick}>
|
||||
<Icon.flag />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Modal } from '../Modal';
|
||||
import { CreateNominationForm } from './CreateNominationForm';
|
||||
import { NominationCard } from './NominationCard';
|
||||
import { CallToPlayActions } from '../../hooks/useCallToPlay';
|
||||
import { sortNominations } from '../../lib/callToPlay';
|
||||
import { Game, Nomination } from '../../lib/types';
|
||||
|
||||
interface Props {
|
||||
@@ -36,10 +37,7 @@ export const CallToPlayOverlay = ({
|
||||
}: Props) => {
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const gameById = new Map(games.map(game => [game.id, game]));
|
||||
const sorted = [...nominations].sort((left, right) =>
|
||||
Number(left.state === 'started') - Number(right.state === 'started')
|
||||
|| left.deadline - right.deadline
|
||||
);
|
||||
const sorted = sortNominations(nominations);
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose} className="ctp-modal">
|
||||
|
||||
@@ -22,6 +22,8 @@ interface Props {
|
||||
}
|
||||
|
||||
const LABEL = {
|
||||
running: 'Running',
|
||||
cancelled: 'Cancelled',
|
||||
scheduled: 'Scheduled',
|
||||
call: 'Call to Play',
|
||||
soon: 'Starting soon',
|
||||
@@ -29,7 +31,7 @@ const LABEL = {
|
||||
expired: 'Time’s up',
|
||||
} as const;
|
||||
|
||||
type TickerStatus = Exclude<CallToPlayStatus, 'started'>;
|
||||
type TickerStatus = CallToPlayStatus;
|
||||
|
||||
const RANK: Record<TickerStatus, number> = {
|
||||
expired: 0,
|
||||
@@ -37,12 +39,12 @@ const RANK: Record<TickerStatus, number> = {
|
||||
soon: 2,
|
||||
call: 3,
|
||||
scheduled: 3,
|
||||
running: 4,
|
||||
cancelled: 4,
|
||||
};
|
||||
|
||||
const tickerStatusOf = (nomination: Nomination, now: number): TickerStatus | null => {
|
||||
const status = statusOf(nomination, now);
|
||||
return status === 'started' ? null : status;
|
||||
};
|
||||
const tickerStatusOf = (nomination: Nomination, now: number): TickerStatus =>
|
||||
statusOf(nomination, now);
|
||||
|
||||
const MiniBubbles = ({ nomination, now }: { nomination: Nomination; now: number }) => {
|
||||
const entries = Object.entries(nomination.participants);
|
||||
@@ -78,27 +80,37 @@ 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.flatMap(nomination => {
|
||||
const status = tickerStatusOf(nomination, now);
|
||||
return status === null ? [] : [{ nomination, status }];
|
||||
}).sort((left, right) =>
|
||||
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 (active.length === 0) return null;
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="ctp-ticker-stack">
|
||||
{active.map(({ nomination, status }) => {
|
||||
{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 === 'scheduled'
|
||||
const count = status === 'running' || status === 'cancelled'
|
||||
? `${total} players`
|
||||
: status === 'scheduled'
|
||||
? `${total} in`
|
||||
: `${ready}/${nomination.maxPlayers} ready`;
|
||||
const time = status === '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`
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
formatUntil,
|
||||
inCountOf,
|
||||
isReady,
|
||||
isTerminal,
|
||||
phaseOf,
|
||||
readyCountOf,
|
||||
statusOf,
|
||||
@@ -82,11 +83,13 @@ export const NominationCard = ({
|
||||
const isMe = myStatus !== undefined;
|
||||
const isCreator = nomination.creatorId === actorId;
|
||||
const isDone = nomination.state === 'done';
|
||||
const isStarted = nomination.state === 'started';
|
||||
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 && !isStarted && !isExpired;
|
||||
const isCheckin = phase === 'checkin' && !isDone && !isStarted && !isExpired;
|
||||
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;
|
||||
@@ -101,8 +104,10 @@ export const NominationCard = ({
|
||||
: (game.installed_peer_count ?? game.peer_count) + (game.installed ? 1 : 0);
|
||||
const lanCount = Math.max(totalPeerCount + 1, installedCount);
|
||||
|
||||
const timer = isStarted
|
||||
? <div className="ctp-card-timer" data-urgency="off">Launching…</div>
|
||||
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
|
||||
@@ -123,7 +128,9 @@ export const NominationCard = ({
|
||||
)
|
||||
: <div className="ctp-card-timer" data-urgency={urgency}>{formatCountdown(remaining)}</div>;
|
||||
|
||||
const rosterLabel = isScheduled
|
||||
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`
|
||||
@@ -132,7 +139,7 @@ export const NominationCard = ({
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`ctp-card ${isDone ? 'is-done' : ''} ${isExpired ? 'is-expired' : ''} ${isStarted ? 'is-started' : ''} ${isCheckin ? 'is-checkin' : ''} ${focused ? 'is-focused' : ''}`}
|
||||
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">
|
||||
@@ -168,10 +175,10 @@ export const NominationCard = ({
|
||||
<div
|
||||
className="ctp-progress-fill"
|
||||
style={{
|
||||
width: `${isStarted || isDone ? 100 : percentage}%`,
|
||||
background: isExpired
|
||||
width: `${terminal || isDone ? 100 : percentage}%`,
|
||||
background: isExpired || isCancelled
|
||||
? 'var(--danger)'
|
||||
: isStarted || isDone
|
||||
: terminal || isDone
|
||||
? 'var(--ok)'
|
||||
: 'var(--accent)',
|
||||
}}
|
||||
@@ -211,11 +218,11 @@ export const NominationCard = ({
|
||||
<CtpChat
|
||||
nomination={nomination}
|
||||
actorId={actorId}
|
||||
disabled={isStarted}
|
||||
disabled={terminal}
|
||||
onSend={text => actions.sendMessage(nomination.id, text)}
|
||||
/>
|
||||
|
||||
{isCreator && !isStarted && (
|
||||
{isCreator && !terminal && (
|
||||
confirmCancel
|
||||
? (
|
||||
<div className="ctp-cancel-confirm">
|
||||
@@ -283,15 +290,19 @@ const CardActions = ({
|
||||
const isMe = myStatus !== undefined;
|
||||
const isCreator = nomination.creatorId === actorId;
|
||||
const isDone = nomination.state === 'done';
|
||||
const isStarted = nomination.state === 'started';
|
||||
const terminal = isTerminal(nomination);
|
||||
const isExpired = statusOf(nomination, now) === 'expired';
|
||||
const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !isStarted;
|
||||
const scheduled = phaseOf(nomination, now) === 'scheduled' && !isDone && !terminal;
|
||||
const readyCount = readyCountOf(nomination, now);
|
||||
|
||||
if (isStarted) {
|
||||
if (terminal) {
|
||||
return (
|
||||
<div className="ctp-note ctp-note-launch">
|
||||
{game ? `Launching ${game.name}…` : 'The match is starting.'}
|
||||
<div className="ctp-note">
|
||||
{nomination.state === 'running'
|
||||
? game
|
||||
? `${game.name} is running.`
|
||||
: 'The match is running.'
|
||||
: 'This call was cancelled.'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -331,7 +342,7 @@ const CardActions = ({
|
||||
onClick={() => void actions.startNow(nomination.id).then(accepted => {
|
||||
if (accepted && game) onLaunch(game);
|
||||
})}
|
||||
><Icon.play /><span>{game ? 'Start now' : 'Mark as started'}</span></button>
|
||||
><Icon.play /><span>{game ? 'Start now' : 'Mark as running'}</span></button>
|
||||
<button className="ghost-btn" onClick={() => actions.addTime(nomination.id)}>
|
||||
Add 5 more minutes
|
||||
</button>
|
||||
|
||||
@@ -2,7 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, UnlistenFn } from '@tauri-apps/api/event';
|
||||
|
||||
import { callToPlayEvent, reduceCallToPlayEvents } from '../lib/callToPlay';
|
||||
import {
|
||||
callToPlayEvent,
|
||||
pruneCallToPlayEvents,
|
||||
reduceCallToPlayEvents,
|
||||
} from '../lib/callToPlay';
|
||||
import { CallToPlayAction, CallToPlayEvent, Nomination } from '../lib/types';
|
||||
|
||||
export interface CallToPlayActions {
|
||||
@@ -50,7 +54,11 @@ export const useCallToPlay = (username: string): UseCallToPlay => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1_000);
|
||||
const timer = window.setInterval(() => {
|
||||
const current = Date.now();
|
||||
setNow(current);
|
||||
setEvents(previous => pruneCallToPlayEvents(previous, current));
|
||||
}, 1_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -6,14 +6,20 @@ import {
|
||||
} from './types';
|
||||
|
||||
export const CHECKIN_LEAD_MS = 15 * 60_000;
|
||||
export const STARTED_RETENTION_MS = 3_000;
|
||||
export const EXPIRED_RETENTION_MS = 5 * 60_000;
|
||||
export const TERMINAL_RETENTION_MS = 15 * 60_000;
|
||||
|
||||
export type CallToPlayPhase = 'now' | 'scheduled' | 'checkin';
|
||||
export type CallToPlayStatus = 'started' | 'expired' | 'ready' | 'soon' | 'scheduled' | 'call';
|
||||
export type CallToPlayStatus =
|
||||
| 'running'
|
||||
| 'cancelled'
|
||||
| 'expired'
|
||||
| 'ready'
|
||||
| 'soon'
|
||||
| 'scheduled'
|
||||
| 'call';
|
||||
|
||||
interface MutableNomination extends Nomination {
|
||||
cancelled: boolean;
|
||||
messageIds: Set<string>;
|
||||
}
|
||||
|
||||
@@ -57,8 +63,30 @@ export const inCountOf = (nomination: Nomination, now: number): number =>
|
||||
!isReady(participant, now) && participant.status === 'in'
|
||||
).length;
|
||||
|
||||
export const isTerminal = (nomination: Nomination): boolean =>
|
||||
nomination.state === 'running' || nomination.state === 'cancelled';
|
||||
|
||||
export const activeCallCount = (nominations: ReadonlyArray<Nomination>): number =>
|
||||
nominations.filter(nomination => !isTerminal(nomination)).length;
|
||||
|
||||
export const sortNominations = (
|
||||
nominations: ReadonlyArray<Nomination>,
|
||||
): Nomination[] => [...nominations].sort((left, right) => {
|
||||
const terminalRank = Number(isTerminal(left)) - Number(isTerminal(right));
|
||||
if (terminalRank !== 0) return terminalRank;
|
||||
if (isTerminal(left) && isTerminal(right)) {
|
||||
return (right.terminalAt ?? 0) - (left.terminalAt ?? 0)
|
||||
|| left.id.localeCompare(right.id);
|
||||
}
|
||||
return left.deadline - right.deadline
|
||||
|| right.createdAt - left.createdAt
|
||||
|| left.id.localeCompare(right.id);
|
||||
});
|
||||
|
||||
export const statusOf = (nomination: Nomination, now: number): CallToPlayStatus => {
|
||||
if (nomination.state === 'started') return 'started';
|
||||
if (nomination.state === 'running' || nomination.state === 'cancelled') {
|
||||
return nomination.state;
|
||||
}
|
||||
if (now >= nomination.deadline) return 'expired';
|
||||
if (nomination.state === 'done' || readyCountOf(nomination, now) >= nomination.maxPlayers) {
|
||||
return 'ready';
|
||||
@@ -71,6 +99,39 @@ export const reduceCallToPlayEvents = (
|
||||
input: ReadonlyArray<CallToPlayEvent>,
|
||||
now: number,
|
||||
): Nomination[] => {
|
||||
const nominations = [...groupEvents(input).values()]
|
||||
.map(events => deriveNomination(events, now))
|
||||
.filter((nomination): nomination is Nomination => nomination !== null);
|
||||
return sortNominations(nominations);
|
||||
};
|
||||
|
||||
export const pruneCallToPlayEvents = (
|
||||
previous: ReadonlyMap<string, CallToPlayEvent>,
|
||||
now: number,
|
||||
): ReadonlyMap<string, CallToPlayEvent> => {
|
||||
const retiredEventIds = new Set<string>();
|
||||
for (const events of groupEvents([...previous.values()]).values()) {
|
||||
if (deriveNomination(events, now) !== null) continue;
|
||||
|
||||
const hasCreate = events.some(event => createPayload(event.action) !== null);
|
||||
const expiredTombstone = events.some(event =>
|
||||
(event.action === 'Start' || event.action === 'Cancel')
|
||||
&& now - event.at > TERMINAL_RETENTION_MS
|
||||
);
|
||||
if (hasCreate || expiredTombstone) {
|
||||
for (const event of events) retiredEventIds.add(event.id);
|
||||
}
|
||||
}
|
||||
if (retiredEventIds.size === 0) return previous;
|
||||
|
||||
const next = new Map(previous);
|
||||
for (const eventId of retiredEventIds) next.delete(eventId);
|
||||
return next;
|
||||
};
|
||||
|
||||
const groupEvents = (
|
||||
input: ReadonlyArray<CallToPlayEvent>,
|
||||
): Map<string, CallToPlayEvent[]> => {
|
||||
const unique = new Map(input.map(event => [event.id, event]));
|
||||
const byCall = new Map<string, CallToPlayEvent[]>();
|
||||
for (const event of unique.values()) {
|
||||
@@ -78,67 +139,63 @@ export const reduceCallToPlayEvents = (
|
||||
events.push(event);
|
||||
byCall.set(event.call_id, events);
|
||||
}
|
||||
return byCall;
|
||||
};
|
||||
|
||||
const nominations: Nomination[] = [];
|
||||
for (const events of byCall.values()) {
|
||||
events.sort(compareEvents);
|
||||
const create = events.find(event => createPayload(event.action) !== null);
|
||||
if (!create) continue;
|
||||
const payload = createPayload(create.action);
|
||||
if (!payload) continue;
|
||||
const deriveNomination = (
|
||||
events: CallToPlayEvent[],
|
||||
now: number,
|
||||
): Nomination | null => {
|
||||
events.sort(compareEvents);
|
||||
const create = events.find(event => createPayload(event.action) !== null);
|
||||
if (!create) return null;
|
||||
const payload = createPayload(create.action);
|
||||
if (!payload) return null;
|
||||
|
||||
const nomination: MutableNomination = {
|
||||
id: create.call_id,
|
||||
gameId: payload.game_id,
|
||||
creatorId: create.actor_id,
|
||||
creator: create.actor_name,
|
||||
maxPlayers: payload.max_players,
|
||||
createdAt: create.at,
|
||||
scheduledFor: payload.scheduled_for,
|
||||
deadline: payload.deadline,
|
||||
participants: {
|
||||
[create.actor_id]: {
|
||||
name: create.actor_name,
|
||||
status: payload.scheduled_for === null ? 'ready' : 'in',
|
||||
joinedAt: create.at,
|
||||
},
|
||||
const nomination: MutableNomination = {
|
||||
id: create.call_id,
|
||||
gameId: payload.game_id,
|
||||
creatorId: create.actor_id,
|
||||
creator: create.actor_name,
|
||||
maxPlayers: payload.max_players,
|
||||
createdAt: create.at,
|
||||
scheduledFor: payload.scheduled_for,
|
||||
deadline: payload.deadline,
|
||||
participants: {
|
||||
[create.actor_id]: {
|
||||
name: create.actor_name,
|
||||
status: payload.scheduled_for === null ? 'ready' : 'in',
|
||||
joinedAt: create.at,
|
||||
},
|
||||
messages: [],
|
||||
state: 'open',
|
||||
cancelled: false,
|
||||
messageIds: new Set(),
|
||||
};
|
||||
},
|
||||
messages: [],
|
||||
state: 'open',
|
||||
terminalAt: null,
|
||||
messageIds: new Set(),
|
||||
};
|
||||
|
||||
for (const event of events) {
|
||||
if (compareEvents(event, create) <= 0 || nomination.cancelled) continue;
|
||||
applyEvent(nomination, event);
|
||||
}
|
||||
|
||||
if (nomination.cancelled) continue;
|
||||
if (nomination.state === 'open'
|
||||
&& (readyCountOf(nomination, now) >= nomination.maxPlayers || now >= nomination.deadline)
|
||||
) {
|
||||
nomination.state = 'done';
|
||||
}
|
||||
if (nomination.state === 'started'
|
||||
&& now - (nomination.startedAt ?? now) > STARTED_RETENTION_MS
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (nomination.state !== 'started'
|
||||
&& now - nomination.deadline > EXPIRED_RETENTION_MS
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { cancelled: _, messageIds: __, ...result } = nomination;
|
||||
nominations.push(result);
|
||||
for (const event of events) {
|
||||
if (compareEvents(event, create) > 0) applyEvent(nomination, event);
|
||||
}
|
||||
|
||||
return nominations.sort((a, b) => b.createdAt - a.createdAt || a.id.localeCompare(b.id));
|
||||
if (nomination.state === 'open'
|
||||
&& (readyCountOf(nomination, now) >= nomination.maxPlayers || now >= nomination.deadline)
|
||||
) {
|
||||
nomination.state = 'done';
|
||||
}
|
||||
if (isTerminal(nomination)) {
|
||||
if (now - (nomination.terminalAt ?? now) > TERMINAL_RETENTION_MS) return null;
|
||||
} else if (now - nomination.deadline > EXPIRED_RETENTION_MS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { messageIds: _, ...result } = nomination;
|
||||
return result;
|
||||
};
|
||||
|
||||
const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void => {
|
||||
if (isTerminal(nomination)) return;
|
||||
|
||||
const action = event.action;
|
||||
if (typeof action === 'string') {
|
||||
applyUnitAction(nomination, event, action);
|
||||
@@ -147,7 +204,6 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
|
||||
|
||||
const response = respondPayload(action);
|
||||
if (response) {
|
||||
if (nomination.state === 'started') return;
|
||||
const existing = nomination.participants[event.actor_id];
|
||||
nomination.participants[event.actor_id] = {
|
||||
name: event.actor_name,
|
||||
@@ -159,7 +215,7 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
|
||||
}
|
||||
|
||||
const message = messagePayload(action);
|
||||
if (message && nomination.state !== 'started' && !nomination.messageIds.has(message.message_id)) {
|
||||
if (message && !nomination.messageIds.has(message.message_id)) {
|
||||
nomination.messageIds.add(message.message_id);
|
||||
nomination.messages.push({
|
||||
id: message.message_id,
|
||||
@@ -175,7 +231,6 @@ const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void
|
||||
const extension = addTimePayload(action);
|
||||
if (extension
|
||||
&& event.actor_id === nomination.creatorId
|
||||
&& nomination.state !== 'started'
|
||||
) {
|
||||
nomination.deadline = extension.deadline;
|
||||
nomination.state = 'open';
|
||||
@@ -189,7 +244,6 @@ const applyUnitAction = (
|
||||
): void => {
|
||||
switch (action) {
|
||||
case 'Rsvp': {
|
||||
if (nomination.state === 'started') return;
|
||||
const existing = nomination.participants[event.actor_id];
|
||||
nomination.participants[event.actor_id] = {
|
||||
name: event.actor_name,
|
||||
@@ -199,19 +253,20 @@ const applyUnitAction = (
|
||||
break;
|
||||
}
|
||||
case 'Leave':
|
||||
if (event.actor_id !== nomination.creatorId && nomination.state !== 'started') {
|
||||
if (event.actor_id !== nomination.creatorId) {
|
||||
delete nomination.participants[event.actor_id];
|
||||
}
|
||||
break;
|
||||
case 'Cancel':
|
||||
if (event.actor_id === nomination.creatorId && nomination.state !== 'started') {
|
||||
nomination.cancelled = true;
|
||||
if (event.actor_id === nomination.creatorId) {
|
||||
nomination.state = 'cancelled';
|
||||
nomination.terminalAt = event.at;
|
||||
}
|
||||
break;
|
||||
case 'Start':
|
||||
if (event.actor_id === nomination.creatorId && nomination.state !== 'started') {
|
||||
nomination.state = 'started';
|
||||
nomination.startedAt = event.at;
|
||||
if (event.actor_id === nomination.creatorId) {
|
||||
nomination.state = 'running';
|
||||
nomination.terminalAt = event.at;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -113,8 +113,8 @@ export interface Nomination {
|
||||
deadline: number;
|
||||
participants: Record<string, CallToPlayParticipant>;
|
||||
messages: CallToPlayMessage[];
|
||||
state: 'open' | 'done' | 'started';
|
||||
startedAt?: number;
|
||||
state: 'open' | 'done' | 'running' | 'cancelled';
|
||||
terminalAt: number | null;
|
||||
}
|
||||
|
||||
export type CallToPlayAction =
|
||||
|
||||
@@ -1920,7 +1920,9 @@
|
||||
}
|
||||
.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-terminal { opacity: 0.72; }
|
||||
.ctp-card.is-running { border-color: color-mix(in srgb, var(--ok) 35%, var(--bd-2)); }
|
||||
.ctp-card.is-cancelled { border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-2)); }
|
||||
.ctp-card.is-focused { border-color: var(--accent); animation: ctp-cardflash 1.4s ease-out 1; }
|
||||
@keyframes ctp-cardflash {
|
||||
0% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent); }
|
||||
@@ -2213,7 +2215,7 @@
|
||||
/* ─── Quick-bar status variants — LED + label + row tint per status:
|
||||
SCHEDULED (neutral) · CALL TO PLAY (accent, pulsing) ·
|
||||
STARTING SOON (amber, glowing) · READY (green, steady) ·
|
||||
TIME'S UP (red, steady) ─── */
|
||||
TIME'S UP (red, steady) · RUNNING / CANCELLED (muted receipts) ─── */
|
||||
.ctp-ticker[data-status="scheduled"] {
|
||||
background: var(--bg-2);
|
||||
border-color: var(--bd-2);
|
||||
@@ -2240,10 +2242,22 @@
|
||||
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[data-status="running"] {
|
||||
background: color-mix(in srgb, var(--ok) 5%, var(--bg-2));
|
||||
border-color: color-mix(in srgb, var(--ok) 25%, var(--bd-2));
|
||||
}
|
||||
.ctp-ticker[data-status="running"]:hover { background: color-mix(in srgb, var(--ok) 9%, var(--bg-2)); }
|
||||
.ctp-ticker[data-status="cancelled"] {
|
||||
background: color-mix(in srgb, var(--danger) 4%, var(--bg-2));
|
||||
border-color: color-mix(in srgb, var(--danger) 22%, var(--bd-2));
|
||||
}
|
||||
.ctp-ticker[data-status="cancelled"]:hover { background: color-mix(in srgb, var(--danger) 8%, 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; }
|
||||
.ctp-ticker-dot[data-status="running"] { background: var(--ok); animation: none; box-shadow: none; opacity: 0.75; }
|
||||
.ctp-ticker-dot[data-status="cancelled"] { background: var(--danger); animation: none; box-shadow: none; opacity: 0.65; }
|
||||
@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; }
|
||||
@@ -2253,9 +2267,13 @@
|
||||
.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-label[data-status="running"] { color: color-mix(in srgb, var(--ok) 75%, var(--t-2)); }
|
||||
.ctp-ticker-label[data-status="cancelled"] { color: color-mix(in srgb, var(--danger) 70%, var(--t-2)); }
|
||||
.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); }
|
||||
.ctp-ticker[data-status="running"] .ctp-ticker-cta,
|
||||
.ctp-ticker[data-status="cancelled"] .ctp-ticker-cta { color: var(--t-2); }
|
||||
|
||||
/* ─── Quick-bar inline chat preview ─── */
|
||||
.ctp-ticker-chat {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import {
|
||||
CHECKIN_LEAD_MS,
|
||||
EXPIRED_RETENTION_MS,
|
||||
TERMINAL_RETENTION_MS,
|
||||
activeCallCount,
|
||||
phaseOf,
|
||||
bumpTime,
|
||||
normalizeTimeInput,
|
||||
pruneCallToPlayEvents,
|
||||
readyCountOf,
|
||||
reduceCallToPlayEvents,
|
||||
sortNominations,
|
||||
statusOf,
|
||||
} from '../src/lib/callToPlay.ts';
|
||||
import { type CallToPlayAction, type CallToPlayEvent } from '../src/lib/types.ts';
|
||||
@@ -148,13 +152,15 @@ Deno.test('creator can extend, start, and cancel a call', () => {
|
||||
create(),
|
||||
event('start', 'Alice', 'Start', NOW + 1),
|
||||
], NOW + 2)[0];
|
||||
assertEquals(started.state, 'started', 'creator start');
|
||||
assertEquals(started.state, 'running', 'creator start');
|
||||
assertEquals(started.terminalAt, NOW + 1, 'running timestamp');
|
||||
|
||||
const cancelled = reduceCallToPlayEvents([
|
||||
const [cancelled] = reduceCallToPlayEvents([
|
||||
create(),
|
||||
event('cancel', 'Alice', 'Cancel', NOW + 1),
|
||||
], NOW + 2);
|
||||
assertEquals(cancelled.length, 0, 'creator cancel removes call');
|
||||
assertEquals(cancelled.state, 'cancelled', 'creator cancel');
|
||||
assertEquals(cancelled.terminalAt, NOW + 1, 'cancel timestamp');
|
||||
});
|
||||
|
||||
Deno.test('reduction is order-independent and deduplicates events and messages', () => {
|
||||
@@ -181,10 +187,75 @@ Deno.test('actions timestamped before creation cannot mutate a call', () => {
|
||||
assertEquals(Object.keys(nomination.participants).length, 1, 'pre-creation response ignored');
|
||||
});
|
||||
|
||||
Deno.test('started calls expire from local presentation history', () => {
|
||||
const events = [create(), event('start', 'Alice', 'Start', NOW + 1)];
|
||||
assertEquals(reduceCallToPlayEvents(events, NOW + 2).length, 1, 'fresh started call remains');
|
||||
assertEquals(reduceCallToPlayEvents(events, NOW + 5_000).length, 0, 'old started call expires');
|
||||
Deno.test('terminal calls retain complete read-only history for fifteen minutes', () => {
|
||||
const events = [
|
||||
create(),
|
||||
event('join', 'Bob', { Respond: { ready_at: null } }, NOW + 1),
|
||||
event('message', 'Bob', {
|
||||
SendMessage: { message_id: 'message-1', text: 'Launching' },
|
||||
}, NOW + 2),
|
||||
event('start', 'Alice', 'Start', NOW + 3),
|
||||
event('late-message', 'Bob', {
|
||||
SendMessage: { message_id: 'message-2', text: 'Too late' },
|
||||
}, NOW + 4),
|
||||
];
|
||||
const [running] = reduceCallToPlayEvents(events, NOW + TERMINAL_RETENTION_MS);
|
||||
assertEquals(running.state, 'running', 'running receipt remains');
|
||||
assertEquals(Object.keys(running.participants).length, 2, 'terminal roster retained');
|
||||
assertEquals(running.messages.length, 1, 'pre-terminal chat retained');
|
||||
assertEquals(
|
||||
reduceCallToPlayEvents(events, NOW + 3 + TERMINAL_RETENTION_MS + 1).length,
|
||||
0,
|
||||
'terminal receipt retires after display window',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('terminal calls sort last and do not contribute to the badge', () => {
|
||||
const active = reduceCallToPlayEvents([create()], NOW)[0];
|
||||
const running = reduceCallToPlayEvents([
|
||||
create(),
|
||||
event('start', 'Alice', 'Start', NOW + 1),
|
||||
], NOW + 2)[0];
|
||||
const cancelled = reduceCallToPlayEvents([
|
||||
create(),
|
||||
event('cancel', 'Alice', 'Cancel', NOW + 2),
|
||||
], NOW + 3)[0];
|
||||
|
||||
const sorted = sortNominations([running, cancelled, active]);
|
||||
assertEquals(sorted[0].state, 'open', 'actionable call sorts first');
|
||||
assertEquals(activeCallCount(sorted), 1, 'terminal calls excluded from badge');
|
||||
assertEquals(statusOf(running, NOW + 2), 'running', 'running ticker status');
|
||||
assertEquals(statusOf(cancelled, NOW + 3), 'cancelled', 'cancelled ticker status');
|
||||
});
|
||||
|
||||
Deno.test('retired calls are pruned from the frontend raw event map', () => {
|
||||
const terminalEvents = [
|
||||
create(),
|
||||
event('start', 'Alice', 'Start', NOW + 1),
|
||||
];
|
||||
const map = new Map(terminalEvents.map(item => [item.id, item]));
|
||||
|
||||
assertEquals(
|
||||
pruneCallToPlayEvents(map, NOW + 1 + TERMINAL_RETENTION_MS).size,
|
||||
2,
|
||||
'visible terminal history stays cached',
|
||||
);
|
||||
assertEquals(
|
||||
pruneCallToPlayEvents(map, NOW + 1 + TERMINAL_RETENTION_MS + 1).size,
|
||||
0,
|
||||
'retired terminal history is pruned',
|
||||
);
|
||||
|
||||
const tombstone = event('terminal-only', 'Alice', 'Cancel', NOW + 1);
|
||||
const tombstoneMap = new Map([[tombstone.id, tombstone]]);
|
||||
assertEquals(
|
||||
pruneCallToPlayEvents(
|
||||
tombstoneMap,
|
||||
NOW + 1 + TERMINAL_RETENTION_MS + 1,
|
||||
).size,
|
||||
0,
|
||||
'backend tombstone is pruned too',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('scheduled time input accepts design formats and wraps steppers', () => {
|
||||
|
||||
Reference in New Issue
Block a user