fix: block duration-aware slot overlaps, not just exact time matches

booked_slots previously recorded only (date, time), so availability
checking excluded an exact time match but not a real overlap — a
4-hour service booked at 9:00 AM didn't block 11:00 AM or 1:00 PM.
Add duration_minutes to booked_slots (populated by sync_booked_slot()
from the booked service's own duration) and switch client-side
computeAvailableSlots to a half-open-interval overlap check, also
rejecting candidates that wouldn't finish before closing time.

Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
This commit is contained in:
belviskhoremk
2026-08-12 23:15:05 +02:00
parent 4ca40bf2b5
commit 43f4f5f46e
8 changed files with 214 additions and 35 deletions

View File

@@ -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<string, BookedSlot[]>([[nextDate, [{ date: nextDate, time: '9:00 AM' }]]])
const bookedByDate = new Map<string, BookedSlot[]>([[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()
})
})

View File

@@ -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<string, BookedSlot[]>,
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
}

View File

@@ -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)
})

View File

@@ -12,8 +12,8 @@ import {
interface UseAvailabilityResult {
loading: boolean
error: string
getSlotsForDate: (date: string) => Promise<TimeSlot[]>
findNextAvailableDate: (fromDate: string, searchWindowDays?: number) => Promise<string | null>
getSlotsForDate: (date: string, requestedDurationMinutes: number) => Promise<TimeSlot[]>
findNextAvailableDate: (fromDate: string, requestedDurationMinutes: number, searchWindowDays?: number) => Promise<string | null>
}
export default function useAvailability(): UseAvailabilityResult {
@@ -51,20 +51,20 @@ export default function useAvailability(): UseAvailabilityResult {
}, [])
const getSlotsForDate = useCallback(
async (date: string): Promise<TimeSlot[]> => {
async (date: string, requestedDurationMinutes: number): Promise<TimeSlot[]> => {
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<string | null> => {
async (fromDate: string, requestedDurationMinutes: number, searchWindowDays = 14): Promise<string | null> => {
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],
)

View File

@@ -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()
})

View File

@@ -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 (
<div className={styles.page}>

View File

@@ -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;
$$;

View File

@@ -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;