'use client'; import React, { useEffect, useState, useMemo, useRef } from 'react'; import { Calendar, Clock, Flag, CalendarDays, Timer, Trophy, ChevronDown, ChevronUp, Play, Eye, CalendarRange, Info, Globe, MapPin, Pencil, Link2, } from 'lucide-react'; import type { LeagueConfigFormModel, LeagueSchedulePreviewDTO, } from '@gridpilot/racing/application'; import type { Weekday } from '@gridpilot/racing/domain/types/Weekday'; import Input from '@/components/ui/Input'; import RangeField from '@/components/ui/RangeField'; // Common time zones for racing leagues const TIME_ZONES = [ { value: 'track', label: 'Track Local Time', icon: MapPin }, { value: 'UTC', label: 'UTC', icon: Globe }, { value: 'America/New_York', label: 'Eastern (US)', icon: Globe }, { value: 'America/Los_Angeles', label: 'Pacific (US)', icon: Globe }, { value: 'Europe/London', label: 'London (UK)', icon: Globe }, { value: 'Europe/Berlin', label: 'Central Europe', icon: Globe }, { value: 'Australia/Sydney', label: 'Sydney (AU)', icon: Globe }, ]; type RecurrenceStrategy = Exclude< NonNullable['recurrenceStrategy'], undefined >; interface LeagueTimingsSectionProps { form: LeagueConfigFormModel; onChange?: (form: LeagueConfigFormModel) => void; readOnly?: boolean; errors?: { qualifyingMinutes?: string; mainRaceMinutes?: string; roundsPlanned?: string; }; title?: string; } // ============================================================================ // PREVIEW COMPONENTS // ============================================================================ /** Race Day View - Shows what happens during a race day */ function RaceDayPreview({ template, practiceMin, qualifyingMin, sprintMin, mainRaceMin, raceTime, }: { template: string; practiceMin: number; qualifyingMin: number; sprintMin?: number; mainRaceMin: number; raceTime?: string; }) { const hasSprint = template === 'sprintFeature' && sprintMin; // Build all possible sessions with their active status const allSessions = useMemo(() => { return [ { name: 'Practice', duration: practiceMin, type: 'practice', active: practiceMin > 0 }, { name: 'Qualifying', duration: qualifyingMin, type: 'qualifying', active: true }, { name: 'Sprint Race', duration: sprintMin ?? 0, type: 'sprint', active: hasSprint }, { name: template === 'endurance' ? 'Endurance Race' : hasSprint ? 'Feature Race' : 'Main Race', duration: mainRaceMin, type: 'race', active: true, }, ]; }, [template, practiceMin, qualifyingMin, sprintMin, mainRaceMin, hasSprint]); const activeSessions = allSessions.filter(s => s.active); const totalDuration = activeSessions.reduce((sum, s) => sum + s.duration, 0); // Calculate start times - use default 20:00 if not set const effectiveRaceTime = raceTime || '20:00'; const getStartTime = (sessionIndex: number) => { const [hoursStr, minutesStr] = effectiveRaceTime.split(':'); const hours = Number(hoursStr ?? '0'); const minutes = Number(minutesStr ?? '0'); let totalMinutes = hours * 60 + minutes; const active = allSessions.filter((s) => s.active); for (let i = 0; i < sessionIndex; i++) { const session = active[i]; if (!session) continue; totalMinutes += session.duration + 10; // 10 min break between sessions } const h = Math.floor(totalMinutes / 60) % 24; const m = totalMinutes % 60; return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`; }; return (
Race Day Schedule Starts {effectiveRaceTime}{!raceTime && (default)}
{/* Timeline visualization - show ALL sessions */}
{allSessions.map((session, i) => { const isRace = session.type === 'race' || session.type === 'sprint'; const isActive = session.active; const activeIndex = isActive ? allSessions.filter((s, idx) => s.active && idx < i).length : -1; const startTime = isActive ? getStartTime(activeIndex) : null; return (
{/* Status badge */} {!isActive && (
Not included
)} {/* Time marker */}
{startTime ?? '—'}
{/* Session indicator */}
{/* Session info */}
{session.name}
{isActive ? `${session.duration} minutes` : 'Disabled'}
{/* Duration bar */} {isActive && (
s.duration))) * 100}%` }} />
)}
); })}
{/* Legend */}
Active race
Active session
Not included
{/* Summary */}
{activeSessions.length} active sessions {activeSessions.filter(s => s.type === 'race' || s.type === 'sprint').length} race{activeSessions.filter(s => s.type === 'race' || s.type === 'sprint').length > 1 ? 's' : ''}
{Math.floor(totalDuration / 60)}h {totalDuration % 60}m
); } /** Full Year Calendar View */ function YearCalendarPreview({ weekdays, frequency, rounds, startDate, endDate, }: { weekdays: Weekday[]; frequency: RecurrenceStrategy; rounds: number; startDate?: string; endDate?: string; }) { // JavaScript getDay(): 0=Sunday, 1=Monday, 2=Tuesday, etc. const dayMap: Record = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, }; const months = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ] as const; const getMonthLabel = (index: number): string => months[index] ?? '—'; // Parse start and end dates const seasonStart = useMemo(() => { const d = startDate ? new Date(startDate) : new Date(); d.setHours(12, 0, 0, 0); return d; }, [startDate]); const seasonEnd = useMemo(() => { if (!endDate) return null; const d = new Date(endDate); d.setHours(12, 0, 0, 0); return d; }, [endDate]); // Calculate race dates based on settings const raceDates = useMemo(() => { const dates: Date[] = []; if (weekdays.length === 0 || rounds <= 0) return dates; // Convert weekday names to day numbers for faster lookup const selectedDayNumbers = new Set(weekdays.map(wd => dayMap[wd])); // If we have both start and end dates, evenly distribute races if (seasonEnd && seasonEnd > seasonStart) { // First, collect all possible race days between start and end const allPossibleDays: Date[] = []; const currentDate = new Date(seasonStart); while (currentDate <= seasonEnd) { const dayOfWeek = currentDate.getDay(); if (selectedDayNumbers.has(dayOfWeek)) { allPossibleDays.push(new Date(currentDate)); } currentDate.setDate(currentDate.getDate() + 1); } // Now evenly distribute the rounds across available days const totalPossible = allPossibleDays.length; if (totalPossible >= rounds) { // Space them evenly const spacing = totalPossible / rounds; for (let i = 0; i < rounds; i++) { const index = Math.min(Math.floor(i * spacing), totalPossible - 1); const chosen = allPossibleDays[index]!; dates.push(chosen); } } else { // Not enough days - use all available dates.push(...allPossibleDays); } return dates; } // Original algorithm: schedule based on frequency const currentDate = new Date(seasonStart); let roundsScheduled = 0; // Generate race dates for up to 2 years to ensure we can schedule all rounds const maxDays = 365 * 2; let daysChecked = 0; while (roundsScheduled < rounds && daysChecked < maxDays) { const dayOfWeek = currentDate.getDay(); const isSelectedDay = selectedDayNumbers.has(dayOfWeek); // Calculate which week this is from the start const daysSinceStart = Math.floor((currentDate.getTime() - seasonStart.getTime()) / (24 * 60 * 60 * 1000)); const currentWeek = Math.floor(daysSinceStart / 7); if (isSelectedDay) { let shouldRace = false; if (frequency === 'weekly') { // Weekly: race every week on selected days shouldRace = true; } else if (frequency === 'everyNWeeks') { // Every 2 weeks: race only on even weeks (0, 2, 4, ...) shouldRace = currentWeek % 2 === 0; } else { // Default to weekly if frequency not set shouldRace = true; } if (shouldRace) { dates.push(new Date(currentDate)); roundsScheduled++; } } currentDate.setDate(currentDate.getDate() + 1); daysChecked++; } return dates; }, [weekdays, frequency, rounds, seasonStart, seasonEnd]); // Helper to check if a date is the season start/end const isSeasonStartDate = (date: Date) => { return date.getFullYear() === seasonStart.getFullYear() && date.getMonth() === seasonStart.getMonth() && date.getDate() === seasonStart.getDate(); }; const isSeasonEndDate = (date: Date) => { if (!seasonEnd) return false; return date.getFullYear() === seasonEnd.getFullYear() && date.getMonth() === seasonEnd.getMonth() && date.getDate() === seasonEnd.getDate(); }; // Create year view - DYNAMIC: shows months from first race to last race + some buffer const yearView = useMemo(() => { type DayInfo = { date: Date; isRace: boolean; dayOfMonth: number; isStart: boolean; isEnd: boolean; raceNumber?: number; }; if (raceDates.length === 0) { // No races scheduled - show next 12 months from today const today = new Date(); const view: { month: string; monthIndex: number; year: number; days: DayInfo[] }[] = []; for (let i = 0; i < 12; i++) { const targetMonth = (today.getMonth() + i) % 12; const targetYear = today.getFullYear() + Math.floor((today.getMonth() + i) / 12); const lastDay = new Date(targetYear, targetMonth + 1, 0); const days: DayInfo[] = []; for (let day = 1; day <= lastDay.getDate(); day++) { const date = new Date(targetYear, targetMonth, day); days.push({ date, isRace: false, dayOfMonth: day, isStart: isSeasonStartDate(date), isEnd: isSeasonEndDate(date), }); } view.push({ month: months[targetMonth] ?? '—', monthIndex: targetMonth, year: targetYear, days }); } return view; } // Get the range of months that contain races const firstRaceDate = raceDates[0]!; const lastRaceDate = raceDates[raceDates.length - 1]!; // Start from first race month, show 12 months total const startMonth = firstRaceDate.getMonth(); const startYear = firstRaceDate.getFullYear(); const view: { month: string; monthIndex: number; year: number; days: DayInfo[] }[] = []; for (let i = 0; i < 12; i++) { const targetMonth = (startMonth + i) % 12; const targetYear = startYear + Math.floor((startMonth + i) / 12); const lastDay = new Date(targetYear, targetMonth + 1, 0); const days: DayInfo[] = []; for (let day = 1; day <= lastDay.getDate(); day++) { const date = new Date(targetYear, targetMonth, day); const raceIndex = raceDates.findIndex(rd => rd.getFullYear() === date.getFullYear() && rd.getMonth() === date.getMonth() && rd.getDate() === date.getDate() ); const isRace = raceIndex >= 0; const raceNumber = isRace ? raceIndex + 1 : undefined; days.push({ date, isRace, dayOfMonth: day, isStart: isSeasonStartDate(date), isEnd: isSeasonEndDate(date), ...(raceNumber !== undefined ? { raceNumber } : {}), }); } view.push({ month: months[targetMonth] ?? '—', monthIndex: targetMonth, year: targetYear, days }); } return view; }, [raceDates, seasonStart, seasonEnd, months, isSeasonStartDate, isSeasonEndDate]); // Calculate season stats const firstRace = raceDates[0]; const lastRace = raceDates[raceDates.length - 1]; const seasonDurationWeeks = firstRace && lastRace ? Math.ceil( (lastRace.getTime() - firstRace.getTime()) / (7 * 24 * 60 * 60 * 1000), ) : 0; return (
Season Calendar
{raceDates.length} race{raceDates.length !== 1 ? 's' : ''} scheduled
{/* Year grid - 3 columns x 4 rows */}
{yearView.map(({ month, monthIndex, year, days }) => { const hasRaces = days.some(d => d.isRace); const raceCount = days.filter(d => d.isRace).length; const uniqueKey = `${year}-${monthIndex}`; return (
{month} {year !== new Date().getFullYear() && {year}} {raceCount > 0 && ( {raceCount} )}
{/* Mini calendar grid */}
{/* Fill empty days at start - getDay() returns 0 for Sunday, we want Monday first */} {Array.from({ length: (new Date(year, monthIndex, 1).getDay() + 6) % 7 }).map((_, i) => (
))} {days.map(({ dayOfMonth, isRace, isStart, isEnd, raceNumber }) => (
))}
); })}
{/* Season summary */}
{rounds}
Rounds
{seasonDurationWeeks || '—'}
Weeks
{firstRace && lastRace ? `${getMonthLabel(firstRace.getMonth())}–${getMonthLabel( lastRace.getMonth(), )}` : '—'}
Duration
{/* Legend */}
Start
Race
{seasonEnd && (
End
)}
No race
); } /** Season Stats Overview */ function SeasonStatsPreview({ rounds, weekdays, frequency, weekendTemplate, practiceMin, qualifyingMin, sprintMin, mainRaceMin, }: { rounds: number; weekdays: Weekday[]; frequency: RecurrenceStrategy; weekendTemplate: string; practiceMin: number; qualifyingMin: number; sprintMin?: number; mainRaceMin: number; }) { const hasSprint = weekendTemplate === 'sprintFeature'; const sessionsPerRound = 2 + (hasSprint ? 2 : 1); // practice + quali + race(s) const totalSessions = rounds * sessionsPerRound; const raceMinutesPerRound = (hasSprint ? (sprintMin ?? 0) : 0) + mainRaceMin; const totalRaceMinutes = rounds * raceMinutesPerRound; const totalMinutesPerRound = practiceMin + qualifyingMin + raceMinutesPerRound; // Estimate season duration const racesPerWeek = frequency === 'weekly' ? weekdays.length : weekdays.length / 2; const weeksNeeded = Math.ceil(rounds / Math.max(racesPerWeek, 0.5)); return (
Season Statistics
{/* Visual rounds */}
{Array.from({ length: Math.min(rounds, 20) }).map((_, i) => (
{i + 1}
))} {rounds > 20 && ( +{rounds - 20} )}
Season start
Finale
{/* Stats grid */}
{totalSessions}
Total sessions
{Math.round(totalRaceMinutes / 60)}h
Racing time
~{weeksNeeded}
Weeks duration
{totalMinutesPerRound}
min/race day
); } // ============================================================================ // INLINE EDITABLE ROUNDS COMPONENT // ============================================================================ function InlineEditableRounds({ value, onChange, min, max, }: { value: number; onChange: (value: number) => void; min: number; max: number; }) { const [isEditing, setIsEditing] = useState(false); const [editValue, setEditValue] = useState(value.toString()); const inputRef = useRef(null); useEffect(() => { if (isEditing && inputRef.current) { inputRef.current.focus(); inputRef.current.select(); } }, [isEditing]); const handleSubmit = () => { const num = parseInt(editValue, 10); if (!isNaN(num) && num >= min && num <= max) { onChange(num); } else { setEditValue(value.toString()); } setIsEditing(false); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { handleSubmit(); } else if (e.key === 'Escape') { setEditValue(value.toString()); setIsEditing(false); } }; return (
{isEditing ? ( setEditValue(e.target.value)} onBlur={handleSubmit} onKeyDown={handleKeyDown} className="w-20 text-4xl font-bold text-center text-white bg-transparent border-b-2 border-primary-blue outline-none" /> ) : ( )}
rounds
Click to edit
); } // ============================================================================ // COLLAPSIBLE SECTION COMPONENT // ============================================================================ interface CollapsibleSectionProps { icon: React.ReactNode; title: string; description?: string; defaultOpen?: boolean; children: React.ReactNode; } function CollapsibleSection({ icon, title, description, defaultOpen = false, children, }: CollapsibleSectionProps) { const [isOpen, setIsOpen] = useState(defaultOpen); return (
{children}
); } // ============================================================================ // MAIN COMPONENT // ============================================================================ export function LeagueTimingsSection({ form, onChange, readOnly, errors, }: LeagueTimingsSectionProps) { const disabled = readOnly || !onChange; const timings = form.timings; const [showAdvanced, setShowAdvanced] = useState(false); const [previewTab, setPreviewTab] = useState<'day' | 'year' | 'stats'>('day'); const updateTimings = ( patch: Partial>, ) => { if (!onChange) return; onChange({ ...form, timings: { ...timings, ...patch, }, }); }; const handleRoundsChange = (value: number) => { if (!onChange) return; updateTimings({ roundsPlanned: value }); }; // Show sprint race field if sprintRaceMinutes is set (will be derived from scoring pattern) const showSprint = 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) }); }; // Read-only view if (disabled) { return (
Schedule & timings
Planned rounds:{' '} {timings.roundsPlanned ?? '—'}
); } const allWeekdays: { day: Weekday; label: string; short: string }[] = [ { day: 'Mon', label: 'Monday', short: 'Mon' }, { day: 'Tue', label: 'Tuesday', short: 'Tue' }, { day: 'Wed', label: 'Wednesday', short: 'Wed' }, { day: 'Thu', label: 'Thursday', short: 'Thu' }, { day: 'Fri', label: 'Friday', short: 'Fri' }, { day: 'Sat', label: 'Saturday', short: 'Sat' }, { day: 'Sun', label: 'Sunday', short: 'Sun' }, ]; return (
{/* LEFT COLUMN: Configuration */}
{/* Session Durations - Collapsible */} } title="Session Durations" description="Configure practice, qualifying, and race lengths" defaultOpen={false} >
updateTimings({ practiceMinutes: v })} unitLabel="min" compact /> updateTimings({ qualifyingMinutes: v })} unitLabel="min" compact error={errors?.qualifyingMinutes} /> {showSprint && ( updateTimings({ sprintRaceMinutes: v })} unitLabel="min" compact /> )} updateTimings({ mainRaceMinutes: v })} unitLabel="min" compact error={errors?.mainRaceMinutes} />
{/* Season Length - Collapsible */} } title="Season Length" description={`${timings.roundsPlanned ?? 8} rounds planned`} defaultOpen={false} >

Each round is one complete race day. Click the number to edit directly.

{/* Race Schedule - Collapsible */} } title="Race Schedule" description="Weekly/bi-weekly and race days" defaultOpen={false} > {/* Frequency */}
{[ { id: 'weekly', label: 'Weekly' }, { id: 'everyNWeeks', label: 'Every 2 weeks' }, ].map((opt) => { const isSelected = (opt.id === 'weekly' && recurrenceStrategy === 'weekly') || (opt.id === 'everyNWeeks' && recurrenceStrategy === 'everyNWeeks'); return ( ); })}
{/* Day selection */}
{weekdays.length === 0 && ( Select at least one )}
{allWeekdays.map(({ day, short }) => { const isSelected = weekdays.includes(day); return ( ); })}
{/* Season Duration - Collapsible */} } title="Season Duration" description="Start/end dates and time settings" defaultOpen={false} >
{/* RIGHT COLUMN: Live Preview */}
{/* Preview header with tabs */}
Preview
{[ { id: 'day', label: 'Race Day', icon: Play }, { id: 'year', label: 'Calendar', icon: CalendarRange }, { id: 'stats', label: 'Stats', icon: Trophy }, ].map((tab) => ( ))}
{/* Preview content */}
{previewTab === 'day' && (() => { const sprintMinutes = showSprint ? timings.sprintRaceMinutes ?? 20 : undefined; return ( ); })()} {previewTab === 'year' && ( )} {previewTab === 'stats' && (() => { const sprintMinutes = showSprint ? timings.sprintRaceMinutes ?? 20 : undefined; return ( ); })()}
{/* Helper tip */}

Preview updates live as you configure. Check Race Day for session timing, Calendar for the full year view, and Stats for season totals.

); }