docs: update test counts from ~1,642 to 1,716 (4 skipped, 0 failures)
Reflect current test run results across README, Overview, Technical Manual, and Testing Architecture docs.
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
# 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}
|
||||
<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 |
|
||||
Reference in New Issue
Block a user