248 lines
9.1 KiB
TypeScript
248 lines
9.1 KiB
TypeScript
/**
|
|
* Booking domain — pure, dependency-free helpers shared by the client and the server.
|
|
*
|
|
* Nothing here touches the database or `window`: slot generation, overlap detection, timezone
|
|
* conversion and status transitions are plain functions so they can be unit-tested in isolation.
|
|
*
|
|
* Timezone strategy: instants are always stored/transported as UTC. The *site* timezone (an IANA
|
|
* name such as `Europe/Minsk`) decides which wall-clock slots exist on a given calendar date.
|
|
* Wall-clock→UTC conversion is done with `Intl.DateTimeFormat` only (no date library).
|
|
*/
|
|
|
|
export type WorkingHour = { weekday: number; startMin: number; endMin: number };
|
|
|
|
/** A half-open UTC interval `[start, end)` in epoch milliseconds. */
|
|
export type Interval = { start: number; end: number };
|
|
|
|
export type Slot = {
|
|
/** Minutes from local midnight (in the site timezone). */
|
|
startMin: number;
|
|
/** ISO-8601 UTC instant of the slot start. */
|
|
start: string;
|
|
/** ISO-8601 UTC instant of the slot end. */
|
|
end: string;
|
|
/** Localized wall-clock label, e.g. `9:00 AM` (rendered once, on the server). */
|
|
label: string;
|
|
};
|
|
|
|
export type SlotInput = {
|
|
/** Calendar date `YYYY-MM-DD` in the site timezone. */
|
|
date: string;
|
|
durationMin: number;
|
|
timezone: string;
|
|
hours: WorkingHour[];
|
|
/** Existing bookings for the considered staff, as UTC intervals. */
|
|
taken: Interval[];
|
|
/** "Now" as epoch milliseconds — used to drop past slots. */
|
|
now: number;
|
|
/** Slot grid step in minutes (default 30). */
|
|
stepMin?: number;
|
|
};
|
|
|
|
const MS_MIN = 60_000;
|
|
const MINUTES_PER_DAY = 24 * 60;
|
|
|
|
export function parseDate(date: string): { year: number; month: number; day: number } | null {
|
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
|
|
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;
|
|
return { year, month, day };
|
|
}
|
|
|
|
export function isValidDate(date: string): boolean {
|
|
const p = parseDate(date);
|
|
if (!p) return false;
|
|
const d = new Date(Date.UTC(p.year, p.month - 1, p.day));
|
|
return d.getUTCFullYear() === p.year && d.getUTCMonth() === p.month - 1 && d.getUTCDate() === p.day;
|
|
}
|
|
|
|
/** Weekday (0 = Sunday .. 6 = Saturday) of a calendar date — timezone-independent. */
|
|
export function weekdayOf(date: string): number {
|
|
const p = parseDate(date);
|
|
if (!p) return 0;
|
|
return new Date(Date.UTC(p.year, p.month - 1, p.day)).getUTCDay();
|
|
}
|
|
|
|
/** Shift a `YYYY-MM-DD` date by `days` (calendar arithmetic, no timezone involved). */
|
|
export function addDays(date: string, days: number): string {
|
|
const p = parseDate(date);
|
|
if (!p) return date;
|
|
const d = new Date(Date.UTC(p.year, p.month - 1, p.day + days));
|
|
return toDateString(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate());
|
|
}
|
|
|
|
export function toDateString(year: number, month: number, day: number): string {
|
|
const mm = String(month).padStart(2, '0');
|
|
const dd = String(day).padStart(2, '0');
|
|
return `${year}-${mm}-${dd}`;
|
|
}
|
|
|
|
/** Offset of `tz` from UTC (ms) at the given instant. */
|
|
function tzOffset(epochMs: number, tz: string): number {
|
|
const parts = new Intl.DateTimeFormat('en-US', {
|
|
timeZone: tz,
|
|
hour12: false,
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
}).formatToParts(new Date(epochMs));
|
|
const get = (type: string): number => {
|
|
const part = parts.find((p) => p.type === type);
|
|
return part ? Number(part.value) : 0;
|
|
};
|
|
const hour = get('hour') % 24; // some ICU builds report midnight as "24"
|
|
const wall = Date.UTC(get('year'), get('month') - 1, get('day'), hour, get('minute'), get('second'));
|
|
return wall - epochMs;
|
|
}
|
|
|
|
/** Convert a wall-clock time (date + minutes from midnight, in `tz`) to a UTC instant (epoch ms). */
|
|
export function zonedTimeToUtc(date: string, minutes: number, tz: string): number {
|
|
const p = parseDate(date);
|
|
if (!p) return Number.NaN;
|
|
const asUtc = Date.UTC(p.year, p.month - 1, p.day) + minutes * MS_MIN;
|
|
// Two passes handle DST transitions well enough for slot grids.
|
|
const first = asUtc - tzOffset(asUtc, tz);
|
|
return asUtc - tzOffset(first, tz);
|
|
}
|
|
|
|
export type ZonedParts = { year: number; month: number; day: number; hour: number; minute: number };
|
|
|
|
/** Break a UTC instant into wall-clock parts in `tz`. */
|
|
export function zonedParts(epochMs: number, tz: string): ZonedParts {
|
|
const parts = new Intl.DateTimeFormat('en-US', {
|
|
timeZone: tz,
|
|
hour12: false,
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
}).formatToParts(new Date(epochMs));
|
|
const get = (type: string): number => {
|
|
const part = parts.find((p) => p.type === type);
|
|
return part ? Number(part.value) : 0;
|
|
};
|
|
return { year: get('year'), month: get('month'), day: get('day'), hour: get('hour') % 24, minute: get('minute') };
|
|
}
|
|
|
|
/** The calendar date (`YYYY-MM-DD`) that a UTC instant falls on in `tz`. */
|
|
export function localDate(epochMs: number, tz: string): string {
|
|
const p = zonedParts(epochMs, tz);
|
|
return toDateString(p.year, p.month, p.day);
|
|
}
|
|
|
|
/** Localized wall-clock label for a UTC instant, e.g. `9:00 AM`. */
|
|
export function slotLabel(epochMs: number, tz: string): string {
|
|
return new Intl.DateTimeFormat('en-US', { timeZone: tz, hour: 'numeric', minute: '2-digit' }).format(epochMs);
|
|
}
|
|
|
|
/** Localized long date for a calendar date, e.g. `Monday, January 1, 2024`. */
|
|
export function formatDayLabel(date: string, tz: string): string {
|
|
const p = parseDate(date);
|
|
if (!p) return date;
|
|
const noonUtc = zonedTimeToUtc(date, 12 * 60, tz);
|
|
const d = Number.isNaN(noonUtc) ? new Date(Date.UTC(p.year, p.month - 1, p.day)) : new Date(noonUtc);
|
|
return new Intl.DateTimeFormat('en-US', { timeZone: tz, weekday: 'long', month: 'long', day: 'numeric' }).format(d);
|
|
}
|
|
|
|
/** Half-open interval overlap: `[aStart, aEnd)` vs `[bStart, bEnd)`. */
|
|
export function overlaps(a: Interval, b: Interval): boolean {
|
|
return a.start < b.end && b.start < a.end;
|
|
}
|
|
|
|
export function isWithinWorkingHours(
|
|
hours: WorkingHour[],
|
|
weekday: number,
|
|
startMin: number,
|
|
endMin: number,
|
|
): boolean {
|
|
return hours.some((h) => h.weekday === weekday && h.startMin <= startMin && endMin <= h.endMin);
|
|
}
|
|
|
|
/**
|
|
* Build the bookable slots for one calendar date and one set of working hours.
|
|
* Past slots (`start <= now`) and slots overlapping an existing booking are excluded.
|
|
*/
|
|
export function generateSlots(input: SlotInput): Slot[] {
|
|
const step = input.stepMin && input.stepMin > 0 ? input.stepMin : 30;
|
|
const weekday = weekdayOf(input.date);
|
|
const slots: Slot[] = [];
|
|
const seen = new Set<number>();
|
|
|
|
for (const band of input.hours) {
|
|
if (band.weekday !== weekday) continue;
|
|
const from = Math.max(0, band.startMin);
|
|
const to = Math.min(MINUTES_PER_DAY, band.endMin);
|
|
for (let startMin = from; startMin + input.durationMin <= to; startMin += step) {
|
|
const start = zonedTimeToUtc(input.date, startMin, input.timezone);
|
|
if (Number.isNaN(start)) continue;
|
|
const end = start + input.durationMin * MS_MIN;
|
|
if (start <= input.now) continue;
|
|
if (input.taken.some((t) => overlaps({ start, end }, t))) continue;
|
|
if (seen.has(startMin)) continue;
|
|
seen.add(startMin);
|
|
slots.push({
|
|
startMin,
|
|
start: new Date(start).toISOString(),
|
|
end: new Date(end).toISOString(),
|
|
label: slotLabel(start, input.timezone),
|
|
});
|
|
}
|
|
}
|
|
|
|
return slots.sort((a, b) => a.startMin - b.startMin);
|
|
}
|
|
|
|
export function formatDuration(minutes: number): string {
|
|
const h = Math.floor(minutes / 60);
|
|
const m = minutes % 60;
|
|
if (h === 0) return `${m} min`;
|
|
if (m === 0) return `${h} h`;
|
|
return `${h} h ${m} min`;
|
|
}
|
|
|
|
/** `540` → `09:00`, for `<input type="time">` values. */
|
|
export function minutesToTime(minutes: number): string {
|
|
const h = Math.floor(minutes / 60);
|
|
const m = minutes % 60;
|
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
|
}
|
|
|
|
/** `"09:00"` → `540`; returns `null` for malformed input. */
|
|
export function timeToMinutes(value: string): number | null {
|
|
const m = /^(\d{1,2}):(\d{2})$/.exec(value);
|
|
if (!m) return null;
|
|
const h = Number(m[1]);
|
|
const min = Number(m[2]);
|
|
if (h < 0 || h > 23 || min < 0 || min > 59) return null;
|
|
return h * 60 + min;
|
|
}
|
|
|
|
export const BOOKING_STATUSES = ['pending', 'confirmed', 'cancelled', 'completed', 'no_show'] as const;
|
|
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
|
|
|
|
export function isBookingStatus(value: unknown): value is BookingStatus {
|
|
return typeof value === 'string' && (BOOKING_STATUSES as readonly string[]).includes(value);
|
|
}
|
|
|
|
/** Allowed admin status transitions (the calendar's action buttons). */
|
|
export const STATUS_TRANSITIONS: Record<BookingStatus, BookingStatus[]> = {
|
|
pending: ['confirmed', 'cancelled', 'no_show'],
|
|
confirmed: ['completed', 'cancelled', 'no_show'],
|
|
cancelled: [],
|
|
completed: [],
|
|
no_show: ['confirmed'],
|
|
};
|
|
|
|
export function canTransition(from: BookingStatus, to: BookingStatus): boolean {
|
|
return STATUS_TRANSITIONS[from].includes(to);
|
|
}
|
|
|
|
export const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] as const;
|