268 lines
7.4 KiB
TypeScript
268 lines
7.4 KiB
TypeScript
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;
|
|
}
|