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