Files
gridpilot.gg/apps/website/components/leagues/LeagueTimingsSection.tsx
2026-01-18 23:24:30 +01:00

1729 lines
63 KiB
TypeScript
Raw Permalink 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 { RangeField } from '@/components/shared/RangeField';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { Weekday } from '@/lib/types/Weekday';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Input } from '@/ui/Input';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import {
Calendar,
CalendarDays,
CalendarRange,
ChevronDown,
ChevronUp,
Clock,
Eye,
Flag,
Globe,
Info,
MapPin,
Pencil,
Play,
Timer,
Trophy,
} from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
// 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<LeagueConfigFormModel['timings']>['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 (
<Stack gap={4}>
<Stack display="flex" alignItems="center" gap={2}>
<Icon icon={Flag} size={3.5} color="text-primary-blue" />
<Text size="xs" weight="medium" color="text-white">Race Day Schedule</Text>
<Text size="xs" color="text-gray-600"></Text>
<Icon icon={Clock} size={3} color="text-gray-400" />
<Text size="xs" color="text-gray-400">
Starts {effectiveRaceTime}{!raceTime && <Text as="span" color="text-gray-600" ml={1}>(default)</Text>}
</Text>
</Stack>
{/* Timeline visualization - show ALL sessions */}
<Stack gap={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 (
<Stack
key={session.name}
position="relative"
display="flex"
alignItems="center"
gap={3}
p={3}
rounded="lg"
transition
opacity={!isActive ? 0.4 : 1}
bg={!isActive ? 'bg-iron-gray/10' : isRace ? 'bg-primary-blue/20' : 'bg-iron-gray/40'}
border
borderStyle={!isActive ? 'dashed' : 'solid'}
borderColor={!isActive ? 'border-charcoal-outline/30' : isRace ? 'border-primary-blue/30' : 'border-charcoal-outline/50'}
// eslint-disable-next-line gridpilot-rules/component-classification
className={isRace && isActive ? 'bg-gradient-to-r from-primary-blue/20 to-transparent' : ''}
>
{/* Status badge */}
{!isActive && (
<Stack position="absolute" top="-1" right="-1" px={1.5} py={0.5} rounded="sm" bg="bg-charcoal-outline">
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '8px' }}
weight="medium"
color="text-gray-400"
>
Not included
</Text>
</Stack>
)}
{/* Time marker */}
<Stack w="12" textAlign="right" flexShrink={0}>
<Text size="xs" font="mono" color={!isActive ? 'text-gray-600' : isRace ? 'text-primary-blue' : 'text-gray-400'}>
{startTime ?? '—'}
</Text>
</Stack>
{/* Session indicator */}
<Stack w="1" h="8" rounded="full" transition bg={!isActive ? 'bg-charcoal-outline/30' : isRace ? 'bg-primary-blue' : 'bg-charcoal-outline'} />
{/* Session info */}
<Stack flexGrow={1}>
<Text size="sm" weight="medium" color={!isActive ? 'text-gray-600' : isRace ? 'text-white' : 'text-gray-300'} block>
{session.name}
</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
block
>
{isActive ? `${session.duration} minutes` : 'Disabled'}
</Text>
</Stack>
{/* Duration bar */}
{isActive && (
<Stack w="16" h="2" bg="bg-charcoal-outline/50" rounded="full" overflow="hidden">
<Stack
h="full"
rounded="full"
bg={isRace ? 'bg-primary-blue' : 'bg-gray-600'}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ width: `${(session.duration / Math.max(...activeSessions.map(s => s.duration))) * 100}%` }}
/>
</Stack>
)}
</Stack>
);
})}
</Stack>
{/* Legend */}
<Stack display="flex" alignItems="center" justifyContent="center" gap={4} pt={2} borderTop borderColor="border-charcoal-outline/30">
<Stack display="flex" alignItems="center" gap={1.5}>
<Stack w="2" h="2" rounded="sm" bg="bg-primary-blue" />
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-400"
>
Active race
</Text>
</Stack>
<Stack display="flex" alignItems="center" gap={1.5}>
<Stack w="2" h="2" rounded="sm" bg="bg-gray-600" />
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-400"
>
Active session
</Text>
</Stack>
<Stack display="flex" alignItems="center" gap={1.5}>
<Stack w="2" h="2" rounded="sm" border borderStyle="dashed" borderColor="border-charcoal-outline/50" bg="transparent" />
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
Not included
</Text>
</Stack>
</Stack>
{/* Summary */}
<Stack display="flex" alignItems="center" justifyContent="between">
<Stack display="flex" alignItems="center" gap={3}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
{activeSessions.length} active sessions
</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
{activeSessions.filter(s => s.type === 'race' || s.type === 'sprint').length} race{activeSessions.filter(s => s.type === 'race' || s.type === 'sprint').length > 1 ? 's' : ''}
</Text>
</Stack>
<Stack display="flex" alignItems="center" gap={1.5} px={2} py={1} rounded="full" bg="bg-iron-gray/60">
<Icon icon={Timer} size={3} color="text-primary-blue" />
<Text size="xs" weight="semibold" color="text-white">
{Math.floor(totalDuration / 60)}h {totalDuration % 60}m
</Text>
</Stack>
</Stack>
</Stack>
);
}
/** Full Year Calendar View */
function YearCalendarPreview({
weekdays,
frequency,
rounds,
startDate,
endDate,
}: {
weekdays: Weekday[];
frequency: RecurrenceStrategy;
rounds: number;
startDate?: string;
endDate?: string;
}) {
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(() => {
// 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,
};
// eslint-disable-next-line gridpilot-rules/component-no-data-manipulation
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
// eslint-disable-next-line gridpilot-rules/component-no-data-manipulation
const allPossibleDays: Date[] = [];
const currentDate = new Date(seasonStart);
while (currentDate <= seasonEnd) {
const dayOfWeek = currentDate.getDay();
if (selectedDayNumbers.has(dayOfWeek)) {
// eslint-disable-next-line gridpilot-rules/component-no-data-manipulation
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]!;
// eslint-disable-next-line gridpilot-rules/component-no-data-manipulation
dates.push(chosen);
}
} else {
// Not enough days - use all available
// eslint-disable-next-line gridpilot-rules/component-no-data-manipulation
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) {
// eslint-disable-next-line gridpilot-rules/component-no-data-manipulation
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 = useCallback((date: Date) => {
return date.getFullYear() === seasonStart.getFullYear() &&
date.getMonth() === seasonStart.getMonth() &&
date.getDate() === seasonStart.getDate();
}, [seasonStart]);
const isSeasonEndDate = useCallback((date: Date) => {
if (!seasonEnd) return false;
return date.getFullYear() === seasonEnd.getFullYear() &&
date.getMonth() === seasonEnd.getMonth() &&
date.getDate() === seasonEnd.getDate();
}, [seasonEnd]);
// Create year view - DYNAMIC: shows months from first race to last race + some buffer
const yearView = useMemo(() => {
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
] as const;
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();
// eslint-disable-next-line gridpilot-rules/component-no-data-manipulation
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);
// eslint-disable-next-line gridpilot-rules/component-no-data-manipulation
const days: DayInfo[] = [];
for (let day = 1; day <= lastDay.getDate(); day++) {
const date = new Date(targetYear, targetMonth, day);
// eslint-disable-next-line gridpilot-rules/component-no-data-manipulation
days.push({
date,
isRace: false,
dayOfMonth: day,
isStart: isSeasonStartDate(date),
isEnd: isSeasonEndDate(date),
});
}
// eslint-disable-next-line gridpilot-rules/component-no-data-manipulation
view.push({
month: months[targetMonth] ?? '—',
monthIndex: targetMonth,
year: targetYear,
days
});
}
return view;
}
// Get the range of months that contain races
const firstRaceDate = raceDates[0]!;
// Start from first race month, show 12 months total
const startMonth = firstRaceDate.getMonth();
const startYear = firstRaceDate.getFullYear();
// eslint-disable-next-line gridpilot-rules/component-no-data-manipulation
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);
// eslint-disable-next-line gridpilot-rules/component-no-data-manipulation
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;
// eslint-disable-next-line gridpilot-rules/component-no-data-manipulation
days.push({
date,
isRace,
dayOfMonth: day,
isStart: isSeasonStartDate(date),
isEnd: isSeasonEndDate(date),
...(raceNumber !== undefined ? { raceNumber } : {}),
});
}
// eslint-disable-next-line gridpilot-rules/component-no-data-manipulation
view.push({
month: months[targetMonth] ?? '—',
monthIndex: targetMonth,
year: targetYear,
days
});
}
return view;
}, [raceDates, 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 (
<Stack gap={4}>
<Stack display="flex" alignItems="center" justifyContent="between">
<Stack display="flex" alignItems="center" gap={2}>
<Icon icon={CalendarRange} size={3.5} color="text-primary-blue" />
<Text size="xs" weight="medium" color="text-white">Season Calendar</Text>
</Stack>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
{raceDates.length} race{raceDates.length !== 1 ? 's' : ''} scheduled
</Text>
</Stack>
{/* Year grid - 3 columns x 4 rows */}
<Stack display="grid" gridCols={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 (
<Stack
key={uniqueKey}
rounded="lg"
p={2}
border
transition
borderColor={hasRaces ? 'border-primary-blue/30' : 'border-charcoal-outline/30'}
bg={hasRaces ? 'bg-primary-blue/5' : 'bg-iron-gray/20'}
>
<Stack display="flex" alignItems="center" justifyContent="between" mb={1}>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
weight="medium"
color={hasRaces ? 'text-white' : 'text-gray-500'}
>
{month} {year !== new Date().getFullYear() && <Text as="span" color="text-gray-600">{year}</Text>}
</Text>
{raceCount > 0 && (
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
color="text-primary-blue"
weight="medium"
>
{raceCount}
</Text>
)}
</Stack>
{/* Mini calendar grid */}
<Stack display="grid" gridCols={7} gap="1px">
{/* 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) => (
<Stack key={`empty-${uniqueKey}-${i}`} w="2" h="2" />
))}
{days.map(({ dayOfMonth, isRace, isStart, isEnd, raceNumber }) => (
<Stack
key={`${uniqueKey}-${dayOfMonth}`}
w="2"
h="2"
rounded="sm"
position="relative"
bg={
isStart ? 'bg-performance-green' :
isEnd ? 'bg-warning-amber' :
isRace ? 'bg-primary-blue' :
'bg-charcoal-outline/30'
}
// eslint-disable-next-line gridpilot-rules/component-classification
className={`
${isStart ? 'shadow-[0_0_6px_rgba(111,227,122,0.8)] ring-1 ring-performance-green' : ''}
${isEnd ? 'shadow-[0_0_6px_rgba(255,197,86,0.8)] ring-1 ring-warning-amber' : ''}
${isRace && !isStart && !isEnd ? 'shadow-[0_0_4px_rgba(25,140,255,0.6)]' : ''}
`}
title={
isStart ? `Season Start: ${month} ${dayOfMonth}, ${year}` :
isEnd ? `Season End: ${month} ${dayOfMonth}, ${year}` :
isRace ? `Round ${raceNumber}: ${month} ${dayOfMonth}, ${year}` :
undefined
}
/>
))}
</Stack>
</Stack>
);
})}
</Stack>
{/* Season summary */}
<Stack display="grid" gridCols={3} gap={2} pt={2} borderTop borderColor="border-charcoal-outline/30">
<Stack textAlign="center">
<Text size="lg" weight="bold" color="text-white" block>{rounds}</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
color="text-gray-500"
block
>
Rounds
</Text>
</Stack>
<Stack textAlign="center">
<Text size="lg" weight="bold" color="text-white" block>
{seasonDurationWeeks || '—'}
</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
color="text-gray-500"
block
>
Weeks
</Text>
</Stack>
<Stack textAlign="center">
<Text size="lg" weight="bold" color="text-primary-blue" block>
{firstRace && lastRace
? `${getMonthLabel(firstRace.getMonth())}${getMonthLabel(
lastRace.getMonth(),
)}`
: '—'}
</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
color="text-gray-500"
block
>
Duration
</Text>
</Stack>
</Stack>
{/* Legend */}
<Stack display="flex" alignItems="center" justifyContent="center" gap={3} flexWrap="wrap">
<Stack display="flex" alignItems="center" gap={1}>
<Stack w="2" h="2" rounded="sm" bg="bg-performance-green"
// eslint-disable-next-line gridpilot-rules/component-classification
className="ring-1 ring-performance-green"
/>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
Start
</Text>
</Stack>
<Stack display="flex" alignItems="center" gap={1}>
<Stack w="2" h="2" rounded="sm" bg="bg-primary-blue" />
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
Race
</Text>
</Stack>
{seasonEnd && (
<Stack display="flex" alignItems="center" gap={1}>
<Stack w="2" h="2" rounded="sm" bg="bg-warning-amber"
// eslint-disable-next-line gridpilot-rules/component-classification
className="ring-1 ring-warning-amber"
/>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
End
</Text>
</Stack>
)}
<Stack display="flex" alignItems="center" gap={1}>
<Stack w="2" h="2" rounded="sm" bg="bg-charcoal-outline/30" />
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
No race
</Text>
</Stack>
</Stack>
</Stack>
);
}
/** 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 (
<Stack gap={4}>
<Stack display="flex" alignItems="center" gap={2}>
<Icon icon={Trophy} size={3.5} color="text-primary-blue" />
<Text size="xs" weight="medium" color="text-white">Season Statistics</Text>
</Stack>
{/* Visual rounds */}
<Stack gap={2}>
<Stack display="flex" alignItems="center" gap={1} flexWrap="wrap">
{Array.from({ length: Math.min(rounds, 20) }).map((_, i) => (
<Stack
key={i}
w="5"
h="5"
rounded="sm"
display="flex"
alignItems="center"
justifyContent="center"
transition
bg={
i === 0 ? 'bg-performance-green' :
i === rounds - 1 ? 'bg-primary-blue' :
'bg-iron-gray/60'
}
border
borderColor={
i === 0 ? 'border-performance-green' :
i === rounds - 1 ? 'border-primary-blue' :
'border-charcoal-outline'
}
color="text-white"
// eslint-disable-next-line gridpilot-rules/component-classification
className={i !== 0 && i !== rounds - 1 ? 'text-gray-500 hover:border-primary-blue/50' : ''}
>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '8px' }}
weight="bold"
>
{i + 1}
</Text>
</Stack>
))}
{rounds > 20 && (
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
ml={1}
>
+{rounds - 20}
</Text>
)}
</Stack>
<Stack display="flex" alignItems="center" gap={3}>
<Stack display="flex" alignItems="center" gap={1}>
<Stack w="2" h="2" rounded="sm" bg="bg-performance-green" />
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
Season start
</Text>
</Stack>
<Stack display="flex" alignItems="center" gap={1}>
<Stack w="2" h="2" rounded="sm" bg="bg-primary-blue" />
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
>
Finale
</Text>
</Stack>
</Stack>
</Stack>
{/* Stats grid */}
<Stack display="grid" gridCols={2} gap={2}>
<Stack rounded="lg" bg="bg-iron-gray/30" border borderColor="border-charcoal-outline/50" p={3}>
<Text size="2xl" weight="bold" color="text-white" block>{totalSessions}</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
block
>
Total sessions
</Text>
</Stack>
<Stack rounded="lg" bg="bg-iron-gray/30" border borderColor="border-charcoal-outline/50" p={3}>
<Text size="2xl" weight="bold" color="text-white" block>{Math.round(totalRaceMinutes / 60)}h</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
block
>
Racing time
</Text>
</Stack>
<Stack rounded="lg" bg="bg-iron-gray/30" border borderColor="border-charcoal-outline/50" p={3}>
<Text size="2xl" weight="bold" color="text-white" block>~{weeksNeeded}</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
block
>
Weeks duration
</Text>
</Stack>
<Stack rounded="lg" bg="bg-iron-gray/30" border borderColor="border-charcoal-outline/50" p={3}>
<Text size="2xl" weight="bold" color="text-white" block>{totalMinutesPerRound}</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
block
>
min/race day
</Text>
</Stack>
</Stack>
</Stack>
);
}
// ============================================================================
// 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 (
<Stack display="flex" alignItems="center" gap={4} p={4} rounded="xl"
// eslint-disable-next-line gridpilot-rules/component-classification
className="bg-gradient-to-br from-iron-gray/60 to-iron-gray/30 border border-charcoal-outline"
>
{isEditing ? (
<Stack
as="input"
ref={inputRef}
type="number"
min={min}
max={max}
value={editValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEditValue(e.target.value)}
onBlur={handleSubmit}
onKeyDown={handleKeyDown}
w="20"
bg="transparent"
borderBottom
borderWidth="2px"
borderColor="border-primary-blue"
color="text-white"
textAlign="center"
weight="bold"
// eslint-disable-next-line gridpilot-rules/component-classification
className="text-4xl outline-none"
/>
) : (
<Stack
as="button"
type="button"
onClick={() => {
setEditValue(value.toString());
setIsEditing(true);
}}
display="flex"
alignItems="center"
gap={2}
color="text-white"
hoverTextColor="text-primary-blue"
transition
group
// eslint-disable-next-line gridpilot-rules/component-classification
className="text-4xl font-bold"
>
{value}
<Icon icon={Pencil} size={4} opacity={0}
// eslint-disable-next-line gridpilot-rules/component-classification
className="group-hover:opacity-100 text-primary-blue transition-opacity"
/>
</Stack>
)}
<Stack flexGrow={1}>
<Text size="sm" weight="medium" color="text-gray-300" block>rounds</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
block
>
Click to edit
</Text>
</Stack>
<Stack display="flex" flexDirection="col" gap={1}>
<Stack
as="button"
type="button"
onClick={() => onChange(Math.min(value + 1, max))}
disabled={value >= max}
p={1.5}
rounded="sm"
bg="bg-iron-gray/60"
border
borderColor="border-charcoal-outline"
hoverBorderColor="border-primary-blue"
transition
opacity={value >= max ? 0.3 : 1}
cursor={value >= max ? 'not-allowed' : 'pointer'}
>
<Icon icon={ChevronUp} size={4} color="text-gray-400" />
</Stack>
<Stack
as="button"
type="button"
onClick={() => onChange(Math.max(value - 1, min))}
disabled={value <= min}
p={1.5}
rounded="sm"
bg="bg-iron-gray/60"
border
borderColor="border-charcoal-outline"
hoverBorderColor="border-primary-blue"
transition
opacity={value <= min ? 0.3 : 1}
cursor={value <= min ? 'not-allowed' : 'pointer'}
>
<Icon icon={ChevronDown} size={4} color="text-gray-400" />
</Stack>
</Stack>
</Stack>
);
}
// ============================================================================
// 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 (
<Stack rounded="xl" border borderColor="border-charcoal-outline/50" bg="bg-iron-gray/20" overflow="hidden" transition>
<Stack
as="button"
type="button"
onClick={() => setIsOpen(!isOpen)}
w="full"
display="flex"
alignItems="center"
justifyContent="between"
p={4}
transition
hoverBg="bg-iron-gray/30"
>
<Stack display="flex" alignItems="center" gap={3}>
<Stack display="flex" h="8" w="8" alignItems="center" justifyContent="center" rounded="lg" bg="bg-primary-blue/10" flexShrink={0}>
{icon}
</Stack>
<Stack textAlign="left">
<Heading level={3} fontSize="sm" weight="semibold" color="text-white">{title}</Heading>
{description && (
<Text size="xs" color="text-gray-500" mt={0.5} block>{description}</Text>
)}
</Stack>
</Stack>
<Stack transform transition
// eslint-disable-next-line gridpilot-rules/component-classification
className={isOpen ? 'rotate-180' : ''}
>
<Icon icon={ChevronDown} size={4} color="text-gray-400" />
</Stack>
</Stack>
<Stack
transition
// eslint-disable-next-line gridpilot-rules/component-classification
className={`ease-in-out ${
isOpen ? 'max-h-[2000px] opacity-100' : 'max-h-0 opacity-0 overflow-hidden'
}`}
>
<Stack px={4} pb={4} pt={2} borderTop borderColor="border-charcoal-outline/30">
{children}
</Stack>
</Stack>
</Stack>
);
}
// ============================================================================
// 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 (
<Stack gap={4}>
<Heading level={3} fontSize="lg" weight="semibold" color="text-white">Schedule & timings</Heading>
<Stack gap={3}>
<Stack>
<Text weight="medium" color="text-gray-200">Planned rounds:</Text>{' '}
<Text color="text-gray-300">{timings.roundsPlanned ?? '—'}</Text>
</Stack>
</Stack>
</Stack>
);
}
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 (
<Stack display="grid" responsiveGridCols={{ base: 1, lg: 2 }} gap={6}>
{/* LEFT COLUMN: Configuration */}
<Stack gap={4}>
{/* Session Durations - Collapsible */}
<CollapsibleSection
icon={<Icon icon={Timer} size={4} color="text-primary-blue" />}
title="Session Durations"
description="Configure practice, qualifying, and race lengths"
defaultOpen={false}
>
<Stack display="grid" gridCols={2} gap={3}>
<RangeField
label="Practice"
value={timings.practiceMinutes ?? 20}
min={0}
max={60}
onChange={(v: number) => updateTimings({ practiceMinutes: v })}
unitLabel="min"
compact
/>
<RangeField
label="Qualifying"
value={timings.qualifyingMinutes ?? 15}
min={5}
max={30}
onChange={(v: number) => updateTimings({ qualifyingMinutes: v })}
unitLabel="min"
compact
error={errors?.qualifyingMinutes}
/>
{showSprint && (
<RangeField
label="Sprint Race"
value={timings.sprintRaceMinutes ?? 20}
min={10}
max={45}
onChange={(v: number) => updateTimings({ sprintRaceMinutes: v })}
unitLabel="min"
compact
/>
)}
<RangeField
label={showSprint ? 'Feature Race' : 'Main Race'}
value={timings.mainRaceMinutes ?? 40}
min={15}
max={180}
onChange={(v: number) => updateTimings({ mainRaceMinutes: v })}
unitLabel="min"
compact
error={errors?.mainRaceMinutes}
/>
</Stack>
</CollapsibleSection>
{/* Season Length - Collapsible */}
<CollapsibleSection
icon={<Icon icon={Trophy} size={4} color="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}
/>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
mt={2}
block
>
Each round is one complete race day. Click the number to edit directly.
</Text>
</CollapsibleSection>
{/* Race Schedule - Collapsible */}
<CollapsibleSection
icon={<Icon icon={CalendarDays} size={4} color="text-primary-blue" />}
title="Race Schedule"
description="Weekly/bi-weekly and race days"
defaultOpen={false}
>
{/* Frequency */}
<Stack gap={2} mb={4}>
<Text as="label" size="xs" color="text-gray-400" block>How often?</Text>
<Stack display="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 (
<Stack
key={opt.id}
as="button"
type="button"
onClick={() =>
updateTimings({
recurrenceStrategy: opt.id as RecurrenceStrategy,
...(opt.id === 'everyNWeeks'
? { intervalWeeks: 2 }
: {}),
})
}
flexGrow={1}
px={3}
py={2}
rounded="lg"
border
transition
borderColor={isSelected ? 'border-primary-blue' : 'border-charcoal-outline'}
bg={isSelected ? 'bg-primary-blue/10' : 'bg-iron-gray/30'}
color={isSelected ? 'text-white' : 'text-gray-400'}
hoverBorderColor={!isSelected ? 'border-gray-500' : undefined}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '12px', fontWeight: 500 }}
>
{opt.label}
</Stack>
);
})}
</Stack>
</Stack>
{/* Day selection */}
<Stack gap={2}>
<Stack display="flex" alignItems="center" justifyContent="between">
<Text as="label" size="xs" color="text-gray-400">Which days?</Text>
{weekdays.length === 0 && (
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-warning-amber"
>
Select at least one
</Text>
)}
</Stack>
<Stack display="flex" gap={1}>
{allWeekdays.map(({ day, short }) => {
const isSelected = weekdays.includes(day);
return (
<Stack
key={day}
as="button"
type="button"
onClick={() => handleWeekdayToggle(day)}
flexGrow={1}
py={2}
rounded="lg"
border
transition
borderColor={isSelected ? 'border-primary-blue' : 'border-charcoal-outline'}
bg={isSelected ? 'bg-primary-blue' : 'bg-iron-gray/30'}
color={isSelected ? 'text-white' : 'text-gray-500'}
shadow={isSelected ? '0_0_8px_rgba(25,140,255,0.4)' : undefined}
hoverBorderColor={!isSelected ? 'border-gray-500' : undefined}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px', fontWeight: 500 }}
>
{short}
</Stack>
);
})}
</Stack>
</Stack>
</CollapsibleSection>
{/* Season Duration - Collapsible */}
<CollapsibleSection
icon={<Icon icon={Calendar} size={4} color="text-primary-blue" />}
title="Season Duration"
description="Start/end dates and time settings"
defaultOpen={false}
>
<Stack gap={4}>
<Stack display="grid" gridCols={2} gap={3}>
<Stack gap={1.5}>
<Text as="label"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-400"
weight="medium"
display="flex"
alignItems="center"
gap={1}
>
<Stack w="2" h="2" rounded="sm" bg="bg-performance-green" />
Season Start
</Text>
<Input
type="date"
value={timings.seasonStartDate ?? ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateTimings({ seasonStartDate: e.target.value })}
// eslint-disable-next-line gridpilot-rules/component-classification
className="bg-iron-gray/30"
/>
</Stack>
<Stack gap={1.5}>
<Text as="label"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-400"
weight="medium"
display="flex"
alignItems="center"
gap={1}
>
<Stack w="2" h="2" rounded="sm" bg="bg-warning-amber" />
Season End
</Text>
<Input
type="date"
value={timings.seasonEndDate ?? ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateTimings({ seasonEndDate: e.target.value })}
// eslint-disable-next-line gridpilot-rules/component-classification
className="bg-iron-gray/30"
/>
</Stack>
</Stack>
{timings.seasonStartDate && timings.seasonEndDate && (
<Stack display="flex" alignItems="start" gap={2} p={2} rounded="lg" bg="bg-primary-blue/5" border borderColor="border-primary-blue/20">
<Icon icon={Info} size={3.5} color="text-primary-blue" mt={0.5} flexShrink={0} />
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-400"
>
Races will be <Text as="span" color="text-white" weight="medium">evenly distributed</Text> between start and end dates on your selected race days.
</Text>
</Stack>
)}
<Stack gap={1.5}>
<Text as="label"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-400"
weight="medium"
>
Race Start Time
</Text>
<Input
type="time"
value={timings.raceStartTime ?? '20:00'}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateTimings({ raceStartTime: e.target.value })}
// eslint-disable-next-line gridpilot-rules/component-classification
className="bg-iron-gray/30"
/>
</Stack>
{/* Time Zone Selector */}
<Stack gap={1.5}>
<Text as="label"
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-400"
weight="medium"
>
Time Zone
</Text>
<Stack display="grid" gridCols={2} gap={2}>
{TIME_ZONES.slice(0, 2).map((tz) => {
const isSelected = (timings.timezoneId ?? 'UTC') === tz.value;
const TzIcon = tz.icon;
return (
<Stack
key={tz.value}
as="button"
type="button"
onClick={() => updateTimings({ timezoneId: tz.value })}
display="flex"
alignItems="center"
gap={2}
px={3}
py={2.5}
rounded="lg"
border
transition
borderColor={isSelected ? (tz.value === 'track' ? 'border-performance-green' : 'border-primary-blue') : 'border-charcoal-outline'}
bg={isSelected ? (tz.value === 'track' ? 'bg-performance-green/10' : 'bg-primary-blue/10') : 'bg-iron-gray/30'}
shadow={isSelected ? '0_0_12px_rgba(25,140,255,0.2)' : undefined}
hoverBorderColor={!isSelected ? 'border-gray-500' : undefined}
textAlign="left"
>
<Icon icon={TzIcon} size={4} color={isSelected ? (tz.value === 'track' ? 'text-performance-green' : 'text-primary-blue') : 'text-gray-400'} />
<Stack flexGrow={1}>
<Text size="xs" weight="medium" color={isSelected ? 'text-white' : 'text-gray-300'} block>
{tz.label}
</Text>
{tz.value === 'track' && (
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '9px' }}
color="text-gray-500"
block
>
Adjusts per track
</Text>
)}
</Stack>
</Stack>
);
})}
</Stack>
{/* More time zones - expandable */}
<Stack
as="button"
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
display="flex"
alignItems="center"
gap={1.5}
transition
color="text-gray-400"
hoverTextColor="text-white"
>
<Icon icon={showAdvanced ? ChevronUp : ChevronDown} size={3} />
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
>
{showAdvanced ? 'Hide' : 'Show'} more time zones
</Text>
</Stack>
{showAdvanced && (
<Stack display="grid" gridCols={2} gap={2} mt={2}>
{TIME_ZONES.slice(2).map((tz) => {
const isSelected = (timings.timezoneId ?? 'UTC') === tz.value;
const TzIcon = tz.icon;
return (
<Stack
key={tz.value}
as="button"
type="button"
onClick={() => updateTimings({ timezoneId: tz.value })}
display="flex"
alignItems="center"
gap={2}
px={3}
py={2}
rounded="lg"
border
transition
borderColor={isSelected ? 'border-primary-blue' : 'border-charcoal-outline'}
bg={isSelected ? 'bg-primary-blue/10' : 'bg-iron-gray/30'}
hoverBorderColor={!isSelected ? 'border-gray-500' : undefined}
textAlign="left"
>
<Icon icon={TzIcon} size={3.5} color={isSelected ? 'text-primary-blue' : 'text-gray-500'} />
<Text size="xs" color={isSelected ? 'text-white' : 'text-gray-400'}>{tz.label}</Text>
</Stack>
);
})}
</Stack>
)}
</Stack>
<Stack display="flex" alignItems="start" gap={2} p={2} rounded="lg" bg="bg-performance-green/5" border borderColor="border-performance-green/20">
<Icon icon={Info} size={3.5} color="text-performance-green" mt={0.5} flexShrink={0} />
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="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.'
}
</Text>
</Stack>
</Stack>
</CollapsibleSection>
</Stack>
{/* RIGHT COLUMN: Live Preview */}
<Stack gap={4}>
<Stack
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ position: 'sticky', top: '1rem' }}
>
<Stack rounded="xl" border borderColor="border-charcoal-outline"
// eslint-disable-next-line gridpilot-rules/component-classification
className="bg-gradient-to-br from-iron-gray/80 to-deep-graphite"
overflow="hidden"
>
{/* Preview header with tabs */}
<Stack display="flex" alignItems="center" justifyContent="between" p={3} borderBottom borderColor="border-charcoal-outline/50" bg="bg-deep-graphite/50">
<Stack display="flex" alignItems="center" gap={2}>
<Icon icon={Eye} size={4} color="text-primary-blue" />
<Text size="xs" weight="semibold" color="text-white">Preview</Text>
</Stack>
<Stack display="flex" gap={1} p={0.5} rounded="lg" bg="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) => (
<Stack
key={tab.id}
as="button"
type="button"
onClick={() => setPreviewTab(tab.id as typeof previewTab)}
display="flex"
alignItems="center"
gap={1}
px={2}
py={1}
rounded="sm"
transition
bg={previewTab === tab.id ? 'bg-primary-blue' : undefined}
color={previewTab === tab.id ? 'text-white' : 'text-gray-400'}
hoverTextColor={previewTab !== tab.id ? 'text-white' : undefined}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px', fontWeight: 500 }}
>
<Icon icon={tab.icon} size={3} />
<Stack as="span"
// eslint-disable-next-line gridpilot-rules/component-classification
className="hidden sm:inline"
>
{tab.label}
</Stack>
</Stack>
))}
</Stack>
</Stack>
{/* Preview content */}
<Stack p={4}
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ minHeight: '300px' }}
>
{previewTab === 'day' && (() => {
const sprintMinutes = showSprint
? timings.sprintRaceMinutes ?? 20
: undefined;
return (
<RaceDayPreview
template={showSprint ? 'sprintFeature' : 'feature'}
practiceMin={timings.practiceMinutes ?? 20}
qualifyingMin={timings.qualifyingMinutes ?? 15}
{...(sprintMinutes !== undefined
? { sprintMin: sprintMinutes }
: {})}
mainRaceMin={timings.mainRaceMinutes ?? 40}
{...(timings.raceStartTime
? { raceTime: timings.raceStartTime }
: {})}
/>
);
})()}
{previewTab === 'year' && (
<YearCalendarPreview
weekdays={weekdays}
frequency={recurrenceStrategy}
rounds={timings.roundsPlanned ?? 8}
{...(timings.seasonStartDate
? { startDate: timings.seasonStartDate }
: {})}
{...(timings.seasonEndDate
? { endDate: timings.seasonEndDate }
: {})}
/>
)}
{previewTab === 'stats' && (() => {
const sprintMinutes = showSprint
? timings.sprintRaceMinutes ?? 20
: undefined;
return (
<SeasonStatsPreview
rounds={timings.roundsPlanned ?? 8}
weekdays={weekdays}
frequency={recurrenceStrategy}
weekendTemplate={showSprint ? 'sprintFeature' : 'feature'}
practiceMin={timings.practiceMinutes ?? 20}
qualifyingMin={timings.qualifyingMinutes ?? 15}
{...(sprintMinutes !== undefined
? { sprintMin: sprintMinutes }
: {})}
mainRaceMin={timings.mainRaceMinutes ?? 40}
/>
);
})()}
</Stack>
</Stack>
{/* Helper tip */}
<Stack mt={3} display="flex" alignItems="start" gap={2} p={3} rounded="lg" bg="bg-primary-blue/5" border borderColor="border-primary-blue/20">
<Icon icon={Info} size={4} color="text-primary-blue" mt={0.5} flexShrink={0} />
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '11px' }}
color="text-gray-400"
leading="relaxed"
>
Preview updates live as you configure. Check <Text as="span" color="text-white" weight="medium">Race Day</Text> for session timing, <Text as="span" color="text-white" weight="medium">Calendar</Text> for the full year view, and <Text as="span" color="text-white" weight="medium">Stats</Text> for season totals.
</Text>
</Stack>
</Stack>
</Stack>
</Stack>
);
}