From 5486d89726a8fbf80d0446dca936ab21b424a472 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Mon, 29 Sep 2025 22:48:33 +0200 Subject: [PATCH] Finish slot generation --- api/src/__tests__/slots.test.ts | 409 +++++++++++++++++++++--- api/src/public.ts | 34 +- api/src/slots.ts | 125 ++++++-- api/test_buffer_time.js | 1 + ui/src/components/EventDetailsModal.tsx | 1 - ui/src/components/EventTypeModal.tsx | 1 - ui/src/pages/PublicBookingPage.tsx | 200 ++++++++++-- 7 files changed, 671 insertions(+), 100 deletions(-) create mode 100644 api/test_buffer_time.js diff --git a/api/src/__tests__/slots.test.ts b/api/src/__tests__/slots.test.ts index abe2210..2f6e815 100644 --- a/api/src/__tests__/slots.test.ts +++ b/api/src/__tests__/slots.test.ts @@ -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; diff --git a/api/src/public.ts b/api/src/public.ts index f8d9dc6..7a23677 100644 --- a/api/src/public.ts +++ b/api/src/public.ts @@ -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, diff --git a/api/src/slots.ts b/api/src/slots.ts index d6231ca..92e061a 100644 --- a/api/src/slots.ts +++ b/api/src/slots.ts @@ -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; } } diff --git a/api/test_buffer_time.js b/api/test_buffer_time.js new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/api/test_buffer_time.js @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/components/EventDetailsModal.tsx b/ui/src/components/EventDetailsModal.tsx index b457e99..28da6d3 100644 --- a/ui/src/components/EventDetailsModal.tsx +++ b/ui/src/components/EventDetailsModal.tsx @@ -78,7 +78,6 @@ export const EventDetailsModal = ({ } }; - console.log(event.tablo_color); return ( { - console.log(key); setFormData({ ...formData, minAdvanceBooking: { diff --git a/ui/src/pages/PublicBookingPage.tsx b/ui/src/pages/PublicBookingPage.tsx index 279ff16..b899aa9 100644 --- a/ui/src/pages/PublicBookingPage.tsx +++ b/ui/src/pages/PublicBookingPage.tsx @@ -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(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 (
{/* Header */} @@ -220,9 +313,10 @@ export function PublicBookingPage() {
- - {formatDuration(eventType.duration)} - + + Durée:{" "} + {formatDuration(eventType.duration)} +
)} @@ -239,7 +333,6 @@ export function PublicBookingPage() {
)} - {eventType?.location && (
@@ -250,7 +343,6 @@ export function PublicBookingPage() {
)} - {eventType?.requiresApproval && (
@@ -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} @@ -396,6 +489,77 @@ export function PublicBookingPage() {
+ + {/* Booking Modal */} + + {selectedSlot && ( +
+
+ + + {selectedSlot.date.toLocaleDateString("fr-FR", { + weekday: "long", + day: "numeric", + month: "long", + })} + +
+
+ + {selectedSlot.slot.time} +
+
+ )} + +
+ + + + setFormData((prev) => ({ ...prev, name: e.target.value })) + } + placeholder="Votre nom complet" + /> + {formErrors.name && {formErrors.name}} + + + + + + setFormData((prev) => ({ ...prev, email: e.target.value })) + } + placeholder="votre@email.com" + /> + {formErrors.email && {formErrors.email}} + + +
+ + Un compte sera créé avec ces informations pour gérer votre + réservation. + +
+
+ +
+ + +
+
); }