feat: enriched edit request system with side-by-side snapshots, calendar preloading, and admin review UI

Backend:
- Add enriched response types (EditSnapshot, EnrichedEditRequest) with original vs proposed snapshots
- Add 4 new GET endpoints for viewing edit requests (user and admin scoped)
- Remove github.com/lib/pq dependency — use native PostgreSQL array scanning
- Clean up edit requests, time blockers, and notifications on booking cancellation
- Validate exceptional closed hours on admin approve (409 Conflict)
- Notification upsert on edit request replace (no duplicate admin notifications)

Frontend:
- New user EditRequestModal with time/services/both modes and lunch protection
- New admin EditRequestModal with side-by-side diff (date/time, services, notes)
- Integrate edit requests into PendingApprovals card and notifications page
- Preload 3 months of availability to prevent calendar snap-back
- Apply lunch protection to isDateUnavailable in BookingFlow and BookingCreateModal
- Fix accessibility: card list items use <button> instead of <div>

Dev & Docs:
- Seed edit requests in local-dev-2.sh
- Update all Obsidian manuals with enriched edit request documentation
- 42 new tests (438/441 passing)
This commit is contained in:
2026-05-26 11:59:07 +01:00
parent 3ccc017716
commit 8574bf2221
19 changed files with 5270 additions and 599 deletions
@@ -479,17 +479,98 @@
);
let placeholder = $state<CalendarDate>(minDate);
let userNavigatedCalendar = $state(false);
$effect(() => {
fetchServices();
});
// Preload 3 months on first render to prevent snap-back during navigation
let initialLoadDone = $state(false);
$effect(() => {
if (!initialLoadDone) {
fetchHoursRange(placeholder, 3);
initialLoadDone = true;
}
});
// Fetch additional months when navigating beyond preloaded range
$effect(() => {
const monthKey = `${placeholder.year}-${String(placeholder.month).padStart(2, '0')}`;
if (!workingHoursCache.has(monthKey) || !availableHoursCache.has(monthKey)) {
if (initialLoadDone && !workingHoursCache.has(monthKey)) {
fetchHoursForMonth(placeholder);
}
});
async function fetchHoursRange(startDate: CalendarDate, months: number) {
// Calculate end month manually (CalendarDate is immutable)
let endYear = startDate.year;
let endMonth = startDate.month + months - 1;
while (endMonth > 12) {
endMonth -= 12;
endYear++;
}
const endMonthDate = new CalendarDate(endYear, endMonth, 1);
const daysInEndMonth = endMonthDate.calendar.getDaysInMonth(endMonthDate);
const startStr = startDate.toString();
const endStr = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(daysInEndMonth).padStart(2, '0')}`;
loadingWorkingHours = true;
loadingAvailableHours = true;
try {
const [whRes, ahRes] = await Promise.all([
fetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`),
fetch(`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`)
]);
if (!whRes.ok || !ahRes.ok) {
throw new Error(`HTTP error! wh: ${whRes.status}, ah: ${ahRes.status}`);
}
const whData: Array<WorkingHoursDay> = await whRes.json();
const ahData: Array<AvailableHoursDay> = await ahRes.json();
const whMap: Record<string, { isOpen: boolean; startTime: string; endTime: string }> = {};
whData.forEach((d) => {
whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime };
});
const ahMap: Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }> = {};
ahData.forEach((d) => {
ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots };
});
// Cache by month key
for (let i = 0; i < months; i++) {
let mYear = startDate.year;
let mMonth = startDate.month + i;
while (mMonth > 12) {
mMonth -= 12;
mYear++;
}
const key = `${mYear}-${String(mMonth).padStart(2, '0')}`;
workingHoursCache.set(key, whMap);
availableHoursCache.set(key, ahMap);
}
workingHours = whMap;
availableHours = ahMap;
if (!selectedDate) {
setDefaultSelectedDate(whMap);
}
} catch (error) {
console.error('Failed to fetch hours:', error);
if (!selectedDate) {
selectedDate = minDate;
}
} finally {
loadingWorkingHours = false;
loadingAvailableHours = false;
}
}
async function fetchHoursForMonth(date: CalendarDate) {
const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`;
@@ -597,29 +678,35 @@
const dateStr = nextDate.toISOString().split('T')[0];
if (hoursMap[dateStr]?.isOpen) {
selectedDate = new CalendarDate(
const calDate = new CalendarDate(
nextDate.getFullYear(),
nextDate.getMonth() + 1,
nextDate.getDate()
);
// Also update placeholder to show the month with first available date
placeholder = new CalendarDate(
nextDate.getFullYear(),
nextDate.getMonth() + 1,
1 // First day of the month
);
break;
const duration = getTotalDuration() || 60;
const slots = generateAvailableTimeSlots(duration, calDate);
if (slots.length > 0) {
selectedDate = calDate;
if (!userNavigatedCalendar) {
placeholder = new CalendarDate(
nextDate.getFullYear(),
nextDate.getMonth() + 1,
1
);
}
return;
}
}
}
if (!selectedDate) {
const tomorrow = new SvelteDate();
tomorrow.setDate(tomorrow.getDate() + 1);
selectedDate = new CalendarDate(
tomorrow.getFullYear(),
tomorrow.getMonth() + 1,
tomorrow.getDate()
);
const tomorrow = new SvelteDate();
tomorrow.setDate(tomorrow.getDate() + 1);
selectedDate = new CalendarDate(
tomorrow.getFullYear(),
tomorrow.getMonth() + 1,
tomorrow.getDate()
);
if (!userNavigatedCalendar) {
placeholder = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, 1);
}
}
@@ -837,16 +924,33 @@
if (!dayHours) return true;
if (!dayHours.isOpen) return true;
// If no services selected, don't check availability slots
// This allows calendar to show open/closed days
if (selectedServices.length === 0) {
return false; // Show all working days as available
return false;
}
const duration = getTotalDuration();
const availableSlots = generateAvailableTimeSlots(duration, date);
if (availableSlots.length === 0) return true;
const dayAvailableHours = availableHours?.[dateStr];
if (dayAvailableHours?.slots) {
const existingBookings = extractBookedSlots(
dayHours.startTime,
dayHours.endTime,
dayAvailableHours.slots
);
const lunchProtection = getLunchProtectionForSlots(
dayHours.startTime,
dayHours.endTime,
existingBookings,
duration,
15,
false
);
const validSlots = availableSlots.filter((t) => !lunchProtection.get(t)?.isBlocked);
if (validSlots.length === 0) return true;
}
return false;
}
@@ -1320,9 +1424,10 @@
selectedDate = newDate;
selectedTime = null;
}}
onPlaceholderChange={(newPlaceholder) => {
placeholder = newPlaceholder;
}}
onPlaceholderChange={(newPlaceholder) => {
userNavigatedCalendar = true;
placeholder = newPlaceholder;
}}
/>
{/if}