import { assertBoundedText } from "@add-ideas/toolbox-helpers"; import { Temporal } from "temporal-polyfill"; import { parseInstant } from "./epoch"; export interface MeetingParticipant { readonly name: string; readonly timeZone: string; readonly workStart: string; readonly workEnd: string; readonly includeWeekends?: boolean; } export interface MeetingCandidate { readonly index: number; readonly start: string; readonly end: string; readonly participants: ReadonlyArray<{ name: string; timeZone: string; startLocal: string; endLocal: string; offset: string; }>; } export interface MeetingPlan { readonly searchedFrom: string; readonly searchedUntil: string; readonly durationMinutes: number; readonly stepMinutes: number; readonly candidates: readonly MeetingCandidate[]; readonly exhausted: boolean; readonly note: string; } function clockMinutes(valueInput: string, label: string): number { const value = assertBoundedText(valueInput, 16, label).trim(); const match = /^(\d{2}):(\d{2})$/u.exec(value); if (!match) throw new SyntaxError(`${label} must use HH:MM.`); const hours = Number(match[1]); const minutes = Number(match[2]); if (hours > 23 || minutes > 59) throw new RangeError(`${label} is outside the 00:00–23:59 range.`); return hours * 60 + minutes; } function positiveInteger( value: number, minimum: number, maximum: number, label: string, ): number { if (!Number.isSafeInteger(value) || value < minimum || value > maximum) throw new RangeError(`${label} must be between ${minimum} and ${maximum}.`); return value; } export function planMeetings(input: { start: string; days: number; durationMinutes: number; stepMinutes: number; participants: readonly MeetingParticipant[]; maxResults?: number; }): MeetingPlan { const days = positiveInteger(input.days, 1, 31, "Search days"); const durationMinutes = positiveInteger( input.durationMinutes, 5, 1_440, "Meeting duration", ); const stepMinutes = positiveInteger(input.stepMinutes, 5, 240, "Search step"); const maxResults = positiveInteger( input.maxResults ?? 24, 1, 100, "Result count", ); if (input.participants.length === 0 || input.participants.length > 24) throw new RangeError("Meeting planning requires 1–24 participants."); const participants = input.participants.map((participant, index) => { const name = assertBoundedText( participant.name || `Participant ${index + 1}`, 128, "Participant name", ).trim(); const timeZone = assertBoundedText( participant.timeZone, 128, "Participant time zone", ).trim(); Temporal.Now.instant().toZonedDateTimeISO(timeZone); const workStart = clockMinutes(participant.workStart, "Work start"); const workEnd = clockMinutes(participant.workEnd, "Work end"); if (workEnd <= workStart) throw new RangeError( `${name}'s work window must end after it starts on the same local day.`, ); return { ...participant, name, timeZone, workStart, workEnd }; }); const start = parseInstant(input.start); const stepNanoseconds = BigInt(stepMinutes) * 60_000_000_000n; const durationNanoseconds = BigInt(durationMinutes) * 60_000_000_000n; const end = start.add({ hours: days * 24 }); const remainder = start.epochNanoseconds % stepNanoseconds; let cursor = remainder === 0n ? start : Temporal.Instant.fromEpochNanoseconds( start.epochNanoseconds + stepNanoseconds - remainder, ); const candidates: MeetingCandidate[] = []; let inspected = 0; const maximumCandidates = Math.ceil((days * 24 * 60) / stepMinutes) + 1; while ( Temporal.Instant.compare(cursor, end) < 0 && inspected < maximumCandidates && candidates.length < maxResults ) { inspected += 1; const candidateEnd = Temporal.Instant.fromEpochNanoseconds( cursor.epochNanoseconds + durationNanoseconds, ); const local = participants.map((participant) => { const localStart = cursor.toZonedDateTimeISO(participant.timeZone); const localEnd = candidateEnd.toZonedDateTimeISO(participant.timeZone); const startMinutes = localStart.hour * 60 + localStart.minute; const endMinutes = localEnd.hour * 60 + localEnd.minute; const sameDay = localStart.toPlainDate().equals(localEnd.toPlainDate()); const weekdayAllowed = participant.includeWeekends === true || localStart.dayOfWeek <= 5; return { allowed: sameDay && weekdayAllowed && startMinutes >= participant.workStart && endMinutes <= participant.workEnd, name: participant.name, timeZone: participant.timeZone, startLocal: localStart.toString(), endLocal: localEnd.toString(), offset: localStart.offset, }; }); if (local.every((participant) => participant.allowed)) candidates.push({ index: candidates.length + 1, start: cursor.toString(), end: candidateEnd.toString(), participants: local.map((participant) => Object.freeze({ name: participant.name, timeZone: participant.timeZone, startLocal: participant.startLocal, endLocal: participant.endLocal, offset: participant.offset, }), ), }); cursor = Temporal.Instant.fromEpochNanoseconds( cursor.epochNanoseconds + stepNanoseconds, ); } return Object.freeze({ searchedFrom: start.toString(), searchedUntil: end.toString(), durationMinutes, stepMinutes, candidates: Object.freeze(candidates), exhausted: candidates.length < maxResults, note: "Availability is inferred only from the entered recurring local work windows. Calendars, holidays, travel, and personal availability are not consulted.", }); }