172 lines
6.6 KiB
TypeScript
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 };
|
|
}
|