'use client'; import { useEffect, useState } from '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 (
{title ?? 'Schedule & timings'}
{/* Season length block */}

Season length

handleRoundsChange(e.target.value)} min={1} error={!!errors?.roundsPlanned} errorMessage={errors?.roundsPlanned} />

Used for planning and drop hints; can be approximate.

handleSessionCountChange(e.target.value)} min={1} />

Typically 1 for feature-only; 2 for sprint + feature.

{/* Race schedule block */}

Race schedule

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, }); }} />
weeks
)}
{requiresWeekdaySelection && ( Select at least one weekday. )}
{(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as Weekday[]).map( (day) => { const isActive = weekdays.includes(day); return ( ); }, )}

Schedule summary

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

{isSchedulePreviewLoading && ( Updating… )}

Schedule preview

First few rounds with your current settings.

{schedulePreviewError && (

{schedulePreviewError}

)} {!schedulePreview && !schedulePreviewError && (

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

)} {schedulePreview && (
{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.

)}
)}
{/* Weekend template block */}

Weekend template

Pick a typical weekend; you can fine-tune durations below.

Templates set starting values only; you can override any number.

{/* Session durations block */}

Session durations

Rough lengths for each session type; used for planning only.

0 ? String(timings.practiceMinutes) : '' } onChange={(e) => handleNumericMinutesChange( 'practiceMinutes', e.target.value, ) } min={0} />

Set to 0 or leave empty if you don’t plan dedicated practice.

0 ? String(timings.qualifyingMinutes) : '' } onChange={(e) => handleNumericMinutesChange( 'qualifyingMinutes', e.target.value, ) } min={5} error={!!errors?.qualifyingMinutes} errorMessage={errors?.qualifyingMinutes} />
{showSprint && (
0 ? String(timings.sprintRaceMinutes) : '' } onChange={(e) => handleNumericMinutesChange( 'sprintRaceMinutes', e.target.value, ) } min={0} />

Only shown when your scoring pattern includes a sprint race.

)}
0 ? String(timings.mainRaceMinutes) : '' } onChange={(e) => handleNumericMinutesChange( 'mainRaceMinutes', e.target.value, ) } min={10} error={!!errors?.mainRaceMinutes} errorMessage={errors?.mainRaceMinutes} />

Approximate length of your main race.

); }