237 lines
7.4 KiB
TypeScript
237 lines
7.4 KiB
TypeScript
/**
|
||
* 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<string, string> = {};
|
||
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<string, number>)[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);
|
||
}
|
||
}
|