Files
lanspread/crates/lanspread-tauri-deno-ts/src/lib/callToPlay.ts
T
ddidderr cc7dacf6c3 fix(call-to-play): expire elapsed calls clearly
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
2026-07-21 22:56:02 +02:00

308 lines
11 KiB
TypeScript

import {
CallToPlayAction,
CallToPlayEvent,
CallToPlayParticipant,
Nomination,
} 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 type CallToPlayPhase = 'now' | 'scheduled' | 'checkin';
export type CallToPlayStatus = 'started' | 'expired' | 'ready' | 'soon' | 'scheduled' | 'call';
interface MutableNomination extends Nomination {
cancelled: boolean;
messageIds: Set<string>;
}
const compareEvents = (a: CallToPlayEvent, b: CallToPlayEvent): number =>
a.at - b.at || a.id.localeCompare(b.id);
type CreatePayload = Extract<CallToPlayAction, { Create: unknown }>['Create'];
type RespondPayload = Extract<CallToPlayAction, { Respond: unknown }>['Respond'];
type MessagePayload = Extract<CallToPlayAction, { SendMessage: unknown }>['SendMessage'];
type AddTimePayload = Extract<CallToPlayAction, { AddTime: unknown }>['AddTime'];
const createPayload = (action: CallToPlayAction): CreatePayload | null =>
typeof action === 'object' && 'Create' in action ? action.Create : null;
const respondPayload = (action: CallToPlayAction): RespondPayload | null =>
typeof action === 'object' && 'Respond' in action ? action.Respond : null;
const messagePayload = (action: CallToPlayAction): MessagePayload | null =>
typeof action === 'object' && 'SendMessage' in action ? action.SendMessage : null;
const addTimePayload = (action: CallToPlayAction): AddTimePayload | null =>
typeof action === 'object' && 'AddTime' in action ? action.AddTime : null;
export const phaseOf = (nomination: Nomination, now: number): CallToPlayPhase => {
if (nomination.scheduledFor === null) return 'now';
return now < nomination.scheduledFor - CHECKIN_LEAD_MS ? 'scheduled' : 'checkin';
};
export const isReady = (
participant: CallToPlayParticipant | undefined,
now: number,
): boolean => Boolean(
participant && (
participant.status === 'ready'
|| (participant.readyAt !== undefined && now >= participant.readyAt)
),
);
export const readyCountOf = (nomination: Nomination, now: number): number =>
Object.values(nomination.participants).filter(participant => isReady(participant, now)).length;
export const inCountOf = (nomination: Nomination, now: number): number =>
Object.values(nomination.participants).filter(participant =>
!isReady(participant, now) && participant.status === 'in'
).length;
export const statusOf = (nomination: Nomination, now: number): CallToPlayStatus => {
if (nomination.state === 'started') return 'started';
if (now >= nomination.deadline) return 'expired';
if (nomination.state === 'done' || readyCountOf(nomination, now) >= nomination.maxPlayers) {
return 'ready';
}
if (nomination.deadline - now <= CHECKIN_LEAD_MS) return 'soon';
return nomination.scheduledFor === null ? 'call' : 'scheduled';
};
export const reduceCallToPlayEvents = (
input: ReadonlyArray<CallToPlayEvent>,
now: number,
): Nomination[] => {
const unique = new Map(input.map(event => [event.id, event]));
const byCall = new Map<string, CallToPlayEvent[]>();
for (const event of unique.values()) {
const events = byCall.get(event.call_id) ?? [];
events.push(event);
byCall.set(event.call_id, events);
}
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 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(),
};
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);
}
return nominations.sort((a, b) => b.createdAt - a.createdAt || a.id.localeCompare(b.id));
};
const applyEvent = (nomination: MutableNomination, event: CallToPlayEvent): void => {
const action = event.action;
if (typeof action === 'string') {
applyUnitAction(nomination, event, action);
return;
}
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,
status: response.ready_at === null ? 'ready' : 'pending',
joinedAt: existing?.joinedAt ?? event.at,
...(response.ready_at === null ? {} : { readyAt: response.ready_at }),
};
return;
}
const message = messagePayload(action);
if (message && nomination.state !== 'started' && !nomination.messageIds.has(message.message_id)) {
nomination.messageIds.add(message.message_id);
nomination.messages.push({
id: message.message_id,
fromId: event.actor_id,
from: event.actor_name,
text: message.text,
at: event.at,
});
nomination.messages.sort((a, b) => a.at - b.at || a.id.localeCompare(b.id));
return;
}
const extension = addTimePayload(action);
if (extension
&& event.actor_id === nomination.creatorId
&& nomination.state !== 'started'
) {
nomination.deadline = extension.deadline;
nomination.state = 'open';
}
};
const applyUnitAction = (
nomination: MutableNomination,
event: CallToPlayEvent,
action: Extract<CallToPlayAction, string>,
): 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,
status: 'in',
joinedAt: existing?.joinedAt ?? event.at,
};
break;
}
case 'Leave':
if (event.actor_id !== nomination.creatorId && nomination.state !== 'started') {
delete nomination.participants[event.actor_id];
}
break;
case 'Cancel':
if (event.actor_id === nomination.creatorId && nomination.state !== 'started') {
nomination.cancelled = true;
}
break;
case 'Start':
if (event.actor_id === nomination.creatorId && nomination.state !== 'started') {
nomination.state = 'started';
nomination.startedAt = event.at;
}
break;
}
};
export const callToPlayEvent = (
callId: string,
actorId: string,
actorName: string,
action: CallToPlayAction,
at = Date.now(),
): CallToPlayEvent => ({
id: globalThis.crypto.randomUUID(),
call_id: callId,
actor_id: actorId,
actor_name: actorName,
at,
action,
});
export const formatClock = (timestamp: number): string => {
const date = new Date(timestamp);
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
};
export const formatCountdown = (milliseconds: number): string => {
if (milliseconds <= 0) return '0:00';
const total = Math.ceil(milliseconds / 1_000);
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
};
export const formatCountdownShort = (milliseconds: number): string => {
if (milliseconds <= 0) return 'now';
const total = Math.ceil(milliseconds / 1_000);
return total < 60 ? `${total}s` : `${Math.ceil(total / 60)}m`;
};
export const formatUntil = (milliseconds: number): string => {
if (milliseconds <= 60_000) return 'in <1 min';
const minutes = Math.round(milliseconds / 60_000);
if (minutes < 60) return `in ${minutes} min`;
const hours = Math.floor(minutes / 60);
const remainder = minutes % 60;
return remainder ? `in ${hours}h ${remainder}m` : `in ${hours}h`;
};
const AVATAR_COLORS = [
'#60a5fa', '#34d399', '#c084fc', '#fbbf24',
'#f472b6', '#38bdf8', '#a3e635', '#fb7185',
];
export const avatarColor = (name: string): string => {
const hash = Array.from(name).reduce((sum, character) => sum + character.charCodeAt(0), 0);
return AVATAR_COLORS[hash % AVATAR_COLORS.length];
};
export const sanitizeTimeDraft = (raw: string, previous: string): string => {
const value = raw.replace(/[^\d:]/g, '');
if ((value.match(/:/g) ?? []).length > 1) return previous;
const colon = value.indexOf(':');
if (colon !== -1) {
if (value.slice(0, colon).length > 2 || value.slice(colon + 1).length > 2) {
return previous;
}
return value;
}
return value.length > 4 ? previous : value;
};
export const normalizeTimeInput = (raw: string): string | null => {
const match = raw.trim().match(/^(\d{1,2})(?:[:.\s]?(\d{2}))?$/);
if (!match) return null;
const hours = Number(match[1]);
const minutes = Number(match[2] ?? 0);
if (hours > 23 || minutes > 59) return null;
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
};
export const bumpTime = (
time: string,
field: 'hours' | 'minutes',
delta: number,
): string => {
let [hours, minutes] = time.split(':').map(Number);
if (field === 'hours') {
hours = (hours + delta + 24) % 24;
} else {
const total = ((hours * 60 + minutes + delta) % 1_440 + 1_440) % 1_440;
hours = Math.floor(total / 60);
minutes = total % 60;
}
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
};