refactor(frontend): timezone-safe date handling with London-aware utilities

Introduce getLondonTodayCalendarDate(), parseWallClockDate(), and formatLocalDateTime() for reliable Europe/London timezone handling. Replace ad-hoc SvelteDate/new Date() usage with these utilities across all components and stores.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-24 23:43:58 +01:00
co-authored by Sisyphus
parent e4b9003439
commit 4ac7768070
32 changed files with 618 additions and 515 deletions
@@ -40,9 +40,12 @@
generateAvailableTimeSlots,
generateGroupedTimeSlots,
formatTime,
formatLocalDateTime,
calculateEndTime,
timeToMinutes,
getDayWithOrdinal,
parseWallClockDate,
getLondonTodayCalendarDate,
type DayHours,
type DayAvailability
} from '$lib/utils/timeSlots';
@@ -173,13 +176,7 @@
>({});
// Step 4: Date & Time
let placeholder = $state<CalendarDate>(
new CalendarDate(
new SvelteDate().getFullYear(),
new SvelteDate().getMonth() + 1,
new SvelteDate().getDate()
)
);
let placeholder = $state<CalendarDate>(getLondonTodayCalendarDate());
let selectedDate = $state<CalendarDate | undefined>(undefined);
let selectedTime = $state<string | null>(null);
let workingHours = $state<Record<string, DayHours> | null>(null);
@@ -196,7 +193,6 @@
$effect(() => {
if (outOfHours !== prevOutOfHours) {
prevOutOfHours = outOfHours;
console.log('[DEBUG] Out-of-hours mode TOGGLED', { now: outOfHours });
// Save normal hours BEFORE clearing, so we can show which slots are genuinely out-of-hours
if (outOfHours && workingHours) {
normalWorkingHours = { ...workingHours };
@@ -218,10 +214,10 @@
});
// Date Boundaries
const today = new SvelteDate();
const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate());
const maxDate = new SvelteDate();
maxDate.setMonth(today.getMonth() + 6);
const today = getLondonTodayCalendarDate();
const minDate = today;
const maxDate = new SvelteDate(today.year, today.month - 1, today.day);
maxDate.setMonth(today.month - 1 + 6);
const maxCalendarDate = new CalendarDate(
maxDate.getFullYear(),
maxDate.getMonth() + 1,
@@ -237,8 +233,8 @@
let isReserving = $state(false);
// =============== Cache ===============
let workingHoursCache: Record<string, Record<string, any>> = {};
let availableHoursCache: Record<string, Record<string, any>> = {};
let workingHoursCache: Record<string, Record<string, DayHours>> = {};
let availableHoursCache: Record<string, Record<string, DayAvailability>> = {};
let loadingMonthKeys: Set<string> = new Set();
// =============== Derived Helpers ===============
@@ -399,7 +395,7 @@
!bookingCreateAutoSelectDone
) {
bookingCreateAutoSelectDone = true;
const now = new SvelteDate();
const now = new SvelteDate(getLondonTodayCalendarDate().toString() + 'T00:00:00');
const maxDateJs = new SvelteDate(
maxCalendarDate.year,
maxCalendarDate.month - 1,
@@ -412,7 +408,7 @@
for (let i = 0; i <= daysToCheck; i++) {
const checkDate = new SvelteDate(now);
checkDate.setDate(now.getDate() + i);
const dateStr = checkDate.toISOString().split('T')[0];
const dateStr = checkDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const calDate = new CalendarDate(
checkDate.getFullYear(),
checkDate.getMonth() + 1,
@@ -467,8 +463,8 @@
reservationId = null;
reservationExpiresAt = null;
reservationCountdown = '';
if ((window as any).__bookingCreateCountdownInterval) {
clearInterval((window as any).__bookingCreateCountdownInterval);
if (window.__bookingCreateCountdownInterval) {
clearInterval(window.__bookingCreateCountdownInterval);
}
}
@@ -492,7 +488,6 @@
);
}
} catch (err) {
console.error('Failed to fetch users', err);
toast.error('Failed to load users');
} finally {
loadingUsers = false;
@@ -514,7 +509,6 @@
services = await response.json();
}
} catch (err) {
console.error('Failed to fetch services', err);
toast.error('Failed to load services');
} finally {
loadingServices = false;
@@ -561,24 +555,8 @@
const whData: WorkingHoursDay[] = await whRes.json();
const ahData: AvailableHoursDay[] = await ahRes.json();
console.log('[DEBUG] API response for hours', {
outOfHours,
start: startStr,
end: endStr,
mode: outOfHours ? 'out_of_hours' : 'normal',
whSample: whData.slice(0, 3).map((d) => ({
date: d.date,
isOpen: d.isOpen,
startTime: d.startTime,
endTime: d.endTime
})),
ahSample: ahData
.slice(0, 3)
.map((d) => ({ date: d.date, isOpen: d.isOpen, slotsCount: d.slots?.length }))
});
const whMap: Record<string, any> = {};
const ahMap: Record<string, any> = {};
const whMap: Record<string, DayHours> = {};
const ahMap: Record<string, DayAvailability> = {};
whData.forEach(
(d) => (whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime })
@@ -605,7 +583,6 @@
availableHours = { ...availableHours, ...ahMap };
}
} catch (err) {
console.error('Failed to fetch hours', err);
toast.error('Failed to load availability');
} finally {
_loadingWorkingHours = false;
@@ -660,8 +637,8 @@
const whData: WorkingHoursDay[] = await whRes.json();
const ahData: AvailableHoursDay[] = await ahRes.json();
const whMap: Record<string, any> = {};
const ahMap: Record<string, any> = {};
const whMap: Record<string, DayHours> = {};
const ahMap: Record<string, DayAvailability> = {};
whData.forEach(
(d) => (whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime })
@@ -676,7 +653,6 @@
availableHours = { ...availableHours, ...ahMap };
}
} catch (err) {
console.error('Failed to fetch hours', err);
toast.error('Failed to load availability');
} finally {
_loadingWorkingHours = false;
@@ -697,11 +673,11 @@
const localDate = selectedDate.toDate(getLocalTimeZone());
const [hours, minutes] = selectedTime.split(':').map(Number);
localDate.setHours(hours, minutes, 0, 0);
const startTimeISO = localDate.toISOString();
const startTimeISO = formatLocalDateTime(localDate);
const serviceIds = selectedServices.filter((s) => !(s as any).is_custom).map((s) => s.id);
const serviceIds = selectedServices.filter((s) => !s.is_custom).map((s) => s.id);
const customServiceIds = selectedServices
.filter((s) => (s as any).is_custom)
.filter((s) => s.is_custom)
.map((s) => s.id);
// Build service overrides payload
@@ -716,7 +692,16 @@
}
}
const payload: Record<string, any> = {
const payload: {
user_id: string | null;
start_time: string;
service_ids: string[];
service_overrides: Array<{ service_id: string; override_duration_minutes: number }>;
ttl_minutes: number;
reservation_type: string;
out_of_hours: boolean;
custom_service_ids?: string[];
} = {
user_id: selectedUserId || null,
start_time: startTimeISO,
service_ids: serviceIds,
@@ -760,7 +745,6 @@
return false;
}
} catch (err) {
console.error('Reservation error:', err);
toast.error('Failed to reserve slot');
return false;
} finally {
@@ -770,8 +754,8 @@
function startCountdown() {
// Clear any existing interval
if ((window as any).__bookingCreateCountdownInterval) {
clearInterval((window as any).__bookingCreateCountdownInterval);
if (window.__bookingCreateCountdownInterval) {
clearInterval(window.__bookingCreateCountdownInterval);
}
const updateCountdown = () => {
@@ -787,8 +771,8 @@
reservationCountdown = 'Expired';
reservationId = null;
reservationExpiresAt = null;
if ((window as any).__bookingCreateCountdownInterval) {
clearInterval((window as any).__bookingCreateCountdownInterval);
if (window.__bookingCreateCountdownInterval) {
clearInterval(window.__bookingCreateCountdownInterval);
}
return;
}
@@ -799,7 +783,7 @@
};
updateCountdown();
(window as any).__bookingCreateCountdownInterval = setInterval(updateCountdown, 1000);
window.__bookingCreateCountdownInterval = setInterval(updateCountdown, 1000);
}
// =============== Logic ===============
@@ -841,7 +825,7 @@
if (response.ok) {
const data = await response.json();
const list = data.services || data;
customServices = list.map((cs: any) => ({ ...cs, is_custom: true }));
customServices = list.map((cs: CustomService) => ({ ...cs, is_custom: true }));
}
} catch {
console.error('Failed to fetch custom services');
@@ -916,17 +900,7 @@
// Out-of-hours: only check if available hours exist with slots
if (outOfHours) {
const ahDay = availableHours?.[dateStr];
const whDay = workingHours[dateStr];
const result = !ahDay?.slots || ahDay.slots.length === 0;
console.log('[DEBUG] isDateUnavailable (outOfHours)', {
dateStr,
result,
hasSlots: ahDay?.slots?.length,
whIsOpen: whDay?.isOpen,
whStart: whDay?.startTime,
whEnd: whDay?.endTime
});
return result;
return !ahDay?.slots || ahDay.slots.length === 0;
}
const dayHours = workingHours[dateStr];
@@ -1012,7 +986,7 @@
const localDate = selectedDate.toDate(getLocalTimeZone());
const [hours, minutes] = selectedTime.split(':').map(Number);
localDate.setHours(hours, minutes, 0, 0);
const dateTimeStr = localDate.toISOString();
const dateTimeStr = formatLocalDateTime(localDate);
const overrides = [];
for (const [serviceId, data] of Object.entries(serviceOverrides)) {
@@ -1028,11 +1002,19 @@
}
}
const payload: Record<string, any> = {
const payload: {
user_id: string;
start_time: string;
service_ids: string[];
custom_service_ids: string[];
service_overrides: Array<{ service_id: string; override_price: number | null; override_duration_minutes: number | null }> | undefined;
notes: string | null;
out_of_hours: boolean;
} = {
user_id: finalUserId,
start_time: dateTimeStr,
service_ids: selectedServices.filter((s) => !(s as any).is_custom).map((s) => s.id),
custom_service_ids: selectedServices.filter((s) => (s as any).is_custom).map((s) => s.id),
service_ids: selectedServices.filter((s) => !s.is_custom).map((s) => s.id),
custom_service_ids: selectedServices.filter((s) => s.is_custom).map((s) => s.id),
service_overrides: overrides.length > 0 ? overrides : undefined,
notes: notes.trim() || null,
out_of_hours: outOfHours
@@ -1054,11 +1036,9 @@
onBookingCreated?.();
} else {
const errorText = await res.text();
console.error('Booking creation failed:', errorText);
toast.error(`Failed to create booking: ${errorText}`);
}
} catch (err) {
console.error('Booking submission error:', err);
toast.error('An error occurred while creating booking');
} finally {
submitting = false;
@@ -1677,7 +1657,7 @@
></path>
</svg>
<span class="text-sm font-medium text-green-800">
Slot reserved until {reservationExpiresAt.toLocaleTimeString([], {
Slot reserved until {parseWallClockDate(reservationExpiresAt.toISOString()).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})}