/** * Availability engine — port of schedule-jeffemmett/src/lib/availability.ts. * * Pure computation: takes config doc + bookings doc + a range, returns free * slots. No side effects. Two callers: * - GET /api/availability (public booking page) * - Admin dashboard preview * * Deliberately avoids date-fns / date-fns-tz to keep the module tree light — * uses Intl.DateTimeFormat for timezone math instead. */ import type { ScheduleConfigDoc, ScheduleBookingsDoc, AvailabilityRule, AvailabilityOverride, CachedGcalEvent, } from "../schemas"; export interface TimeSlot { /** UTC ISO string — acts as stable slot id. */ id: string; startUtc: string; endUtc: string; /** YYYY-MM-DD in viewer timezone. */ date: string; /** "9:00 AM" in viewer timezone. */ startDisplay: string; endDisplay: string; startMs: number; endMs: number; } export interface GetSlotsOpts { rangeStartMs: number; rangeEndMs: number; viewerTimezone: string; /** Client-selected duration override (clamped 15–120). */ durationOverride?: number; } interface Interval { startMs: number; endMs: number; } // ── Timezone helpers (no external deps) ── /** * Return the UTC ms offset to apply to a wall-clock instant in `tz` to get * the real UTC instant. Uses Intl.DateTimeFormat with `timeZoneName: 'longOffset'`. */ function tzOffsetMinutes(dateMs: number, tz: string): number { const dtf = new Intl.DateTimeFormat("en-US", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, }); const parts = dtf.formatToParts(new Date(dateMs)); const map: Record = {}; for (const p of parts) if (p.type !== "literal") map[p.type] = p.value; const asUTC = Date.UTC( Number(map.year), Number(map.month) - 1, Number(map.day), Number(map.hour === "24" ? "0" : map.hour), Number(map.minute), Number(map.second), ); return Math.round((asUTC - dateMs) / 60000); } /** Convert a wall-clock time in `tz` (y/m/d/h/m) to UTC ms. */ function wallClockToUtcMs(y: number, m: number, d: number, h: number, min: number, tz: string): number { // First pass: treat as UTC, then correct by the tz offset at that moment. const guess = Date.UTC(y, m - 1, d, h, min, 0); const off = tzOffsetMinutes(guess, tz); return guess - off * 60000; } /** Format a UTC ms in `tz` as YYYY-MM-DD. */ function fmtDate(ms: number, tz: string): string { const dtf = new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit", }); return dtf.format(new Date(ms)); } /** Format a UTC ms in `tz` as "h:mm AM/PM". */ function fmtTime(ms: number, tz: string): string { const dtf = new Intl.DateTimeFormat("en-US", { timeZone: tz, hour: "numeric", minute: "2-digit", hour12: true, }); return dtf.format(new Date(ms)); } /** Day-of-week in `tz` (0=Sun..6=Sat). */ function dayOfWeekInTz(ms: number, tz: string): number { const dtf = new Intl.DateTimeFormat("en-US", { timeZone: tz, weekday: "short" }); const short = dtf.format(new Date(ms)); return ({ Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 } as Record)[short] ?? 0; } function parseHHMM(s: string): [number, number] { const [h, m] = s.split(":").map((n) => Number(n)); return [h || 0, m || 0]; } // ── Main engine ── export function getAvailableSlots( config: ScheduleConfigDoc, bookings: ScheduleBookingsDoc, opts: GetSlotsOpts, ): TimeSlot[] { const { settings, rules: rulesMap, overrides: overridesMap, googleEvents } = config; const hostTz = settings.timezone || "UTC"; const slotMin = opts.durationOverride ? Math.max(15, Math.min(120, opts.durationOverride)) : settings.slotDurationMin; const now = Date.now(); const earliest = now + settings.minNoticeHours * 3600_000; const latest = now + settings.maxAdvanceDays * 86400_000; const startMs = Math.max(opts.rangeStartMs, earliest); const endMs = Math.min(opts.rangeEndMs, latest); if (startMs >= endMs) return []; const rules = Object.values(rulesMap).filter((r) => r.isActive); const overrides = Object.values(overridesMap); const gcalBusy: CachedGcalEvent[] = Object.values(googleEvents).filter( (e) => e.transparency === "opaque" && e.status !== "cancelled", ); // Build busy intervals from confirmed bookings + gcal cache const busy: Interval[] = []; for (const b of Object.values(bookings.bookings)) { if (b.status !== "confirmed") continue; if (b.endTime < startMs || b.startTime > endMs) continue; busy.push({ startMs: b.startTime - settings.bufferBeforeMin * 60000, endMs: b.endTime + settings.bufferAfterMin * 60000, }); } for (const g of gcalBusy) { if (g.endTime < startMs || g.startTime > endMs) continue; busy.push({ startMs: g.startTime, endMs: g.endTime }); } const slots: TimeSlot[] = []; // Walk day-by-day in host timezone const msPerDay = 86400_000; // Start at midnight in host tz of the effective range start const startDate = fmtDate(startMs, hostTz); const endDate = fmtDate(endMs, hostTz); let cursor = parseDate(startDate); const end = parseDate(endDate); while (cursor.getTime() <= end.getTime()) { const dateStr = toDateStr(cursor); const override = overrides.find((o) => o.date === dateStr); if (override?.isBlocked) { cursor = addDays(cursor, 1); continue; } // Windows for this day type Window = { startHHMM: string; endHHMM: string }; let windows: Window[] = []; if (override && override.startTime && override.endTime) { windows = [{ startHHMM: override.startTime, endHHMM: override.endTime }]; } else if (!override) { const dow = dayOfWeekInTz( wallClockToUtcMs(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, cursor.getUTCDate(), 12, 0, hostTz), hostTz, ); windows = rules .filter((r: AvailabilityRule) => r.dayOfWeek === dow) .map((r) => ({ startHHMM: r.startTime, endHHMM: r.endTime })); } for (const w of windows) { const [sh, sm] = parseHHMM(w.startHHMM); const [eh, em] = parseHHMM(w.endHHMM); const wStart = wallClockToUtcMs( cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, cursor.getUTCDate(), sh, sm, hostTz, ); const wEnd = wallClockToUtcMs( cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, cursor.getUTCDate(), eh, em, hostTz, ); let t = wStart; while (t + slotMin * 60000 <= wEnd) { const slotEnd = t + slotMin * 60000; if (t < earliest) { t += slotMin * 60000; continue; } if (t > latest) break; const conflict = busy.some((b) => t < b.endMs && slotEnd > b.startMs); if (!conflict) { slots.push({ id: new Date(t).toISOString(), startUtc: new Date(t).toISOString(), endUtc: new Date(slotEnd).toISOString(), date: fmtDate(t, opts.viewerTimezone), startDisplay: fmtTime(t, opts.viewerTimezone), endDisplay: fmtTime(slotEnd, opts.viewerTimezone), startMs: t, endMs: slotEnd, }); } t += slotMin * 60000; } } cursor = addDays(cursor, 1); } return slots; // ── local helpers ── function parseDate(s: string): Date { const [y, m, d] = s.split("-").map(Number); return new Date(Date.UTC(y, m - 1, d)); } function toDateStr(d: Date): string { const y = d.getUTCFullYear(); const m = String(d.getUTCMonth() + 1).padStart(2, "0"); const day = String(d.getUTCDate()).padStart(2, "0"); return `${y}-${m}-${day}`; } function addDays(d: Date, n: number): Date { return new Date(d.getTime() + n * msPerDay); } }