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
308 lines
11 KiB
TypeScript
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')}`;
|
|
};
|