feat: edit request time blockers, today closing time, UI polish, and test fixes

- Add time blocker management for booking edit requests
- Add closing_time field to admin today/current-next endpoint
- Update UserBookingModal and CurrentAppointment UI components
- Fix fmt import in bookings_test.go (was missing)
- Fix created_by FK in TestAdminApproveEditRequest_TimeBlockerOverlap
- Update test coverage for edit request time blocker overlap
- Update gap backlog documentation
This commit is contained in:
2026-05-10 16:53:17 +01:00
parent f3fb44f401
commit 83c62ffb97
10 changed files with 950 additions and 37 deletions
@@ -351,6 +351,70 @@
async function submitReschedule() {
if (!selectedBooking || !rescheduleDate || !rescheduleTime) return;
// Re-fetch available hours to confirm slot is still open
try {
const dateStr = rescheduleDate.toString();
const monthKey = `${rescheduleDate.year}-${String(rescheduleDate.month).padStart(2, '0')}`;
const startOfMonth = new CalendarDate(rescheduleDate.year, rescheduleDate.month, 1);
const endOfMonth = new CalendarDate(rescheduleDate.year, rescheduleDate.month, rescheduleDate.calendar.getDaysInMonth(rescheduleDate));
const [whRes, ahRes] = await Promise.all([
fetch(`/api/scheduling/working-hours?start=${startOfMonth}&end=${endOfMonth}`),
fetch(`/api/scheduling/available-hours?start=${startOfMonth}&end=${endOfMonth}`)
]);
if (whRes.ok && ahRes.ok) {
const whData: WorkingHoursDay[] = await whRes.json();
const ahData: AvailableHoursDay[] = await ahRes.json();
const freshWH: Record<string, { isOpen: boolean; startTime: string; endTime: string }> = {};
whData.forEach((d) => { freshWH[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime }; });
const freshAH: Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }> = {};
ahData.forEach((d) => { freshAH[d.date] = { isOpen: d.isOpen, slots: d.slots }; });
const dayWH = freshWH[dateStr];
const dayAH = freshAH[dateStr];
if (!dayWH?.isOpen || !dayAH?.slots) {
toast.error('This date is no longer available. Please select a different date.');
rescheduleDate = undefined;
rescheduleTime = '';
return;
}
// Check if the selected time is still available
const freshSlots: string[] = [];
for (const slot of dayAH.slots) {
const [sh, sm] = slot.startTime.split(':').map(Number);
const [eh, em] = slot.endTime.split(':').map(Number);
for (let m = sh * 60 + sm; m < eh * 60 + em; m += 15) {
if (m + totalDuration <= eh * 60 + em) {
freshSlots.push(`${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`);
}
}
}
if (!freshSlots.includes(rescheduleTime)) {
toast.error('This time slot is no longer available. Please choose a different time.');
rescheduleTime = '';
return;
}
// Check lunch protection
const existingBookings = extractBookedSlots(dayWH.startTime, dayWH.endTime, dayAH.slots);
const freshLunch = getLunchProtectionForSlots(dayWH.startTime, dayWH.endTime, existingBookings, totalDuration, 15, false);
if (freshLunch.get(rescheduleTime)?.isBlocked) {
toast.error('This time slot is no longer available. Please choose a different time.');
rescheduleTime = '';
return;
}
}
} catch {
toast.error('Could not verify slot availability. Please try again.');
return;
}
rescheduleSubmitting = true;
try {
const [hours, minutes] = rescheduleTime.split(':').map(Number);
@@ -37,7 +37,9 @@
let currentAppointment = $state<Booking | null>(null);
let nextAppointment = $state<Booking | null>(null);
let closingTime = $state<string | null>(null); // "HH:MM" format
let freeTimeAfter = $state(0); // minutes of free time after current/next appointment
let freeTimeCapped = $state(false); // true if free time extends to/past closing
let loading = $state(true);
let timeRemaining = $state(0); // minutes remaining in current appointment
let isInProgress = $state(false);
@@ -52,48 +54,53 @@
isInProgress = currentAppointment.status === 'in_progress';
const startTime = new SvelteDate(currentAppointment.start_time);
if (isInProgress) {
// Appointment is in progress - show time remaining until end
const endTime = new SvelteDate(
startTime.getTime() + currentAppointment.duration_minutes * 60 * 1000
);
const endTime = new SvelteDate(
startTime.getTime() + currentAppointment.duration_minutes * 60 * 1000
);
// If current time is before start time, show time until start
if (isInProgress) {
if (now.getTime() < startTime.getTime()) {
const timeUntilMs = startTime.getTime() - now.getTime();
timeRemaining = Math.max(0, Math.floor(timeUntilMs / 60000));
} else {
// Otherwise show time until end
const remainingMs = endTime.getTime() - now.getTime();
timeRemaining = Math.max(0, Math.floor(remainingMs / 60000));
}
// Calculate free time until next appointment
if (nextAppointment) {
const endTime = new SvelteDate(
startTime.getTime() + currentAppointment.duration_minutes * 60 * 1000
);
const nextStart = new SvelteDate(nextAppointment.start_time);
const gapMs = nextStart.getTime() - endTime.getTime();
freeTimeAfter = Math.max(0, Math.floor(gapMs / 60000));
} else {
freeTimeAfter = 0;
}
} else {
// Appointment is upcoming - show time until start
const timeUntilMs = startTime.getTime() - now.getTime();
timeRemaining = Math.max(0, Math.floor(timeUntilMs / 60000));
freeTimeAfter = 0;
}
// If there's a next appointment, calculate free time after this one ends
if (nextAppointment) {
const endTime = new SvelteDate(
startTime.getTime() + currentAppointment.duration_minutes * 60 * 1000
);
const nextStart = new SvelteDate(nextAppointment.start_time);
const gapMs = nextStart.getTime() - endTime.getTime();
freeTimeAfter = Math.max(0, Math.floor(gapMs / 60000));
// Calculate free time after this appointment
let rawFreeMinutes = 0;
if (nextAppointment) {
const nextStart = new SvelteDate(nextAppointment.start_time);
const gapMs = nextStart.getTime() - endTime.getTime();
rawFreeMinutes = Math.max(0, Math.floor(gapMs / 60000));
}
// Cap at closing time
if (closingTime) {
const [ch, cm] = closingTime.split(':').map(Number);
const today = new SvelteDate();
const closing = new SvelteDate(today.getFullYear(), today.getMonth() + 1, today.getDate(), ch, cm, 0);
const minutesToClosing = Math.max(0, Math.floor((closing.getTime() - endTime.getTime()) / 60000));
if (!nextAppointment) {
// No next appointment — show time until closing
freeTimeAfter = minutesToClosing;
freeTimeCapped = true;
} else if (rawFreeMinutes > minutesToClosing) {
// Next appointment is after closing — cap at closing
freeTimeAfter = minutesToClosing;
freeTimeCapped = true;
} else {
freeTimeAfter = rawFreeMinutes;
freeTimeCapped = false;
}
} else {
freeTimeAfter = nextAppointment ? rawFreeMinutes : 0;
freeTimeCapped = false;
}
}
}
@@ -113,6 +120,7 @@
const data = await response.json();
currentAppointment = data.current || null;
nextAppointment = data.next || null;
closingTime = data.closing_time || null;
calculateTimes();
} else {
toast.error('Failed to load current appointment');
@@ -203,10 +211,16 @@
</span>
In Progress • {timeRemaining} min remaining
</Badge>
{#if freeTimeAfter > 0 && timeRemaining > 29}
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800">
{freeTimeAfter} min free afterwards
</Badge>
{#if timeRemaining > 29}
{#if freeTimeCapped && freeTimeAfter === 0}
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800">
closing after
</Badge>
{:else if freeTimeAfter > 0}
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800">
{freeTimeAfter} min free afterwards{#if freeTimeCapped} (closing after){/if}
</Badge>
{/if}
{/if}
</div>
{:else}