import type { Tables } from "./database.types.js"; import { DateTime } from "luxon"; // 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; }