Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/booking/services/slotEngine.ts
T
2026-07-08 16:15:37 +02:00

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;
}