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:
@@ -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'
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user