xtablo-source/api/src/slots.ts
Arthur Belleville 370cd11dad
Format api
2025-10-24 08:39:16 +02:00

252 lines
7.4 KiB
TypeScript

import { DateTime } from "luxon";
import type { Tables } from "./database.types.js";
// Types for availability calculation
type TimeRange = {
start: string;
end: string;
};
type DayAvailability = {
enabled: boolean;
timeRanges: TimeRange[];
};
export type WeeklyAvailability = {
[key: number]: DayAvailability;
};
export type Exception = {
date: string;
} & (
| {
type: "hours";
hours: TimeRange[];
}
| {
type: "day";
}
);
export type TimeSlot = {
date: string;
time: string;
available: boolean;
};
export type EventTypeConfig = {
name: string;
description: string;
duration: number; // in minutes
bufferTime?: number; // buffer time before/after in minutes
maxBookingsPerDay?: number;
requiresApproval: boolean;
price?: number;
location?: string;
minAdvanceBooking?: {
value: number;
unit: "minutes" | "hours" | "days";
};
};
// Helper functions for slot calculation
function parseTime(timeStr: string): { hours: number; minutes: number } {
const [hours, minutes] = timeStr.split(":").map(Number);
return { hours, minutes };
}
function formatTime(hours: number, minutes: number): string {
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
}
function addMinutes(timeStr: string, minutesToAdd: number): string {
const { hours, minutes } = parseTime(timeStr);
const totalMinutes = hours * 60 + minutes + minutesToAdd;
// Handle negative time (before 00:00)
if (totalMinutes < 0) {
return "00:00"; // Return start of day for negative times
}
// Handle time beyond 24 hours
const dayMinutes = totalMinutes % (24 * 60);
const newHours = Math.floor(dayMinutes / 60);
const newMinutes = dayMinutes % 60;
return formatTime(newHours, newMinutes);
}
function mergeOverlappingTimeRanges(ranges: TimeRange[]): TimeRange[] {
if (ranges.length <= 1) return ranges;
// Sort ranges by start time
const sortedRanges = [...ranges].sort((a, b) => a.start.localeCompare(b.start));
const merged: TimeRange[] = [sortedRanges[0]];
for (let i = 1; i < sortedRanges.length; i++) {
const current = sortedRanges[i];
const lastMerged = merged[merged.length - 1];
// Check if current range overlaps with the last merged range
if (current.start <= lastMerged.end) {
// Merge by extending the end time if current range extends further
lastMerged.end = current.end > lastMerged.end ? current.end : lastMerged.end;
} else {
// No overlap, add current range to merged array
merged.push(current);
}
}
return merged;
}
export function getDayOfWeek(date: Date): number {
// Convert JS day (0=Sunday) to our format (0=Monday)
const jsDay = date.getDay();
return jsDay === 0 ? 6 : jsDay - 1;
}
function getMinAdvanceBookingDate(
config: EventTypeConfig,
currentDate: Date
): { date: string; time: string } {
if (!config.minAdvanceBooking) {
return {
date: getDateStringCET(currentDate),
time: formatTime(currentDate.getHours(), currentDate.getMinutes()),
};
}
const { value, unit } = config.minAdvanceBooking;
const advanceDate = new Date(currentDate);
switch (unit) {
case "minutes":
advanceDate.setMinutes(currentDate.getMinutes() + value);
break;
case "hours":
advanceDate.setHours(currentDate.getHours() + value);
break;
case "days":
advanceDate.setDate(currentDate.getDate() + value);
break;
}
return {
date: getDateStringCET(advanceDate),
time: formatTime(advanceDate.getHours(), advanceDate.getMinutes()),
};
}
export function getDateStringCET(date: Date): string {
return DateTime.fromJSDate(date).setZone("Europe/Paris").toFormat("yyyy-MM-dd");
}
export function generateTimeSlots(
currentTime: Date, // in CET
date: Date, // in CET
availability: DayAvailability,
eventTypeConfig: EventTypeConfig,
exceptions: Exception[],
existingEvents: Tables<"events">[]
): TimeSlot[] {
const dateStr = getDateStringCET(date);
const slots: TimeSlot[] = [];
// Check if this date has an exception
const exception = exceptions.find((e) => {
const exceptionDate = new Date(e.date);
const exceptionDateStr = getDateStringCET(exceptionDate);
return exceptionDateStr === dateStr;
});
// If there's a "day" exception, no slots available
if (exception?.type === "day") {
return slots;
}
// Get time ranges for this day
let timeRanges: TimeRange[];
if (exception?.type === "hours") {
timeRanges = mergeOverlappingTimeRanges(exception.hours);
} else if (availability.enabled) {
timeRanges = mergeOverlappingTimeRanges(availability.timeRanges);
} else {
return slots; // Day not available
}
// Check minimum advance booking
const minAdvanceBooking = getMinAdvanceBookingDate(eventTypeConfig, currentTime);
// Generate slots for each time range
for (const range of timeRanges) {
const startTime = parseTime(range.start);
const endTime = parseTime(range.end);
let currentMinutes = startTime.hours * 60 + startTime.minutes;
const endMinutes = endTime.hours * 60 + endTime.minutes;
while (currentMinutes + eventTypeConfig.duration <= endMinutes) {
const slotTime = formatTime(Math.floor(currentMinutes / 60), currentMinutes % 60);
// Check if slot is in the future (considering minimum advance booking)
// Compare dates first, then times if on the same date
const isInFuture =
dateStr > minAdvanceBooking.date ||
(dateStr === minAdvanceBooking.date && slotTime >= minAdvanceBooking.time);
slots.push({
date: dateStr,
time: slotTime,
available: isInFuture, // Will be updated later based on conflicts and buffer time
});
// Move to next slot (without buffer time - generate all possible slots)
currentMinutes += eventTypeConfig.duration;
}
}
// Apply buffer time and conflict detection to all generated slots
const bufferTime = eventTypeConfig.bufferTime || 0;
for (const slot of slots) {
if (!slot.available) continue; // Skip slots already marked as unavailable
const slotEndTime = addMinutes(slot.time, eventTypeConfig.duration);
// Check if this slot conflicts with existing events (including buffer time)
const hasConflict = existingEvents.some((event) => {
if (event.start_date !== dateStr || event.deleted_at) return false;
const eventStart = event.start_time;
const eventEnd = event.end_time || addMinutes(eventStart, eventTypeConfig.duration);
// Apply buffer time around the existing event
const bufferedEventStart = addMinutes(eventStart, -bufferTime);
const bufferedEventEnd = addMinutes(eventEnd, bufferTime);
// Check for overlap with buffered event time
return (
(slot.time >= bufferedEventStart && slot.time < bufferedEventEnd) ||
(slotEndTime > bufferedEventStart && slotEndTime <= bufferedEventEnd) ||
(slot.time <= bufferedEventStart && slotEndTime >= bufferedEventEnd)
);
});
if (hasConflict) {
slot.available = false;
}
}
// Apply max bookings per day limit
if (eventTypeConfig.maxBookingsPerDay) {
const existingBookingsCount = existingEvents.filter(
(event) => event.start_date === dateStr && !event.deleted_at
).length;
if (existingBookingsCount >= eventTypeConfig.maxBookingsPerDay) {
return slots.map((slot) => ({ ...slot, available: false }));
}
}
return slots;
}