# Staged Default Hours Change with Conflict Resolution ## Overview When an admin edits default working hours, instead of applying changes immediately: 1. Run conflict resolution (same pattern as holiday hours/time blockers) 2. Stage the change with a future effective date (admin picks, default +2 weeks) 3. Current hours continue to apply until the switch-over date 4. At midnight on the effective date, the change auto-applies and triggers a notification 5. Contact page shows "These opening hours will change from [date]" 6. Available hours use current hours before the date, new hours after --- ## What Changes ### New DB Table: `default_hours_scheduled_changes` ```sql CREATE TABLE default_hours_scheduled_changes ( id SERIAL PRIMARY KEY, effective_date DATE NOT NULL, -- London midnight date to switch over created_by CHAR(12) NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ DEFAULT NOW(), applied_at TIMESTAMPTZ, -- NULL until cron applies it cancelled_at TIMESTAMPTZ, -- NULL unless admin cancels hours JSONB NOT NULL -- [{weekday, startTime, endTime, isOpen}] ); ``` Only ONE pending change is allowed at a time. If a pending change exists and the admin tries to create another, they must cancel the existing one first. ### New `admin_notification_reason` enum value ```sql ALTER TYPE admin_notification_reason ADD VALUE 'default_hours_changed'; ``` Used by the cron job when it applies the change — creates a single notification for admin review. --- ## Files to Create/Modify ### Backend | # | File | Action | |---|---|---| | 1 | `init-scripts/init-script.sql` | Add new table and enum value | | 2 | `backend/handlers/scheduling/default-hours.go` | Add `ScheduleDefaultHoursChange`, `GetScheduledDefaultHoursChange`, `CancelScheduledDefaultHoursChange` handlers | | 3 | `backend/handlers/scheduling/default-hours.go` | Modify `GetDefaultHours` — return both current and pending future hours | | 4 | `backend/handlers/scheduling/default-hours.go` | Modify `computeAvailableHours` / `GetWorkingHours` — inject future hours for dates >= effective_date | | 5 | `backend/handlers/scheduling/scheduled-cleanup.go` | Add `ApplyScheduledDefaultHours` cron handler | | 6 | `backend/internal/jobs/cleanup.go` | Register `apply-default-hours` cron job (daily at 00:05) | | 7 | `backend/main.go` | Register new routes | ### Frontend | # | File | Action | |---|---|---| | 8 | `frontend/src/lib/components/admin/WeeklySchedule.svelte` | Add conflict resolution UI + date picker + staged save flow | | 9 | `frontend/src/lib/components/layout/BusinessHours.svelte` | Show upcoming hours change with date | | 10 | `frontend/src/routes/admin/+page.svelte` | Wire modal props if needed | --- ## Detail: Backend Design ### Handler: `ScheduleDefaultHoursChange` (POST) `PUT /api/admin/default-hours` → replaced with a staging flow: ``` POST /api/admin/default-hours/schedule Content-Type: application/json { "hours": [{"weekday": 0, "startTime": "10:00", "endTime": "18:00", "isOpen": true}, ...], "effective_date": "2026-08-17" // optional, defaults to +14 days from today London } ``` **Flow:** 1. Parse + validate input 2. Check for existing pending change — return 409 if one exists 3. Run conflict detection between current bookings and the proposed hours 4. If conflicts exist → return 409 with `{error: "conflicts exist", bookings: [...]}` (same format as holiday hours) 5. If no conflicts → store the pending change, return 201 with `{effective_date: "2026-08-17"}` ### Handler: `GetScheduledDefaultHoursChange` (GET) ``` GET /api/admin/default-hours/scheduled ``` Returns the pending change or 404: ```json { "effective_date": "2026-08-17", "hours": [...], "created_at": "...", "created_by": "..." } ``` ### Handler: `CancelScheduledDefaultHoursChange` (DELETE) ``` DELETE /api/admin/default-hours/scheduled ``` Sets `cancelled_at` on the pending change. Returns 200. ### Handler: `ApplyScheduledDefaultHours` (cron) Runs at "5 0 * * *" (00:05 daily — 5 minutes after midnight to avoid midnight race conditions). **Flow:** 1. Query `default_hours_scheduled_changes` where `effective_date <= CURRENT_DATE` (London time) AND `applied_at IS NULL` AND `cancelled_at IS NULL` 2. For each due change: a. BEGIN transaction b. DELETE all existing `working_hours` rows c. INSERT new rows from the change's hours JSON d. INSERT an `admin_notifications` with reason `default_hours_changed` (details: "Default hours changed from [old summary] to [new summary]") e. SET `applied_at = NOW()` f. COMMIT ### Modified: `GetDefaultHours` Current: returns `SELECT weekday, start_time, end_time, is_open FROM working_hours ORDER BY weekday` New: if a pending change exists with `effective_date > today`, include an extra field: ```json [ {"weekday": 0, "startTime": "00:00", "endTime": "00:00", "isOpen": false, ...}, ... ] ``` Response changes to include optional `scheduled_change` field: ```json { "current": [...], "scheduled_change": { "effective_date": "2026-08-17", "hours": [...] } } ``` This is backwards-compatible for existing consumers — they read the array from `current`. ### Modified: `computeAvailableHours` / `GetWorkingHours` The hours resolution currently goes: proposed > exceptional > default > closed. For the staged hours feature, I need to inject the **scheduled future default hours** for dates >= effective_date: Priority order: proposed > exceptional > scheduled_future_default > default > closed In `computeAvailableHours` (the per-day loop), after looking up the `defaultMap` entry: ```go // Before falling through to default/closed, check if there's a staged future change if scheduledChangeHours != nil && d.In(londonLocation).Format("2006-01-02") >= scheduledEffectiveDate { if fh, ok := scheduledChangeHours[weekday]; ok { baseStart = fh.StartTime baseEnd = fh.EndTime isOpen = fh.IsOpen day.Source = "scheduled_change" } } else if def, ok := defaultMap[weekday]; ok { // existing default logic... } ``` This means: - Today → current working hours apply - Between today and effective_date → current working hours apply (no change) - On/after effective_date → new scheduled hours apply - Exceptional hours always override (higher priority) For `GetWorkingHours`, same logic applies — it needs to return the correct hours for each date in the range. --- ## Detail: Frontend Design ### WeeklySchedule.svelte — "Edit Schedule" Modal The modal gets a new top section (same pattern as HolidayHours conflict resolution): **Step 1: Admin opens modal, edits hours** Same time picker interface as today. No change to the editing UX. **Step 2: Conflict resolution (new)** - A date picker for "Apply from" (defaults to +14 days from today in London) - A `checkConflictingBookings()` function that: - Takes the proposed hours + effective date range (from effective_date to effective_date + 90 days or so) - Calls `POST /api/admin/default-hours/schedule` with a dry-run flag or a dedicated conflict endpoint - Shows amber warning with conflicting bookings + "View Booking" / "View Client" buttons - Guard: submit button disabled while conflicts exist **Step 3: Submit** - Button text: `Schedule Change for [date]` - On success toast: `Default hours will change at 23:59 on 17/08/2026` - The modal closes, admin sees a "Pending change" indicator on the WeeklySchedule card **Step 4: Pending change indicator** - After a change is scheduled, the WeeklySchedule card shows: - An amber banner: "Default hours are scheduled to change on 17/08/2026" - A "Cancel" button that calls `DELETE /api/admin/default-hours/scheduled` - The `fetchDefaultHours` response now includes `scheduled_change` — display it ### BusinessHours.svelte — Contact Page Add a new section below the current "Upcoming Holiday Hours" section: ```svelte {#if scheduledChange}
Opening hours will change from {formattedDate}
{#each scheduledChange.hours as h}