import { formatDate, formatRange, formatTime } from './dateTime'; import * as React from 'react'; import { __ } from '@wordpress/i18n'; import type { BookingApi } from './api'; import type { Slot } from './types'; import './calendar.css'; import { calendarAxis } from './calendarLayout'; export interface CalendarPrefill { resource_id: number; date: string; starts_at: string; min_duration_minutes: number; resource_name?: string; availability?: Slot; } export interface CalendarEvent { id?: number; reference?: string; resource_id: number; starts_at: string; ends_at: string; state: string; label: string; is_own?: boolean; status?: string; booking_source?: string; block_type?: string; reason?: string; is_recurring?: boolean; } interface CalendarResource { exception_title?: string | null; id: number; name: string; sport_id: number | null; sport_name: string | null; surface: string | null; capacity: number | null; booking_step_minutes: number; opening_intervals: { starts_at: string; ends_at: string; }[]; slots: { starts_at: string; ends_at: string; state: string; can_book: boolean; availability: Slot | null; booking_restriction_message?: string; booking_contact_tel?: string | null; booking_contact_phone?: string; }[]; bookings: CalendarEvent[]; blocks: CalendarEvent[]; } interface CalendarData { date: string; today: string; previous_date: string; next_date: string; timezone: string; locale: string; now: string; admin: boolean; authenticated: boolean; sports: { id: number; name: string; }[]; grid: { starts_at: string | null; ends_at: string | null; resource_specific_steps: boolean; ticks: { at: string; label: string; offset: string; }[]; }; resources: CalendarResource[]; } function stateLabel(state: string): string { const labels: Record = { online_closed: __('Online closed / phone only', 'abece-booking'), free: __('Free', 'abece-booking'), busy: __('Busy', 'abece-booking'), own: __('My booking', 'abece-booking'), blocked: __('Unavailable', 'abece-booking'), closed: __('Outside opening hours', 'abece-booking'), past: __('Past time', 'abece-booking'), unavailable: __('Unavailable', 'abece-booking') }; return labels[state] ?? state; } function blockTypeLabel(type?: string): string { const labels: Record = { maintenance: __('Maintenance', 'abece-booking'), tournament: __('Tournament', 'abece-booking'), club_event: __('Club event', 'abece-booking'), weather: __('Weather', 'abece-booking'), private: __('Private', 'abece-booking'), other: __('Other', 'abece-booking') }; return labels[type ?? ''] ?? __('Resource block', 'abece-booking'); } export function Calendar({ api, admin = false, refresh = 0, paused = false, initialDate = '', onDateChange, onSelect, onBooking, onRefresh }: { api: BookingApi; admin?: boolean; refresh?: number; paused?: boolean; initialDate?: string; onDateChange?: (date: string) => void; onSelect?: (p: CalendarPrefill) => void; onBooking?: (id: number) => void; onRefresh?: () => void; }) { const dateListener = React.useRef(onDateChange); dateListener.current = onDateChange; const [restriction, setRestriction] = React.useState<{ message: string; tel?: string | null; phone?: string; } | null>(null); const [date, setDate] = React.useState(initialDate); const [sport, setSport] = React.useState(''); const [data, setData] = React.useState(null); const [error, setError] = React.useState(''); const [loading, setLoading] = React.useState(false); const [revision, bump] = React.useReducer((n: number) => n + 1, 0); React.useEffect(() => { let alive = true; const abort = new AbortController(); setLoading(true); setError(''); const query: Record = {}; if (date) query.date = date; if (sport) query.sport_id = sport; api.request(admin ? 'admin/calendar' : 'calendar', 'GET', undefined, query, abort.signal).then(value => { if (alive) { setData(value); dateListener.current?.(value.date); } }).catch(e => { if (alive) setError(e.message); }).finally(() => { if (alive) setLoading(false); }); return () => { alive = false; abort.abort(); }; }, [api, admin, date, sport, refresh, revision]); React.useEffect(() => { if (paused) return; const timer = window.setInterval(() => { if (document.visibilityState === 'visible') bump(); }, 45000); return () => window.clearInterval(timer); }, [paused]); // One formatting pass per distinct instant, instead of scanning every row for every slot. const times = React.useMemo(() => { const plain = new Map(), rows = new Map>(); let ticks = new Map(); function collect(values: string[]) { const choices = new Map(), groups = new Map(); for (const at of values) { if (!plain.has(at)) plain.set(at, formatTime(at, data?.timezone)); const label = plain.get(at)!; groups.set(label, [...(groups.get(label) ?? []), at]); } for (const [label, ats] of groups) ats.forEach((at, i) => choices.set(at, ats.length > 1 ? `${label} (${i === 0 ? __('First occurrence', 'abece-booking') : __('Second occurrence', 'abece-booking')})` : label)); return choices; } if (data) { ticks = collect(calendarAxis(data.grid.starts_at, data.grid.ends_at).ticks); data.resources.forEach(row => rows.set(row.id, collect(row.slots.map(slot => slot.starts_at)))); } return { plain, rows, ticks }; }, [data]); const axis = React.useMemo(() => calendarAxis(data?.grid.starts_at ?? null, data?.grid.ends_at ?? null), [data?.grid.starts_at, data?.grid.ends_at]); const { start, end } = axis, span = end - start; const pixelsPerMinute = Math.max(44 / 30, 24 / Math.max(1, Math.min(...(data?.resources.map(r => r.booking_step_minutes) ?? [30])))); const axisWidth = Math.max(44, Math.ceil(span / 60000 * pixelsPerMinute)); function position(from: string, to?: string): React.CSSProperties { const a = Math.max(start, Date.parse(from)), b = to ? Math.min(end, Date.parse(to)) : a; return { left: `${100 * (a - start) / span}%`, ...(to ? { width: `${100 * (b - a) / span}%` } : {}) }; } function event(event: CalendarEvent, resource: CalendarResource, block: boolean) { const label = block && admin ? `${__('Resource block', 'abece-booking')}: ${blockTypeLabel(event.block_type)} ${event.reason ?? ''}` : event.label; const time = formatRange(event.starts_at, event.ends_at, data?.timezone, false); const aria = `${resource.name}, ${time}, ${label}${event.is_recurring ? `, ${__('Recurring booking', 'abece-booking')}` : ''}`; const className = `asb-calendar-event asb-calendar-${block ? 'blocked' : event.state}${!block && admin && event.booking_source !== 'web' ? ' asb-calendar-admin-booking' : ''}`; const content = <>{event.is_recurring && ↻ }{label}{time}; return !block && event.id && (admin || event.is_own) && onBooking ? : {content}; } const now = data?.today === data?.date && data && Date.parse(data.now) >= start && Date.parse(data.now) <= end ? data.now : null; return
{(admin || (data && (date || data.date) > data.today)) && }
{data &&

{formatDate(data.date, 'full', data.locale)} · {data.timezone}

}
    {['free', 'busy', 'own', 'blocked', 'closed', ...(!admin ? ['online_closed'] : []), 'past'].map(state =>
  • {state === 'blocked' && admin ? __('Resource block', 'abece-booking') : stateLabel(state)}
  • )}{admin &&
  • {__('Admin / phone booking', 'abece-booking')}
  • }
{restriction &&

{restriction.message}

{restriction.tel && {restriction.phone}}
} {error &&

{error}

}{loading &&

{__('Loading…', 'abece-booking')}

} {data && !error && <>{!data.resources.length &&

{__('No resources found.', 'abece-booking')}

}{data.resources.length > 0 && !span &&

{__('No opening hours or reservations for this date.', 'abece-booking')}

}
{span > 0 &&
{__('Resource', 'abece-booking')}
{axis.ticks.map((at, i) => {times.ticks.get(at)})}
} {data.resources.map(r =>
{[r.name, r.sport_name, r.surface].filter(Boolean).join(' · ')}{!span && {r.exception_title || __('Outside opening hours', 'abece-booking')}}
{span > 0 &&
{axis.padding.map(p => )}{r.slots.map(s => { const compact = (Date.parse(s.ends_at) - Date.parse(s.starts_at)) * axisWidth / span < 24; const label = `${r.name}, ${times.rows.get(r.id)?.get(s.starts_at)}, ${s.state === 'closed' && r.exception_title ? r.exception_title : stateLabel(s.state)}`; if (s.booking_restriction_message) return ; return s.can_book && s.availability && onSelect ? : {['busy', 'own', 'blocked'].includes(s.state) ? '' : s.state === 'closed' && r.exception_title ? r.exception_title : admin ? stateLabel(s.state) : '—'}; })}{r.blocks.map(b => event(b, r, true))}{r.bookings.map(b => event(b, r, false))}{now && }
}
)}
}
; } export function CalendarPanel({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode; }) { const ref = React.useRef(null); const heading = React.useId(); React.useEffect(() => { const previous = document.activeElement as HTMLElement | null; const fallback = previous?.closest('.asb-calendar')?.querySelector('.asb-calendar-scroll'); const dialog = ref.current; const focus = () => { const input = dialog?.querySelector('[data-asb-initial-focus]:not(:disabled)'); if (input) { input.focus(); return true; } return false; }; dialog?.focus(); const focused = focus(); const observer = new MutationObserver(() => { if (focus()) observer.disconnect(); }); if (!focused && dialog) observer.observe(dialog, { childList: true, subtree: true, attributes: true, attributeFilter: ['disabled'] }); return () => { observer.disconnect(); if (previous?.isConnected) previous.focus(); else fallback?.focus(); }; }, []); function keyboard(e: React.KeyboardEvent) { if (e.key === 'Escape') { e.preventDefault(); onClose(); } if (e.key === 'Tab') { const dialog = ref.current; if (!dialog) return; const controls = Array.from(dialog.querySelectorAll('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], [tabindex="0"]')).filter(el => el.getClientRects().length > 0 && !el.closest('fieldset:disabled')); const first = controls[0], last = controls.at(-1); if (e.shiftKey && (document.activeElement === first || document.activeElement === dialog)) { e.preventDefault(); last?.focus(); } else if (!e.shiftKey && (document.activeElement === last || document.activeElement === dialog)) { e.preventDefault(); first?.focus(); } } } return

{title}

{children}
; }