'use client'; import { useEffect, useState } from 'react'; import { Calendar, Clock, MapPin, Zap, Info, Loader2 } from 'lucide-react'; import type { LeagueConfigFormModel, LeagueSchedulePreviewDTO, } from '@gridpilot/racing/application'; import type { Weekday } from '@gridpilot/racing/domain/value-objects/Weekday'; import Heading from '@/components/ui/Heading'; import Input from '@/components/ui/Input'; import SegmentedControl from '@/components/ui/SegmentedControl'; type RecurrenceStrategy = NonNullable['recurrenceStrategy']; interface LeagueTimingsSectionProps { form: LeagueConfigFormModel; onChange?: (form: LeagueConfigFormModel) => void; readOnly?: boolean; errors?: { qualifyingMinutes?: string; mainRaceMinutes?: string; roundsPlanned?: string; }; /** * Optional override for the section heading. * When omitted, defaults to "Schedule & timings". */ title?: string; /** * Local wizard-only weekend template identifier. */ weekendTemplate?: string; /** * Callback when the weekend template selection changes. */ onWeekendTemplateChange?: (template: string) => void; } export function LeagueTimingsSection({ form, onChange, readOnly, errors, title, weekendTemplate, onWeekendTemplateChange, }: LeagueTimingsSectionProps) { const disabled = readOnly || !onChange; const timings = form.timings; const [schedulePreview, setSchedulePreview] = useState(null); const [schedulePreviewError, setSchedulePreviewError] = useState( null, ); const [isSchedulePreviewLoading, setIsSchedulePreviewLoading] = useState(false); const updateTimings = ( patch: Partial>, ) => { if (!onChange) return; onChange({ ...form, timings: { ...timings, ...patch, }, }); }; const handleRoundsChange = (value: string) => { if (!onChange) return; const trimmed = value.trim(); if (trimmed === '') { updateTimings({ roundsPlanned: undefined }); return; } const parsed = parseInt(trimmed, 10); updateTimings({ roundsPlanned: Number.isNaN(parsed) || parsed <= 0 ? undefined : parsed, }); }; const showSprint = form.scoring.patternId === 'sprint-main-driver' || (typeof timings.sprintRaceMinutes === 'number' && timings.sprintRaceMinutes > 0); const recurrenceStrategy: RecurrenceStrategy = timings.recurrenceStrategy ?? 'weekly'; const weekdays: Weekday[] = (timings.weekdays ?? []) as Weekday[]; const handleWeekdayToggle = (day: Weekday) => { const current = new Set(weekdays); if (current.has(day)) { current.delete(day); } else { current.add(day); } updateTimings({ weekdays: Array.from(current).sort() }); }; const handleRecurrenceChange = (value: string) => { updateTimings({ recurrenceStrategy: value as RecurrenceStrategy, }); }; const requiresWeekdaySelection = (recurrenceStrategy === 'weekly' || recurrenceStrategy === 'everyNWeeks') && weekdays.length === 0; useEffect(() => { if (!timings) return; const { seasonStartDate, raceStartTime, timezoneId, recurrenceStrategy: currentStrategy, intervalWeeks, weekdays: currentWeekdays, monthlyOrdinal, monthlyWeekday, roundsPlanned, } = timings; const hasCoreFields = !!seasonStartDate && !!raceStartTime && !!timezoneId && !!currentStrategy && typeof roundsPlanned === 'number' && roundsPlanned > 0; if (!hasCoreFields) { setSchedulePreview(null); setSchedulePreviewError(null); setIsSchedulePreviewLoading(false); return; } if ( (currentStrategy === 'weekly' || currentStrategy === 'everyNWeeks') && (!currentWeekdays || currentWeekdays.length === 0) ) { setSchedulePreview(null); setSchedulePreviewError(null); setIsSchedulePreviewLoading(false); return; } const controller = new AbortController(); const timeoutId = setTimeout(async () => { try { setIsSchedulePreviewLoading(true); setSchedulePreviewError(null); const payload = { seasonStartDate, raceStartTime, timezoneId, recurrenceStrategy: currentStrategy, intervalWeeks, weekdays: currentWeekdays, monthlyOrdinal, monthlyWeekday, plannedRounds: roundsPlanned, }; const response = await fetch('/api/leagues/schedule-preview', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(payload), signal: controller.signal, }); if (!response.ok) { const message = response.status === 400 ? 'Could not compute schedule with current values.' : 'Failed to load schedule preview.'; setSchedulePreviewError(message); return; } const data = (await response.json()) as LeagueSchedulePreviewDTO; setSchedulePreview(data); } catch (err) { if ((err as any).name === 'AbortError') { return; } setSchedulePreviewError('Could not compute schedule with current values.'); } finally { setIsSchedulePreviewLoading(false); } }, 400); return () => { clearTimeout(timeoutId); controller.abort(); }; }, [ timings?.seasonStartDate, timings?.raceStartTime, timings?.timezoneId, timings?.recurrenceStrategy, timings?.intervalWeeks, timings?.monthlyOrdinal, timings?.monthlyWeekday, timings?.roundsPlanned, // eslint-disable-next-line react-hooks/exhaustive-deps JSON.stringify(timings?.weekdays ?? []), ]); if (disabled) { return (
{title ?? 'Schedule & timings'}
Planned rounds:{' '} {timings.roundsPlanned ?? '—'}
Qualifying:{' '} {timings.qualifyingMinutes} min
Main race:{' '} {timings.mainRaceMinutes} min
Practice:{' '} {typeof timings.practiceMinutes === 'number' ? `${timings.practiceMinutes} min` : '—'}
{showSprint && (
Sprint:{' '} {typeof timings.sprintRaceMinutes === 'number' ? `${timings.sprintRaceMinutes} min` : '—'}
)}
Sessions per weekend:{' '} {timings.sessionCount}

Used for planning and hints; alpha-only and not yet fully wired into race scheduling.

); } const handleNumericMinutesChange = ( field: | 'practiceMinutes' | 'qualifyingMinutes' | 'sprintRaceMinutes' | 'mainRaceMinutes', raw: string, ) => { if (!onChange) return; const trimmed = raw.trim(); if (trimmed === '') { updateTimings({ [field]: undefined } as Partial< NonNullable >); return; } const parsed = parseInt(trimmed, 10); updateTimings({ [field]: Number.isNaN(parsed) || parsed <= 0 ? undefined : parsed, } as Partial>); }; const handleSessionCountChange = (raw: string) => { const trimmed = raw.trim(); if (trimmed === '') { updateTimings({ sessionCount: 1 }); return; } const parsed = parseInt(trimmed, 10); updateTimings({ sessionCount: Number.isNaN(parsed) || parsed <= 0 ? 1 : parsed, }); }; const weekendTemplateValue = weekendTemplate ?? ''; return (
{/* Step intro */}

Quick setup: Pick a weekend template and season length. The detailed schedule configuration is optional—you can set it now or schedule races manually later.

{/* 1. Weekend template - FIRST */}

1. Choose your race weekend format

This determines session counts and sets sensible duration defaults

{/* 2. Season length */}

2. How many race rounds?

handleRoundsChange(e.target.value)} min={1} error={!!errors?.roundsPlanned} errorMessage={errors?.roundsPlanned} className="w-32" placeholder="e.g., 10" />

