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