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
@@ -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;