Repository Public
1/**2 * Availability engine — pure, no I/O. Everything the public page, the request form, the approval and the3 * rescheduling paths know about "is this time free?" comes from here.4 *5 * Model: opening hours are wall-clock (minutes of the day, in the owner's IANA zone); what is taken is a set of6 * instants. For each local date the wall-clock windows are converted to instants, merged, reduced by closures and7 * then cut into slots by ELAPSED time, so a slot always lasts exactly `durationMin` real minutes, including across8 * a DST transition (the skipped hour simply does not exist in the window; the repeated hour is real time).9 */1011export type Ms = number;12export interface Interval { start: Ms; end: Ms }1314export interface WeeklyRule { weekday: number; startMinute: number; endMinute: number }1516export type ExceptionInput =17 | { kind: "open" | "closed"; mode: "range"; startsLocal: string; endsLocal: string; allDay?: boolean }18 | { kind: "open" | "closed"; mode: "weekly"; weekdays: number[]; startMinute: number; durationMin: number; validFrom: string; validUntil?: string | null };1920/** Something that occupies time. Buffers apply only to appointments (confirmed bookings and held requests). */21export interface Taken { start: Ms; end: Ms; bufferBeforeMin?: number; bufferAfterMin?: number }2223/** An appointment that counts towards the daily / weekly limits of the type being computed. */24export interface Counted { start: Ms }2526export interface SlotParams {27 timezone: string;28 durationMin: number;29 slotStepMin: number;30 bufferBeforeMin: number;31 bufferAfterMin: number;32 minNoticeMin: number;33 horizonDays: number;34 dailyLimit?: number | null;35 weeklyLimit?: number | null;36}3738export interface EngineInput {39 params: SlotParams;40 rules: WeeklyRule[];41 exceptions: ExceptionInput[];42 taken: Taken[];43 counted: Counted[];44 now: Ms;45 /** Inclusive local dates (YYYY-MM-DD) in the owner's zone. */46 fromDate: string;47 toDate: string;48}4950export interface Slot { start: Ms; end: Ms }5152const MIN = 60_000;5354// ── Wall-clock ↔ instant ────────────────────────────────────────────────────────────────────────────5556/**57 * Instant of a wall-clock time. A time skipped by a DST jump resolves to the first instant after the gap58 * ("compatible" shifts forward); a repeated time resolves to its first occurrence, so a window that ends in the59 * repeated hour is never longer than what the owner wrote.60 */61export function wallToMs(tz: string, date: Temporal.PlainDate, minuteOfDay: number): Ms {62 const day = date.add({ days: Math.floor(minuteOfDay / 1440) });63 const m = ((minuteOfDay % 1440) + 1440) % 1440;64 const pdt = day.toPlainDateTime({ hour: Math.floor(m / 60), minute: m % 60 });65 return pdt.toZonedDateTime(tz, { disambiguation: "compatible" }).epochMilliseconds;66}6768export function localDateTimeToMs(tz: string, local: string): Ms {69 return Temporal.PlainDateTime.from(local).toZonedDateTime(tz, { disambiguation: "compatible" }).epochMilliseconds;70}7172export function msToLocalDate(tz: string, ms: Ms): Temporal.PlainDate {73 return Temporal.Instant.fromEpochMilliseconds(ms).toZonedDateTimeISO(tz).toPlainDate();74}7576/** ISO week key (year-week) of an instant, in the owner's zone: weekly limits follow the local Monday–Sunday week. */77export function localWeekKey(tz: string, ms: Ms): string {78 const d = msToLocalDate(tz, ms);79 return `${d.yearOfWeek}-W${String(d.weekOfYear).padStart(2, "0")}`;80}8182// ── Interval algebra ────────────────────────────────────────────────────────────────────────────────8384export function mergeIntervals(list: Interval[]): Interval[] {85 const sorted = list.filter((i) => i.end > i.start).sort((a, b) => a.start - b.start);86 const out: Interval[] = [];87 for (const i of sorted) {88 const last = out[out.length - 1];89 if (last && i.start <= last.end) last.end = Math.max(last.end, i.end);90 else out.push({ ...i });91 }92 return out;93}9495/** a − b, both merged and sorted. */96export function subtractIntervals(a: Interval[], b: Interval[]): Interval[] {97 const out: Interval[] = [];98 for (const base of a) {99 let pieces: Interval[] = [base];100 for (const cut of b) {101 if (cut.end <= base.start || cut.start >= base.end) continue;102 const next: Interval[] = [];103 for (const p of pieces) {104 if (cut.end <= p.start || cut.start >= p.end) { next.push(p); continue; }105 if (cut.start > p.start) next.push({ start: p.start, end: cut.start });106 if (cut.end < p.end) next.push({ start: cut.end, end: p.end });107 }108 pieces = next;109 }110 out.push(...pieces);111 }112 return out;113}114115// ── Exceptions ──────────────────────────────────────────────────────────────────────────────────────116117/** Instants covered by exceptions of one kind between two local dates (inclusive), padded by a day on each side. */118export function exceptionIntervals(tz: string, exceptions: ExceptionInput[], kind: "open" | "closed", from: Temporal.PlainDate, to: Temporal.PlainDate): Interval[] {119 const out: Interval[] = [];120 const lo = from.subtract({ days: 1 });121 const hi = to.add({ days: 1 });122 for (const e of exceptions) {123 if (e.kind !== kind) continue;124 if (e.mode === "range") {125 let s = Temporal.PlainDateTime.from(e.startsLocal);126 let en = Temporal.PlainDateTime.from(e.endsLocal);127 if (e.allDay) {128 // Whole days. Stored with an exclusive end at 00:00 (17 → 20 closes the 17th, 18th and 19th); an end129 // that is not midnight is rounded up to the end of its day.130 s = s.toPlainDate().toPlainDateTime();131 en = en.toPlainTime().equals(new Temporal.PlainTime()) ? en : en.toPlainDate().add({ days: 1 }).toPlainDateTime();132 }133 out.push({134 start: s.toZonedDateTime(tz, { disambiguation: "compatible" }).epochMilliseconds,135 end: en.toZonedDateTime(tz, { disambiguation: "compatible" }).epochMilliseconds,136 });137 } else {138 const validFrom = Temporal.PlainDate.from(e.validFrom);139 const validUntil = e.validUntil ? Temporal.PlainDate.from(e.validUntil) : null;140 for (let d = lo; Temporal.PlainDate.compare(d, hi) <= 0; d = d.add({ days: 1 })) {141 if (!e.weekdays.includes(d.dayOfWeek)) continue;142 if (Temporal.PlainDate.compare(d, validFrom) < 0) continue;143 if (validUntil && Temporal.PlainDate.compare(d, validUntil) > 0) continue;144 // start + duration in wall-clock minutes: may run past midnight into the next day.145 out.push({ start: wallToMs(tz, d, e.startMinute), end: wallToMs(tz, d, e.startMinute + e.durationMin) });146 }147 }148 }149 return mergeIntervals(out);150}151152/** Open time between two local dates: weekly rules ∪ extra openings − closures. */153export function openWindows(tz: string, rules: WeeklyRule[], exceptions: ExceptionInput[], from: Temporal.PlainDate, to: Temporal.PlainDate): Interval[] {154 const weekly: Interval[] = [];155 for (let d = from; Temporal.PlainDate.compare(d, to) <= 0; d = d.add({ days: 1 })) {156 for (const r of rules) {157 if (r.weekday !== d.dayOfWeek) continue;158 weekly.push({ start: wallToMs(tz, d, r.startMinute), end: wallToMs(tz, d, r.endMinute) });159 }160 }161 const dayStart = wallToMs(tz, from, 0);162 const dayEnd = wallToMs(tz, to, 1440);163 const opened = exceptionIntervals(tz, exceptions, "open", from, to);164 const closed = exceptionIntervals(tz, exceptions, "closed", from, to);165 const all = mergeIntervals([...weekly, ...opened]);166 return subtractIntervals(all, closed)167 .map((i) => ({ start: Math.max(i.start, dayStart), end: Math.min(i.end, dayEnd) }))168 .filter((i) => i.end > i.start);169}170171// ── Conflicts ───────────────────────────────────────────────────────────────────────────────────────172173/**174 * Does [start, end) with the type's buffers collide with something taken? Buffers do not stack: between two175 * appointments the gap must be at least the larger of "after the earlier one" and "before the later one".176 */177export function collides(start: Ms, end: Ms, bufferBeforeMin: number, bufferAfterMin: number, taken: Taken[]): boolean {178 for (const t of taken) {179 const gapIfAfter = Math.max(bufferBeforeMin, t.bufferAfterMin ?? 0) * MIN; // candidate comes after t180 const gapIfBefore = Math.max(bufferAfterMin, t.bufferBeforeMin ?? 0) * MIN; // candidate comes before t181 if (start < t.end + gapIfAfter && end + gapIfBefore > t.start) return true;182 }183 return false;184}185186// ── Slots ───────────────────────────────────────────────────────────────────────────────────────────187188export interface LimitState { perDay: Map<string, number>; perWeek: Map<string, number> }189190export function limitState(tz: string, counted: Counted[]): LimitState {191 const perDay = new Map<string, number>();192 const perWeek = new Map<string, number>();193 for (const c of counted) {194 const day = msToLocalDate(tz, c.start).toString();195 const week = localWeekKey(tz, c.start);196 perDay.set(day, (perDay.get(day) ?? 0) + 1);197 perWeek.set(week, (perWeek.get(week) ?? 0) + 1);198 }199 return { perDay, perWeek };200}201202function withinLimits(p: SlotParams, limits: LimitState, start: Ms): boolean {203 if (p.dailyLimit != null && (limits.perDay.get(msToLocalDate(p.timezone, start).toString()) ?? 0) >= p.dailyLimit) return false;204 if (p.weeklyLimit != null && (limits.perWeek.get(localWeekKey(p.timezone, start)) ?? 0) >= p.weeklyLimit) return false;205 return true;206}207208export function computeSlots(input: EngineInput): Slot[] {209 const { params: p, now } = input;210 const from = Temporal.PlainDate.from(input.fromDate);211 const to = Temporal.PlainDate.from(input.toDate);212 if (Temporal.PlainDate.compare(to, from) < 0) return [];213 const earliest = now + p.minNoticeMin * MIN;214 const latest = now + p.horizonDays * 1440 * MIN;215 const windows = openWindows(p.timezone, input.rules, input.exceptions, from, to);216 const limits = limitState(p.timezone, input.counted);217 const dur = p.durationMin * MIN;218 const step = p.slotStepMin * MIN;219 const out: Slot[] = [];220 for (const w of windows) {221 for (let s = w.start; s + dur <= w.end; s += step) {222 if (s < earliest || s > latest) continue;223 if (!withinLimits(p, limits, s)) continue;224 if (collides(s, s + dur, p.bufferBeforeMin, p.bufferAfterMin, input.taken)) continue;225 out.push({ start: s, end: s + dur });226 }227 }228 return out;229}230231export type SlotRefusal = "past" | "too_soon" | "too_far" | "closed" | "taken" | "limit" | "misaligned";232233/**234 * Re-check of one precise slot (submission, approval, rescheduling). `enforceNotice: false` is for the owner, who235 * may confirm inside the notice period and outside the horizon, but never in the past, never on a closed period236 * unless `allowOutsideHours`, and never on taken time.237 */238export function checkSlot(input: Omit<EngineInput, "fromDate" | "toDate">, start: Ms, opts: { enforceNotice?: boolean; allowOutsideHours?: boolean; enforceLimits?: boolean } = {}): SlotRefusal | null {239 const { params: p, now } = input;240 const enforceNotice = opts.enforceNotice ?? true;241 const end = start + p.durationMin * MIN;242 if (start <= now) return "past";243 if (enforceNotice && start < now + p.minNoticeMin * MIN) return "too_soon";244 if (enforceNotice && start > now + p.horizonDays * 1440 * MIN) return "too_far";245 if (!opts.allowOutsideHours) {246 const d0 = msToLocalDate(p.timezone, start).subtract({ days: 1 });247 const d1 = msToLocalDate(p.timezone, end).add({ days: 1 });248 const windows = openWindows(p.timezone, input.rules, input.exceptions, d0, d1);249 const w = windows.find((x) => start >= x.start && end <= x.end);250 if (!w) return "closed";251 if (enforceNotice && (start - w.start) % (p.slotStepMin * MIN) !== 0) return "misaligned";252 }253 if ((opts.enforceLimits ?? true) && !withinLimits(p, limitState(p.timezone, input.counted), start)) return "limit";254 if (collides(start, end, p.bufferBeforeMin, p.bufferAfterMin, input.taken)) return "taken";255 return null;256}257