# Holiday Hours Conflict Resolution Plan ## Overview Add a conflict resolution flow to the "Create Exception Schedule" modal (similar to the Time Blocker flow) that prevents saving holiday hours until all affected client bookings have been moved out of the newly-closed/rescheduled time periods. --- ## Key Differences from Time Blocker Flow | Aspect | Time Blockers | Holiday Hours | |---|---|---| | Scope | Single day, specific time range | Entire weeks, full daily schedules | | Available-for-reschedule logic | Same working hours apply (no change) | **Dual mode**: For same-week affected days → use **new holiday hours**; for other weeks → use **default hours** | | Bypass mechanism | N/A (blocker is hard block) | `out_of_hours` flag on admin booking create/reserve | | Existing conflict check | Client-side overlap against one date | Need per-week overlap against up to N weeks | | Placeholder blocker pattern | `RESERVATION:placeholder:{bookingId}` | New pattern needed (likely holiday-specific placeholder) | --- ## Files to Modify ### Frontend 1. **`frontend/src/lib/components/admin/HolidayHours.svelte`** — Add conflict detection UI 2. **`frontend/src/routes/admin/+page.svelte`** — Wire `openUserModal`, `openBookingModal`, `rescheduleVersion` props to `HolidayHours` ### Backend 3. **`backend/handlers/scheduling/exceptional-hours.go`** — Add conflict-checking API or expand existing 4. **`backend/handlers/scheduling/default-hours.go`** — Add "preview" mode to GetAvailableHours that accepts proposed exceptional hours for conflict-specific available-slot preview 5. **`backend/main.go`** — Register new route(s) --- ## Frontend Design ### A. Props Interface (HolidayHours.svelte) ```typescript interface Props { openUserModal?: (userId: string) => void; openBookingModal?: (bookingId: string) => void; rescheduleVersion?: number; } ``` Wire these from `admin/+page.svelte`: ```svelte ``` ### B. New State Variables ```typescript let overlappingBookings = $state([]); let checkingOverlap = $state(false); let hasOverlap = $state(false); let conflictWeeks = $state>(new Map()); type OverlappingBooking = { id: string; start_time: string; duration_minutes: number; status: string; user: { id: string; full_name: string; email: string | null; phone: string | null } | null; services: string[]; week_start: string; // Which Monday this booking falls in day_of_week: number; // 0=Mon..6=Sun is_same_week_as_change: boolean; // Whether this week is being modified }; ``` ### C. Guard Condition ```typescript const canSave = $derived.by(() => { return isFormValid && !hasOverlap && !checkingOverlap; }); ``` ### D. Conflict Checking Flow When admin opens the modal or changes the week selection: 1. **Collect all affected dates**: For each week_start in `weekStarts`, expand to the 7 days (Mon-Sun). Figure out which days have changes (different hours from default, or newly closed). 2. **Fetch conflicting bookings**: Call a new backend endpoint: ``` GET /api/admin/bookings/conflicting-for-exception ?week_starts[]=2026-08-03&week_starts[]=2026-08-10 &proposed_hours=[{weekday:1,start:"10:00",end:"18:00",isOpen:true},...] ``` This returns ONLY bookings that fall within the affected hours (e.g., if Mon is now closed, all Monday bookings in that week conflict; if Mon hours changed from 9-5 to 12-8, bookings at 9am-12pm conflict since they'd be in the newly-closed part). 3. **Client-side display**: Group conflicting bookings by week, show in amber warning section with: - Week label ("Week of 3 Aug 2026") - For each conflicting booking: client name, time, services - "View Booking" / "View Client" buttons ### E. Available-For-Reschedule Display For each conflicting booking, the admin needs to know where they **can** reschedule to. This requires a **preview endpoint** that shows available hours **as-if** the holiday hours were already applied: - If the booking is in a week being modified by this exception → show available hours **using the new proposed holiday hours** (the hours that will apply once saved) - If the booking is in any other week → show available hours **using the existing working hours** (default + any already-applied exceptions) Backend endpoint: ``` GET /api/scheduling/preview-available-hours ?start=2026-08-03&end=2026-08-10 &proposed_hours=[{weekday:1,start:"10:00",end:"18:00",isOpen:true},...] &proposed_weeks=["2026-08-03"] // proposed_hours + proposed_weeks simulate the new exception group ``` This is like `GetAvailableHours` but overrides the working hours resolution with the proposed exception for the specified weeks. ### F. Save Flow Changes When admin clicks "Create Schedule": 1. Check if conflicts exist → prevent save 2. Admin must click "View Booking" to cancel/reschedule each conflicting booking 3. After each booking is resolved, re-check conflicts 4. When no conflicts remain → "Create Schedule" button enables 5. On save: Optionally create placeholder blockers for the conflicting bookings (similar to `RESERVATION:placeholder:` pattern but adapted for holiday hours). These would block the affected SLOTS (not the full week) to prevent re-booking during the transition. **Placeholder strategy**: For each affected booking time, create a `RESERVATION:holiday_placeholder:{bookingId}:{weekStart}` entry in time_blockers with TTL = 24 hours (enough for the resolution process). --- ## Backend Design ### A. New Endpoint: `GET /api/admin/bookings/conflicting-for-exception` Purpose: Return all bookings that would conflict with a proposed exception group. Input: ``` week_starts: string[] // Mondays of affected weeks (YYYY-MM-DD) proposed_hours: [{ weekday: number // 0=Mon..6=Sun startTime: string // HH:MM endTime: string // HH:MM isOpen: boolean }] ``` Logic: 1. For each week_start, expand to 7 days (Mon-Sun) 2. For each day, look up the proposed hours for that weekday 3. Query bookings table for bookings on those dates 4. Filter bookings that fall OUTSIDE the proposed open hours (if the day is closed → all bookings conflict; if hours changed → bookings before new start or after new end conflict) 5. Return filtered bookings grouped by week_start with user info SQL approach: ```sql SELECT b.id, b.start_time, b.total_duration_minutes as duration, b.status, u.id as user_id, u.fn as full_name, u.email, u.phone FROM bookings b LEFT JOIN users u ON b.user_id = u.id WHERE b.status NOT IN ('completed','client_cancelled','we_cancelled','no_show','deposit_lapsed') AND b.start_time >= $1 -- range start AND b.start_time < $2 -- range end AND ( (b.start_time::time < $3) -- starts before new opening time OR (b.start_time::time + (b.total_duration_minutes || ' minutes')::interval > $4) -- ends after new closing ) ORDER BY b.start_time ``` ### B. New/Modified Endpoint: `GET /api/scheduling/preview-available-hours` Purpose: Show available hours as-if the proposed exception group were already applied. This is a modified version of `GetAvailableHours` that: 1. Accepts the same parameters (start, end) 2. Accepts additional `proposed_hours` and `proposed_weeks` parameters 3. For days in proposed_weeks, uses proposed_hours instead of actual exceptional hours 4. Otherwise, behaves identically to the current logic (including time blocker subtraction, late-night lock, etc.) ### C. Admin Override Mechanism The existing `out_of_hours` flag on `AdminCreateBookingForUserRequest` and `AdminReserveSlotRequest` already exists. When an admin is rescheduling a booking during the conflict resolution flow: - The "available hours" preview from step B shows valid slots - If the admin needs to place a booking outside those hours (e.g., there's no good time in the holiday hours), they can use the existing **admin booking creation** with `out_of_hours: true` to bypass working hours restrictions - This is already supported in: - `AdminCreateBookingForUserHandler` (manage.go:622) — skips exceptional-hours closed check when `out_of_hours=true` - `AdminReserveSlotHandler` (admin_reserve.go:118) — skips closing-hours check when `out_of_hours=true` - The frontend could expose this via a "Place outside working hours" checkbox on the booking modal (only visible during admin conflict resolution) ### D. Time Blocker Integration The holiday hours placeholder pattern (`RESERVATION:holiday_placeholder:*`) should use the existing `CleanupOldReservations` mechanism with a custom TTL. Since these are conceptually similar to `RESERVATION:edit_request:*` (24h TTL), we can add a new TTL category: ```go // In CleanupOldReservations, add: // RESERVATION:holiday_placeholder:* -> 24 hours ``` --- ## Implementation Steps ### Step 1: Backend — Add conflicting bookings endpoint 1.1 Implement `GetConflictingBookingsForExceptionHandler` in `exceptional-hours.go` 1.2 Register route: `r.Get("/conflicting-for-exception", scheduling.GetConflictingBookingsForExceptionHandler)` in `main.go` 1.3 Handle the logic: iterate weeks → expand to days → join with proposed hours → find bookings outside new hours ### Step 2: Backend — Add preview available hours endpoint 2.1 Add `GetPreviewAvailableHours` in `default-hours.go` 2.2 Accept `proposed_hours` and `proposed_weeks` as query params (JSON-encoded arrays) 2.3 Override working hours resolution for days in proposed_weeks ### Step 3: Frontend — Add conflict UI to HolidayHours.svelte 3.1 Add `openUserModal`, `openBookingModal`, `rescheduleVersion` props 3.2 Add `checkConflictingBookings()` function 3.3 Add amber warning section for conflicts (reuse pattern from TimeBlockers.svelte) 3.4 Wire `canSave` guard to `hasOverlap` 3.5 Group conflicts by week_start for clarity ### Step 4: Frontend — Wire props in admin/+page.svelte 4.1 Pass `openUserModal`, `openBookingModal`, `rescheduleVersion` to `` ### Step 5: Backend — Add placeholder cleanup category 5.1 Add `RESERVATION:holiday_placeholder:*` → 24 hours in `CleanupOldReservations` ### Step 6: Edge Cases 6.1 **Existing exception groups stacking**: If multiple exception groups already apply, the new proposed hours should override them for the preview (the newest exception wins) 6.2 **Partial day changes**: If a day goes from 9-5 to 12-8, only bookings starting before 12pm conflict 6.3 **Day closing entirely**: All bookings on that day conflict 6.4 **Week already has an exception**: The proposed exception replaces it entirely for those weeks — check against the new hours, not the current exception 6.5 **Admin override flow**: When admin clicks "View Booking", the booking modal should let them reschedule with `out_of_hours` option if needed (this may already work through admin booking creation) --- ## Sequence Diagram (Text) ``` Admin opens "Create Exception Schedule" modal → Selects weeks + sets per-day hours → For each clicked change, calls checkConflictingBookings() → GET /api/admin/bookings/conflicting-for-exception ?week_starts=2026-08-03,2026-08-10 &proposed_hours=[{...}] → Returns list of conflicting bookings with user info → Shows amber warning with grouped conflicts → Admin clicks "View Booking" on a conflict → BookingModal opens, admin cancels or reschedules → BookingModal calls onReschedule callback → Re-check conflicts → When all conflicts resolved → "Create Schedule" enables → Admin clicks "Create Schedule" → Creates placeholder blockers for each resolved conflict → POST /api/scheduling/exceptional-groups (existing) → Placeholder blockers auto-cleanup after 24h ``` --- ## Compatibility Assessment (from 5 parallel investigations) ### 1. Timezone & DST Handling — SAFE with one watch **Architecture**: UTC-normalised. `clock.Now()` returns UTC. PostgreSQL runs with `timezone = "UTC"`. London conversion applied only where wall-clock rules matter. **BST midnight pattern** (critical): Every date-boundary query converts London-midnight to UTC using: ```go startTime = time.Date(startLondon.Year(), startLondon.Month(), startLondon.Day(), 0, 0, 0, 0, londonLocation).UTC() ``` This ensures a booking at 00:30 BST (23:30 UTC previous day) is included in the correct London date. Found in **9 locations**. The new endpoints MUST follow this pattern. **`expandCronOccurrences`** is DST-safe — extracts hour/minute from London time, pins occurrences to London wall-clock. **Watch**: Late-night lock (default-hours.go:594-611) uses `now.AddDate(0, 0, 1)` on **UTC** time, not London time, then formats the date string. If `now` is 22:30 BST (21:30 UTC), this is practically safe since 22:30 London doesn't cross a calendar boundary. But for the preview endpoint, replicate the London-midnight boundary pattern directly rather than copying the late-night lock's UTC date logic. ### 2. Weekday Mapping — MUST MATCH EXACTLY **7 different conversion patterns exist.** The holiday hours code must use the **same patterns** as the existing booking handlers: **For Go time → DB weekday** (0=Monday): ```go weekday := int((localStart.Weekday() + 6) % 7) ``` This is used in all booking handlers (reserve.go, admin_reserve.go, bookings.go, manage.go). **For date → weekStart (Monday at UTC midnight)**: ```go localStart := req.StartTime.In(londonLocation) daysToMonday := int(localStart.Weekday()) if daysToMonday == 0 { daysToMonday = 7 } tm := localStart.AddDate(0, 0, -daysToMonday+1) weekStart := time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, time.UTC) ``` Used in 4 booking handler locations. The UTC-midnight trick is essential — `tm` has Location=London from `.In()`, so `.Year()/Month()/Day()` return London calendar values. `time.Date(..., time.UTC)` creates a UTC midnight at that London date, which pgx's DATE codec maps correctly regardless of BST/GMT. **Do NOT use** the `daysSinceMonday := int(d.Weekday()) - 1` pattern from `GetWorkingHours`/`GetAvailableHours` — that's used for date iteration loops, not individual booking lookups. ### 3. Booking Statuses — Match time-blocker flow (includes `pending_release`) **For conflict detection**, use the same filter as the time-blocker flow: ```sql status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed') ``` This **includes `pending_release`** as a conflicting status. Rationale: `pending_release` represents a client who owes a deposit but hasn't paid on time. The system allows other users to book over the slot until payment is received (guaranteeing the slot for the business — if the original client is a no-show, another booking might fill it). However, if a holiday hours change is applied, the slot will be blocked entirely, defeating that safety mechanism. These clients should be contacted too — they may want to pay or choose a different time before the holiday hours take effect. Status breakdown: - `pending` — active (needs admin contact) - `confirmed` — active (needs admin contact) - `in_progress` — active (might overlap with change) - `pending_release` — active (client owes deposit, slot still reclaimable via payment) - `completed` — excluded (already done) - `client_cancelled`/`we_cancelled` — excluded (no client to contact) - `no_show` — excluded (no active booking) - `deposit_lapsed` — excluded (slot was already reclaimed by another booking) ### 4. Overlap SQL Pattern — Use the overlap condition, not the date-range condition **Critical distinction**: - `GetBookingsByDateRangeHandler` uses `start_time >= $1 AND start_time < $2` — this only finds bookings that **start** within the range. It misses bookings that started before but extend into it. - Real overlap checks use `start_time < $2 AND end_time > $1` — this catches all bookings that occupy any part of the range. For holiday hours conflict detection, the holiday hours cover FULL DAYS (00:00-23:59 London). Active bookings on those days overlap by definition. The filter should find bookings whose time falls **OUTSIDE** the proposed new open window: ```sql WHERE status NOT IN ('client_cancelled','we_cancelled','no_show','pending_release','deposit_lapsed') AND start_time < day_end_utc AND end_time > day_start_utc AND ( start_time::time AT TIME ZONE 'Europe/London' < proposed_open -- starts before opening OR end_time::time AT TIME ZONE 'Europe/London' > proposed_close -- ends after closing OR proposed_is_open = false -- day is closed entirely ) ``` ### 5. GetAvailableHours Pipeline — Injection Point for Preview The `preview-available-hours` endpoint must mirror `GetAvailableHours` exactly, with proposed hours injected at step 7a: ``` Priority order (highest to lowest): 1. Proposed holiday hours (new) <-- inject here 2. Existing exceptional hours <-- existing `applied` check 3. Default working_hours <-- existing `defaultMap` fallback 4. Closed (00:00-00:00, isOpen=false) <-- existing fallback ``` Injection point is at `default-hours.go` lines 549-564 (inside the per-day loop). Add BEFORE the `if applied != nil` block: ```go if proposed, ok := proposedHours[day.Date]; ok { baseStart = proposed.StartTime baseEnd = proposed.EndTime isOpen = proposed.IsOpen day.Source = "proposed" } else if applied != nil { // existing exceptional hours check... ``` All downstream steps (booking subtraction, blocker subtraction, late-night lock, `out_of_hours` override) remain unchanged — they operate on the resolved `baseStart`/`baseEnd`/`isOpen` values. ### Summary: No blockers found All 5 investigations confirmed the plan is compatible with the existing timezone, weekday mapping, status filtering, and slot calculation systems. The critical invariants to maintain are: 1. **London-midnight → UTC conversion** for all date boundaries 2. **`(weekday + 6) % 7`** for Go→DB weekday mapping 3. **UTC-midnight weekStart** trick for exceptional hours lookups 4. **time-blocker status filter** (includes `pending_release`) for conflict detection 5. **Overlap condition** (`start_time < end AND end_time > start`) not date-range condition --- ## Risk Assessment | Risk | Mitigation | |---|---| | Race condition: booking created while admin is resolving conflicts | Placeholder blockers prevent this (Step 5) | | Admin creates overlapping exceptional hours across multiple groups | Each week_start is unique per group application (DB unique index on `exceptional_group_applications(week_start)`) | | Large number of affected weeks overwhelms UI | Paginate conflicts, show week-by-week summary | | Same-day bookings at BST midnight boundary fall in wrong date | Use existing London-midnight → UTC boundary pattern (not `GetBookingsByDateRange` approach) | | Wrong weekday mapping at DST boundary | Use `(localStart.Weekday() + 6) % 7` + UTC-midnight weekStart trick (same as all booking handlers) | | `pending_release` clients contacted unnecessarily | They owe a deposit — treating their booking as active is correct. If they don't pay, the slot would be evictable anyway, but contacting them ensures they have a chance to respond before holiday hours lock it | | Late-night lock (22:00-11:00) uses UTC dates inconsistently | Preview endpoint should use London-midnight for all date comparisons, not UTC `AddDate` |