@@ -0,0 +1,175 @@
|
||||
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.",
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user