179 lines
7.1 KiB
TypeScript
179 lines
7.1 KiB
TypeScript
// AK-P16b · slot engine (PURE, timezone-aware) — M06 Block A.6.
|
|
//
|
|
// Computes bookable start slots for a booking page on a given provider-local day.
|
|
// No Prisma / no React: plain inputs in, UTC instants out. The loader-side wrapper
|
|
// (SlotQueryService.server.ts) loads rows and pre-merges busy intervals; this file
|
|
// holds only the deterministic, unit-testable arithmetic.
|
|
//
|
|
// Per provider, for the requested day:
|
|
// windows = exceptions(date) OR weekly rules(weekday) (M06 A.4 overrides A.3)
|
|
// for each window → step by slotIncrement, slot = [t, t+duration)
|
|
// keep slot when its buffered range [t-bufferBefore, end+bufferAfter) does NOT
|
|
// overlap the provider's busy intervals (other appointments ±buffer + Google
|
|
// busy from AK-P16a availability.ts — leak-safe {from,to} only)
|
|
// AND slot.start >= now + minNoticeHours AND slot.start <= now + maxAdvanceDays
|
|
// `first_available` (v1): union all providers' slots, each slot carrying the ids of
|
|
// every provider free at that exact instant.
|
|
//
|
|
// The requested `date` is the PROVIDER-LOCAL calendar date (each provider's rules /
|
|
// exceptions carry their own IANA timezone). The public UI formats the returned UTC
|
|
// instants in the viewer's timezone.
|
|
|
|
import { fromZonedTime } from "date-fns-tz";
|
|
import { mergeBusyIntervals, overlapsBusy, type BusyInterval } from "~/modules/calendar/services/availability";
|
|
|
|
export type BookingWeekday = "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday" | "sunday";
|
|
|
|
const WEEKDAY_BY_UTC_DAY: readonly BookingWeekday[] = [
|
|
"sunday",
|
|
"monday",
|
|
"tuesday",
|
|
"wednesday",
|
|
"thursday",
|
|
"friday",
|
|
"saturday",
|
|
];
|
|
|
|
/** Weekly standard-availability window (local "HH:mm" in `timezone`). */
|
|
export interface SlotRuleInput {
|
|
readonly dayOfWeek: BookingWeekday;
|
|
readonly startTime: string;
|
|
readonly endTime: string;
|
|
readonly timezone: string;
|
|
}
|
|
|
|
/** Date-specific override. `unavailable` clears the day; `custom_window` replaces it. */
|
|
export interface SlotExceptionInput {
|
|
/** ISO calendar date `YYYY-MM-DD`. */
|
|
readonly date: string;
|
|
readonly kind: "unavailable" | "custom_window";
|
|
readonly startTime: string | null;
|
|
readonly endTime: string | null;
|
|
readonly timezone: string;
|
|
}
|
|
|
|
/** One provider's availability inputs. `busyIntervals` is pre-merged + leak-safe. */
|
|
export interface ProviderAvailabilityInput {
|
|
readonly providerUserId: string;
|
|
readonly rules: readonly SlotRuleInput[];
|
|
readonly exceptions: readonly SlotExceptionInput[];
|
|
readonly busyIntervals: readonly BusyInterval[];
|
|
}
|
|
|
|
/** Booking-page slot parameters (M06 A.1). */
|
|
export interface SlotEngineConfig {
|
|
readonly durationMinutes: number;
|
|
readonly bufferBeforeMinutes: number;
|
|
readonly bufferAfterMinutes: number;
|
|
readonly slotIncrementMinutes: number;
|
|
readonly minNoticeHours: number;
|
|
readonly maxAdvanceDays: number;
|
|
}
|
|
|
|
export interface ComputeSlotsInput {
|
|
/** Provider-local calendar date `YYYY-MM-DD`. */
|
|
readonly date: string;
|
|
/** Injected wall clock (purity / testability). */
|
|
readonly now: Date;
|
|
readonly providers: readonly ProviderAvailabilityInput[];
|
|
readonly config: SlotEngineConfig;
|
|
}
|
|
|
|
/** A free slot. `providerUserIds` = everyone free at this exact instant (v1 union). */
|
|
export interface ComputedSlot {
|
|
readonly startAt: Date;
|
|
readonly endAt: Date;
|
|
readonly providerUserIds: string[];
|
|
}
|
|
|
|
const MINUTE_MS = 60_000;
|
|
const HOUR_MS = 3_600_000;
|
|
const DAY_MS = 86_400_000;
|
|
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/u;
|
|
|
|
/** Weekday of an ISO `YYYY-MM-DD` (tz-independent — the date string has no zone). */
|
|
export function weekdayOfIsoDate(date: string): BookingWeekday {
|
|
return WEEKDAY_BY_UTC_DAY[new Date(`${date}T00:00:00Z`).getUTCDay()];
|
|
}
|
|
|
|
/** Convert a local "HH:mm" on `date` in `timezone` to a UTC instant. */
|
|
function localTimeToUtc(date: string, time: string, timezone: string): Date {
|
|
return fromZonedTime(`${date}T${time}:00`, timezone);
|
|
}
|
|
|
|
interface Window {
|
|
readonly start: Date;
|
|
readonly end: Date;
|
|
}
|
|
|
|
/** Resolve the active windows for one provider on `date` (exceptions override rules). */
|
|
function windowsForProvider(provider: ProviderAvailabilityInput, date: string): Window[] {
|
|
const dayExceptions = provider.exceptions.filter((e) => e.date === date);
|
|
if (dayExceptions.some((e) => e.kind === "unavailable")) {
|
|
return [];
|
|
}
|
|
const customWindows = dayExceptions.filter((e) => e.kind === "custom_window" && e.startTime && e.endTime);
|
|
if (customWindows.length > 0) {
|
|
return customWindows
|
|
.map((e) => ({ start: localTimeToUtc(date, e.startTime as string, e.timezone), end: localTimeToUtc(date, e.endTime as string, e.timezone) }))
|
|
.filter((w) => w.end.getTime() > w.start.getTime());
|
|
}
|
|
const weekday = weekdayOfIsoDate(date);
|
|
return provider.rules
|
|
.filter((r) => r.dayOfWeek === weekday)
|
|
.map((r) => ({ start: localTimeToUtc(date, r.startTime, r.timezone), end: localTimeToUtc(date, r.endTime, r.timezone) }))
|
|
.filter((w) => w.end.getTime() > w.start.getTime());
|
|
}
|
|
|
|
/** Compute bookable slots for one booking page on `date` across all providers. */
|
|
export function computeSlots(input: ComputeSlotsInput): ComputedSlot[] {
|
|
if (!ISO_DATE.test(input.date)) {
|
|
return [];
|
|
}
|
|
const { config, now } = input;
|
|
const earliest = now.getTime() + config.minNoticeHours * HOUR_MS;
|
|
const latest = now.getTime() + config.maxAdvanceDays * DAY_MS;
|
|
const durationMs = config.durationMinutes * MINUTE_MS;
|
|
const stepMs = Math.max(config.slotIncrementMinutes, 1) * MINUTE_MS;
|
|
const bufferBeforeMs = config.bufferBeforeMinutes * MINUTE_MS;
|
|
const bufferAfterMs = config.bufferAfterMinutes * MINUTE_MS;
|
|
|
|
// instant(ms) → set of providers free there, so the union stays deduped + stable.
|
|
const byInstant = new Map<number, { startAt: Date; endAt: Date; providers: Set<string> }>();
|
|
|
|
for (const provider of input.providers) {
|
|
const busy = mergeBusyIntervals(provider.busyIntervals);
|
|
for (const window of windowsForProvider(provider, input.date)) {
|
|
for (let t = window.start.getTime(); t + durationMs <= window.end.getTime(); t += stepMs) {
|
|
const startAt = new Date(t);
|
|
const endAt = new Date(t + durationMs);
|
|
if (t < earliest || t > latest) {
|
|
continue;
|
|
}
|
|
// Buffered occupancy must be free of any existing busy block.
|
|
if (overlapsBusy(new Date(t - bufferBeforeMs), new Date(t + durationMs + bufferAfterMs), busy)) {
|
|
continue;
|
|
}
|
|
const existing = byInstant.get(t);
|
|
if (existing) {
|
|
existing.providers.add(provider.providerUserId);
|
|
} else {
|
|
byInstant.set(t, { startAt, endAt, providers: new Set([provider.providerUserId]) });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...byInstant.values()]
|
|
.sort((a, b) => a.startAt.getTime() - b.startAt.getTime())
|
|
.map((s) => ({ startAt: s.startAt, endAt: s.endAt, providerUserIds: [...s.providers].sort() }));
|
|
}
|
|
|
|
/**
|
|
* `first_available` provider pick (M06 A.2 v1): the deterministic choice for a slot
|
|
* is the lowest-id free provider. Returns null when nobody is free at that instant.
|
|
*/
|
|
export function pickFirstAvailableProvider(slot: ComputedSlot): string | null {
|
|
return slot.providerUserIds.length > 0 ? slot.providerUserIds[0] : null;
|
|
}
|