Finish slot generation

This commit is contained in:
Arthur Belleville 2025-09-29 22:48:33 +02:00
parent 586b8119e2
commit 5486d89726
No known key found for this signature in database
7 changed files with 671 additions and 100 deletions

View file

@ -40,7 +40,8 @@ describe("generateTimeSlots", () => {
describe("Basic functionality (no events already booked)", () => {
it("should generate time slots for a basic availability window", () => {
const slots = generateTimeSlots(
testDate,
testDate, // currentTime
testDate, // date
basicAvailability,
basicEventTypeConfig,
[],
@ -75,7 +76,8 @@ describe("generateTimeSlots", () => {
};
const slots = generateTimeSlots(
testDate,
testDate, // currentTime
testDate, // date
availability,
basicEventTypeConfig,
[],
@ -100,7 +102,8 @@ describe("generateTimeSlots", () => {
};
const slots = generateTimeSlots(
testDate,
testDate, // currentTime
testDate, // date
disabledAvailability,
basicEventTypeConfig,
[],
@ -117,7 +120,8 @@ describe("generateTimeSlots", () => {
};
const slots = generateTimeSlots(
testDate,
testDate, // currentTime
testDate, // date
basicAvailability,
shortEventConfig,
[],
@ -140,7 +144,8 @@ describe("generateTimeSlots", () => {
};
const slots = generateTimeSlots(
testDate,
testDate, // currentTime
testDate, // date
basicAvailability,
longEventConfig,
[],
@ -162,7 +167,8 @@ describe("generateTimeSlots", () => {
};
const slots = generateTimeSlots(
testDate,
testDate, // currentTime
testDate, // date
basicAvailability,
basicEventTypeConfig,
[dayException],
@ -180,7 +186,8 @@ describe("generateTimeSlots", () => {
};
const slots = generateTimeSlots(
testDate,
testDate, // currentTime
testDate, // date
basicAvailability,
basicEventTypeConfig,
[hoursException],
@ -203,7 +210,8 @@ describe("generateTimeSlots", () => {
};
const slots = generateTimeSlots(
testDate,
testDate, // currentTime
testDate, // date
basicAvailability,
basicEventTypeConfig,
[multipleHoursException],
@ -234,7 +242,8 @@ describe("generateTimeSlots", () => {
};
const slots = generateTimeSlots(
testDate,
testDate, // currentTime
testDate, // date
basicAvailability,
basicEventTypeConfig,
[overlappingHoursException],
@ -264,7 +273,8 @@ describe("generateTimeSlots", () => {
};
const slots = generateTimeSlots(
testDate,
testDate, // currentTime
testDate, // date
basicAvailability,
basicEventTypeConfig,
[multipleOverlappingException],
@ -298,7 +308,8 @@ describe("generateTimeSlots", () => {
};
const slots = generateTimeSlots(
testDate,
testDate, // currentTime
testDate, // date
basicAvailability,
basicEventTypeConfig,
[adjacentRangesException],
@ -320,7 +331,8 @@ describe("generateTimeSlots", () => {
};
const slots = generateTimeSlots(
testDate,
testDate, // currentTime
testDate, // date
basicAvailability,
basicEventTypeConfig,
[differentDateException],
@ -348,7 +360,8 @@ describe("generateTimeSlots", () => {
};
const slots = generateTimeSlots(
testDate,
testDate, // currentTime
testDate, // date
basicAvailability,
basicEventTypeConfig,
[],
@ -382,7 +395,8 @@ describe("generateTimeSlots", () => {
};
const slots = generateTimeSlots(
testDate,
testDate, // currentTime
testDate, // date
basicAvailability,
basicEventTypeConfig,
[],
@ -409,7 +423,8 @@ describe("generateTimeSlots", () => {
};
const slots = generateTimeSlots(
testDate,
testDate, // currentTime
testDate, // date
basicAvailability,
basicEventTypeConfig,
[],
@ -427,28 +442,34 @@ describe("generateTimeSlots", () => {
describe("Minimum advance booking", () => {
it("should mark past slots as unavailable", () => {
// Test date is same as current date
const todayDate = new Date("2024-01-15T10:00:00Z"); // Same day as mock current time
const todayDate = new Date("2024-01-15T00:00:00Z"); // Date for slot generation
// Mock current time in CET (11:00 CET = 10:00 UTC in winter)
const currentTimeCET = new Date("2024-01-15T11:00:00"); // 11:00 CET
const slots = generateTimeSlots(
todayDate,
currentTimeCET, // currentTime
todayDate, // date
basicAvailability,
basicEventTypeConfig,
[],
[]
);
// Slots before current time (10:00) should be unavailable
// Slots before current time (11:00 CET) should be unavailable
const slot09_00 = slots.find((s) => s.time === "09:00");
const slot09_30 = slots.find((s) => s.time === "09:30");
const slot10_00 = slots.find((s) => s.time === "10:00");
const slot10_30 = slots.find((s) => s.time === "10:30");
console.log(slots);
const slot11_00 = slots.find((s) => s.time === "11:00");
const slot11_30 = slots.find((s) => s.time === "11:30");
expect(slot09_00?.available).to.be.false;
expect(slot09_30?.available).to.be.false;
expect(slot10_00?.available).to.be.true;
expect(slot10_30?.available).to.be.true;
expect(slot10_00?.available).to.be.false;
expect(slot10_30?.available).to.be.false;
expect(slot11_00?.available).to.be.true;
expect(slot11_30?.available).to.be.true;
});
it("should respect minimum advance booking in hours", () => {
@ -460,10 +481,13 @@ describe("generateTimeSlots", () => {
},
};
// Mock current time in CET (10:00 CET)
const currentTimeCET = new Date("2024-01-15T10:00:00");
const todayDate = new Date("2024-01-15T00:00:00Z");
const slots = generateTimeSlots(
todayDate,
currentTimeCET, // currentTime
todayDate, // date
basicAvailability,
configWithAdvanceBooking,
[],
@ -487,10 +511,14 @@ describe("generateTimeSlots", () => {
},
};
const todayDate = new Date("2024-01-15T00:00:00Z"); // Same day as current time
const todayDate = new Date("2024-01-15T00:00:00Z");
// Mock current time in CET (10:00 CET)
const currentTimeCET = new Date("2024-01-15T10:00:00");
const slots = generateTimeSlots(
todayDate,
currentTimeCET, // currentTime
todayDate, // date
basicAvailability,
configWithAdvanceBooking,
[],
@ -502,10 +530,41 @@ describe("generateTimeSlots", () => {
expect(slot.available).to.be.false;
});
});
it("should ignore minimum advance booking if the date is in the future", () => {
const configWithAdvanceBooking = {
...basicEventTypeConfig,
minAdvanceBooking: {
value: 2,
unit: "hours" as const,
},
};
// Mock current time in CET (10:00 CET on Jan 15)
const currentTimeCET = new Date("2024-01-15T10:00:00");
// Test date is Jan 16 (future date)
const futureDate = new Date("2024-01-16T00:00:00Z");
const slots = generateTimeSlots(
currentTimeCET, // currentTime
futureDate, // date
basicAvailability,
configWithAdvanceBooking,
[],
existingEvents
);
// All slots should be available since we're checking a future date
const slot09_00 = slots.find((s) => s.time === "09:00");
const slot09_30 = slots.find((s) => s.time === "09:30");
expect(slot09_00?.available).to.be.true;
expect(slot09_30?.available).to.be.true;
});
});
describe("Buffer time", () => {
it("should add buffer time between slots", () => {
it("should generate all possible slots regardless of buffer time", () => {
const configWithBuffer = {
...basicEventTypeConfig,
duration: 30,
@ -513,22 +572,226 @@ describe("generateTimeSlots", () => {
};
const slots = generateTimeSlots(
testDate,
testDate, // currentTime
testDate, // date
basicAvailability,
configWithBuffer,
[],
existingEvents
);
// With 30min duration + 15min buffer = 45min between slots
// Should generate slots every 30 minutes (duration), not considering buffer time for slot generation
expect(slots).to.have.length(16); // Same as without buffer time
expect(slots[0].time).to.equal("09:00");
expect(slots[1].time).to.equal("09:45");
expect(slots[2].time).to.equal("10:30");
expect(slots[3].time).to.equal("11:15");
expect(slots[1].time).to.equal("09:30");
expect(slots[2].time).to.equal("10:00");
expect(slots[3].time).to.equal("10:30");
});
it("should handle buffer time that prevents slots from fitting", () => {
it("should apply buffer time around existing events to disable slots", () => {
const configWithBuffer = {
...basicEventTypeConfig,
duration: 30,
bufferTime: 15, // 15 minutes buffer before and after events
};
const existingEvent: Tables<"events"> = {
id: "1",
start_date: "2024-01-16",
start_time: "10:00",
end_time: "10:30",
created_by: "user1",
deleted_at: null,
title: "Existing Meeting",
description: null,
created_at: "2024-01-15T00:00:00Z",
tablo_id: "",
};
const slots = generateTimeSlots(
testDate, // currentTime
testDate, // date
basicAvailability,
configWithBuffer,
[],
[existingEvent]
);
// Find slots around the existing event (10:00-10:30)
// Buffer time should disable slots that ends after 09:45 or starts before 10:45
const slot09_00 = slots.find((s) => s.time === "09:00");
const slot09_30 = slots.find((s) => s.time === "09:30");
const slot10_00 = slots.find((s) => s.time === "10:00");
const slot10_30 = slots.find((s) => s.time === "10:30");
const slot11_00 = slots.find((s) => s.time === "11:00");
expect(slot09_00?.available).to.be.true; // Ends before buffer starts
expect(slot09_30?.available).to.be.false; // Ends at 10:00, overlaps with buffer (09:45-10:45)
expect(slot10_00?.available).to.be.false; // Overlaps with buffered event
expect(slot10_30?.available).to.be.false; // Starts at 10:30, overlaps with buffer until 10:45
expect(slot11_00?.available).to.be.true; // Starts after buffer ends
});
it("should handle buffer time with events without end_time", () => {
const configWithBuffer = {
...basicEventTypeConfig,
duration: 30,
bufferTime: 15,
};
const eventWithoutEndTime: Tables<"events"> = {
id: "1",
start_date: "2024-01-16",
start_time: "10:00",
end_time: null, // Will use event duration (30 min)
created_by: "user1",
deleted_at: null,
title: "Meeting without end time",
description: null,
created_at: "2024-01-15T00:00:00Z",
tablo_id: "",
};
const slots = generateTimeSlots(
testDate, // currentTime
testDate, // date
basicAvailability,
configWithBuffer,
[],
[eventWithoutEndTime]
);
// Event is 10:00-10:30, with 15min buffer: 09:45-10:45
const slot09_00 = slots.find((s) => s.time === "09:00");
const slot09_30 = slots.find((s) => s.time === "09:30");
const slot10_00 = slots.find((s) => s.time === "10:00");
const slot10_30 = slots.find((s) => s.time === "10:30");
const slot11_00 = slots.find((s) => s.time === "11:00");
expect(slot09_00?.available, "09:00 should be available").to.be.true;
expect(slot09_30?.available, "09:30 should not be available").to.be.false;
expect(slot10_00?.available, "10:00 should not be unavailable").to.be
.false; // Within buffered time
expect(slot10_30?.available, "10:30 should not be unavailable").to.be
.false; // Within buffered time
expect(slot11_00?.available, "11:00 should be available").to.be.true; // After buffered time
});
it("should handle large buffer time that affects multiple slots", () => {
const configWithLargeBuffer = {
...basicEventTypeConfig,
duration: 30,
bufferTime: 60, // 1 hour buffer
};
const existingEvent: Tables<"events"> = {
id: "1",
start_date: "2024-01-16",
start_time: "12:00",
end_time: "12:30",
created_by: "user1",
deleted_at: null,
title: "Existing Meeting",
description: null,
created_at: "2024-01-15T00:00:00Z",
tablo_id: "",
};
const slots = generateTimeSlots(
testDate, // currentTime
testDate, // date
basicAvailability,
configWithLargeBuffer,
[],
[existingEvent]
);
// Event is 12:00-12:30, with 60min buffer: 11:00-13:30
const slot10_30 = slots.find((s) => s.time === "10:30");
const slot11_00 = slots.find((s) => s.time === "11:00");
const slot11_30 = slots.find((s) => s.time === "11:30");
const slot12_00 = slots.find((s) => s.time === "12:00");
const slot12_30 = slots.find((s) => s.time === "12:30");
const slot13_00 = slots.find((s) => s.time === "13:00");
const slot13_30 = slots.find((s) => s.time === "13:30");
const slot14_00 = slots.find((s) => s.time === "14:00");
expect(slot10_30?.available).to.be.true; // Before buffer
expect(slot11_00?.available).to.be.false; // Within buffer
expect(slot11_30?.available).to.be.false; // Within buffer
expect(slot12_00?.available).to.be.false; // Within buffer
expect(slot12_30?.available).to.be.false; // Within buffer
expect(slot13_00?.available).to.be.false; // Within buffer (ends at 13:30)
expect(slot13_30?.available).to.be.true; // After buffer
expect(slot14_00?.available).to.be.true; // After buffer
});
it("should handle multiple events with overlapping buffer times", () => {
const configWithBuffer = {
...basicEventTypeConfig,
duration: 30,
bufferTime: 30,
};
const existingEvents: Tables<"events">[] = [
{
id: "1",
start_date: "2024-01-16",
start_time: "10:00",
end_time: "10:30",
created_by: "user1",
deleted_at: null,
title: "First Meeting",
description: null,
created_at: "2024-01-15T00:00:00Z",
tablo_id: "",
},
{
id: "2",
start_date: "2024-01-16",
start_time: "11:30",
end_time: "12:00",
created_by: "user1",
deleted_at: null,
title: "Second Meeting",
description: null,
created_at: "2024-01-15T00:00:00Z",
tablo_id: "",
},
];
const slots = generateTimeSlots(
testDate, // currentTime
testDate, // date
basicAvailability,
configWithBuffer,
[],
existingEvents
);
// First event: 10:00-10:30, buffer: 09:30-11:00
// Second event: 11:30-12:00, buffer: 11:00-12:30
// Combined buffer coverage: 09:30-12:30
const slot09_00 = slots.find((s) => s.time === "09:00");
const slot09_30 = slots.find((s) => s.time === "09:30");
const slot10_00 = slots.find((s) => s.time === "10:00");
const slot11_00 = slots.find((s) => s.time === "11:00");
const slot12_00 = slots.find((s) => s.time === "12:00");
const slot12_30 = slots.find((s) => s.time === "12:30");
const slot13_00 = slots.find((s) => s.time === "13:00");
expect(slot09_00?.available).to.be.true; // Before any buffer
expect(slot09_30?.available).to.be.false; // Within first event's buffer
expect(slot10_00?.available).to.be.false; // Within first event's buffer
expect(slot11_00?.available).to.be.false; // Within second event's buffer
expect(slot12_00?.available).to.be.false; // Within second event's buffer
expect(slot12_30?.available).to.be.true; // After all buffers
expect(slot13_00?.available).to.be.true; // After all buffers
});
it("should not affect slot generation in short availability windows", () => {
const configWithBuffer = {
...basicEventTypeConfig,
duration: 30,
bufferTime: 60, // Large buffer
@ -540,16 +803,70 @@ describe("generateTimeSlots", () => {
};
const slots = generateTimeSlots(
testDate,
testDate, // currentTime
testDate, // date
shortAvailability,
configWithLargeBuffer,
configWithBuffer,
[],
existingEvents
);
// Only one slot should fit (09:00-09:30 + 60min buffer = 10:30)
expect(slots).to.have.length(1);
// Should generate all possible slots within the availability window
expect(slots).to.have.length(3); // 09:00, 09:30, 10:00
expect(slots[0].time).to.equal("09:00");
expect(slots[1].time).to.equal("09:30");
expect(slots[2].time).to.equal("10:00");
// All should be available since there are no existing events
expect(slots.every((slot) => slot.available)).to.be.true;
});
it("should handle buffer time that extends before start of day", () => {
const configWithBuffer = {
...basicEventTypeConfig,
duration: 30,
bufferTime: 60, // 1 hour buffer
};
const earlyMorningAvailability = {
enabled: true,
timeRanges: [{ start: "08:00", end: "12:00" }],
};
const earlyEvent: Tables<"events"> = {
id: "1",
start_date: "2024-01-16",
start_time: "08:30",
end_time: "09:00",
created_by: "user1",
deleted_at: null,
title: "Early Meeting",
description: null,
created_at: "2024-01-15T00:00:00Z",
tablo_id: "",
};
const slots = generateTimeSlots(
testDate, // currentTime
testDate, // date
earlyMorningAvailability,
configWithBuffer,
[],
[earlyEvent]
);
// Event is 08:30-09:00, with 60min buffer: 07:30-10:00
// Since 07:30 is before availability starts, buffer should start from 08:00
const slot08_00 = slots.find((s) => s.time === "08:00");
const slot08_30 = slots.find((s) => s.time === "08:30");
const slot09_00 = slots.find((s) => s.time === "09:00");
const slot09_30 = slots.find((s) => s.time === "09:30");
const slot10_00 = slots.find((s) => s.time === "10:00");
expect(slot08_00?.available).to.be.false; // Within buffer
expect(slot08_30?.available).to.be.false; // Within buffer
expect(slot09_00?.available).to.be.false; // Within buffer
expect(slot09_30?.available).to.be.false; // Within buffer (ends at 10:00)
expect(slot10_00?.available).to.be.true; // After buffer
});
});
@ -588,7 +905,8 @@ describe("generateTimeSlots", () => {
];
const slots = generateTimeSlots(
testDate,
testDate, // currentTime
testDate, // date
basicAvailability,
configWithMaxBookings,
[],
@ -635,7 +953,8 @@ describe("generateTimeSlots", () => {
];
const slots = generateTimeSlots(
testDate,
testDate, // currentTime
testDate, // date
basicAvailability,
configWithMaxBookings,
[],
@ -656,7 +975,8 @@ describe("generateTimeSlots", () => {
};
const slots = generateTimeSlots(
testDate,
testDate, // currentTime
testDate, // date
emptyAvailability,
basicEventTypeConfig,
[],
@ -673,7 +993,8 @@ describe("generateTimeSlots", () => {
};
const slots = generateTimeSlots(
testDate,
testDate, // currentTime
testDate, // date
shortRange,
basicEventTypeConfig, // 30 minute duration
[],
@ -690,7 +1011,8 @@ describe("generateTimeSlots", () => {
};
const slots = generateTimeSlots(
testDate,
testDate, // currentTime
testDate, // date
lateNightAvailability,
basicEventTypeConfig,
[],
@ -730,7 +1052,8 @@ describe("generateTimeSlots", () => {
];
const slots = generateTimeSlots(
testDate,
testDate, // currentTime
testDate, // date
basicAvailability,
basicEventTypeConfig,
[],
@ -738,7 +1061,7 @@ describe("generateTimeSlots", () => {
);
// Check that all conflicting slots are marked unavailable
const conflictingTimes = ["09:30", "10:00", "10:30", "11:00", "11:30"];
const conflictingTimes = ["10:00", "10:30", "11:00", "11:30"];
conflictingTimes.forEach((time) => {
const slot = slots.find((s) => s.time === time);
expect(slot?.available).to.be.false;

View file

@ -12,6 +12,34 @@ import {
type EventTypeConfig,
} from "./slots.js";
// Helper function to get current time in CET
function getCETTime(): Date {
const utcNow = new Date();
// Use Intl.DateTimeFormat to get the correct CET/CEST time
const formatter = new Intl.DateTimeFormat("en", {
timeZone: "Europe/Paris",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
const parts = formatter.formatToParts(utcNow);
const year = parseInt(parts.find((p) => p.type === "year")?.value || "0");
const month =
parseInt(parts.find((p) => p.type === "month")?.value || "0") - 1; // Month is 0-indexed
const day = parseInt(parts.find((p) => p.type === "day")?.value || "0");
const hour = parseInt(parts.find((p) => p.type === "hour")?.value || "0");
const minute = parseInt(parts.find((p) => p.type === "minute")?.value || "0");
const second = parseInt(parts.find((p) => p.type === "second")?.value || "0");
return new Date(year, month, day, hour, minute, second);
}
export const publicRouter = new Hono<{
Variables: {
supabase: SupabaseClient;
@ -73,7 +101,8 @@ publicRouter.get("/slots/:shortUserId/:standardName", async (c) => {
const exceptions = (availabilities.exceptions as Exception[]) || [];
// Get existing events for the next month
const now = new Date();
// Use CET time for availability calculations
const now = getCETTime();
const nextMonth = new Date(now);
nextMonth.setMonth(now.getMonth() + 1);
@ -101,7 +130,8 @@ publicRouter.get("/slots/:shortUserId/:standardName", async (c) => {
if (dayAvailability) {
const daySlots = generateTimeSlots(
new Date(currentDate),
now, // Pass CET current time as first parameter
currentDate,
dayAvailability,
eventTypeConfig,
exceptions,

View file

@ -1,5 +1,34 @@
import type { Tables } from "./database.types.js";
// Helper function to convert UTC date to CET
function convertToCET(utcDate: Date): Date {
// Create a new date object to avoid mutating the original
const cetDate = new Date(utcDate);
// Use Intl.DateTimeFormat to get the correct CET/CEST offset
const formatter = new Intl.DateTimeFormat("en", {
timeZone: "Europe/Paris",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
const parts = formatter.formatToParts(utcDate);
const year = parseInt(parts.find((p) => p.type === "year")?.value || "0");
const month =
parseInt(parts.find((p) => p.type === "month")?.value || "0") - 1; // Month is 0-indexed
const day = parseInt(parts.find((p) => p.type === "day")?.value || "0");
const hour = parseInt(parts.find((p) => p.type === "hour")?.value || "0");
const minute = parseInt(parts.find((p) => p.type === "minute")?.value || "0");
const second = parseInt(parts.find((p) => p.type === "second")?.value || "0");
return new Date(year, month, day, hour, minute, second);
}
// Types for availability calculation
type TimeRange = {
start: string;
@ -63,8 +92,16 @@ function formatTime(hours: number, minutes: number): string {
function addMinutes(timeStr: string, minutesToAdd: number): string {
const { hours, minutes } = parseTime(timeStr);
const totalMinutes = hours * 60 + minutes + minutesToAdd;
const newHours = Math.floor(totalMinutes / 60);
const newMinutes = totalMinutes % 60;
// 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);
}
@ -109,25 +146,28 @@ function getMinAdvanceBookingDate(
config: EventTypeConfig,
currentDate: Date
): { date: string; time: string } {
// Convert current UTC date to CET
const cetCurrentDate = convertToCET(currentDate);
if (!config.minAdvanceBooking) {
return {
date: getDateString(currentDate),
time: formatTime(currentDate.getHours(), currentDate.getMinutes()),
date: getDateString(cetCurrentDate),
time: formatTime(cetCurrentDate.getHours(), cetCurrentDate.getMinutes()),
};
}
const { value, unit } = config.minAdvanceBooking;
const advanceDate = new Date(currentDate);
const advanceDate = new Date(cetCurrentDate);
switch (unit) {
case "minutes":
advanceDate.setMinutes(currentDate.getMinutes() + value);
advanceDate.setMinutes(cetCurrentDate.getMinutes() + value);
break;
case "hours":
advanceDate.setHours(currentDate.getHours() + value);
advanceDate.setHours(cetCurrentDate.getHours() + value);
break;
case "days":
advanceDate.setDate(currentDate.getDate() + value);
advanceDate.setDate(cetCurrentDate.getDate() + value);
break;
}
@ -142,7 +182,8 @@ export function getDateString(date: Date): string {
}
export function generateTimeSlots(
date: Date,
currentTime: Date, // in CET
date: Date, // in CET
availability: DayAvailability,
eventTypeConfig: EventTypeConfig,
exceptions: Exception[],
@ -169,10 +210,11 @@ export function generateTimeSlots(
return slots; // Day not available
}
console.log(timeRanges);
// Check minimum advance booking
const minAdvanceBooking = getMinAdvanceBookingDate(eventTypeConfig, date);
const minAdvanceBooking = getMinAdvanceBookingDate(
eventTypeConfig,
currentTime
);
// Generate slots for each time range
for (const range of timeRanges) {
@ -188,25 +230,6 @@ export function generateTimeSlots(
currentMinutes % 60
);
// Check if this slot conflicts with existing events
const slotEndTime = addMinutes(slotTime, eventTypeConfig.duration);
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);
// Check for overlap
return (
(slotTime >= eventStart && slotTime < eventEnd) ||
(slotEndTime > eventStart && slotEndTime <= eventEnd) ||
(slotTime <= eventStart && slotEndTime >= eventEnd)
);
});
console.log(slotTime, minAdvanceBooking.time);
// Check if slot is in the future (considering minimum advance booking)
// Compare dates first, then times if on the same date
const isInFuture =
@ -217,12 +240,44 @@ export function generateTimeSlots(
slots.push({
date: dateStr,
time: slotTime,
available: !hasConflict && isInFuture,
available: isInFuture, // Will be updated later based on conflicts and buffer time
});
// Move to next slot (considering buffer time)
const bufferTime = eventTypeConfig.bufferTime || 0;
currentMinutes += eventTypeConfig.duration + bufferTime;
// 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;
}
}

1
api/test_buffer_time.js Normal file
View file

@ -0,0 +1 @@

View file

@ -78,7 +78,6 @@ export const EventDetailsModal = ({
}
};
console.log(event.tablo_color);
return (
<CustomModal
isOpen={isOpen}

View file

@ -150,7 +150,6 @@ export function EventTypeModal({
formData.minAdvanceBooking?.unit || "minutes"
)}
onSelectionChange={(key) => {
console.log(key);
setFormData({
...formData,
minAdvanceBooking: {

View file

@ -3,6 +3,8 @@ import { useState } from "react";
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import { Strong, Text } from "@ui/ui-library/text";
import { Button } from "@ui/ui-library/button";
import { CustomModal } from "@ui/components/CustomModal";
import { TextField, Label, Input, FieldError } from "@ui/ui-library/field";
import { useTheme } from "@ui/contexts/ThemeContext";
import {
CalendarIcon,
@ -38,6 +40,21 @@ export function PublicBookingPage() {
const [currentDate, setCurrentDate] = useState(new Date());
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
// Modal state
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedSlot, setSelectedSlot] = useState<{
date: Date;
slot: TimeSlot;
} | null>(null);
const [formData, setFormData] = useState({
email: "",
name: "",
});
const [formErrors, setFormErrors] = useState({
email: "",
name: "",
});
// Theme
const { theme, setTheme } = useTheme();
@ -64,15 +81,29 @@ export function PublicBookingPage() {
}
};
// Helper function to convert date to CET timezone string (YYYY-MM-DD)
const formatDateToCET = (date: Date): string => {
return date.toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
};
// Helper function to get current date in CET timezone
const getCurrentDateInCET = (): Date => {
const now = new Date();
const cetTime = new Date(
now.toLocaleString("en-US", { timeZone: "Europe/Paris" })
);
return cetTime;
};
// Get available time slots for a specific date
const getAvailableSlots = (date: Date): TimeSlot[] => {
const dateStr = date.toISOString().split("T")[0];
const dateStr = formatDateToCET(date);
return slotsData[dateStr]?.filter((slot) => slot.available) || [];
};
// Check if a date has any available slots
const hasAvailableSlots = (date: Date): boolean => {
const dateStr = date.toISOString().split("T")[0];
const dateStr = formatDateToCET(date);
return slotsData[dateStr]?.some((slot) => slot.available) || false;
};
@ -80,13 +111,21 @@ export function PublicBookingPage() {
const getDaysInMonth = (date: Date) => {
const year = date.getFullYear();
const month = date.getMonth();
const firstDay = new Date(year, month, 1);
// Create first day of month and get its day of week in CET
const firstDayStr = `${year}-${String(month + 1).padStart(2, "0")}-01`;
const firstDay = new Date(firstDayStr + "T12:00:00");
const firstDayOfWeekInCET = new Date(
firstDay.toLocaleString("en-US", { timeZone: "Europe/Paris" })
).getDay();
// Adjust for Monday as first day of week
const mondayStartingDay =
firstDayOfWeekInCET === 0 ? 6 : firstDayOfWeekInCET - 1;
// Get number of days in month
const lastDay = new Date(year, month + 1, 0);
const daysInMonth = lastDay.getDate();
// Adjust for Monday as first day of week
const startingDayOfWeek = firstDay.getDay();
const mondayStartingDay =
startingDayOfWeek === 0 ? 6 : startingDayOfWeek - 1;
const days = [];
@ -97,7 +136,10 @@ export function PublicBookingPage() {
// Add all days of the month
for (let day = 1; day <= daysInMonth; day++) {
days.push(new Date(year, month, day));
const dayStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(
day
).padStart(2, "0")}`;
days.push(new Date(dayStr + "T12:00:00"));
}
return days;
@ -116,14 +158,17 @@ export function PublicBookingPage() {
};
const isToday = (date: Date) => {
const today = new Date();
return date.toDateString() === today.toDateString();
const todayInCET = getCurrentDateInCET();
const todayStr = formatDateToCET(todayInCET);
const dateStr = formatDateToCET(date);
return dateStr === todayStr;
};
const isPastDate = (date: Date) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
return date < today;
const todayInCET = getCurrentDateInCET();
const todayStr = formatDateToCET(todayInCET);
const dateStr = formatDateToCET(date);
return dateStr < todayStr;
};
const formatMonthYear = (date: Date) => {
@ -155,6 +200,54 @@ export function PublicBookingPage() {
return `${hours}h ${remainingMinutes}min`;
};
// Modal and form handlers
const handleSlotClick = (date: Date, slot: TimeSlot) => {
setSelectedSlot({ date, slot });
setIsModalOpen(true);
setFormData({ email: "", name: "" });
setFormErrors({ email: "", name: "" });
};
const handleCloseModal = () => {
setIsModalOpen(false);
setSelectedSlot(null);
setFormData({ email: "", name: "" });
setFormErrors({ email: "", name: "" });
};
const validateForm = () => {
const errors = { email: "", name: "" };
let isValid = true;
if (!formData.email.trim()) {
errors.email = "L'adresse email est requise";
isValid = false;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
errors.email = "Veuillez entrer une adresse email valide";
isValid = false;
}
if (!formData.name.trim()) {
errors.name = "Le nom est requis";
isValid = false;
}
setFormErrors(errors);
return isValid;
};
const handleSubmit = () => {
if (validateForm()) {
// TODO: Implement account creation logic
console.log("Creating account with:", {
email: formData.email,
name: formData.name,
slot: selectedSlot,
});
handleCloseModal();
}
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
@ -220,9 +313,10 @@ export function PublicBookingPage() {
<div className="flex items-center gap-3">
<ClockIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />
<div>
<Strong className="text-gray-900 dark:text-white text-sm">
{formatDuration(eventType.duration)}
</Strong>
<Text className="text-gray-900 dark:text-white text-sm">
Durée:{" "}
<Strong>{formatDuration(eventType.duration)}</Strong>
</Text>
</div>
</div>
)}
@ -239,7 +333,6 @@ export function PublicBookingPage() {
</div>
</div>
)}
{eventType?.location && (
<div className="flex items-center gap-3">
<MapPinIcon className="w-5 h-5 text-green-600 dark:text-green-400" />
@ -250,7 +343,6 @@ export function PublicBookingPage() {
</div>
</div>
)}
{eventType?.requiresApproval && (
<div className="flex items-center gap-3">
<UserIcon className="w-5 h-5 text-indigo-600 dark:text-indigo-400" />
@ -370,6 +462,7 @@ export function PublicBookingPage() {
key={index}
variant="outline"
className="w-full justify-center py-3 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:border-blue-300 dark:hover:border-blue-600"
onPress={() => handleSlotClick(selectedDate, slot)}
>
{slot.time}
</Button>
@ -396,6 +489,77 @@ export function PublicBookingPage() {
</div>
</div>
</div>
{/* Booking Modal */}
<CustomModal
isOpen={isModalOpen}
onClose={handleCloseModal}
title="Créer un compte pour réserver"
width="md"
>
{selectedSlot && (
<div className="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<div className="flex items-center gap-2 text-blue-700 dark:text-blue-300">
<CalendarIcon className="w-4 h-4" />
<Text className="font-medium">
{selectedSlot.date.toLocaleDateString("fr-FR", {
weekday: "long",
day: "numeric",
month: "long",
})}
</Text>
</div>
<div className="flex items-center gap-2 text-blue-700 dark:text-blue-300 mt-1">
<ClockIcon className="w-4 h-4" />
<Text className="font-medium">{selectedSlot.slot.time}</Text>
</div>
</div>
)}
<div className="space-y-4">
<TextField isRequired isInvalid={!!formErrors.name}>
<Label>Nom complet</Label>
<Input
type="text"
value={formData.name}
onChange={(e) =>
setFormData((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="Votre nom complet"
/>
{formErrors.name && <FieldError>{formErrors.name}</FieldError>}
</TextField>
<TextField isRequired isInvalid={!!formErrors.email}>
<Label>Adresse email</Label>
<Input
type="email"
value={formData.email}
onChange={(e) =>
setFormData((prev) => ({ ...prev, email: e.target.value }))
}
placeholder="votre@email.com"
/>
{formErrors.email && <FieldError>{formErrors.email}</FieldError>}
</TextField>
<div className="pt-2">
<Text className="text-sm text-gray-600 dark:text-gray-400">
Un compte sera créé avec ces informations pour gérer votre
réservation.
</Text>
</div>
</div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<Button variant="outline" onPress={handleCloseModal}>
Annuler
</Button>
<Button variant="solid" onPress={handleSubmit}>
Créer le compte et réserver
</Button>
</div>
</CustomModal>
</div>
);
}