Files
Crussell/.sisyphus/plans/staged-default-hours-change.md
T
popertots 9eecb375cf 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.
2026-08-22 00:34:49 +01:00

11 KiB
Raw Blame History

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

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:

  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:

{
    "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:

[
    {"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/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:

{#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