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
@@ -15,6 +15,7 @@
getLunchProtectionForSlots,
timeToMinutes
} from '$lib/lunchProtection';
import { formatLocalDateTime, getLondonTodayCalendarDate } from '$lib/utils/timeSlots';
import ClockIcon from '@lucide/svelte/icons/clock';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
@@ -67,10 +68,10 @@
let userNavigatedCalendar = $state(false);
let editRequestAutoSelectDone = $state(false);
// ─── Date constants ─────────────────────────────────────
const today = new Date();
const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate());
const maxDate = new Date();
maxDate.setMonth(today.getMonth() + 6);
const todayCalendarDate = getLondonTodayCalendarDate();
const minDate = todayCalendarDate;
const maxDate = new SvelteDate(todayCalendarDate.year, todayCalendarDate.month - 1, todayCalendarDate.day);
maxDate.setMonth(todayCalendarDate.month - 1 + 6);
const maxCalendarDate = new CalendarDate(
maxDate.getFullYear(),
maxDate.getMonth() + 1,
@@ -204,8 +205,7 @@
if (!dayWH || !dayWH.isOpen || !dayAH || !dayAH.slots) return [];
const slots: string[] = [];
const now = new SvelteDate();
const todayCal = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
const todayCal = getLondonTodayCalendarDate();
const isToday = date.compare(todayCal) === 0;
for (const slot of dayAH.slots) {
@@ -217,7 +217,10 @@
const endMin = eh * 60 + em;
if (isToday) {
const currentMin = now.getHours() * 60 + now.getMinutes();
const now = new Date();
const londonTime = now.toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
const [londonHours, londonMinutes] = londonTime.split(':').map(Number);
const currentMin = londonHours * 60 + londonMinutes;
startMin = Math.max(startMin, Math.ceil((currentMin + 60) / 15) * 15);
}
@@ -261,11 +264,13 @@
let startMin = sh * 60 + sm;
const endMin = eh * 60 + em;
const now = new SvelteDate();
const todayCal = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
const todayCal = getLondonTodayCalendarDate();
const isToday = date.compare(todayCal) === 0;
if (isToday) {
const currentMin = now.getHours() * 60 + now.getMinutes();
const now = new Date();
const londonTime = now.toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
const [londonHours, londonMinutes] = londonTime.split(':').map(Number);
const currentMin = londonHours * 60 + londonMinutes;
startMin = Math.max(startMin, Math.ceil((currentMin + 60) / 15) * 15);
}
@@ -324,10 +329,6 @@
function isDateUnavailable(date: DateValue): boolean {
const d = date as CalendarDate;
const jsDate = d.toDate(getLocalTimeZone());
const now = new SvelteDate();
const todayStart = new SvelteDate(now.getFullYear(), now.getMonth(), now.getDate());
if (jsDate < todayStart) return true;
if (d.compare(minDate) < 0 || d.compare(maxCalendarDate) > 0) return true;
if (!workingHours) return true;
const dateStr = d.toString();
@@ -431,7 +432,7 @@
return;
editRequestAutoSelectDone = true;
const currentDate = new SvelteDate();
const currentDate = new SvelteDate(getLondonTodayCalendarDate().toString() + 'T00:00:00');
const maxDateJs = new SvelteDate(
maxCalendarDate.year,
maxCalendarDate.month - 1,
@@ -446,7 +447,7 @@
for (let i = 0; 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(
@@ -464,11 +465,14 @@
}
}
const tomorrow = new SvelteDate();
tomorrow.setDate(tomorrow.getDate() + 1);
newDate = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, tomorrow.getDate());
const tomorrowCal = getLondonTodayCalendarDate();
newDate = new CalendarDate(
tomorrowCal.year,
tomorrowCal.month,
tomorrowCal.day + 1
);
if (!userNavigatedCalendar) {
placeholderDate = new CalendarDate(tomorrow.getFullYear(), tomorrow.getMonth() + 1, 1);
placeholderDate = new CalendarDate(tomorrowCal.year, tomorrowCal.month, 1);
}
});
@@ -612,7 +616,7 @@
if (!workingHours || !availableHours) return 0;
const bookingDate = new SvelteDate(booking.start_time);
const dateStr = `${bookingDate.getFullYear()}-${String(bookingDate.getMonth() + 1).padStart(2, '0')}-${String(bookingDate.getDate()).padStart(2, '0')}`;
const dateStr = bookingDate.toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const dayWH = workingHours[dateStr];
const dayAH = availableHours[dateStr];
@@ -687,7 +691,7 @@
const [hours, minutes] = newTime.split(':').map(Number);
const bookingDate = newDate!.toDate(getLocalTimeZone());
bookingDate.setHours(hours || 0, minutes || 0, 0, 0);
body.new_start_time = bookingDate.toISOString();
body.new_start_time = formatLocalDateTime(bookingDate);
}
if (editMode === 'services' || editMode === 'both-time') {
@@ -1,17 +1,18 @@
<script lang="ts">
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { POLICY } from '$lib/constants/policy';
import { authStore } from '$lib/stores/auth.svelte';
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
import EditRequestModal from '$lib/components/account/EditRequestModal.svelte';
import { computeBalanceDue } from '$lib/utils/booking';
import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { POLICY } from '$lib/constants/policy';
import { authStore } from '$lib/stores/auth.svelte';
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
import EditRequestModal from '$lib/components/account/EditRequestModal.svelte';
import { computeBalanceDue } from '$lib/utils/booking';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
interface Props {
open: boolean;
bookingId: string;
@@ -306,7 +307,7 @@
<h2>Receipt</h2>
<table>
<tr><td style="width:110px;font-weight:600">Booking Ref</td><td>${esc(selectedBooking.id)}</td></tr>
<tr><td style="font-weight:600">Date</td><td>${new Date(selectedBooking.start_time).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}</td></tr>
<tr><td style="font-weight:600">Date</td><td>${parseWallClockDate(selectedBooking.start_time).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}</td></tr>
<tr><td style="font-weight:600">Status</td><td style="text-transform:capitalize">${selectedBooking.status.replace('_', ' ')}</td></tr>
</table>
<h2>Services</h2>
@@ -474,7 +475,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
<div class="text-xs text-gray-500">Scheduled Date & Time</div>
<div class="font-medium">
{(() => {
const date = new SvelteDate(selectedBooking.start_time);
const date = parseWallClockDate(selectedBooking.start_time);
const dateStr = date.toLocaleDateString('en-US', {
weekday: 'long',
day: 'numeric',
@@ -552,7 +553,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
</span>
{#if !selectedBooking.deposit_paid && selectedBooking.deposit_deadline}
<span class="text-gray-500">
• Due: {new SvelteDate(selectedBooking.deposit_deadline).toLocaleDateString(
• Due: {parseWallClockDate(selectedBooking.deposit_deadline).toLocaleDateString(
'en-GB',
{
weekday: 'short',
@@ -560,7 +561,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
month: 'short',
year: 'numeric'
}
)} at {new SvelteDate(selectedBooking.deposit_deadline).toLocaleTimeString(
)} at {parseWallClockDate(selectedBooking.deposit_deadline).toLocaleTimeString(
'en-GB',
{
hour: 'numeric',
@@ -778,7 +779,7 @@ ${hasVAT ? `<p class="warning">VAT is included at ${biz?.default_vat_rate ?? 20}
<p class="font-medium">Awaiting admin approval</p>
<p class="mt-0.5 text-amber-700">
{#if timeChanged}
Reschedule requested from {new SvelteDate(pendingEditRequest.original.start_time!).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })} to {new SvelteDate(pendingEditRequest.proposed.start_time!).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
Reschedule requested from {parseWallClockDate(pendingEditRequest.original.start_time!).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })} to {parseWallClockDate(pendingEditRequest.proposed.start_time!).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
{/if}
</p>
{#if addedServices.length > 0}
@@ -1,10 +1,11 @@
<script lang="ts">
import { SvelteDate } from 'svelte/reactivity';
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from 'svelte-sonner';
import { sanitizeText } from '$lib/utils/toast-safe';
import { formatDateTime, formatDuration, calculateAge } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay';
import { SvelteDate } from 'svelte/reactivity';
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from 'svelte-sonner';
import { sanitizeText } from '$lib/utils/toast-safe';
import { formatDateTime, formatDuration, calculateAge } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
@@ -73,7 +74,7 @@
function getBookingDateTime(): string {
if (!booking?.start_time) return '';
const d = new SvelteDate(booking.start_time);
const d = parseWallClockDate(booking.start_time);
return (
d.toLocaleDateString('en-GB', {
weekday: 'long',
@@ -152,7 +153,7 @@
overlappingBookings = data.bookings || [];
}
} catch (err) {
console.error('Error fetching overlapping bookings:', err);
// Silently handle - overlapping bookings couldn't be fetched
} finally {
loadingOverlaps = false;
}
@@ -340,7 +341,6 @@
toast.error('Failed to confirm: ' + sanitizeText(text), { id: loadingToast });
}
} catch (err) {
console.error('Error confirming booking:', err);
toast.error('Network error confirming booking', { id: loadingToast });
} finally {
submitting = false;
@@ -370,7 +370,6 @@
toast.error('Failed to decline: ' + sanitizeText(text), { id: loadingToast });
}
} catch (err) {
console.error('Error declining booking:', err);
toast.error('Network error declining booking', { id: loadingToast });
} finally {
submitting = false;
@@ -417,7 +416,7 @@
{formatUserName(ob.user?.full_name || 'Unknown', ob.user?.previous_first_name, ob.user?.previous_last_name)}
</div>
<div class="text-xs text-gray-500">
{new SvelteDate(ob.start_time).toLocaleDateString('en-GB', {
{parseWallClockDate(ob.start_time).toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short',
@@ -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'
})}
@@ -118,10 +118,14 @@
}
function validateVatNumber(value: unknown): string {
const v = value === null || value === undefined ? '' : String(value);
const trimmed = v.trim();
const trimmed = String(value ?? '').trim();
if (!trimmed) return '';
if (trimmed.length > 20) return 'Must be 20 characters or fewer';
// UK VAT numbers: GB + 9 digits (standard) or GB + 12 digits (branch)
if (trimmed.length !== 11 && trimmed.length !== 14) return 'Must be GB followed by 9 or 12 digits';
if (!trimmed.startsWith('GB')) return 'Must start with GB';
const digits = trimmed.slice(2);
if (!/^\d+$/.test(digits)) return 'Must contain only digits after GB';
if (digits.length !== 9 && digits.length !== 12) return 'Must be GB followed by 9 or 12 digits';
return '';
}
@@ -207,7 +211,7 @@
];
for (const field of fields) {
const newVal = normalisedForm[field] as unknown;
const newVal = normalisedForm[field];
const oldVal = settings[field];
if (JSON.stringify(newVal) !== JSON.stringify(oldVal)) {
patch[field] = newVal;
@@ -286,7 +286,7 @@
<td class="py-3 text-right font-medium">£{service.price.toFixed(2)}</td>
<td class="py-3 text-right">{service.duration_minutes} min</td>
<td class="py-3 text-right">
{service.usage_count}×{service.last_used_at ? ` (last: ${new Date(service.last_used_at).toLocaleDateString()})` : ''}
{service.usage_count}×{service.last_used_at ? ` (last: ${new Date(service.last_used_at).toLocaleDateString('en-GB', { timeZone: 'Europe/London' })})` : ''}
</td>
<td class="py-3">
<div class="flex justify-center gap-2">
@@ -147,8 +147,8 @@
campaign_type: c.campaign_type,
discount_percent: c.discount_percent,
scope: c.scope || 'all_bookings',
start_date: c.start_date ? new Date(c.start_date).toISOString().slice(0, 10) : '',
end_date: c.end_date ? new Date(c.end_date).toISOString().slice(0, 10) : '',
start_date: c.start_date ? c.start_date.slice(0, 10) : '',
end_date: c.end_date ? c.end_date.slice(0, 10) : '',
milestone_type: c.milestone_type || 'per_user_booking_count',
milestone_value: c.milestone_value || 0,
milestone_unit: c.milestone_unit || 'bookings',
@@ -1,9 +1,10 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { formatDuration } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay';
import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { formatDuration } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import * as Modal from '$lib/components/ui/dialog';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
@@ -437,7 +438,7 @@
<div class="text-xs text-gray-500">Time</div>
<div class="font-medium">
{(() => {
const date = new SvelteDate(booking.start_time);
const date = parseWallClockDate(booking.start_time);
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
@@ -1,11 +1,12 @@
<script lang="ts">
import { SvelteDate } from 'svelte/reactivity';
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from 'svelte-sonner';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { formatUserName } from '$lib/utils/nameDisplay';
import { SvelteDate } from 'svelte/reactivity';
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from 'svelte-sonner';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
interface ServiceItem {
id: string;
@@ -55,7 +56,7 @@
let showDenyConfirm = $state(false);
function formatDateLine1(dateTimeString: string): string {
const d = new SvelteDate(dateTimeString);
const d = parseWallClockDate(dateTimeString);
const dateStr = d.toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
@@ -71,7 +72,7 @@
}
function formatDateLine2(dateTimeString: string, durationMinutes: number): string {
const d = new SvelteDate(dateTimeString);
const d = parseWallClockDate(dateTimeString);
const endMinutes = d.getHours() * 60 + d.getMinutes() + durationMinutes;
const endH = Math.floor(endMinutes / 60);
const endM = endMinutes % 60;
@@ -752,7 +752,8 @@
return new Date(dateStr).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric'
year: 'numeric',
timeZone: 'Europe/London'
});
}
@@ -96,12 +96,15 @@
}
function isoDateOf(d: Date) {
return d.toISOString().slice(0, 10);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function addWeeksToException(fromISO: string, toISO: string, dest: string[]) {
const from = new SvelteDate(fromISO + 'T00:00:00');
const to = new SvelteDate(toISO + 'T00:00:00');
const from = new SvelteDate(fromISO + 'T00:00:00Z');
const to = new SvelteDate(toISO + 'T00:00:00Z');
const first = new SvelteDate(from);
const day = first.getDay();
const daysToMonday = day === 0 ? -6 : 1 - day;
@@ -19,8 +19,11 @@
generateAvailableTimeSlots,
generateGroupedTimeSlots,
formatTime,
formatLocalDateTime,
calculateEndTime,
getDayWithOrdinal,
getLondonTodayCalendarDate,
parseWallClockDate,
type DayHours,
type DayAvailability
} from '$lib/utils/timeSlots';
@@ -35,13 +38,7 @@
let { open = $bindable(), booking, onSaved }: Props = $props();
let saving = $state(false);
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);
@@ -51,17 +48,17 @@
let hoursMonthGeneration = $state(0);
let userNavigatedCalendar = $state(false);
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 loadingMonths: Record<string, boolean> = {};
let loadingMonthKeys = new SvelteSet<string>();
let initialLoadDone = $state(false);
let rescheduleAutoSelectDone = $state(false);
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,
@@ -300,11 +297,7 @@
loadingMonths = {};
loadingMonthKeys = new SvelteSet<string>();
rescheduleAutoSelectDone = false;
placeholder = new CalendarDate(
new SvelteDate().getFullYear(),
new SvelteDate().getMonth() + 1,
new SvelteDate().getDate()
);
placeholder = getLondonTodayCalendarDate();
}
async function reschedule() {
@@ -315,7 +308,7 @@
const localDate = selectedDate.toDate(getLocalTimeZone());
const [hours, minutes] = selectedTime.split(':').map(Number);
localDate.setHours(hours, minutes, 0, 0);
const newStartTime = localDate.toISOString();
const newStartTime = formatLocalDateTime(localDate);
if (new Date(newStartTime).getTime() <= Date.now()) {
toast.error('Start time must be in the future');
return;
@@ -365,8 +358,8 @@
}
const key = `${mYear}-${String(mMonth).padStart(2, '0')}`;
if (!(key in workingHoursCache)) {
workingHoursCache[key] = null as unknown as Record<string, any>;
availableHoursCache[key] = null as unknown as Record<string, any>;
workingHoursCache[key] = null as unknown as Record<string, DayHours>;
availableHoursCache[key] = null as unknown as Record<string, DayAvailability>;
loadingMonths[key] = true;
}
}
@@ -397,7 +390,7 @@
!rescheduleAutoSelectDone
) {
rescheduleAutoSelectDone = true;
const now = new SvelteDate();
const now = new SvelteDate(getLondonTodayCalendarDate().toString() + 'T00:00:00');
const maxDateJs = new SvelteDate(
maxCalendarDate.year,
maxCalendarDate.month - 1,
@@ -410,7 +403,7 @@
for (let i = 1; 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,
@@ -484,12 +477,12 @@
<Card.Root class="mb-4">
<Card.Content class="pt-4">
<div class="text-sm font-medium">
Current: {new SvelteDate(booking.start_time).toLocaleDateString('en-GB', {
Current: {parseWallClockDate(booking.start_time).toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric'
})} at {new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
})} at {parseWallClockDate(booking.start_time).toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
@@ -1,10 +1,12 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { CalendarDate } from '@internationalized/date';
import { toast } from 'svelte-sonner';
import { sanitizeText } from '$lib/utils/toast-safe';
import { formatDuration } from '$lib/utils/format';
import { formatUserName } from '$lib/utils/nameDisplay';
import { formatLocalDateTime, parseWallClockDate } from '$lib/utils/timeSlots';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
@@ -115,7 +117,7 @@
function getWorkingHoursForDate(dateStr: string): WorkingHourRow | null {
if (!dateStr || defaultHours.length === 0) return null;
const d = new SvelteDate(dateStr + 'T00:00:00');
const d = new SvelteDate(dateStr + 'T00:00:00Z');
const jsDay = d.getDay();
const weekday = jsDay === 0 ? 6 : jsDay - 1;
return defaultHours.find((h) => h.weekday === weekday) ?? null;
@@ -265,7 +267,12 @@
): string | null {
if (!date) return null;
const t24 = to24h(hour, minute, period);
return `${date}T${t24}:00`;
const [y, m, d] = date.split('-').map(Number);
const cal = new CalendarDate(y, m, d);
const [h, min] = t24.split(':').map(Number);
const dt = cal.toDate('Europe/London');
dt.setHours(h, min, 0, 0);
return formatLocalDateTime(dt);
}
let formComplete = $derived.by(() => {
@@ -403,7 +410,7 @@
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
start_time: start.toISOString(),
start_time: startIso,
duration_minutes: 60,
description: `RESERVATION:placeholder:${booking.id}`
})
@@ -422,7 +429,7 @@
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
start_time: start.toISOString(),
start_time: startIso,
duration_minutes: durationMinutes,
description: newDescription.trim()
})
@@ -572,7 +579,7 @@
<div class="truncate font-medium text-gray-900">{b.description || 'Untitled'}</div>
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-gray-600">
<span>
{new SvelteDate(b.start_time).toLocaleDateString('en-GB', {
{parseWallClockDate(b.start_time).toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short'
@@ -580,7 +587,7 @@
</span>
<span class="text-gray-400">·</span>
<span>
{new SvelteDate(b.start_time).toLocaleTimeString('en-GB', {
{parseWallClockDate(b.start_time).toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
@@ -589,7 +596,7 @@
<span class="text-gray-400"></span>
<span>
{(() => {
const end = new SvelteDate(b.start_time);
const end = parseWallClockDate(b.start_time);
end.setMinutes(end.getMinutes() + b.duration_minutes);
return end.toLocaleTimeString('en-GB', {
hour: 'numeric',
@@ -712,7 +719,7 @@
size="sm"
class="flex-1 text-xs"
onclick={() => {
newStartDate = new Date().toISOString().slice(0, 10);
newStartDate = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
onStartDateChange();
}}
>
@@ -724,7 +731,7 @@
size="sm"
class="flex-1 text-xs"
onclick={() => {
newStartDate = new Date(Date.now() + 86400000).toISOString().slice(0, 10);
newStartDate = new Date(Date.now() + 86400000).toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
onStartDateChange();
}}
>
@@ -746,7 +753,7 @@
<p class="text-sm text-gray-400">Select a date first</p>
{:else if !selectedWorkingHours.isOpen}
<p class="text-sm text-red-500">
Closed on {new SvelteDate(newStartDate + 'T00:00:00').toLocaleDateString('en-GB', {
Closed on {new SvelteDate(newStartDate + 'T00:00:00Z').toLocaleDateString('en-GB', {
weekday: 'long'
})}
</p>
@@ -855,7 +862,7 @@
<div class="min-w-0">
<div class="text-sm font-medium">{formatUserName(booking.user?.full_name || 'Unknown', booking.user?.previous_first_name, booking.user?.previous_last_name)}</div>
<div class="text-xs text-gray-500">
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
{parseWallClockDate(booking.start_time).toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
@@ -1,11 +1,12 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import PatchTestModal from './PatchTestModal.svelte';
import { formatUserName } from '$lib/utils/nameDisplay';
import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import PatchTestModal from './PatchTestModal.svelte';
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
interface Props {
open: boolean;
@@ -404,7 +405,7 @@
<div class="flex-1">
<div class="font-medium">
{(() => {
const date = new SvelteDate(booking.start_time);
const date = parseWallClockDate(booking.start_time);
const dateStr = date.toLocaleDateString('en-US', {
weekday: 'long',
day: 'numeric',
@@ -13,6 +13,7 @@
calculateMiddleWindow,
shouldApplyLunchProtection
} from '$lib/lunchProtection';
import { formatLocalDateTime } from '$lib/utils/timeSlots';
const RESERVATION_TTL = 15;
@@ -50,7 +51,7 @@
}
}
} catch (err) {
console.error('Failed to fetch services', err);
// Silently handled - services list remains empty
}
}
@@ -143,7 +144,9 @@
try {
const now = new SvelteDate();
const today = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const [y, m, d] = londonDateStr.split('-').map(Number);
const today = new CalendarDate(y, m, d);
const response = await fetch(`/api/scheduling/available-hours?start=${today}&end=${today}`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
@@ -234,18 +237,11 @@
isReserving = true;
try {
const now = new SvelteDate();
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const [y, m, d] = londonDateStr.split('-').map(Number);
const [hours, minutes] = startTime.split(':').map(Number);
const start = new SvelteDate(
now.getFullYear(),
now.getMonth(),
now.getDate(),
hours,
minutes,
0,
0
);
const startTimeISO = start.toISOString();
const start = new SvelteDate(y, m - 1, d, hours, minutes, 0, 0);
const startTimeISO = formatLocalDateTime(start);
const response = await fetch('/api/admin/bookings/reserve', {
method: 'POST',
@@ -290,8 +286,8 @@
}
function startWalkInCountdown() {
if ((window as any).__walkInCountdownInterval) {
clearInterval((window as any).__walkInCountdownInterval);
if (window.__walkInCountdownInterval) {
clearInterval(window.__walkInCountdownInterval);
}
const updateCountdown = () => {
@@ -308,8 +304,8 @@
reservationId = null;
reservationExpiresAt = null;
toast.error('Slot released — please re-check availability');
if ((window as any).__walkInCountdownInterval) {
clearInterval((window as any).__walkInCountdownInterval);
if (window.__walkInCountdownInterval) {
clearInterval(window.__walkInCountdownInterval);
}
showCreateModal = false;
return;
@@ -321,7 +317,7 @@
};
updateCountdown();
(window as any).__walkInCountdownInterval = setInterval(updateCountdown, 1000);
window.__walkInCountdownInterval = setInterval(updateCountdown, 1000);
}
let availableMinutes = $derived.by(() => {
@@ -379,8 +375,8 @@
reservationCountdown = '';
reservedDuration = 0;
reservedStartTime = null;
if ((window as any).__walkInCountdownInterval) {
clearInterval((window as any).__walkInCountdownInterval);
if (window.__walkInCountdownInterval) {
clearInterval(window.__walkInCountdownInterval);
}
}
</script>
@@ -6,6 +6,7 @@
import { getLocalTimeZone } from '@internationalized/date';
import { isValidUKPhone, toE164UK } from '$lib/utils/phone';
import { formatUserName } from '$lib/utils/nameDisplay';
import { formatLocalDateTime } from '$lib/utils/timeSlots';
// UI Components
import * as Modal from '$lib/components/ui/dialog';
@@ -213,8 +214,8 @@
});
function startCountdown() {
if ((window as any).__walkInModalCountdownInterval) {
clearInterval((window as any).__walkInModalCountdownInterval);
if (window.__walkInModalCountdownInterval) {
clearInterval(window.__walkInModalCountdownInterval);
}
const updateCountdown = () => {
@@ -229,8 +230,8 @@
if (diff <= 0) {
reservationCountdown = 'Expired';
isReservationExpired = true;
if ((window as any).__walkInModalCountdownInterval) {
clearInterval((window as any).__walkInModalCountdownInterval);
if (window.__walkInModalCountdownInterval) {
clearInterval(window.__walkInModalCountdownInterval);
}
return;
}
@@ -241,7 +242,7 @@
};
updateCountdown();
(window as any).__walkInModalCountdownInterval = setInterval(updateCountdown, 1000);
window.__walkInModalCountdownInterval = setInterval(updateCountdown, 1000);
}
function resetState() {
@@ -299,7 +300,6 @@
services = await response.json();
}
} catch (err) {
console.error('Failed to fetch services', err);
toast.error('Failed to load services');
} finally {
loadingServices = false;
@@ -461,19 +461,16 @@
if (availableStartTime) {
// Parse the time from the widget (format: "HH:MM" or "HH:MM:SS")
const [hours, minutes] = availableStartTime.split(':').map(Number);
const now = new SvelteDate();
start = new SvelteDate(
now.getFullYear(),
now.getMonth(),
now.getDate(),
hours,
minutes,
0,
0
);
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const [y, m, d] = londonDateStr.split('-').map(Number);
start = new SvelteDate(y, m - 1, d, hours, minutes, 0, 0);
} else {
// Fallback: Calculate immediate start time (rounded to next 15 min)
const now = new SvelteDate();
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const londonTimeStr = new Date().toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
const [y, m, d] = londonDateStr.split('-').map(Number);
const [h, min] = londonTimeStr.split(':').map(Number);
const now = new SvelteDate(y, m - 1, d, h, min, 0, 0);
start = new SvelteDate(now);
const minutes = start.getMinutes();
const remainder = 15 - (minutes % 15);
@@ -484,7 +481,7 @@
start.setMilliseconds(0);
}
const dateTimeStr = start.toISOString();
const dateTimeStr = formatLocalDateTime(start);
const overrides = [];
for (const [serviceId, data] of Object.entries(serviceOverrides)) {
@@ -500,11 +497,18 @@
}
}
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;
} = {
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
};
@@ -526,11 +530,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;
@@ -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>
@@ -148,7 +148,7 @@
const today = new SvelteDate();
const closing = new SvelteDate(
today.getFullYear(),
today.getMonth() + 1,
today.getMonth(),
today.getDate(),
ch,
cm,
@@ -1,6 +1,7 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { CalendarDate } from '@internationalized/date';
import { toast } from 'svelte-sonner';
import { sanitizeText } from '$lib/utils/toast-safe';
import { formatUserName } from '$lib/utils/nameDisplay';
@@ -20,6 +21,7 @@
timeToMinutes
} from '$lib/lunchProtection';
import { formatDuration, formatDateISO } from '$lib/utils/format';
import { formatLocalDateTime } from '$lib/utils/timeSlots';
interface Props {
openBookingModal: (bookingId: string) => void;
@@ -104,10 +106,11 @@
let startSelectValue = $derived(`${startHour}:${startMinute}:${startPeriod}`);
let endSelectValue = $derived(`${endHour}:${endMinute}:${endPeriod}`);
const today = $derived(formatDateISO(new SvelteDate()));
const today = $derived(new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' }));
const weekStartStr = $derived.by(() => {
const d = new SvelteDate();
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const d = new SvelteDate(londonDateStr + 'T00:00:00Z');
const day = d.getDay();
const diff = day === 0 ? 6 : day - 1;
d.setDate(d.getDate() - diff);
@@ -115,7 +118,7 @@
});
const weekEndStr = $derived.by(() => {
const d = new SvelteDate(weekStartStr);
const d = new SvelteDate(weekStartStr + 'T00:00:00Z');
d.setDate(d.getDate() + 6);
return formatDateISO(d);
});
@@ -490,7 +493,12 @@
): string | null {
if (!date) return null;
const t24 = to24h(hour, minute, period);
return `${date}T${t24}:00`;
const [y, m, d] = date.split('-').map(Number);
const cal = new CalendarDate(y, m, d);
const [h, min] = t24.split(':').map(Number);
const dt = cal.toDate('Europe/London');
dt.setHours(h, min, 0, 0);
return formatLocalDateTime(dt);
}
let canCreate = $derived.by(() => !hasOverlap && !checkingOverlap);
@@ -513,7 +521,7 @@
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify({
start_time: start.toISOString(),
start_time: startIso,
duration_minutes: durationMinutes,
description: newDescription.trim() || 'break'
})
@@ -3,6 +3,7 @@
import { authStore } from '$lib/stores/auth.svelte';
import { Skeleton } from '$lib/components/ui/skeleton';
import * as Card from '$lib/components/ui/card';
import { formatLocalDateTime } from '$lib/utils/timeSlots';
type TodayAppointment = {
id: string;
@@ -40,10 +41,7 @@
let prevJson = $state('');
let initialized = $state(false);
const today = $derived.by(() => {
const d = new SvelteDate();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
});
const today = $derived(new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' }));
function timeToMinutes(time: string): number {
const [h, m] = time.split(':').map(Number);
@@ -51,7 +49,8 @@
}
async function findLastWorkingDayClose(): Promise<{ cutoff: string; spansClosed: boolean }> {
const now = new SvelteDate();
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const now = new SvelteDate(londonDateStr + 'T00:00:00Z');
let spansClosed = false;
for (let i = 1; i <= 14; i++) {
@@ -77,7 +76,7 @@
const [h, m] = closeTime.split(':').map(Number);
const closeDate = new SvelteDate(d);
closeDate.setHours(h, m, 0, 0);
return { cutoff: closeDate.toISOString(), spansClosed };
return { cutoff: formatLocalDateTime(closeDate), spansClosed };
} else {
spansClosed = true;
}
@@ -87,7 +86,7 @@
const fallback = new SvelteDate(now);
fallback.setDate(fallback.getDate() - 1);
fallback.setHours(17, 0, 0, 0);
return { cutoff: fallback.toISOString(), spansClosed };
return { cutoff: formatLocalDateTime(fallback), spansClosed };
}
async function fetchTodayStats() {
@@ -153,7 +152,7 @@
}
const { cutoff, spansClosed } = await findLastWorkingDayClose();
const nowISO = new SvelteDate().toISOString();
const nowISO = formatLocalDateTime(new SvelteDate());
let bookingsMade = 0;
try {
const bmRes = await fetch(
@@ -7,13 +7,14 @@
type Props = WithElementRef<
Omit<HTMLInputAttributes, 'type'> &
({ type: 'file'; files?: FileList } | { type?: InputType; files?: undefined })
>;
> & { error?: string };
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
error = '',
class: className,
'data-slot': dataSlot = 'input',
...restProps
@@ -33,6 +34,7 @@
type="file"
bind:files
bind:value
aria-invalid={!!error}
{...restProps}
/>
{:else}
@@ -47,6 +49,7 @@
)}
{type}
bind:value
aria-invalid={!!error}
{...restProps}
/>
{/if}
@@ -231,8 +231,7 @@
if (!map || !isLoaded) return;
if (map.getLayer(layerId)) {
for (const [key, value] of Object.entries(mergedPaint)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
map.setPaintProperty(layerId, key as any, value);
map.setPaintProperty(layerId, key as keyof MapArcLinePaint, value);
}
}
});
+2 -4
View File
@@ -102,8 +102,7 @@ class AuthStore {
const payload = token.split('.')[1];
const decoded = JSON.parse(atob(payload));
return decoded;
} catch (e) {
console.error('Failed to decode token:', e);
} catch {
return null;
}
}
@@ -149,8 +148,7 @@ class AuthStore {
const userData = await response.json();
this.user = userData;
} catch (error) {
console.error('Failed to fetch user profile:', error);
} catch {
this.clearAuth();
}
}
+1
View File
@@ -9,6 +9,7 @@ export interface Service {
patch_test_duration_hours: number;
minimum_age_required: number;
patch_test_status?: 'ok' | 'required' | 'expired';
is_custom?: boolean;
}
export interface CustomService extends Service {
+18 -9
View File
@@ -32,12 +32,14 @@ export function formatDateTime(date: Date | string): string {
const dateStr = d.toLocaleDateString('en-US', {
weekday: 'long',
month: 'short',
day: 'numeric'
day: 'numeric',
timeZone: 'Europe/London'
});
const timeStr = d.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
hour12: true,
timeZone: 'Europe/London'
});
return `${dateStr} at ${timeStr}`;
}
@@ -47,8 +49,8 @@ export function formatDateTime(date: Date | string): string {
*
* Example: new Date(2026, 4, 29) "2026-05-29"
*/
export function formatDateISO(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
export function formatDateISO(d: Date, timeZone = 'Europe/London'): string {
return d.toLocaleDateString('en-CA', { timeZone });
}
/**
@@ -61,7 +63,8 @@ export function formatDate(date: Date | string): string {
return d.toLocaleDateString('en-US', {
weekday: 'long',
month: 'short',
day: 'numeric'
day: 'numeric',
timeZone: 'Europe/London'
});
}
@@ -75,7 +78,8 @@ export function formatTime(date: Date | string): string {
return d.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
hour12: true,
timeZone: 'Europe/London'
});
}
@@ -88,9 +92,14 @@ export function calculateAge(dateOfBirth: string | undefined | null): number | n
const dob = new Date(dateOfBirth);
if (isNaN(dob.getTime())) return null;
const today = new Date();
let age = today.getFullYear() - dob.getFullYear();
const monthDiff = today.getMonth() - dob.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) {
// Get London date components to ensure correct DST-safe age calculation
const [dobY, dobM, dobD] = dob.toLocaleDateString('en-CA', { timeZone: 'Europe/London' }).split('-').map(Number);
const [todayY, todayM, todayD] = today.toLocaleDateString('en-CA', { timeZone: 'Europe/London' }).split('-').map(Number);
let age = todayY - dobY;
const monthDiff = todayM - dobM;
if (monthDiff < 0 || (monthDiff === 0 && todayD < dobD)) {
age--;
}
return age;
+79 -51
View File
@@ -6,6 +6,72 @@ import {
type LunchProtectionResult
} from '$lib/lunchProtection';
/**
* Returns the UTC datetime string with explicit +00:00 offset, suitable for
* sending to the backend for scheduling/booking purposes.
* This is functionally equivalent to `Date.toISOString()` except the suffix
* is `+00:00` instead of `Z`. Both are valid RFC3339, but Go's `time.Time`
* JSON unmarshalling prefers the explicit offset form when constructing
* wall-clock timestamps that should not be shifted by the backend's timezone.
* The DST correctness of the resulting datetime comes from the CalendarDate
* / getLocalTimeZone() path used to construct the Date object not from
* this formatting function.
*/
export function formatLocalDateTime(date: Date): string {
const y = date.getUTCFullYear();
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
const d = String(date.getUTCDate()).padStart(2, '0');
const h = String(date.getUTCHours()).padStart(2, '0');
const min = String(date.getUTCMinutes()).padStart(2, '0');
const s = String(date.getUTCSeconds()).padStart(2, '0');
return `${y}-${m}-${d}T${h}:${min}:${s}+00:00`;
}
/**
* Parses a UTC ISO datetime string (e.g. "2026-06-15T09:00:00Z" or
* "2026-06-15T09:00:00+00:00") from the backend and returns a Date
* whose getHours()/getMinutes() in the browser's local timezone
* reflect the local wall-clock time.
*
* The backend stores all times as UTC wall-clock values. This function
* uses the standard Date parser which correctly interprets the ISO
* string as UTC and applies the browser timezone for get*() accessors.
* Example: "2026-06-15T09:00:00Z" getHours() returns 10 in BST.
*/
export function parseWallClockDate(iso: string): Date {
return new Date(iso);
}
/**
* Formats a UTC ISO datetime string as a human-readable time (e.g. "10:00").
* Always shows the wall-clock time, NOT shifted by timezone.
*/
export function formatWallClockTime(iso: string): string {
const d = parseWallClockDate(iso);
return d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', hour12: false });
}
/**
* Formats a UTC ISO datetime string as a human-readable date (e.g. "Monday, 15 June 2026").
* Always shows the wall-clock date, NOT shifted by timezone.
*/
export function formatWallClockDate(iso: string): string {
const d = parseWallClockDate(iso);
return d.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
}
/**
* Returns a CalendarDate representing today's date in the Europe/London timezone.
* This ensures "is today" comparisons are correct even during BST (British Summer Time)
* when there is a window between 00:00-01:00 BST where the UTC date differs from the
* London date. Avoids using browser-local time which can be wrong in that window.
*/
export function getLondonTodayCalendarDate(): CalendarDate {
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
const [y, m, d] = londonDateStr.split('-').map(Number);
return new CalendarDate(y, m, d);
}
export interface TimeSlotGroup {
type: 'available' | 'unavailable';
startTime: string;
@@ -55,7 +121,8 @@ export function calculatePreviousTime(time: string): string {
export function normalizeTime(time: string): string {
const parts = time.split(':');
return `${parts[0]}:${parts[1]}`;
if (parts.length < 2) return time.padStart(5, '0');
return `${parts[0].padStart(2, '0')}:${parts[1].padStart(2, '0')}`;
}
export function getDayWithOrdinal(date: CalendarDate): string {
@@ -113,19 +180,11 @@ export function generateAvailableTimeSlots(
const dayWH = workingHours[dateStr];
const dayAH = availableHours[dateStr];
if (!dayWH?.isOpen || !dayAH?.slots) {
console.log('[DEBUG] generateAvailableTimeSlots - early return', {
dateStr,
hasDayWH: !!dayWH,
dayWH_isOpen: dayWH?.isOpen,
hasDayAH: !!dayAH,
dayAH_slots: dayAH?.slots?.length
});
return [];
}
const slots: string[] = [];
const now = new SvelteDate();
const todayCal = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
const todayCal = getLondonTodayCalendarDate();
const isToday = date.compare(todayCal) === 0;
for (const slot of dayAH.slots) {
@@ -135,8 +194,11 @@ export function generateAvailableTimeSlots(
const endTotalMinutes = endHour * 60 + endMinute;
if (isToday) {
const currentMinutes = now.getHours() * 60 + now.getMinutes();
let minimumStart = Math.ceil((currentMinutes + 15) / 15) * 15;
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 currentMinutes = londonHours * 60 + londonMinutes;
let minimumStart = Math.ceil((currentMinutes + 60) / 15) * 15;
startTotalMinutes = Math.max(startTotalMinutes, minimumStart);
}
@@ -150,16 +212,6 @@ export function generateAvailableTimeSlots(
}
}
}
console.log('[DEBUG] generateAvailableTimeSlots result', {
dateStr,
duration,
dayWH_startTime: dayWH.startTime,
dayWH_endTime: dayWH.endTime,
dayAH_slots_raw: dayAH.slots,
isToday,
generatedSlotsCount: slots.length,
generatedSlots: slots.slice(0, 20) + (slots.length > 20 ? `... (${slots.length} total)` : '')
});
return slots;
}
@@ -171,35 +223,26 @@ export function generateGroupedTimeSlots(
protection: Map<string, LunchProtectionResult>
): TimeSlotGroup[] {
if (!workingHours) {
console.log('[DEBUG] generateGroupedTimeSlots - no workingHours');
return [];
}
const dateStr = date.toString();
const dayWH = workingHours[dateStr];
if (!dayWH || !dayWH.isOpen) {
console.log('[DEBUG] generateGroupedTimeSlots - day not open', { dateStr, dayWH });
return [];
}
console.log('[DEBUG] generateGroupedTimeSlots - inputs', {
dateStr,
duration,
startTime: dayWH.startTime,
endTime: dayWH.endTime,
isOpen: dayWH.isOpen,
source: (dayWH as any).source
});
const grouped: TimeSlotGroup[] = [];
const [startHour, startMinute] = dayWH.startTime.split(':').map(Number);
const [endHour, endMinute] = dayWH.endTime.split(':').map(Number);
let startTotalMinutes = startHour * 60 + startMinute;
const endTotalMinutes = endHour * 60 + endMinute;
const now = new SvelteDate();
const todayCal = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
const todayCal = getLondonTodayCalendarDate();
if (date.compare(todayCal) === 0) {
const currentMinutes = now.getHours() * 60 + now.getMinutes();
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 currentMinutes = londonHours * 60 + londonMinutes;
startTotalMinutes = Math.max(startTotalMinutes, Math.ceil((currentMinutes + 15) / 15) * 15);
}
@@ -211,14 +254,6 @@ export function generateGroupedTimeSlots(
protection
);
console.log('[DEBUG] generateGroupedTimeSlots - availableSlots', {
availableSlotsCount: availableSlots.length,
availableSlots: availableSlots.slice(0, 30) + (availableSlots.length > 30 ? `... (${availableSlots.length} total)` : ''),
dayRange: `${dayWH.startTime}-${dayWH.endTime}`,
startTotalMinutes,
endTotalMinutes
});
let currentUnavailableStart: string | null = null;
let lastAvailableEndTime: string | null = null;
@@ -266,12 +301,5 @@ export function generateGroupedTimeSlots(
}
}
console.log('[DEBUG] generateGroupedTimeSlots - final result', {
availableCount: grouped.filter(s => s.type === 'available').length,
unavailableCount: grouped.filter(s => s.type === 'unavailable').length,
availableTimes: grouped.filter(s => s.type === 'available').map(s => s.startTime + '-' + s.endTime).slice(0, 20),
unavailableRanges: grouped.filter(s => s.type === 'unavailable').map(s => s.startTime + '-' + s.endTime)
});
return grouped;
}