SPB Git

Repository Public

slots.ts 12.5 KB 257 lines typescript

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
Files main
Menu

Theme