Reflect current test run results across README, Overview, Technical Manual, and Testing Architecture docs.
11 KiB
Staged Default Hours Change with Conflict Resolution
Overview
When an admin edits default working hours, instead of applying changes immediately:
- Run conflict resolution (same pattern as holiday hours/time blockers)
- Stage the change with a future effective date (admin picks, default +2 weeks)
- Current hours continue to apply until the switch-over date
- At midnight on the effective date, the change auto-applies and triggers a notification
- Contact page shows "These opening hours will change from [date]"
- Available hours use current hours before the date, new hours after
What Changes
New DB Table: default_hours_scheduled_changes
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
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:
- Parse + validate input
- Check for existing pending change — return 409 if one exists
- Run conflict detection between current bookings and the proposed hours
- If conflicts exist → return 409 with
{error: "conflicts exist", bookings: [...]}(same format as holiday hours) - 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:
{
"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:
- Query
default_hours_scheduled_changeswhereeffective_date <= CURRENT_DATE(London time) ANDapplied_at IS NULLANDcancelled_at IS NULL - For each due change:
a. BEGIN transaction
b. DELETE all existing
working_hoursrows c. INSERT new rows from the change's hours JSON d. INSERT anadmin_notificationswith reasondefault_hours_changed(details: "Default hours changed from [old summary] to [new summary]") e. SETapplied_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:
[
{"weekday": 0, "startTime": "00:00", "endTime": "00:00", "isOpen": false, ...},
...
]
Response changes to include optional scheduled_change field:
{
"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:
// 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/schedulewith 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
fetchDefaultHoursresponse now includesscheduled_change— display it
BusinessHours.svelte — Contact Page
Add a new section below the current "Upcoming Holiday Hours" section:
{#if scheduledChange}
<hr class="my-2 border-gray-200" />
<p class="mb-2 text-center text-xs font-medium text-amber-600">
Opening hours will change from {formattedDate}
</p>
{#each scheduledChange.hours as h}
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700">{dayNames[h.weekday]}</span>
<span class="text-gray-500">
{#if h.isOpen}
{formatTime(h.startTime)} – {formatTime(h.endTime)}
{:else}
Closed
{/if}
</span>
</div>
{/each}
{/if}
To load this, the fetchData function needs an additional API call:
GET /api/admin/default-hours/scheduled (public, or a new public endpoint)
Since this is shown on the public contact page, the endpoint should be under the public read-only group (OptionalAuth) — similar to how GET /scheduling/working-hours is public.
admin/+page.svelte
If WeeklySchedule needs openUserModal / openBookingModal for conflict resolution, wire the same props. Currently WeeklySchedule does not accept these props, but the conflict resolution flow needs "View Client" and "View Booking" buttons.
Conflict Resolution vs Holiday Hours — Reusing the Pattern
The conflict detection for default hours changes reuses the EXACT same pattern as the holiday hours conflict handler, but with a date range instead of week starts:
POST /api/admin/default-hours/conflicting-bookings
{
"proposed_hours": [{"weekday": 0, "startTime": "10:00", "endTime": "18:00", "isOpen": true}, ...],
"start_date": "2026-08-17",
"end_date": "2026-11-17" // default: +90 days from effective date
}
This reuses the same parseTimeToMinutes comparison logic and the ActiveBookingStatuses filter. The response is the same OverlappingBookingsResponse format.
Note: The availability-for-reschedule logic is simpler than holiday hours because:
- Default hours changes apply PERMANENTLY (not per-week like holiday hours)
- The "what hours apply for rescheduling" is just: current hours until effective_date, future hours after
- No per-week mapping needed
Risks
| Risk | Mitigation |
|---|---|
| Admin sets effective_date in the past | Validate: must be >= tomorrow (London date + 1) |
| Cron miss at midnight doesn't apply change | Cron runs 00:05 to avoid midnight race. Query uses <= CURRENT_DATE so it catches any missed days |
| Two admins try to schedule simultaneously | Unique constraint on (applied_at IS NULL AND cancelled_at IS NULL) — use a partial unique index |
| Notification is an admin-only todo placeholder | Add default_hours_changed to the enum, create one notification. Future work: user notification |
| Scheduling a change far in the future (6+ months) | Conflicts only checked against existing bookings in the window. Long-range changes may need re-checking when new bookings are made — acceptable for v1 |