176 lines
5.8 KiB
TypeScript
176 lines
5.8 KiB
TypeScript
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.",
|
||
});
|
||
}
|