Files
gridpilot.gg/apps/website/components/leagues/LeagueTimingsSection.tsx
2025-12-06 00:17:24 +01:00

1264 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { 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/value-objects/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 = NonNullable<LeagueConfigFormModel['timings']>['recurrenceStrategy'];
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 [hours, minutes] = effectiveRaceTime.split(':').map(Number);
let totalMinutes = hours * 60 + minutes;
const active = allSessions.filter(s => s.active);
for (let i = 0; i < sessionIndex; i++) {
totalMinutes += active[i].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 (
<div className="space-y-4">
<div className="flex items-center gap-2 text-xs text-gray-400">
<Flag className="w-3.5 h-3.5 text-primary-blue" />
<span className="font-medium text-white">Race Day Schedule</span>
<span className="text-gray-600"></span>
<Clock className="w-3 h-3" />
<span>Starts {effectiveRaceTime}{!raceTime && <span className="text-gray-600 ml-1">(default)</span>}</span>
</div>
{/* Timeline visualization - show ALL sessions */}
<div className="space-y-2">
{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 (
<div
key={session.name}
className={`
relative flex items-center gap-3 p-3 rounded-lg transition-all duration-200
${!isActive
? 'opacity-40 bg-iron-gray/10 border border-dashed border-charcoal-outline/30'
: isRace
? 'bg-gradient-to-r from-primary-blue/20 to-transparent border border-primary-blue/30'
: 'bg-iron-gray/40 border border-charcoal-outline/50'
}
`}
>
{/* Status badge */}
{!isActive && (
<div className="absolute -top-1 -right-1 px-1.5 py-0.5 rounded text-[8px] font-medium bg-charcoal-outline text-gray-400">
Not included
</div>
)}
{/* Time marker */}
<div className="w-12 text-right shrink-0">
<span className={`text-xs font-mono ${!isActive ? 'text-gray-600' : isRace ? 'text-primary-blue' : 'text-gray-400'}`}>
{startTime ?? '—'}
</span>
</div>
{/* Session indicator */}
<div className={`w-1 h-8 rounded-full transition-colors ${!isActive ? 'bg-charcoal-outline/30' : isRace ? 'bg-primary-blue' : 'bg-charcoal-outline'}`} />
{/* Session info */}
<div className="flex-1">
<div className={`text-sm font-medium ${!isActive ? 'text-gray-600' : isRace ? 'text-white' : 'text-gray-300'}`}>
{session.name}
</div>
<div className="text-[10px] text-gray-500">
{isActive ? `${session.duration} minutes` : 'Disabled'}
</div>
</div>
{/* Duration bar */}
{isActive && (
<div className="w-16 h-2 bg-charcoal-outline/50 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${isRace ? 'bg-primary-blue' : 'bg-gray-600'}`}
style={{ width: `${(session.duration / Math.max(...activeSessions.map(s => s.duration))) * 100}%` }}
/>
</div>
)}
</div>
);
})}
</div>
{/* Legend */}
<div className="flex items-center justify-center gap-4 pt-2 border-t border-charcoal-outline/30">
<div className="flex items-center gap-1.5 text-[10px]">
<div className="w-2 h-2 rounded bg-primary-blue" />
<span className="text-gray-400">Active race</span>
</div>
<div className="flex items-center gap-1.5 text-[10px]">
<div className="w-2 h-2 rounded bg-gray-600" />
<span className="text-gray-400">Active session</span>
</div>
<div className="flex items-center gap-1.5 text-[10px]">
<div className="w-2 h-2 rounded border border-dashed border-charcoal-outline/50 bg-transparent" />
<span className="text-gray-500">Not included</span>
</div>
</div>
{/* Summary */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-[10px] text-gray-500">
<span>{activeSessions.length} active sessions</span>
<span></span>
<span>{activeSessions.filter(s => s.type === 'race' || s.type === 'sprint').length} race{activeSessions.filter(s => s.type === 'race' || s.type === 'sprint').length > 1 ? 's' : ''}</span>
</div>
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full bg-iron-gray/60">
<Timer className="w-3 h-3 text-primary-blue" />
<span className="text-xs font-semibold text-white">
{Math.floor(totalDuration / 60)}h {totalDuration % 60}m
</span>
</div>
</div>
</div>
);
}
/** 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<Weekday, number> = {
'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'];
// 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);
dates.push(allPossibleDays[index]);
}
} 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;
days.push({
date,
isRace,
dayOfMonth: day,
isStart: isSeasonStartDate(date),
isEnd: isSeasonEndDate(date),
raceNumber: isRace ? raceIndex + 1 : undefined,
});
}
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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-gray-400">
<CalendarRange className="w-3.5 h-3.5 text-primary-blue" />
<span className="font-medium text-white">Season Calendar</span>
</div>
<span className="text-[10px] text-gray-500">
{raceDates.length} race{raceDates.length !== 1 ? 's' : ''} scheduled
</span>
</div>
{/* Year grid - 3 columns x 4 rows */}
<div className="grid grid-cols-3 gap-2">
{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 (
<div
key={uniqueKey}
className={`
rounded-lg p-2 border transition-colors
${hasRaces
? 'border-primary-blue/30 bg-primary-blue/5'
: 'border-charcoal-outline/30 bg-iron-gray/20'
}
`}
>
<div className="flex items-center justify-between mb-1">
<span className={`text-[10px] font-medium ${hasRaces ? 'text-white' : 'text-gray-500'}`}>
{month} {year !== new Date().getFullYear() && <span className="text-gray-600">{year}</span>}
</span>
{raceCount > 0 && (
<span className="text-[9px] text-primary-blue font-medium">
{raceCount}
</span>
)}
</div>
{/* Mini calendar grid */}
<div className="grid grid-cols-7 gap-px">
{/* 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) => (
<div key={`empty-${uniqueKey}-${i}`} className="w-2 h-2" />
))}
{days.map(({ dayOfMonth, isRace, isStart, isEnd, raceNumber }) => (
<div
key={`${uniqueKey}-${dayOfMonth}`}
className={`
w-2 h-2 rounded-sm relative
${isStart
? 'bg-performance-green shadow-[0_0_6px_rgba(111,227,122,0.8)] ring-1 ring-performance-green'
: isEnd
? 'bg-warning-amber shadow-[0_0_6px_rgba(255,197,86,0.8)] ring-1 ring-warning-amber'
: isRace
? 'bg-primary-blue shadow-[0_0_4px_rgba(25,140,255,0.6)]'
: 'bg-charcoal-outline/30'
}
`}
title={
isStart ? `Season Start: ${month} ${dayOfMonth}, ${year}` :
isEnd ? `Season End: ${month} ${dayOfMonth}, ${year}` :
isRace ? `Round ${raceNumber}: ${month} ${dayOfMonth}, ${year}` :
undefined
}
/>
))}
</div>
</div>
);
})}
</div>
{/* Season summary */}
<div className="grid grid-cols-3 gap-2 pt-2 border-t border-charcoal-outline/30">
<div className="text-center">
<div className="text-lg font-bold text-white">{rounds}</div>
<div className="text-[9px] text-gray-500">Rounds</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-white">{seasonDurationWeeks || '—'}</div>
<div className="text-[9px] text-gray-500">Weeks</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-primary-blue">
{firstRace ? `${months[firstRace.getMonth()]}${months[lastRace?.getMonth() ?? 0]}` : '—'}
</div>
<div className="text-[9px] text-gray-500">Duration</div>
</div>
</div>
{/* Legend */}
<div className="flex items-center justify-center gap-3 flex-wrap text-[10px] text-gray-500">
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-performance-green ring-1 ring-performance-green" />
<span>Start</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-primary-blue" />
<span>Race</span>
</div>
{seasonEnd && (
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-warning-amber ring-1 ring-warning-amber" />
<span>End</span>
</div>
)}
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-charcoal-outline/30" />
<span>No race</span>
</div>
</div>
</div>
);
}
/** 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 (
<div className="space-y-4">
<div className="flex items-center gap-2 text-xs text-gray-400">
<Trophy className="w-3.5 h-3.5 text-primary-blue" />
<span className="font-medium text-white">Season Statistics</span>
</div>
{/* Visual rounds */}
<div className="space-y-2">
<div className="flex items-center gap-1 flex-wrap">
{Array.from({ length: Math.min(rounds, 20) }).map((_, i) => (
<div
key={i}
className={`
w-5 h-5 rounded flex items-center justify-center text-[8px] font-bold
transition-colors
${i === 0
? 'bg-performance-green text-white'
: i === rounds - 1
? 'bg-primary-blue text-white'
: 'bg-iron-gray/60 border border-charcoal-outline text-gray-500 hover:border-primary-blue/50'
}
`}
>
{i + 1}
</div>
))}
{rounds > 20 && (
<span className="text-[10px] text-gray-500 ml-1">+{rounds - 20}</span>
)}
</div>
<div className="flex items-center gap-3 text-[10px] text-gray-500">
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded bg-performance-green" />
<span>Season start</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded bg-primary-blue" />
<span>Finale</span>
</div>
</div>
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-2">
<div className="rounded-lg bg-iron-gray/30 border border-charcoal-outline/50 p-3">
<div className="text-2xl font-bold text-white">{totalSessions}</div>
<div className="text-[10px] text-gray-500">Total sessions</div>
</div>
<div className="rounded-lg bg-iron-gray/30 border border-charcoal-outline/50 p-3">
<div className="text-2xl font-bold text-white">{Math.round(totalRaceMinutes / 60)}h</div>
<div className="text-[10px] text-gray-500">Racing time</div>
</div>
<div className="rounded-lg bg-iron-gray/30 border border-charcoal-outline/50 p-3">
<div className="text-2xl font-bold text-white">~{weeksNeeded}</div>
<div className="text-[10px] text-gray-500">Weeks duration</div>
</div>
<div className="rounded-lg bg-iron-gray/30 border border-charcoal-outline/50 p-3">
<div className="text-2xl font-bold text-white">{totalMinutesPerRound}</div>
<div className="text-[10px] text-gray-500">min/race day</div>
</div>
</div>
</div>
);
}
// ============================================================================
// 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<HTMLInputElement>(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 (
<div className="flex items-center gap-4 p-4 rounded-xl bg-gradient-to-br from-iron-gray/60 to-iron-gray/30 border border-charcoal-outline">
{isEditing ? (
<input
ref={inputRef}
type="number"
min={min}
max={max}
value={editValue}
onChange={(e) => 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"
/>
) : (
<button
type="button"
onClick={() => {
setEditValue(value.toString());
setIsEditing(true);
}}
className="group flex items-center gap-2 text-4xl font-bold text-white hover:text-primary-blue transition-colors"
>
{value}
<Pencil className="w-4 h-4 opacity-0 group-hover:opacity-100 text-primary-blue transition-opacity" />
</button>
)}
<div className="flex-1">
<div className="text-sm font-medium text-gray-300">rounds</div>
<div className="text-[10px] text-gray-500">Click to edit</div>
</div>
<div className="flex flex-col gap-1">
<button
type="button"
onClick={() => onChange(Math.min(value + 1, max))}
disabled={value >= max}
className="p-1.5 rounded bg-iron-gray/60 border border-charcoal-outline hover:border-primary-blue disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronUp className="w-4 h-4 text-gray-400" />
</button>
<button
type="button"
onClick={() => onChange(Math.max(value - 1, min))}
disabled={value <= min}
className="p-1.5 rounded bg-iron-gray/60 border border-charcoal-outline hover:border-primary-blue disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronDown className="w-4 h-4 text-gray-400" />
</button>
</div>
</div>
);
}
// ============================================================================
// 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 (
<div className="rounded-xl border border-charcoal-outline/50 bg-iron-gray/20 overflow-hidden transition-all duration-200">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between p-4 hover:bg-iron-gray/30 transition-colors"
>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-blue/10 shrink-0">
{icon}
</div>
<div className="text-left">
<h3 className="text-sm font-semibold text-white">{title}</h3>
{description && (
<p className="text-xs text-gray-500 mt-0.5">{description}</p>
)}
</div>
</div>
<div className={`transform transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}>
<ChevronDown className="w-4 h-4 text-gray-400" />
</div>
</button>
<div
className={`transition-all duration-200 ease-in-out ${
isOpen ? 'max-h-[2000px] opacity-100' : 'max-h-0 opacity-0 overflow-hidden'
}`}
>
<div className="px-4 pb-4 pt-2 border-t border-charcoal-outline/30">
{children}
</div>
</div>
</div>
);
}
// ============================================================================
// 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<NonNullable<LeagueConfigFormModel['timings']>>,
) => {
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 (
<div className="space-y-4">
<div className="text-lg font-semibold text-white">Schedule & timings</div>
<div className="space-y-3 text-sm text-gray-300">
<div>
<span className="font-medium text-gray-200">Planned rounds:</span>{' '}
<span>{timings.roundsPlanned ?? '—'}</span>
</div>
</div>
</div>
);
}
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 (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* LEFT COLUMN: Configuration */}
<div className="space-y-4">
{/* Session Durations - Collapsible */}
<CollapsibleSection
icon={<Timer className="w-4 h-4 text-primary-blue" />}
title="Session Durations"
description="Configure practice, qualifying, and race lengths"
defaultOpen={false}
>
<div className="grid grid-cols-2 gap-3">
<RangeField
label="Practice"
value={timings.practiceMinutes ?? 20}
min={0}
max={60}
onChange={(v) => updateTimings({ practiceMinutes: v })}
unitLabel="min"
compact
/>
<RangeField
label="Qualifying"
value={timings.qualifyingMinutes ?? 15}
min={5}
max={30}
onChange={(v) => updateTimings({ qualifyingMinutes: v })}
unitLabel="min"
compact
error={errors?.qualifyingMinutes}
/>
{showSprint && (
<RangeField
label="Sprint Race"
value={timings.sprintRaceMinutes ?? 20}
min={10}
max={45}
onChange={(v) => updateTimings({ sprintRaceMinutes: v })}
unitLabel="min"
compact
/>
)}
<RangeField
label={showSprint ? 'Feature Race' : 'Main Race'}
value={timings.mainRaceMinutes ?? 40}
min={15}
max={180}
onChange={(v) => updateTimings({ mainRaceMinutes: v })}
unitLabel="min"
compact
error={errors?.mainRaceMinutes}
/>
</div>
</CollapsibleSection>
{/* Season Length - Collapsible */}
<CollapsibleSection
icon={<Trophy className="w-4 h-4 text-primary-blue" />}
title="Season Length"
description={`${timings.roundsPlanned ?? 8} rounds planned`}
defaultOpen={false}
>
<InlineEditableRounds
value={timings.roundsPlanned ?? 8}
onChange={handleRoundsChange}
min={4}
max={24}
/>
<p className="text-[10px] text-gray-500 mt-2">Each round is one complete race day. Click the number to edit directly.</p>
</CollapsibleSection>
{/* Race Schedule - Collapsible */}
<CollapsibleSection
icon={<CalendarDays className="w-4 h-4 text-primary-blue" />}
title="Race Schedule"
description="Weekly/bi-weekly and race days"
defaultOpen={false}
>
{/* Frequency */}
<div className="space-y-2 mb-4">
<label className="text-xs text-gray-400">How often?</label>
<div className="flex gap-2">
{[
{ 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 (
<button
key={opt.id}
type="button"
onClick={() =>
updateTimings({
recurrenceStrategy: opt.id as RecurrenceStrategy,
intervalWeeks: opt.id === 'everyNWeeks' ? 2 : undefined,
})
}
className={`
flex-1 px-3 py-2 rounded-lg border text-xs font-medium transition-all
${isSelected
? 'border-primary-blue bg-primary-blue/10 text-white'
: 'border-charcoal-outline bg-iron-gray/30 text-gray-400 hover:border-gray-500'
}
`}
>
{opt.label}
</button>
);
})}
</div>
</div>
{/* Day selection */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">Which days?</label>
{weekdays.length === 0 && (
<span className="text-[10px] text-warning-amber">Select at least one</span>
)}
</div>
<div className="flex gap-1">
{allWeekdays.map(({ day, short }) => {
const isSelected = weekdays.includes(day);
return (
<button
key={day}
type="button"
onClick={() => handleWeekdayToggle(day)}
className={`
flex-1 py-2 rounded-lg border text-[10px] font-medium transition-all
${isSelected
? 'border-primary-blue bg-primary-blue text-white shadow-[0_0_8px_rgba(25,140,255,0.4)]'
: 'border-charcoal-outline bg-iron-gray/30 text-gray-500 hover:border-gray-500'
}
`}
>
{short}
</button>
);
})}
</div>
</div>
</CollapsibleSection>
{/* Season Duration - Collapsible */}
<CollapsibleSection
icon={<Calendar className="w-4 h-4 text-primary-blue" />}
title="Season Duration"
description="Start/end dates and time settings"
defaultOpen={false}
>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-[10px] text-gray-400 font-medium flex items-center gap-1">
<div className="w-2 h-2 rounded bg-performance-green" />
Season Start
</label>
<Input
type="date"
value={timings.seasonStartDate ?? ''}
onChange={(e) => updateTimings({ seasonStartDate: e.target.value || undefined })}
className="bg-iron-gray/30"
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] text-gray-400 font-medium flex items-center gap-1">
<div className="w-2 h-2 rounded bg-warning-amber" />
Season End
</label>
<Input
type="date"
value={timings.seasonEndDate ?? ''}
onChange={(e) => updateTimings({ seasonEndDate: e.target.value || undefined })}
className="bg-iron-gray/30"
/>
</div>
</div>
{timings.seasonStartDate && timings.seasonEndDate && (
<div className="flex items-start gap-2 p-2 rounded-lg bg-primary-blue/5 border border-primary-blue/20">
<Info className="w-3.5 h-3.5 text-primary-blue shrink-0 mt-0.5" />
<p className="text-[10px] text-gray-400">
Races will be <span className="text-white font-medium">evenly distributed</span> between start and end dates on your selected race days.
</p>
</div>
)}
<div className="space-y-1.5">
<label className="text-[10px] text-gray-400 font-medium">Race Start Time</label>
<Input
type="time"
value={timings.raceStartTime ?? '20:00'}
onChange={(e) => updateTimings({ raceStartTime: e.target.value || undefined })}
className="bg-iron-gray/30"
/>
</div>
{/* Time Zone Selector */}
<div className="space-y-1.5">
<label className="text-[10px] text-gray-400 font-medium">Time Zone</label>
<div className="grid grid-cols-2 gap-2">
{TIME_ZONES.slice(0, 2).map((tz) => {
const isSelected = (timings.timezoneId ?? 'UTC') === tz.value;
const Icon = tz.icon;
return (
<button
key={tz.value}
type="button"
onClick={() => updateTimings({ timezoneId: tz.value })}
className={`
flex items-center gap-2 px-3 py-2.5 rounded-lg border text-left transition-all
${isSelected
? tz.value === 'track'
? 'border-performance-green bg-performance-green/10 shadow-[0_0_12px_rgba(111,227,122,0.2)]'
: 'border-primary-blue bg-primary-blue/10 shadow-[0_0_12px_rgba(25,140,255,0.2)]'
: 'border-charcoal-outline bg-iron-gray/30 hover:border-gray-500'
}
`}
>
<Icon className={`w-4 h-4 ${isSelected ? (tz.value === 'track' ? 'text-performance-green' : 'text-primary-blue') : 'text-gray-400'}`} />
<div className="flex-1">
<div className={`text-xs font-medium ${isSelected ? 'text-white' : 'text-gray-300'}`}>
{tz.label}
</div>
{tz.value === 'track' && (
<div className="text-[9px] text-gray-500">Adjusts per track</div>
)}
</div>
</button>
);
})}
</div>
{/* More time zones - expandable */}
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-1.5 text-[10px] text-gray-400 hover:text-white transition-colors"
>
{showAdvanced ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
{showAdvanced ? 'Hide' : 'Show'} more time zones
</button>
{showAdvanced && (
<div className="grid grid-cols-2 gap-2 mt-2">
{TIME_ZONES.slice(2).map((tz) => {
const isSelected = (timings.timezoneId ?? 'UTC') === tz.value;
const Icon = tz.icon;
return (
<button
key={tz.value}
type="button"
onClick={() => updateTimings({ timezoneId: tz.value })}
className={`
flex items-center gap-2 px-3 py-2 rounded-lg border text-left transition-all text-xs
${isSelected
? 'border-primary-blue bg-primary-blue/10'
: 'border-charcoal-outline bg-iron-gray/30 hover:border-gray-500'
}
`}
>
<Icon className={`w-3.5 h-3.5 ${isSelected ? 'text-primary-blue' : 'text-gray-500'}`} />
<span className={isSelected ? 'text-white' : 'text-gray-400'}>{tz.label}</span>
</button>
);
})}
</div>
)}
</div>
<div className="flex items-start gap-2 p-2 rounded-lg bg-performance-green/5 border border-performance-green/20">
<Info className="w-3.5 h-3.5 text-performance-green shrink-0 mt-0.5" />
<p className="text-[10px] text-gray-400">
{(timings.timezoneId ?? 'UTC') === 'track'
? 'Track Local Time means race times will be displayed in each track\'s local time zone. Great for immersive scheduling!'
: 'All race times will be displayed in the selected time zone for consistency.'
}
</p>
</div>
</div>
</CollapsibleSection>
</div>
{/* RIGHT COLUMN: Live Preview */}
<div className="space-y-4">
<div className="sticky top-4">
<div className="rounded-xl border border-charcoal-outline bg-gradient-to-br from-iron-gray/80 to-deep-graphite overflow-hidden">
{/* Preview header with tabs */}
<div className="flex items-center justify-between p-3 border-b border-charcoal-outline/50 bg-deep-graphite/50">
<div className="flex items-center gap-2">
<Eye className="w-4 h-4 text-primary-blue" />
<span className="text-xs font-semibold text-white">Preview</span>
</div>
<div className="flex gap-1 p-0.5 rounded-lg bg-iron-gray/60">
{[
{ id: 'day', label: 'Race Day', icon: Play },
{ id: 'year', label: 'Calendar', icon: CalendarRange },
{ id: 'stats', label: 'Stats', icon: Trophy },
].map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setPreviewTab(tab.id as typeof previewTab)}
className={`
flex items-center gap-1 px-2 py-1 text-[10px] font-medium rounded transition-colors
${previewTab === tab.id
? 'bg-primary-blue text-white'
: 'text-gray-400 hover:text-white'
}
`}
>
<tab.icon className="w-3 h-3" />
<span className="hidden sm:inline">{tab.label}</span>
</button>
))}
</div>
</div>
{/* Preview content */}
<div className="p-4 min-h-[300px]">
{previewTab === 'day' && (
<RaceDayPreview
template={showSprint ? 'sprintFeature' : 'feature'}
practiceMin={timings.practiceMinutes ?? 20}
qualifyingMin={timings.qualifyingMinutes ?? 15}
sprintMin={showSprint ? (timings.sprintRaceMinutes ?? 20) : undefined}
mainRaceMin={timings.mainRaceMinutes ?? 40}
raceTime={timings.raceStartTime}
/>
)}
{previewTab === 'year' && (
<YearCalendarPreview
weekdays={weekdays}
frequency={recurrenceStrategy}
rounds={timings.roundsPlanned ?? 8}
startDate={timings.seasonStartDate}
endDate={timings.seasonEndDate}
/>
)}
{previewTab === 'stats' && (
<SeasonStatsPreview
rounds={timings.roundsPlanned ?? 8}
weekdays={weekdays}
frequency={recurrenceStrategy}
weekendTemplate={showSprint ? 'sprintFeature' : 'feature'}
practiceMin={timings.practiceMinutes ?? 20}
qualifyingMin={timings.qualifyingMinutes ?? 15}
sprintMin={showSprint ? (timings.sprintRaceMinutes ?? 20) : undefined}
mainRaceMin={timings.mainRaceMinutes ?? 40}
/>
)}
</div>
</div>
{/* Helper tip */}
<div className="mt-3 flex items-start gap-2 p-3 rounded-lg bg-primary-blue/5 border border-primary-blue/20">
<Info className="w-4 h-4 text-primary-blue shrink-0 mt-0.5" />
<p className="text-[11px] text-gray-400 leading-relaxed">
Preview updates live as you configure. Check <span className="text-white font-medium">Race Day</span> for session timing, <span className="text-white font-medium">Calendar</span> for the full year view, and <span className="text-white font-medium">Stats</span> for season totals.
</p>
</div>
</div>
</div>
</div>
);
}