Used for drop rule suggestions. Can be approximate—you can always add or remove rounds.

{/* 3. Optional: Detailed schedule */}

3. Automatic schedule (optional)

Configure when races happen automatically, or skip this and schedule rounds manually later

updateTimings({ seasonStartDate: e.target.value || undefined }) } />

Round 1 will use this date; later rounds follow your pattern.

updateTimings({ raceStartTime: e.target.value || undefined }) } />

Local time in your league's timezone.

{recurrenceStrategy === 'everyNWeeks' && (
Every { const raw = e.target.value.trim(); if (raw === '') { updateTimings({ intervalWeeks: undefined }); return; } const parsed = parseInt(raw, 10); updateTimings({ intervalWeeks: Number.isNaN(parsed) || parsed <= 0 ? 2 : parsed, }); }} className="w-20" /> weeks
)}
{requiresWeekdaySelection && ( Pick at least one )}
{(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as Weekday[]).map( (day) => { const isActive = weekdays.includes(day); return ( ); }, )}
{/* Schedule preview */} {(timings.seasonStartDate && timings.raceStartTime && weekdays.length > 0) && (

Schedule preview

{schedulePreview?.summary ?? 'Set a start date, time, and at least one weekday to preview the schedule.'}

{isSchedulePreviewLoading && ( )}
{schedulePreviewError && (
⚠️ {schedulePreviewError}
)} {!schedulePreview && !schedulePreviewError && (

Adjust the fields above to see a preview of your calendar.

)} {schedulePreview && (

First few rounds with your current settings:

{schedulePreview.rounds.map((round) => { const date = new Date(round.scheduledAt); const dateStr = date.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short', }); const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', }); return (
Round {round.roundNumber} {dateStr}, {timeStr}{' '} {round.timezoneId}
); })} {typeof timings.roundsPlanned === 'number' && timings.roundsPlanned > schedulePreview.rounds.length && (

+ {timings.roundsPlanned - schedulePreview.rounds.length} more rounds scheduled

)}
)}
)}
{/* 4. Optional: Session duration overrides */}

4. Customize session durations (optional)

Click to customize

Your weekend template already set reasonable defaults. Only change these if you need specific timings.

0 ? String(timings.practiceMinutes) : '' } onChange={(e) => handleNumericMinutesChange('practiceMinutes', e.target.value)} min={0} className="w-24" placeholder="20" />
0 ? String(timings.qualifyingMinutes) : '' } onChange={(e) => handleNumericMinutesChange('qualifyingMinutes', e.target.value)} min={5} error={!!errors?.qualifyingMinutes} errorMessage={errors?.qualifyingMinutes} className="w-24" placeholder="30" />
{showSprint && (
0 ? String(timings.sprintRaceMinutes) : '' } onChange={(e) => handleNumericMinutesChange('sprintRaceMinutes', e.target.value)} min={0} className="w-24" placeholder="20" />
)}
0 ? String(timings.mainRaceMinutes) : '' } onChange={(e) => handleNumericMinutesChange('mainRaceMinutes', e.target.value)} min={10} error={!!errors?.mainRaceMinutes} errorMessage={errors?.mainRaceMinutes} className="w-24" placeholder="40" />
); }