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; } const compareEvents = (a: CallToPlayEvent, b: CallToPlayEvent): number => a.at - b.at || a.id.localeCompare(b.id); type CreatePayload = Extract['Create']; type RespondPayload = Extract['Respond']; type MessagePayload = Extract['SendMessage']; type AddTimePayload = Extract['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, now: number, ): Nomination[] => { const unique = new Map(input.map(event => [event.id, event])); const byCall = new Map(); 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, ): 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')}`; };