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
@@ -13,8 +13,12 @@
// INTENTIONAL: We use the browser's local timezone (getLocalTimeZone) because Crussell is a UK-only
// salon app. All customers are physically in the UK and book UK appointment slots. We do NOT
// auto-adjust for international timezones — the slot time shown is the actual UK salon time.
// Cloudflare geo-blocking prevents non-UK access. BST/GMT transitions are handled manually by
// staff adjusting working hours; the app does not need timezone-aware scheduling logic.
// Cloudflare geo-blocking prevents non-UK access. BST/GMT transitions are handled automatically:
// formatLocalDateTime converts wall-clock time to UTC using the correct DST offset for the
// target date (via @internationalized/date's CalendarDate.toDate which applies the target
// date's timezone rules, not the current date's). The backend stores all timestamps as
// TIMESTAMPTZ (UTC) and converts to Europe/London for display. This ensures a booking at
// "10am June 15" stays at 10am BST regardless of when the booking was made.
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
@@ -40,6 +44,7 @@
getLunchProtectionForSlots,
type TimeSlot
} from '$lib/lunchProtection';
import { formatLocalDateTime, getLondonTodayCalendarDate } from '$lib/utils/timeSlots';
import type {
Service,
@@ -52,7 +57,18 @@
} from '$lib/types/booking';
// =============== State Management ===============
let currentStep = $state<number>(authStore.isAuthenticated ? 1 : 0);
let currentStep = $state<number>(0);
let authReady = $state(false);
// Wait for auth store to finish initializing before deciding which step to show.
// This prevents a flash of the login prompt on SSR + hydration — the skeleton
// displays while auth checks are pending, then the correct screen appears.
$effect(() => {
if (authStore.hasLoaded && !authReady) {
authReady = true;
currentStep = authStore.isAuthenticated ? 1 : 0;
}
});
let selectedServices = $state<Service[]>([]);
let selectedDate = $state<CalendarDate | undefined>(undefined);
let selectedTime = $state<string | null>(null);
@@ -198,7 +214,6 @@
userDepositsRequired = user.deposits_required ?? 0;
}
} catch (err) {
console.error('Failed to fetch user deposits status:', err);
userDepositsRequired = 0;
}
}
@@ -234,7 +249,6 @@
hasActiveBooking = data.bookings && data.bookings.length > 0;
}
} catch (err) {
console.error('Failed to check active booking status:', err);
hasActiveBooking = false;
} finally {
activeBookingCheckDone = true;
@@ -265,7 +279,6 @@
paymentMethods = [];
}
} catch (err) {
console.error('Failed to fetch payment methods:', err);
paymentMethods = [];
} finally {
paymentMethodsLoading = false;
@@ -417,7 +430,7 @@
const [hours, minutes] = selectedTime.split(':').map(Number);
const bookingDate = selectedDate.toDate(getLocalTimeZone());
bookingDate.setHours(hours, minutes, 0, 0);
const startTimeISO = bookingDate.toISOString();
const startTimeISO = formatLocalDateTime(bookingDate);
const serviceIds = selectedServices.map((s) => s.id);
const response = await fetch('/api/bookings/reserve', {
@@ -533,11 +546,9 @@
// Combine: valid first, then grayed out
services = [...valid, ...grayedOut];
} else {
console.error('Failed to fetch services:', response.status);
toast.error('Failed to load services');
}
} catch (err) {
console.error('Error fetching services:', err);
toast.error('Network error loading services');
} finally {
servicesLoading = false;
@@ -569,14 +580,10 @@
> = {};
// Initialize date boundaries
const today = new SvelteDate();
const tomorrow = new SvelteDate(today);
tomorrow.setDate(today.getDate() + 1);
const maxDate = new SvelteDate();
maxDate.setMonth(today.getMonth() + 6);
// Create CalendarDate objects
const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate());
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,
@@ -609,14 +616,8 @@
}
const key = `${mYear}-${String(mMonth).padStart(2, '0')}`;
if (!(key in workingHoursCache)) {
workingHoursCache[key] = null as unknown as Record<
string,
{ isOpen: boolean; startTime: string; endTime: string }
>;
availableHoursCache[key] = null as unknown as Record<
string,
{ isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }
>;
workingHoursCache[key] = null as unknown as Record<string, { isOpen: boolean; startTime: string; endTime: string }>;
availableHoursCache[key] = null as unknown as Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }>;
loadingMonths[key] = true;
}
}
@@ -646,7 +647,7 @@
) {
bookingFlowAutoSelectDone = true;
const currentDate = new SvelteDate();
const currentDate = new SvelteDate(getLondonTodayCalendarDate().toString() + 'T00:00:00');
const maxDateJs = new SvelteDate(
maxCalendarDate.year,
maxCalendarDate.month - 1,
@@ -661,7 +662,7 @@
for (let i = 1; i <= daysToCheck; i++) {
const nextDate = new SvelteDate(currentDate);
nextDate.setDate(currentDate.getDate() + i);
const dateStr = nextDate.toISOString().split('T')[0];
const dateStr = nextDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
if (workingHours[dateStr]?.isOpen) {
const calDate = new CalendarDate(
@@ -679,15 +680,15 @@
}
}
const tomorrow = new SvelteDate();
tomorrow.setDate(tomorrow.getDate() + 1);
selectedDate = new CalendarDate(
tomorrow.getFullYear(),
tomorrow.getMonth() + 1,
tomorrow.getDate()
const tomorrowCal = getLondonTodayCalendarDate();
const tomorrowDate = new CalendarDate(
tomorrowCal.year,
tomorrowCal.month,
tomorrowCal.day + 1
);
selectedDate = tomorrowDate;
if (!userNavigatedCalendar) {
placeholder = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, 1);
placeholder = new CalendarDate(tomorrowCal.year, tomorrowCal.month, 1);
}
}
});
@@ -752,7 +753,6 @@
workingHours = { ...workingHours, ...whMap };
availableHours = { ...availableHours, ...ahMap };
} catch (error) {
console.error('Failed to fetch hours:', error);
// Clean up loadingMonths for the range
for (let i = 0; i < months; i++) {
let mYear = startDate.year;
@@ -853,7 +853,6 @@
availableHoursCache[monthKey] = availableHoursMap;
availableHours = { ...availableHours, ...availableHoursMap };
} catch (error) {
console.error('Failed to fetch hours:', error);
if (!selectedDate) {
selectedDate = minDate;
}
@@ -911,8 +910,10 @@
}
const slots: string[] = [];
const now = new SvelteDate();
const today = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
const today = getLondonTodayCalendarDate();
const now = new Date();
const londonTimeStr = now.toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
const [londonHours, londonMinutes] = londonTimeStr.split(':').map(Number);
const isToday = date.compare(today) === 0;
for (const slot of dayAvailableHours.slots) {
@@ -923,7 +924,7 @@
const endTotalMinutes = endHour * 60 + endMinute;
if (isToday) {
const currentMinutes = now.getHours() * 60 + now.getMinutes();
const currentMinutes = londonHours * 60 + londonMinutes;
const minimumStartMinutes = Math.ceil((currentMinutes + 60) / 15) * 15;
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
}
@@ -979,12 +980,14 @@
let startTotalMinutes = startHour * 60 + startMinute;
const endTotalMinutes = endHour * 60 + endMinute;
const now = new SvelteDate();
const today = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
const isToday = date.compare(today) === 0;
const todayCal = getLondonTodayCalendarDate();
const now = new Date();
const londonTimeStr = now.toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
const [londonHours, londonMinutes] = londonTimeStr.split(':').map(Number);
const isToday = date.compare(todayCal) === 0;
if (isToday) {
const currentMinutes = now.getHours() * 60 + now.getMinutes();
const currentMinutes = londonHours * 60 + londonMinutes;
const minimumStartMinutes = Math.ceil((currentMinutes + 60) / 15) * 15;
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
}
@@ -1281,13 +1284,17 @@
);
let depositRequired = $derived(calculateDepositRequired());
let totalSteps = $derived(depositRequired ? 5 : 4);
let totalSteps = $derived(authStore.isAuthenticated ? 4 : 5);
// StepIndicator uses displayNumber = startAt + index. currentStep aligns with displayNumber,
// not the array index. For auth: startAt=1, totalSteps=4 → last displayNumber=4.
// For guest: startAt=0, totalSteps=5 → last displayNumber=4. Always evaluates to 4.
let finalStep = $derived(totalSteps - 1 + (authStore.isAuthenticated ? 1 : 0));
let stepLabels = $derived(
authStore.isAuthenticated
? depositRequired
? ['Service', 'Date & Time', 'Details', 'Payment', 'Confirmation']
: ['Service', 'Date & Time', 'Details', 'Confirmation']
: ['Welcome', 'Service', 'Date & Time', 'Details', 'Payment', 'Confirmation']
? ['Service', 'Date & Time', 'Details', 'Payment']
: userDepositsRequired > 0
? ['Welcome', 'Service', 'Date & Time', 'Details', 'Payment']
: ['Welcome', 'Service', 'Date & Time', 'Details', 'Confirmation']
);
// =============== Navigation ===============
@@ -1308,24 +1315,24 @@
if (!reserved) return;
}
// Step 3 -> Step 4 (if deposit required) or Step 4 (confirmation, if no deposit)
// Step 3 -> Final step (Payment if deposit required, else submit booking)
if (currentStep === 3) {
if (calculateDepositRequired()) {
currentStep = 4;
currentStep = finalStep;
} else {
await submitAndProceed();
}
return;
}
// Step 4: if deposit required, this is payment step -> submit booking -> step 5
// Step 4: if no deposit, this is confirmation step -> nothing
if (currentStep === 4 && calculateDepositRequired()) {
await submitAndProceed();
// Final step with deposit: user must pay before booking is created.
// Payment is handled by processPayment(), not nextStep().
// This guards against manual increment from the payment step.
if (currentStep === finalStep && calculateDepositRequired()) {
return;
}
if (currentStep < (depositRequired ? 5 : 4)) {
if (currentStep < finalStep) {
currentStep++;
setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
@@ -1364,7 +1371,7 @@
const [hours, minutes] = selectedTime.split(':').map(Number);
const bookingDate = selectedDate.toDate(getLocalTimeZone());
bookingDate.setHours(hours, minutes, 0, 0);
const startTimeISO = bookingDate.toISOString();
const startTimeISO = formatLocalDateTime(bookingDate);
const serviceIds = selectedServices.map((s) => s.id);
@@ -1436,7 +1443,7 @@
total_amount: booking.total_amount || getTotalPrice(),
duration_minutes: booking.duration_minutes || getTotalDuration()
};
currentStep = depositRequired ? 5 : 4;
currentStep = finalStep;
fetchDiscountPreview();
setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
@@ -1478,10 +1485,8 @@
} else {
toast.error('Failed to submit booking: ' + errorMessage);
}
console.error('Booking submission failed:', response.status, errorText);
}
} catch (error) {
console.error('Booking submission error:', error);
toast.error('Network error. Please check your connection and try again.');
} finally {
isSubmitting = false;
@@ -1532,6 +1537,34 @@
<p class="text-gray-600">Professional beauty treatments in a calm and friendly environment</p>
</div>
{#if !authReady}
<div class="mb-6 flex items-center justify-center gap-2">
{#each [1, 2, 3, 4] as _}
<div class="flex items-center gap-1">
<div class="h-8 w-8 animate-pulse rounded-full bg-gray-200" />
<div class="h-3 w-16 animate-pulse rounded bg-gray-200" />
</div>
{#if _ < 4}
<div class="mx-1 h-0.5 w-8 animate-pulse rounded bg-gray-200" />
{/if}
{/each}
</div>
<Card.Root>
<Card.Header>
<div class="h-7 w-40 animate-pulse rounded bg-gray-200" />
<div class="mt-2 h-4 w-64 animate-pulse rounded bg-gray-200" />
</Card.Header>
<Card.Content class="space-y-4">
<div class="h-16 animate-pulse rounded-lg bg-gray-100" />
<div class="h-16 animate-pulse rounded-lg bg-gray-100" />
<div class="h-16 animate-pulse rounded-lg bg-gray-100" />
</Card.Content>
</Card.Root>
{:else}
<StepIndicator
{currentStep}
steps={stepLabels}
@@ -1752,6 +1785,12 @@
<!-- Step 3: Customer Details -->
{#if currentStep === 3}
<div class="mb-6 text-center">
<h2 class="font-['Playfair_Display'] text-2xl font-bold">Almost There</h2>
{#if !authStore.isAuthenticated}
<p class="mt-1 text-gray-500">Just a couple more details</p>
{/if}
</div>
<Card.Root>
<Card.Header>
<Card.Title>Your Details</Card.Title>
@@ -1765,7 +1804,7 @@
{:else}
<div class="rounded-lg bg-blue-50 p-4 text-center">
<p class="text-blue-700">
Your slot is reserved for {reservationCountdown} — complete your booking before time expires
Your slot will be held for {reservationCountdown} — complete your booking before time expires
</p>
</div>
{/if}
@@ -1903,119 +1942,8 @@
</Card.Root>
{/if}
<!-- Step 4: Deposit Payment (only shown if deposit required) -->
{#if currentStep === 4 && depositRequired}
<Card.Root>
<Card.Header>
<Card.Title>Pay Your Deposit</Card.Title>
<Card.Description>
A deposit of <span class="font-semibold">£{calculateDepositAmount()}</span> is required to secure
your appointment.
</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
<BookingSummary
services={selectedServices}
date={selectedDate}
time={selectedTime}
customer={authStore.isAuthenticated
? {
firstName: authStore.currentUser?.firstName ?? '',
lastName: authStore.currentUser?.lastName ?? '',
email: authStore.currentUser?.email ?? '',
phone: authStore.currentUser?.phone ?? '',
specialRequests: customerInfo.specialRequests
}
: customerInfo}
showCustomer={true}
/>
<div class="rounded-lg border border-gray-200 bg-white p-6">
<h3 class="mb-4 text-xl font-semibold">Pay Deposit</h3>
{#if authStore.isAuthenticated}
{#if paymentMethodsLoading}
<div class="py-4 text-center text-gray-500">Loading payment methods...</div>
{:else if paymentMethods.length > 0}
<div class="mb-6">
<h4 class="mb-3 text-sm font-medium text-gray-700">Saved Cards</h4>
<div class="space-y-3">
{#each paymentMethods as method (method.id)}
<div
class="flex items-center justify-between rounded-lg border border-gray-200 p-3 {selectedPaymentMethod ===
method.id
? 'border-primary bg-primary/5'
: ''}"
>
<div class="flex items-center gap-3">
<div
class="flex h-10 w-14 items-center justify-center rounded bg-gray-100 text-xs font-medium"
>
{method.brand}
</div>
<div class="text-sm">
<span class="font-mono">**** {method.last4}</span>
<span class="ml-2 text-gray-500">
{formatCardExpiry(method.expiry_month, method.expiry_year)}
</span>
</div>
</div>
<Button
size="sm"
variant={selectedPaymentMethod === method.id ? 'default' : 'outline'}
onclick={() => {
selectedPaymentMethod = method.id;
showNewCardForm = false;
}}
>
{selectedPaymentMethod === method.id ? 'Selected' : 'Use this card'}
</Button>
</div>
{/each}
</div>
</div>
{/if}
{#if !showNewCardForm}
<Button
variant="outline"
class="mb-6"
onclick={() => {
showNewCardForm = true;
selectedPaymentMethod = null;
}}
>
+ Add new card
</Button>
{/if}
{/if}
{#if showNewCardForm || !authStore.isAuthenticated}
<CardInput
bind:cardNumber={newCardNumber}
bind:cardExpiry={newCardExpiry}
bind:cardCVC={newCardCVC}
disabled={isProcessingPayment}
/>
{/if}
<div class="flex items-center justify-between border-t pt-4">
<Button variant="ghost" onclick={prevStep}>Back</Button>
<Button
disabled={isProcessingPayment || !depositCardFormValid}
onclick={() => processPayment(calculateDepositAmount())}
class="bg-primary text-primary-foreground"
>
{isProcessingPayment ? 'Processing...' : `Pay Deposit £${calculateDepositAmount()}`}
</Button>
</div>
</div>
</Card.Content>
</Card.Root>
{/if}
<!-- Step 5: Confirmation (or Step 4 if no deposit required) -->
{#if currentStep === 5 || (currentStep === 4 && !depositRequired)}
<!-- Step 4: Payment & Confirmation (final step) -->
{#if currentStep === finalStep}
{#if confirmedBooking}
{@const isRequested = confirmedBooking.notes && confirmedBooking.notes.length > 0}
{@const bookingDate = new SvelteDate(confirmedBooking.start_time)}
@@ -2209,14 +2137,111 @@
</Button>
</Card.Footer>
</Card.Root>
{:else}
{:else if depositRequired}
<Card.Root>
<Card.Content class="flex items-center justify-center p-12">
<div class="text-center">
<div
class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
></div>
<p class="text-gray-600">Confirming your booking...</p>
<Card.Header>
<Card.Title>Pay Your Deposit</Card.Title>
<Card.Description>
A deposit of <span class="font-semibold">£{calculateDepositAmount()}</span> is required to secure
your appointment.
</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
<BookingSummary
services={selectedServices}
date={selectedDate}
time={selectedTime}
customer={authStore.isAuthenticated
? {
firstName: authStore.currentUser?.firstName ?? '',
lastName: authStore.currentUser?.lastName ?? '',
email: authStore.currentUser?.email ?? '',
phone: authStore.currentUser?.phone ?? '',
specialRequests: customerInfo.specialRequests
}
: customerInfo}
showCustomer={true}
/>
<div class="rounded-lg border border-gray-200 bg-white p-6">
<h3 class="mb-4 text-xl font-semibold">Pay Deposit</h3>
{#if authStore.isAuthenticated}
{#if paymentMethodsLoading}
<div class="py-4 text-center text-gray-500">Loading payment methods...</div>
{:else if paymentMethods.length > 0}
<div class="mb-6">
<h4 class="mb-3 text-sm font-medium text-gray-700">Saved Cards</h4>
<div class="space-y-3">
{#each paymentMethods as method (method.id)}
<div
class="flex items-center justify-between rounded-lg border border-gray-200 p-3 {selectedPaymentMethod ===
method.id
? 'border-primary bg-primary/5'
: ''}"
>
<div class="flex items-center gap-3">
<div
class="flex h-10 w-14 items-center justify-center rounded bg-gray-100 text-xs font-medium"
>
{method.brand}
</div>
<div class="text-sm">
<span class="font-mono">**** {method.last4}</span>
<span class="ml-2 text-gray-500">
{formatCardExpiry(method.expiry_month, method.expiry_year)}
</span>
</div>
</div>
<Button
size="sm"
variant={selectedPaymentMethod === method.id ? 'default' : 'outline'}
onclick={() => {
selectedPaymentMethod = method.id;
showNewCardForm = false;
}}
>
{selectedPaymentMethod === method.id ? 'Selected' : 'Use this card'}
</Button>
</div>
{/each}
</div>
</div>
{/if}
{#if !showNewCardForm}
<Button
variant="outline"
class="mb-6"
onclick={() => {
showNewCardForm = true;
selectedPaymentMethod = null;
}}
>
+ Add new card
</Button>
{/if}
{/if}
{#if showNewCardForm || !authStore.isAuthenticated}
<CardInput
bind:cardNumber={newCardNumber}
bind:cardExpiry={newCardExpiry}
bind:cardCVC={newCardCVC}
disabled={isProcessingPayment}
/>
{/if}
<div class="flex items-center justify-between border-t pt-4">
<Button variant="ghost" onclick={prevStep}>Back</Button>
<Button
disabled={isProcessingPayment || !depositCardFormValid}
onclick={() => processPayment(calculateDepositAmount())}
class="bg-primary text-primary-foreground"
>
{isProcessingPayment ? 'Processing...' : `Pay Deposit £${calculateDepositAmount()}`}
</Button>
</div>
</div>
</Card.Content>
</Card.Root>
@@ -2255,4 +2280,5 @@
canSaveCards={authStore.isAuthenticated}
/>
{/if}
{/if}
</div>