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:
@@ -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;
|
||||
$$;
|
||||
Reference in New Issue
Block a user