templates2.Booking.Nuxt/shared/booking.ts
w4c-templates 3491e2d39c
Some checks failed
CI / quality (push) Has been cancelled
chore(template): publish Booking.Nuxt
2026-10-11 23:27:22 +03:00

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;