Release Time Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 05:05:59 +02:00
parent 3c82e7a305
commit 5e462f881d
30 changed files with 1830 additions and 110 deletions
+175
View File
@@ -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: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.",
});
}