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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user