Files
time-tools/src/time/meeting.ts
T
zemion 5e462f881d
Verify / verify (push) Canceled after 0s
Release Time Tools 0.2.0
2026-09-02 05:05:59 +02:00

176 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:0023: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 124 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.",
});
}