19 KiB
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
frontend/src/lib/components/admin/HolidayHours.svelte— Add conflict detection UIfrontend/src/routes/admin/+page.svelte— WireopenUserModal,openBookingModal,rescheduleVersionprops toHolidayHours
Backend
backend/handlers/scheduling/exceptional-hours.go— Add conflict-checking API or expand existingbackend/handlers/scheduling/default-hours.go— Add "preview" mode to GetAvailableHours that accepts proposed exceptional hours for conflict-specific available-slot previewbackend/main.go— Register new route(s)
Frontend Design
A. Props Interface (HolidayHours.svelte)
interface Props {
openUserModal?: (userId: string) => void;
openBookingModal?: (bookingId: string) => void;
rescheduleVersion?: number;
}
Wire these from admin/+page.svelte:
<HolidayHours {openUserModal} {openBookingModal} {rescheduleVersion} />
B. New State Variables
let overlappingBookings = $state<OverlappingBooking[]>([]);
let checkingOverlap = $state(false);
let hasOverlap = $state(false);
let conflictWeeks = $state<Map<string, OverlappingBooking[]>>(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
const canSave = $derived.by(() => {
return isFormValid && !hasOverlap && !checkingOverlap;
});
D. Conflict Checking Flow
When admin opens the modal or changes the week selection:
-
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). -
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).
-
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":
- Check if conflicts exist → prevent save
- Admin must click "View Booking" to cancel/reschedule each conflicting booking
- After each booking is resolved, re-check conflicts
- When no conflicts remain → "Create Schedule" button enables
- 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:
- For each week_start, expand to 7 days (Mon-Sun)
- For each day, look up the proposed hours for that weekday
- Query bookings table for bookings on those dates
- 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)
- Return filtered bookings grouped by week_start with user info
SQL approach:
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:
- Accepts the same parameters (start, end)
- Accepts additional
proposed_hoursandproposed_weeksparameters - For days in proposed_weeks, uses proposed_hours instead of actual exceptional hours
- 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: trueto bypass working hours restrictions - This is already supported in:
AdminCreateBookingForUserHandler(manage.go:622) — skips exceptional-hours closed check whenout_of_hours=trueAdminReserveSlotHandler(admin_reserve.go:118) — skips closing-hours check whenout_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:
// 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 <HolidayHours>
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:
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):
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):
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:
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:
GetBookingsByDateRangeHandlerusesstart_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:
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:
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:
- London-midnight → UTC conversion for all date boundaries
(weekday + 6) % 7for Go→DB weekday mapping- UTC-midnight weekStart trick for exceptional hours lookups
- time-blocker status filter (includes
pending_release) for conflict detection - 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 |