/** * Shared formatting utilities for consistent display across the app. */ /** * Convert minutes to a human-readable duration string. * * Examples: * 45 → "45m" * 60 → "1h" * 90 → "1h 30m" * 120 → "2h" * 150 → "2h 30m" */ export function formatDuration(minutes: number): string { const hours = Math.floor(minutes / 60); const mins = minutes % 60; if (hours === 0) return `${mins}m`; if (mins === 0) return `${hours}h`; return `${hours}h ${mins}m`; } /** * Format a date/time to "Weekday, Month Day at HH:MM AM/PM". * * Examples: * "2026-05-29T10:00:00Z" → "Friday, May 29 at 10:00 AM" * "2026-06-05T14:30:00Z" → "Friday, Jun 5 at 2:30 PM" */ export function formatDateTime(date: Date | string): string { const d = typeof date === 'string' ? new Date(date) : date; const dateStr = d.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric', timeZone: 'Europe/London' }); const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true, timeZone: 'Europe/London' }); return `${dateStr} at ${timeStr}`; } /** * Format a Date to ISO date string "YYYY-MM-DD". * * Example: new Date(2026, 4, 29) → "2026-05-29" */ export function formatDateISO(d: Date, timeZone = 'Europe/London'): string { return d.toLocaleDateString('en-CA', { timeZone }); } /** * Calculate age from a date-of-birth string. * Returns the number of full years, or null if DOB is invalid. */ export function calculateAge(dateOfBirth: string | undefined | null): number | null { if (!dateOfBirth) return null; const dob = new Date(dateOfBirth); if (isNaN(dob.getTime())) return null; const today = new Date(); // 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; } // Single cached GBP formatter shared by every `formatCurrency` call — one // `Intl.NumberFormat` instance instead of a fresh allocation per call, since // currency formatting runs on the app's hottest rendering paths. The explicit // `minimumFractionDigits: 2` guarantees whole pounds render as "£5.00", never // "£5". `formatCurrency` takes the amount in POUNDS (not pence). const gbpFormatter = new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP', minimumFractionDigits: 2 }); /** * Format a monetary amount as GBP, e.g. 19.5 → "£19.50". * * The amount must be in POUNDS (e.g. `booking.total_amount`, `subtotal`). * For pence values, divide by 100 at the call site: `formatCurrency(pence / 100)`. */ export function formatCurrency(amount: number): string { return gbpFormatter.format(amount); } /** * Create an array of numbers from 0 to n-1. * * Useful for iterating a fixed number of times in Svelte templates: * * {#each range(3) as i} *
{i}
* {/each} * * Replaces the `{#each Array(3) as _, i (i)}{__}{/each}` workaround pattern * that was needed to suppress eslint unused-variable warnings. */ export function range(n: number): number[] { return Array.from({ length: n }, (_, i) => i); }