Add UTC datetime helpers to attempt to fix running issue

This commit is contained in:
2026-05-29 18:51:28 +01:00
parent 000555dbd7
commit 2d5bdcbe35
25 changed files with 7373 additions and 5 deletions
+3411
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,77 @@
import React from 'react';
import { User } from '../../services/membershipService';
import ProfileMenu from '../ProfileMenu';
import PortalBrand from '../layout/PortalBrand';
interface DashboardTopbarProps {
activeTab: 'overview' | 'questions' | 'settings' | 'admin';
isAdmin: boolean;
isAdminWorkspace: boolean;
navigateToTab: (tab: 'overview' | 'questions' | 'settings' | 'admin') => void;
enterAdminArea: () => void;
exitAdminArea: () => void;
onEditProfile: () => void;
subtitle?: string;
user: User | null;
}
const userTabs: Array<{ key: 'overview' | 'questions' | 'settings'; label: string }> = [
{ key: 'overview', label: 'Overview' },
{ key: 'questions', label: 'Profile Questions' },
{ key: 'settings', label: 'Profile Settings' }
];
const DashboardTopbar: React.FC<DashboardTopbarProps> = ({
activeTab,
isAdmin,
isAdminWorkspace,
navigateToTab,
enterAdminArea,
exitAdminArea,
onEditProfile,
subtitle,
user
}) => (
<nav className={isAdminWorkspace ? 'portal-topbar portal-topbar-admin' : 'portal-topbar member-topbar'}>
<PortalBrand
title={isAdminWorkspace ? 'SASA Admin' : 'SASA Member Portal'}
subtitle={subtitle || (isAdminWorkspace ? 'Operations network' : `Welcome, ${user?.first_name || 'Member'}`)}
admin={isAdminWorkspace}
/>
{!isAdminWorkspace && (
<div className="portal-nav">
{userTabs.map((tab) => (
<button
key={tab.key}
className={activeTab === tab.key ? 'portal-tab active' : 'portal-tab'}
onClick={() => navigateToTab(tab.key)}
>
{tab.label}
</button>
))}
{isAdmin && (
<button className="portal-switch-button" onClick={enterAdminArea}>
Enter Admin Area
</button>
)}
</div>
)}
<div className="portal-meta">
{isAdminWorkspace && (
<button className="portal-exit-button" onClick={exitAdminArea}>
Back to User Space
</button>
)}
<ProfileMenu
userName={`${user?.first_name || ''} ${user?.last_name || ''}`.trim()}
userRole={user?.role || ''}
user={user}
onEditProfile={onEditProfile}
/>
</div>
</nav>
);
export default DashboardTopbar;
@@ -0,0 +1,14 @@
import React from 'react';
import { Link } from 'react-router-dom';
const AppFooter: React.FC = () => (
<footer className="site-footer">
<div>
<Link to="/privacy-policy">Privacy Policy</Link>
<Link to="/terms-of-service">Terms of Service</Link>
</div>
<div className="site-footer-caption">SASA Portal</div>
</footer>
);
export default AppFooter;
@@ -0,0 +1,18 @@
import React from 'react';
interface CookieBannerProps {
onDismiss: () => void;
}
const CookieBanner: React.FC<CookieBannerProps> = ({ onDismiss }) => (
<div className="cookie-banner">
<div>
We use cookies for session authentication, security, and basic site functionality.
</div>
<button className="btn btn-primary cookie-banner-button" onClick={onDismiss}>
OK
</button>
</div>
);
export default CookieBanner;
@@ -0,0 +1,19 @@
import React from 'react';
interface PortalBrandProps {
title: string;
subtitle: string;
admin?: boolean;
}
const PortalBrand: React.FC<PortalBrandProps> = ({ title, subtitle, admin = false }) => (
<div className="portal-brand">
<div className="portal-mark">S</div>
<div className={`portal-brand-text${admin ? ' admin-brand-text' : ''}`}>
<h1>{title}</h1>
<div className="portal-subtitle">{subtitle}</div>
</div>
</div>
);
export default PortalBrand;
+108
View File
@@ -0,0 +1,108 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
interface ConfirmOptions {
title?: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
tone?: 'default' | 'danger';
}
interface ConfirmState extends ConfirmOptions {
open: boolean;
}
interface ConfirmContextValue {
confirm: (options: ConfirmOptions) => Promise<boolean>;
}
const ConfirmContext = createContext<ConfirmContextValue | null>(null);
export const ConfirmProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const location = useLocation();
const resolverRef = useRef<((value: boolean) => void) | null>(null);
const lastLocationKeyRef = useRef(location.key);
const [dialog, setDialog] = useState<ConfirmState>({
open: false,
title: '',
message: '',
confirmLabel: 'Confirm',
cancelLabel: 'Cancel',
tone: 'default'
});
const closeDialog = useCallback((result: boolean) => {
resolverRef.current?.(result);
resolverRef.current = null;
setDialog((prev) => ({ ...prev, open: false }));
}, []);
const confirm = useCallback((options: ConfirmOptions) => {
return new Promise<boolean>((resolve) => {
resolverRef.current = resolve;
setDialog({
open: true,
title: options.title || 'Confirm action',
message: options.message,
confirmLabel: options.confirmLabel || 'Confirm',
cancelLabel: options.cancelLabel || 'Cancel',
tone: options.tone || 'default'
});
});
}, []);
const value = useMemo<ConfirmContextValue>(() => ({ confirm }), [confirm]);
useEffect(() => {
if (lastLocationKeyRef.current === location.key) {
return;
}
lastLocationKeyRef.current = location.key;
if (!dialog.open) {
return;
}
resolverRef.current?.(false);
resolverRef.current = null;
setDialog((prev) => ({ ...prev, open: false }));
}, [dialog.open, location.key]);
return (
<ConfirmContext.Provider value={value}>
{children}
{dialog.open && (
<div className="modal-overlay" onClick={() => closeDialog(false)}>
<div className="modal-content confirm-dialog" onClick={(event) => event.stopPropagation()}>
<h3 className={dialog.tone === 'danger' ? 'confirm-dialog-title danger' : 'confirm-dialog-title'}>
{dialog.title}
</h3>
<p className="confirm-dialog-message">{dialog.message}</p>
<div className="modal-button-row">
<button className="btn btn-secondary" type="button" onClick={() => closeDialog(false)}>
{dialog.cancelLabel}
</button>
<button
className={dialog.tone === 'danger' ? 'btn btn-danger' : 'btn btn-primary'}
type="button"
onClick={() => closeDialog(true)}
>
{dialog.confirmLabel}
</button>
</div>
</div>
</div>
)}
</ConfirmContext.Provider>
);
};
export const useConfirm = (): ConfirmContextValue => {
const context = useContext(ConfirmContext);
if (!context) {
throw new Error('useConfirm must be used within a ConfirmProvider');
}
return context;
};
+65
View File
@@ -0,0 +1,65 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
type ToastTone = 'success' | 'error' | 'info';
interface ToastItem {
id: number;
message: string;
tone: ToastTone;
}
interface ToastContextValue {
showToast: (message: string, tone?: ToastTone) => void;
success: (message: string) => void;
error: (message: string) => void;
info: (message: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const dismissToast = useCallback((id: number) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
const showToast = useCallback((message: string, tone: ToastTone = 'info') => {
const id = Date.now() + Math.floor(Math.random() * 1000);
setToasts((prev) => [...prev, { id, message, tone }]);
window.setTimeout(() => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, 5000);
}, []);
const value = useMemo<ToastContextValue>(() => ({
showToast,
success: (message) => showToast(message, 'success'),
error: (message) => showToast(message, 'error'),
info: (message) => showToast(message, 'info')
}), [showToast]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="toast-viewport" aria-live="polite" aria-atomic="true">
{toasts.map((toast) => (
<div key={toast.id} className={`toast toast-${toast.tone}`}>
<div className="toast-message">{toast.message}</div>
<button className="toast-close" type="button" onClick={() => dismissToast(toast.id)} aria-label="Dismiss notification">
×
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
};
export const useToast = (): ToastContextValue => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};
@@ -0,0 +1,81 @@
import React from 'react';
type AdminAreaKey = 'operations' | 'rfid' | 'comms' | 'flags' | 'tiers';
type AdminSectionKey =
| 'overview'
| 'users'
| 'events'
| 'profileQuestions'
| 'espActions'
| 'espReaders'
| 'espCards'
| 'espActivity'
| 'featureFlags'
| 'tiers'
| 'email'
| 'bounces';
interface AdminWorkspacePageProps {
activeAdminArea: AdminAreaKey;
activePageItems: Array<{ key: AdminSectionKey; label: string }>;
adminPrimaryItems: Array<{ key: AdminAreaKey; label: string; defaultSection: AdminSectionKey }>;
adminSection: AdminSectionKey;
children: React.ReactNode;
renderAdminRailTools: () => React.ReactNode;
renderPrimaryIcon: (area: AdminAreaKey) => React.ReactNode;
showAdminPageRail: boolean;
navigateToAdminSection: (section: AdminSectionKey) => void;
}
const AdminWorkspacePage: React.FC<AdminWorkspacePageProps> = ({
activeAdminArea,
activePageItems,
adminPrimaryItems,
adminSection,
children,
renderAdminRailTools,
renderPrimaryIcon,
showAdminPageRail,
navigateToAdminSection
}) => (
<div className={`admin-workspace ${showAdminPageRail ? 'has-page-rail' : 'single-page-area'}`}>
<aside className="admin-primary-rail">
<nav className="admin-primary-nav" aria-label="Admin areas">
{adminPrimaryItems.map((item) => (
<button
key={item.key}
className={activeAdminArea === item.key ? 'admin-primary-link active' : 'admin-primary-link'}
onClick={() => navigateToAdminSection(item.defaultSection)}
title={item.label}
>
<span className="admin-primary-icon">{renderPrimaryIcon(item.key)}</span>
</button>
))}
</nav>
</aside>
{showAdminPageRail && (
<aside className="admin-page-rail">
<div className="admin-page-rail-title">
{adminPrimaryItems.find((item) => item.key === activeAdminArea)?.label}
</div>
<nav className="admin-page-nav" aria-label="Admin pages">
{activePageItems.map((item) => (
<button
key={item.key}
className={adminSection === item.key ? 'admin-page-link active' : 'admin-page-link'}
onClick={() => navigateToAdminSection(item.key)}
>
{item.label}
</button>
))}
</nav>
{renderAdminRailTools()}
</aside>
)}
<section className="admin-content">{children}</section>
</div>
);
export default AdminWorkspacePage;
@@ -0,0 +1,189 @@
import React from 'react';
import { Event, Membership, Payment } from '../../services/membershipService';
import { utcToLondonTimeInput } from '../../utils/timezone';
interface MemberOverviewPageProps {
activeMembership?: Membership;
formatDate: (dateString: string) => string;
getStatusClass: (status: string) => string;
handleMembershipSetup: () => void;
handleRSVP: (eventId: number, status: 'attending' | 'maybe' | 'not_attending') => void;
payments: Payment[];
rsvpLoading: { [eventId: number]: boolean };
upcomingEvents: Event[];
}
const MemberOverviewPage: React.FC<MemberOverviewPageProps> = ({
activeMembership,
formatDate,
getStatusClass,
handleMembershipSetup,
handleRSVP,
payments,
rsvpLoading,
upcomingEvents
}) => (
<>
<section className="member-hero">
<div>
<p className="member-hero-kicker">Member Dashboard</p>
<h2 className="member-hero-title">Everything you need for your SASA membership</h2>
<p className="member-hero-copy">
Track your status, respond to upcoming events, and keep your details current from one place.
</p>
</div>
<div className="member-stat-strip">
<div className="member-stat-chip">
<span className="member-stat-label">Membership</span>
<strong className="member-stat-value">{activeMembership ? activeMembership.status : 'Not set up'}</strong>
</div>
<div className="member-stat-chip">
<span className="member-stat-label">Events</span>
<strong className="member-stat-value">{upcomingEvents.length}</strong>
</div>
<div className="member-stat-chip">
<span className="member-stat-label">Payments</span>
<strong className="member-stat-value">{payments.length}</strong>
</div>
</div>
</section>
<div className="dashboard-grid member-overview-grid">
{activeMembership ? (
<div className="card member-card">
<div className="member-card-header">
<div>
<p className="member-card-kicker">Membership</p>
<h3>Your Membership</h3>
</div>
<span className={`status-badge ${getStatusClass(activeMembership.status)}`}>{activeMembership.status.toUpperCase()}</span>
</div>
<h4 className="member-tier-title">{activeMembership.tier.name}</h4>
<div className="member-data-list">
<div className="member-data-row"><strong>Membership Number</strong><span>{activeMembership.id}</span></div>
<div className="member-data-row"><strong>Annual Fee</strong><span>£{activeMembership.tier.annual_fee.toFixed(2)}</span></div>
<div className="member-data-row"><strong>Valid From</strong><span>{formatDate(activeMembership.start_date)}</span></div>
<div className="member-data-row"><strong>Valid Until</strong><span>{formatDate(activeMembership.end_date)}</span></div>
<div className="member-data-row"><strong>Auto Renew</strong><span>{activeMembership.auto_renew ? 'Yes' : 'No'}</span></div>
</div>
<div className="member-info-panel">
<strong>Benefits:</strong>
<p>{activeMembership.tier.benefits}</p>
</div>
</div>
) : (
<div className="card member-card">
<div className="member-card-header">
<div>
<p className="member-card-kicker">Membership</p>
<h3>Set Up Your Membership</h3>
</div>
</div>
<p>Choose from our membership tiers to get started with SASA benefits.</p>
<p className="member-muted-copy">Available tiers include Personal, Aircraft Owners, and Corporate memberships.</p>
<button className="btn btn-primary member-inline-action" onClick={handleMembershipSetup}>
Set Up Membership
</button>
</div>
)}
<div className="card member-card">
<div className="member-card-header">
<div>
<p className="member-card-kicker">Calendar</p>
<h3>Upcoming Events</h3>
</div>
</div>
{upcomingEvents.length > 0 ? (
<div className="events-container">
{upcomingEvents.map((event) => (
<div key={event.id} className="event-card">
<div className="event-header">
<div className="event-info">
<h4 className="event-title">{event.title}</h4>
<p className="event-datetime">
{formatDate(event.event_date)} at {utcToLondonTimeInput(event.event_date)}
</p>
{event.location && <p className="event-location">{event.location}</p>}
</div>
<div className="event-rsvp-buttons">
<button
className={`rsvp-btn rsvp-btn-attending ${event.rsvp_status === 'attending' ? 'active' : ''}`}
onClick={() => handleRSVP(event.id, 'attending')}
disabled={rsvpLoading[event.id]}
>
{rsvpLoading[event.id] ? '...' : 'Attending'}
</button>
<button
className={`rsvp-btn rsvp-btn-maybe ${event.rsvp_status === 'maybe' ? 'active' : ''}`}
onClick={() => handleRSVP(event.id, 'maybe')}
disabled={rsvpLoading[event.id]}
>
{rsvpLoading[event.id] ? '...' : 'Maybe'}
</button>
<button
className={`rsvp-btn rsvp-btn-not-attending ${event.rsvp_status === 'not_attending' ? 'active' : ''}`}
onClick={() => handleRSVP(event.id, 'not_attending')}
disabled={rsvpLoading[event.id]}
>
{rsvpLoading[event.id] ? '...' : 'Not Attending'}
</button>
</div>
</div>
{event.description && <p className="event-description">{event.description}</p>}
{event.rsvp_status && (
<div className={`event-rsvp-status ${event.rsvp_status}`}>
<strong>Your RSVP:</strong> <span className="member-rsvp-state">{event.rsvp_status.replace('_', ' ')}</span>
</div>
)}
</div>
))}
</div>
) : (
<p className="member-muted-copy">No upcoming events at this time.</p>
)}
</div>
</div>
<div className="card member-card">
<div className="member-card-header">
<div>
<p className="member-card-kicker">Billing</p>
<h3>Payment History</h3>
</div>
</div>
{payments.length > 0 ? (
<div className="table-container">
<table className="member-table">
<thead>
<tr>
<th>Date</th>
<th>Amount</th>
<th>Method</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{payments.map((payment) => (
<tr key={payment.id}>
<td>{payment.payment_date ? formatDate(payment.payment_date) : 'Pending'}</td>
<td>£{payment.amount.toFixed(2)}</td>
<td className="member-table-caps">{payment.payment_method}</td>
<td>
<span className={`status-badge ${getStatusClass(payment.status)}`}>
{payment.status.toUpperCase()}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="member-muted-copy">No payment history available.</p>
)}
</div>
</>
);
export default MemberOverviewPage;
@@ -0,0 +1,21 @@
import React from 'react';
import ProfileQuestionsForm from '../../components/ProfileQuestionsForm';
import { ProfileAnswerInput, ProfileQuestionForUser } from '../../services/membershipService';
interface MemberQuestionsPageProps {
onSave: (answers: ProfileAnswerInput[]) => Promise<void>;
questions: ProfileQuestionForUser[];
}
const MemberQuestionsPage: React.FC<MemberQuestionsPageProps> = ({ onSave, questions }) => (
<ProfileQuestionsForm
title="Your Profile Questions"
description="Optional details that help us support your membership and volunteering. Some fields are admin-managed."
questions={questions}
onSave={onSave}
saveLabel="Save Profile Answers"
surface="member"
/>
);
export default MemberQuestionsPage;
@@ -0,0 +1,165 @@
import React from 'react';
interface MemberSettingsPageProps {
passwordError: string;
passwordForm: {
current_password: string;
new_password: string;
confirm_password: string;
};
passwordSaving: boolean;
passwordSuccess: string;
profileError: string;
profileFormData: {
first_name: string;
last_name: string;
email: string;
phone: string;
address: string;
};
profileSaving: boolean;
profileSuccess: string;
setPasswordForm: React.Dispatch<React.SetStateAction<{
current_password: string;
new_password: string;
confirm_password: string;
}>>;
setProfileFormData: React.Dispatch<React.SetStateAction<{
first_name: string;
last_name: string;
email: string;
phone: string;
address: string;
}>>;
onChangePassword: () => void;
onSaveProfile: () => void;
}
const MemberSettingsPage: React.FC<MemberSettingsPageProps> = ({
passwordError,
passwordForm,
passwordSaving,
passwordSuccess,
profileError,
profileFormData,
profileSaving,
profileSuccess,
setPasswordForm,
setProfileFormData,
onChangePassword,
onSaveProfile
}) => (
<div className="card member-card member-settings-card">
<div className="member-card-header">
<div>
<p className="member-card-kicker">Settings</p>
<h3>Profile Settings</h3>
</div>
</div>
{profileError && <div className="alert alert-error">{profileError}</div>}
{profileSuccess && <div className="alert alert-success">{profileSuccess}</div>}
<div className="member-settings-grid">
<div className="form-group">
<label htmlFor="settings-first-name">First Name</label>
<input
id="settings-first-name"
type="text"
placeholder="First Name"
value={profileFormData.first_name}
onChange={(e) => setProfileFormData((prev) => ({ ...prev, first_name: e.target.value }))}
/>
</div>
<div className="form-group">
<label htmlFor="settings-last-name">Last Name</label>
<input
id="settings-last-name"
type="text"
placeholder="Last Name"
value={profileFormData.last_name}
onChange={(e) => setProfileFormData((prev) => ({ ...prev, last_name: e.target.value }))}
/>
</div>
<div className="form-group">
<label htmlFor="settings-email">Email</label>
<input
id="settings-email"
type="email"
placeholder="Email"
value={profileFormData.email}
onChange={(e) => setProfileFormData((prev) => ({ ...prev, email: e.target.value }))}
/>
</div>
<div className="form-group">
<label htmlFor="settings-phone">Phone</label>
<input
id="settings-phone"
type="text"
placeholder="Phone"
value={profileFormData.phone}
onChange={(e) => setProfileFormData((prev) => ({ ...prev, phone: e.target.value }))}
/>
</div>
</div>
<div className="form-group">
<label htmlFor="settings-address">Address</label>
<textarea
id="settings-address"
placeholder="Address"
value={profileFormData.address}
onChange={(e) => setProfileFormData((prev) => ({ ...prev, address: e.target.value }))}
rows={3}
/>
</div>
<div className="member-settings-actions">
<button className="btn btn-primary" disabled={profileSaving} onClick={onSaveProfile}>
{profileSaving ? 'Saving...' : 'Save Profile'}
</button>
</div>
<div className="member-settings-divider" />
<h4 className="member-section-heading">Change Password</h4>
{passwordError && <div className="alert alert-error">{passwordError}</div>}
{passwordSuccess && <div className="alert alert-success">{passwordSuccess}</div>}
<div className="member-settings-grid">
<div className="form-group">
<label htmlFor="settings-current-password">Current Password</label>
<input
id="settings-current-password"
type="password"
placeholder="Current Password"
value={passwordForm.current_password}
onChange={(e) => setPasswordForm((prev) => ({ ...prev, current_password: e.target.value }))}
/>
</div>
<div className="form-group">
<label htmlFor="settings-new-password">New Password</label>
<input
id="settings-new-password"
type="password"
placeholder="New Password"
value={passwordForm.new_password}
onChange={(e) => setPasswordForm((prev) => ({ ...prev, new_password: e.target.value }))}
/>
</div>
<div className="form-group">
<label htmlFor="settings-confirm-password">Confirm New Password</label>
<input
id="settings-confirm-password"
type="password"
placeholder="Confirm New Password"
value={passwordForm.confirm_password}
onChange={(e) => setPasswordForm((prev) => ({ ...prev, confirm_password: e.target.value }))}
/>
</div>
</div>
<div className="member-settings-actions">
<button className="btn btn-secondary" disabled={passwordSaving} onClick={onChangePassword}>
{passwordSaving ? 'Updating...' : 'Update Password'}
</button>
</div>
</div>
);
export default MemberSettingsPage;
+1 -5
View File
@@ -28,15 +28,11 @@ export const canEditProfileQuestion = (
question: EditableProfileQuestion,
allowAdminManagedEdit = false
): boolean => {
if (allowAdminManagedEdit) {
return true;
}
if (!question.can_edit) {
return false;
}
if (question.admin_only_edit) {
if (question.admin_only_edit && !allowAdminManagedEdit) {
return false;
}
+92
View File
@@ -0,0 +1,92 @@
const LONDON_TIME_ZONE = 'Europe/London';
const parseUtcDate = (value: string): Date => {
const normalized = /(?:Z|[+-]\d{2}:?\d{2})$/.test(value) ? value : `${value}Z`;
return new Date(normalized);
};
const londonParts = (date: Date) => {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: LONDON_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
}).formatToParts(date);
const get = (type: string) => parts.find((part) => part.type === type)?.value || '00';
return {
year: get('year'),
month: get('month'),
day: get('day'),
hour: get('hour'),
minute: get('minute')
};
};
const timeZoneOffsetMs = (instant: Date): number => {
const parts = londonParts(instant);
const wallAsUtc = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour),
Number(parts.minute)
);
return wallAsUtc - instant.getTime();
};
export const ensureUtcIso = (value: string): string => parseUtcDate(value).toISOString();
export const utcMillis = (value: string | null | undefined): number => {
if (!value) return 0;
return parseUtcDate(value).getTime();
};
export const formatLondonDate = (value: string): string => {
return new Intl.DateTimeFormat('en-GB', {
timeZone: LONDON_TIME_ZONE,
day: 'numeric',
month: 'long',
year: 'numeric'
}).format(parseUtcDate(value));
};
export const formatLondonDateTime = (value: string | null): string => {
if (!value) return 'Never';
return new Intl.DateTimeFormat('en-GB', {
timeZone: LONDON_TIME_ZONE,
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
}).format(parseUtcDate(value));
};
export const utcToLondonDateInput = (value: string): string => {
const parts = londonParts(parseUtcDate(value));
return `${parts.year}-${parts.month}-${parts.day}`;
};
export const utcToLondonTimeInput = (value: string): string => {
const parts = londonParts(parseUtcDate(value));
return `${parts.hour}:${parts.minute}`;
};
export const londonTodayDateInput = (): string => {
const parts = londonParts(new Date());
return `${parts.year}-${parts.month}-${parts.day}`;
};
export const londonInputToUtcIso = (dateValue: string, timeValue: string = '00:00'): string => {
const [year, month, day] = dateValue.split('-').map(Number);
const [hour, minute] = timeValue.split(':').map(Number);
const wallAsUtc = Date.UTC(year, month - 1, day, hour || 0, minute || 0);
const firstPass = new Date(wallAsUtc - timeZoneOffsetMs(new Date(wallAsUtc)));
const secondPass = new Date(wallAsUtc - timeZoneOffsetMs(firstPass));
return secondPass.toISOString();
};