diff --git a/src/data/availability.test.ts b/src/data/availability.test.ts index f60792d..3b8ae23 100644 --- a/src/data/availability.test.ts +++ b/src/data/availability.test.ts @@ -17,7 +17,7 @@ describe('computeAvailableSlots', () => { it('generates slots from start_time to end_time stepped by slot_minutes on a working day', () => { const date = '2026-08-11' const template = templateFor(date) - const slots = computeAvailableSlots(template, [], [], date) + const slots = computeAvailableSlots(template, [], [], date, 60) expect(slots).toEqual([ { date, time: '9:00 AM', available: true }, { date, time: '10:00 AM', available: true }, @@ -28,15 +28,15 @@ describe('computeAvailableSlots', () => { it('returns no slots instead of hanging when slot_minutes is zero or negative', () => { const date = '2026-08-11' - expect(computeAvailableSlots(templateFor(date, { slot_minutes: 0 }), [], [], date)).toEqual([]) - expect(computeAvailableSlots(templateFor(date, { slot_minutes: -30 }), [], [], date)).toEqual([]) + expect(computeAvailableSlots(templateFor(date, { slot_minutes: 0 }), [], [], date, 60)).toEqual([]) + expect(computeAvailableSlots(templateFor(date, { slot_minutes: -30 }), [], [], date, 60)).toEqual([]) }) it('returns no slots when the date is a closed exception', () => { const date = '2026-08-11' const template = templateFor(date) const exceptions: ScheduleException[] = [{ date, closed: true, note: 'Congé' }] - expect(computeAvailableSlots(template, exceptions, [], date)).toEqual([]) + expect(computeAvailableSlots(template, exceptions, [], date, 60)).toEqual([]) }) it('returns no slots when the day of week is not a working day', () => { @@ -44,14 +44,14 @@ describe('computeAvailableSlots', () => { const day = new Date(`${date}T00:00:00`).getDay() const otherDay = (day + 1) % 7 const template = templateFor(date, { working_days: [otherDay] }) - expect(computeAvailableSlots(template, [], [], date)).toEqual([]) + expect(computeAvailableSlots(template, [], [], date, 60)).toEqual([]) }) it('marks a slot unavailable when it already appears in booked_slots for that date', () => { const date = '2026-08-11' const template = templateFor(date) - const bookedSlots: BookedSlot[] = [{ date, time: '10:00 AM' }] - const slots = computeAvailableSlots(template, [], bookedSlots, date) + const bookedSlots: BookedSlot[] = [{ date, time: '10:00 AM', duration_minutes: 60 }] + const slots = computeAvailableSlots(template, [], bookedSlots, date, 60) expect(slots.find((s) => s.time === '10:00 AM')?.available).toBe(false) expect(slots.find((s) => s.time === '9:00 AM')?.available).toBe(true) }) @@ -59,10 +59,42 @@ describe('computeAvailableSlots', () => { it('ignores booked_slots rows for a different date', () => { const date = '2026-08-11' const template = templateFor(date) - const bookedSlots: BookedSlot[] = [{ date: '2026-08-12', time: '9:00 AM' }] - const slots = computeAvailableSlots(template, [], bookedSlots, date) + const bookedSlots: BookedSlot[] = [{ date: '2026-08-12', time: '9:00 AM', duration_minutes: 60 }] + const slots = computeAvailableSlots(template, [], bookedSlots, date, 60) expect(slots.every((s) => s.available)).toBe(true) }) + + it('blocks slots that would overlap a long booked service, not just its exact start time', () => { + const date = '2026-08-11' + const template = templateFor(date, { start_time: '09:00:00', end_time: '15:00:00', slot_minutes: 60 }) + // A 240-minute (4-hour) service booked at 9:00 AM occupies 9:00 AM-1:00 PM. + const bookedSlots: BookedSlot[] = [{ date, time: '9:00 AM', duration_minutes: 240 }] + const slots = computeAvailableSlots(template, [], bookedSlots, date, 60) + expect(slots.find((s) => s.time === '9:00 AM')?.available).toBe(false) + expect(slots.find((s) => s.time === '11:00 AM')?.available).toBe(false) + expect(slots.find((s) => s.time === '12:00 PM')?.available).toBe(false) + expect(slots.find((s) => s.time === '1:00 PM')?.available).toBe(true) + }) + + it('blocks a candidate slot whose own requested duration would overlap an existing booking', () => { + const date = '2026-08-11' + const template = templateFor(date, { start_time: '09:00:00', end_time: '15:00:00', slot_minutes: 60 }) + // An existing 60-minute booking at 1:00 PM. A candidate 3-hour service + // starting at 11:00 AM would run 11:00 AM-2:00 PM, overlapping it. + const bookedSlots: BookedSlot[] = [{ date, time: '1:00 PM', duration_minutes: 60 }] + const slots = computeAvailableSlots(template, [], bookedSlots, date, 180) + expect(slots.find((s) => s.time === '11:00 AM')?.available).toBe(false) + expect(slots.find((s) => s.time === '9:00 AM')?.available).toBe(true) + }) + + it('marks a slot unavailable when the requested duration would not finish before closing', () => { + const date = '2026-08-11' + const template = templateFor(date, { start_time: '09:00:00', end_time: '13:00:00', slot_minutes: 60 }) + const slots = computeAvailableSlots(template, [], [], date, 120) + // Closing is 1:00 PM; a 2-hour service starting at 12:00 PM would finish at 2:00 PM. + expect(slots.find((s) => s.time === '12:00 PM')?.available).toBe(false) + expect(slots.find((s) => s.time === '11:00 AM')?.available).toBe(true) + }) }) describe('getNextAvailableDateFromSlots', () => { @@ -73,7 +105,7 @@ describe('getNextAvailableDateFromSlots', () => { const template: ScheduleTemplate = { id: 'default', working_days: [nextDay], start_time: '09:00:00', end_time: '10:00:00', slot_minutes: 60, } - const result = getNextAvailableDateFromSlots(template, [], new Map(), fromDate, 14) + const result = getNextAvailableDateFromSlots(template, [], new Map(), fromDate, 60, 14) expect(result).toBe(nextDate) }) @@ -81,7 +113,7 @@ describe('getNextAvailableDateFromSlots', () => { const template: ScheduleTemplate = { id: 'default', working_days: [], start_time: '09:00:00', end_time: '10:00:00', slot_minutes: 60, } - expect(getNextAvailableDateFromSlots(template, [], new Map(), '2026-08-10', 14)).toBeNull() + expect(getNextAvailableDateFromSlots(template, [], new Map(), '2026-08-10', 60, 14)).toBeNull() }) it('respects a fully booked date by skipping it', () => { @@ -90,8 +122,8 @@ describe('getNextAvailableDateFromSlots', () => { const template: ScheduleTemplate = { id: 'default', working_days: [nextDay], start_time: '09:00:00', end_time: '10:00:00', slot_minutes: 60, } - const bookedByDate = new Map([[nextDate, [{ date: nextDate, time: '9:00 AM' }]]]) + const bookedByDate = new Map([[nextDate, [{ date: nextDate, time: '9:00 AM', duration_minutes: 60 }]]]) // With a 1-hour window (09:00-10:00, 60min slots) there's only one slot, and it's booked. - expect(getNextAvailableDateFromSlots(template, [], bookedByDate, '2026-08-10', 1)).toBeNull() + expect(getNextAvailableDateFromSlots(template, [], bookedByDate, '2026-08-10', 60, 1)).toBeNull() }) }) diff --git a/src/data/availability.ts b/src/data/availability.ts index 4a92e2a..2727225 100644 --- a/src/data/availability.ts +++ b/src/data/availability.ts @@ -21,6 +21,7 @@ export interface ScheduleException { export interface BookedSlot { date: string time: string + duration_minutes: number } function parseTimeToMinutes(time: string): number { @@ -28,6 +29,16 @@ function parseTimeToMinutes(time: string): number { return hours * 60 + minutes } +// booked_slots.time is stored in the same "H:MM AM/PM" display format this +// module generates via formatMinutesAsTime, not the 24-hour "HH:MM" format +// used by the schedule template's start_time/end_time. +function parseDisplayTimeToMinutes(time: string): number { + const [rawTime, period] = time.split(' ') + const [hours, minutes] = rawTime.split(':').map(Number) + const hour24 = period === 'PM' ? (hours % 12) + 12 : hours % 12 + return hour24 * 60 + minutes +} + function formatMinutesAsTime(totalMinutes: number): string { const hour24 = Math.floor(totalMinutes / 60) const minute = totalMinutes % 60 @@ -41,6 +52,7 @@ export function computeAvailableSlots( exceptions: ScheduleException[], bookedSlots: BookedSlot[], date: string, + requestedDurationMinutes: number, ): TimeSlot[] { const exception = exceptions.find((e) => e.date === date) if (exception?.closed) { @@ -68,14 +80,20 @@ export function computeAvailableSlots( const startMinutes = parseTimeToMinutes(template.start_time) const endMinutes = parseTimeToMinutes(template.end_time) - const bookedTimes = new Set( - bookedSlots.filter((b) => b.date === date).map((b) => b.time), - ) + const dayBookings = bookedSlots.filter((b) => b.date === date) const slots: TimeSlot[] = [] for (let m = startMinutes; m < endMinutes; m += template.slot_minutes) { const time = formatMinutesAsTime(m) - slots.push({ date, time, available: !bookedTimes.has(time) }) + const candidateStart = m + const candidateEnd = m + requestedDurationMinutes + const fitsBeforeClosing = candidateEnd <= endMinutes + const overlapsExistingBooking = dayBookings.some((b) => { + const bookedStart = parseDisplayTimeToMinutes(b.time) + const bookedEnd = bookedStart + b.duration_minutes + return candidateStart < bookedEnd && bookedStart < candidateEnd + }) + slots.push({ date, time, available: fitsBeforeClosing && !overlapsExistingBooking }) } return slots } @@ -85,6 +103,7 @@ export function getNextAvailableDateFromSlots( exceptions: ScheduleException[], bookedSlotsByDate: Map, fromDate: string, + requestedDurationMinutes: number, searchWindowDays = 14, ): string | null { const start = new Date(`${fromDate}T00:00:00`) @@ -92,7 +111,7 @@ export function getNextAvailableDateFromSlots( const candidate = new Date(start) candidate.setDate(start.getDate() + i) const iso = candidate.toISOString().slice(0, 10) - const slots = computeAvailableSlots(template, exceptions, bookedSlotsByDate.get(iso) ?? [], iso) + const slots = computeAvailableSlots(template, exceptions, bookedSlotsByDate.get(iso) ?? [], iso, requestedDurationMinutes) if (slots.some((s) => s.available)) { return iso } diff --git a/src/hooks/useAvailability.test.ts b/src/hooks/useAvailability.test.ts index dd45010..fdb5de7 100644 --- a/src/hooks/useAvailability.test.ts +++ b/src/hooks/useAvailability.test.ts @@ -55,12 +55,12 @@ describe('useAvailability', () => { it('getSlotsForDate fetches booked_slots for that date and returns computed slots', async () => { const day = new Date('2026-08-11T00:00:00').getDay() templateSingleMock.mockResolvedValue({ data: { ...TEMPLATE, working_days: [day] }, error: null }) - bookedSlotsEqMock.mockResolvedValue({ data: [{ date: '2026-08-11', time: '10:00 AM' }], error: null }) + bookedSlotsEqMock.mockResolvedValue({ data: [{ date: '2026-08-11', time: '10:00 AM', duration_minutes: 60 }], error: null }) const { result } = renderHook(() => useAvailability()) await waitFor(() => expect(result.current.loading).toBe(false)) - const slots = await result.current.getSlotsForDate('2026-08-11') + const slots = await result.current.getSlotsForDate('2026-08-11', 60) expect(bookedSlotsEqMock).toHaveBeenCalledWith('date', '2026-08-11') expect(slots.find((s) => s.time === '10:00 AM')?.available).toBe(false) expect(slots.find((s) => s.time === '9:00 AM')?.available).toBe(true) @@ -74,7 +74,7 @@ describe('useAvailability', () => { const { result } = renderHook(() => useAvailability()) await waitFor(() => expect(result.current.loading).toBe(false)) - const next = await result.current.findNextAvailableDate('2026-08-10', 14) + const next = await result.current.findNextAvailableDate('2026-08-10', 60, 14) expect(bookedSlotsGteMock).toHaveBeenCalledWith('date', '2026-08-10') expect(next).toBe(nextDate) }) diff --git a/src/hooks/useAvailability.ts b/src/hooks/useAvailability.ts index 9dc1678..17efacb 100644 --- a/src/hooks/useAvailability.ts +++ b/src/hooks/useAvailability.ts @@ -12,8 +12,8 @@ import { interface UseAvailabilityResult { loading: boolean error: string - getSlotsForDate: (date: string) => Promise - findNextAvailableDate: (fromDate: string, searchWindowDays?: number) => Promise + getSlotsForDate: (date: string, requestedDurationMinutes: number) => Promise + findNextAvailableDate: (fromDate: string, requestedDurationMinutes: number, searchWindowDays?: number) => Promise } export default function useAvailability(): UseAvailabilityResult { @@ -51,20 +51,20 @@ export default function useAvailability(): UseAvailabilityResult { }, []) const getSlotsForDate = useCallback( - async (date: string): Promise => { + async (date: string, requestedDurationMinutes: number): Promise => { if (!template) return [] const { data, error: fetchError } = await supabase.from('booked_slots').select('*').eq('date', date) if (fetchError) { setError(fetchError.message) return [] } - return computeAvailableSlots(template, exceptions, (data ?? []) as BookedSlot[], date) + return computeAvailableSlots(template, exceptions, (data ?? []) as BookedSlot[], date, requestedDurationMinutes) }, [template, exceptions], ) const findNextAvailableDate = useCallback( - async (fromDate: string, searchWindowDays = 14): Promise => { + async (fromDate: string, requestedDurationMinutes: number, searchWindowDays = 14): Promise => { if (!template) return null const start = new Date(`${fromDate}T00:00:00`) const end = new Date(start) @@ -87,7 +87,7 @@ export default function useAvailability(): UseAvailabilityResult { bookedByDate.set(row.date, list) } - return getNextAvailableDateFromSlots(template, exceptions, bookedByDate, fromDate, searchWindowDays) + return getNextAvailableDateFromSlots(template, exceptions, bookedByDate, fromDate, requestedDurationMinutes, searchWindowDays) }, [template, exceptions], ) diff --git a/src/pages/Book.test.tsx b/src/pages/Book.test.tsx index c31a89e..38d80bb 100644 --- a/src/pages/Book.test.tsx +++ b/src/pages/Book.test.tsx @@ -77,10 +77,11 @@ describe('Book page', () => { expect(screen.getByLabelText(/choisissez une date/i)).toBeInTheDocument() }) - it('fetches and displays available slots for the chosen date', async () => { + it('fetches and displays available slots for the chosen date, using the selected service duration', async () => { renderBook([{ pathname: '/book', state: { serviceId: 'cornrows' } }]) await userEvent.type(screen.getByLabelText(/choisissez une date/i), '2026-08-11') - await waitFor(() => expect(getSlotsForDateMock).toHaveBeenCalledWith('2026-08-11')) + // cornrows has duration_minutes: 120 in SERVICE_ROWS. + await waitFor(() => expect(getSlotsForDateMock).toHaveBeenCalledWith('2026-08-11', 120)) expect(await screen.findByRole('button', { name: '9:00 AM' })).toBeInTheDocument() }) diff --git a/src/pages/Book.tsx b/src/pages/Book.tsx index 3b977d0..a910b8b 100644 --- a/src/pages/Book.tsx +++ b/src/pages/Book.tsx @@ -35,21 +35,24 @@ function Book() { void load() }, []) + const selectedService = services.find((s) => s.id === booking.state.serviceId) + const selectedServiceDuration = selectedService?.durationMinutes + useEffect(() => { let cancelled = false async function load() { - if (!dateInput || availability.loading) { + if (!dateInput || availability.loading || !selectedServiceDuration) { setSlots([]) setNextAvailable(null) return } setNextAvailable(null) - const result = await availability.getSlotsForDate(dateInput) + const result = await availability.getSlotsForDate(dateInput, selectedServiceDuration) if (cancelled) return setSlots(result) if (!result.some((s) => s.available)) { - const next = await availability.findNextAvailableDate(dateInput) + const next = await availability.findNextAvailableDate(dateInput, selectedServiceDuration) if (!cancelled) setNextAvailable(next) } } @@ -58,10 +61,9 @@ function Book() { return () => { cancelled = true } - }, [dateInput, booking.state.step, availability]) + }, [dateInput, booking.state.step, availability, selectedServiceDuration]) const availableSlots = slots.filter((s) => s.available) - const selectedService = services.find((s) => s.id === booking.state.serviceId) return (
diff --git a/supabase/migrations/20260812210641_duration_aware_booked_slots.sql b/supabase/migrations/20260812210641_duration_aware_booked_slots.sql new file mode 100644 index 0000000..39edea5 --- /dev/null +++ b/supabase/migrations/20260812210641_duration_aware_booked_slots.sql @@ -0,0 +1,84 @@ +-- Follow-up to Phase 2C's final whole-branch review: booked_slots only +-- recorded (date, time), so availability checking only ever excluded an +-- exact time match. A 4-hour service booked at 9:00 AM did not block +-- 11:00 AM or 1:00 PM, even though the two would physically overlap. This +-- was inert while nothing persisted (Phase 2A/2B); Phase 2C is what makes +-- it reachable by real customers, so it's fixed before that branch merges +-- rather than shipped as a known gap. +-- +-- Fix: booked_slots gains duration_minutes, populated from the booked +-- service's own duration_minutes at sync time. Availability computation +-- (client-side, src/data/availability.ts) changes from an exact-time-match +-- check to a real half-open-interval overlap check: a candidate slot +-- [candidateStart, candidateStart + requestedDuration) is unavailable if it +-- overlaps ANY existing booked slot's [bookedStart, bookedStart + +-- bookedDuration) interval. booked_slots keeps exactly one row per active +-- booking (not one row per slot-grid-interval it spans) -- the overlap math +-- lives entirely in the client's pure computation function, matching this +-- project's established preference for a thin client over new DB logic. + +alter table public.booked_slots add column duration_minutes integer not null default 0; + +-- The default above only exists so the column can be added without a +-- backfill value for any pre-existing rows; every row from this point +-- forward is written by sync_booked_slot() below, which always supplies a +-- real duration. Drop the default so a future direct insert can't silently +-- create a zero-duration (and therefore never-overlapping, i.e. bookable +-- over) row by omitting the column. +alter table public.booked_slots alter column duration_minutes drop default; + +create or replace function public.sync_booked_slot() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_duration integer; +begin + if tg_op = 'DELETE' then + if old.status in ('confirmed', 'completed') then + delete from public.booked_slots + where date = old.date and time = old.time + and not exists ( + select 1 from public.bookings b + where b.date = old.date and b.time = old.time and b.status in ('confirmed', 'completed') + ); + end if; + return old; + end if; + + if tg_op = 'UPDATE' then + -- Covers both a status change away from 'confirmed'/'completed' + -- (cancellation) and a reschedule of a still-occupying booking to a + -- different (date, time): either way, the OLD slot may no longer have + -- any confirmed/completed booking holding it and its mirror row needs + -- to go. + if old.status in ('confirmed', 'completed') + and (old.date, old.time, old.status) is distinct from (new.date, new.time, new.status) then + delete from public.booked_slots + where date = old.date and time = old.time + and not exists ( + select 1 from public.bookings b + where b.date = old.date and b.time = old.time and b.status in ('confirmed', 'completed') + ); + end if; + if new.status in ('confirmed', 'completed') then + select duration_minutes into v_duration from public.services where id = new.service_id; + insert into public.booked_slots (date, time, duration_minutes) + values (new.date, new.time, coalesce(v_duration, 0)) + on conflict (date, time) do update set duration_minutes = excluded.duration_minutes; + end if; + return new; + end if; + + -- INSERT + if new.status in ('confirmed', 'completed') then + select duration_minutes into v_duration from public.services where id = new.service_id; + insert into public.booked_slots (date, time, duration_minutes) + values (new.date, new.time, coalesce(v_duration, 0)) + on conflict (date, time) do update set duration_minutes = excluded.duration_minutes; + end if; + return new; +end; +$$; diff --git a/supabase/tests/database/rls.test.sql b/supabase/tests/database/rls.test.sql index 85da329..d2e868c 100644 --- a/supabase/tests/database/rls.test.sql +++ b/supabase/tests/database/rls.test.sql @@ -1,5 +1,5 @@ begin; -select plan(47); +select plan(53); -- Seed reference data as service_role (bypasses RLS) set local role service_role; @@ -350,5 +350,46 @@ select lives_ok( 'the unique index can be created after the self-heal dedupe removes the duplicate' ); +-- Duration-aware booked_slots: the mirror row now carries the booked +-- service's duration, so client-side availability computation can detect +-- overlap (not just an exact time match). knotless has duration_minutes = 240 +-- (seeded at the top of this file). +set local role service_role; + +select lives_ok( + $$ insert into public.bookings (service_id, date, time, contact_name, contact_phone, contact_email, contact_notes) + values ('knotless', '2026-12-01', '9:00 AM', 'Duration Client', '555-0301', 'duration@example.com', '') $$, + 'confirmed booking for the duration test succeeds' +); + +select results_eq( + $$ select duration_minutes from public.booked_slots where date = '2026-12-01' and time = '9:00 AM' $$, + $$ values (240) $$, + 'booked_slots mirror row carries the booked services duration_minutes' +); + +select lives_ok( + $$ update public.bookings set status = 'completed' + where date = '2026-12-01' and time = '9:00 AM' and status = 'confirmed' $$, + 'marking the duration-test booking completed succeeds' +); + +select results_eq( + $$ select duration_minutes from public.booked_slots where date = '2026-12-01' and time = '9:00 AM' $$, + $$ values (240) $$, + 'booked_slots keeps the correct duration_minutes after confirmed -> completed' +); + +select lives_ok( + $$ update public.bookings set status = 'cancelled' + where date = '2026-12-01' and time = '9:00 AM' and status = 'completed' $$, + 'cancelling the duration-test booking succeeds' +); + +select is_empty( + $$ select * from public.booked_slots where date = '2026-12-01' and time = '9:00 AM' $$, + 'booked_slots mirror row is removed after the duration-test booking is cancelled, regardless of duration' +); + select * from finish(); rollback;