rspace-online/modules/rschedule/lib/availability.ts

237 lines
7.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 15120). */
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);
}
}