templates2.Booking.Next/lib/slots.ts
w4c-templates 6a0c2e88d2
Some checks are pending
CI / quality (push) Waiting to run
chore(template): publish Booking.Next
2026-10-11 23:27:02 +03:00

172 lines
6.6 KiB
TypeScript

// Pure slot/date helpers shared by client and server. No dependencies: date math uses `Date`
// and `Intl.DateTimeFormat` only, so it runs in Node, the edge runtime and the browser alike.
export type Interval = { start: Date; end: Date };
export type WorkingInterval = { weekday: number; startMin: number; endMin: number };
export type Slot = { start: string; end: string };
export const MINUTE_MS = 60_000;
/** Weekday of a calendar date string ('YYYY-MM-DD'): 0 = Sunday … 6 = Saturday. */
export function weekdayOfDate(date: { year: number; month: number; day: number }): number {
return new Date(Date.UTC(date.year, date.month - 1, date.day)).getUTCDay();
}
export function parseDate(dateStr: string): { year: number; month: number; day: number } | null {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr);
if (!m) return null;
const year = Number(m[1]);
const month = Number(m[2]);
const day = Number(m[3]);
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
const check = new Date(Date.UTC(year, month - 1, day));
if (check.getUTCFullYear() !== year || check.getUTCMonth() !== month - 1 || check.getUTCDate() !== day) return null;
return { year, month, day };
}
const partCache = new Map<string, Intl.DateTimeFormat>();
function formatter(timeZone: string): Intl.DateTimeFormat {
let fmt = partCache.get(timeZone);
if (!fmt) {
fmt = new Intl.DateTimeFormat('en-US', {
timeZone,
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
partCache.set(timeZone, fmt);
}
return fmt;
}
/** Offset (ms) of `timeZone` at the given instant: local wall-clock minus UTC. */
export function timeZoneOffsetMs(timeZone: string, date: Date): number {
const parts = formatter(timeZone).formatToParts(date);
const map: Record<string, number> = {};
for (const p of parts) {
if (p.type !== 'literal') map[p.type] = Number(p.value);
}
const hour = map.hour === 24 ? 0 : map.hour;
const asUtc = Date.UTC(map.year, map.month - 1, map.day, hour, map.minute, map.second);
return asUtc - date.getTime();
}
/** Convert a wall-clock time in `timeZone` (minutes from midnight) to a UTC instant. */
export function zonedTimeToUtc(
date: { year: number; month: number; day: number },
minutesFromMidnight: number,
timeZone: string,
): Date {
const hour = Math.floor(minutesFromMidnight / 60);
const minute = minutesFromMidnight % 60;
const utcGuess = Date.UTC(date.year, date.month - 1, date.day, hour, minute, 0, 0);
const offset1 = timeZoneOffsetMs(timeZone, new Date(utcGuess));
let ts = utcGuess - offset1;
const offset2 = timeZoneOffsetMs(timeZone, new Date(ts));
if (offset2 !== offset1) ts = utcGuess - offset2;
return new Date(ts);
}
/** Wall-clock parts of an instant in `timeZone`. */
export function zonedParts(date: Date, timeZone: string): { year: number; month: number; day: number; hour: number; minute: number } {
const parts = formatter(timeZone).formatToParts(date);
const map: Record<string, number> = {};
for (const p of parts) {
if (p.type !== 'literal') map[p.type] = Number(p.value);
}
return { year: map.year, month: map.month, day: map.day, hour: map.hour === 24 ? 0 : map.hour, minute: map.minute };
}
export function isoDateInTimeZone(date: Date, timeZone: string): string {
const { year, month, day } = zonedParts(date, timeZone);
return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
export function todayInTimeZone(timeZone: string, now: Date = new Date()): string {
return isoDateInTimeZone(now, timeZone);
}
/** Human-readable time in the site timezone, e.g. "2:30 PM". */
export function formatTimeInTimeZone(date: Date, timeZone: string): string {
return new Intl.DateTimeFormat('en-US', { timeZone, hour: 'numeric', minute: '2-digit' }).format(date);
}
export function minutesFromMidnight(date: Date, timeZone: string): number {
const { hour, minute } = zonedParts(date, timeZone);
return hour * 60 + minute;
}
/** True when the two half-open intervals [start, end) intersect. */
export function overlaps(a: Interval, b: Interval): boolean {
return a.start.getTime() < b.end.getTime() && b.start.getTime() < a.end.getTime();
}
export function addMinutes(date: Date, minutes: number): Date {
return new Date(date.getTime() + minutes * MINUTE_MS);
}
export type GenerateSlotsOptions = {
date: string; // 'YYYY-MM-DD' in the site timezone
durationMin: number; // length of the appointment
hours: WorkingInterval[]; // working windows (any weekday; filtered here)
busy?: Interval[]; // existing (non-cancelled) bookings
timeZone: string;
now?: Date; // current instant (defaults to new Date()); slots starting before it are excluded
stepMin?: number; // slot granularity; defaults to durationMin
leadMin?: number; // minimum notice before a slot can be booked
};
/**
* Generate the bookable slots for one date: within working hours, at `stepMin` granularity,
* excluding anything already taken (`busy`) and anything in the past (relative to `now`).
*/
export function generateSlots({
date,
durationMin,
hours,
busy = [],
timeZone,
now = new Date(),
stepMin,
leadMin = 0,
}: GenerateSlotsOptions): Slot[] {
const parsed = parseDate(date);
if (!parsed || durationMin <= 0) return [];
const step = stepMin && stepMin > 0 ? stepMin : durationMin;
const weekday = weekdayOfDate(parsed);
const earliest = now.getTime() + leadMin * MINUTE_MS;
const slots: Slot[] = [];
const seen = new Set<number>();
for (const window of hours) {
if (window.weekday !== weekday) continue;
const startMin = Math.max(0, Math.min(window.startMin, window.endMin));
const endMin = Math.max(window.startMin, window.endMin);
for (let t = startMin; t + durationMin <= endMin; t += step) {
const start = zonedTimeToUtc(parsed, t, timeZone);
const end = addMinutes(start, durationMin);
if (start.getTime() < earliest) continue;
if (seen.has(start.getTime())) continue;
if (busy.some((b) => overlaps({ start, end }, b))) continue;
seen.add(start.getTime());
slots.push({ start: start.toISOString(), end: end.toISOString() });
}
}
return slots.sort((a, b) => a.start.localeCompare(b.start));
}
/** The UTC instant hours that bound a calendar date in the site timezone (for DB range queries). */
export function dayBoundsUtc(date: string, timeZone: string): Interval | null {
const parsed = parseDate(date);
if (!parsed) return null;
const start = zonedTimeToUtc(parsed, 0, timeZone);
const end = zonedTimeToUtc({ ...parsed, day: parsed.day + 1 }, 0, timeZone);
return { start, end };
}