1322 lines
49 KiB
TypeScript
1322 lines
49 KiB
TypeScript
'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,
|
||
} from 'lucide-react';
|
||
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
|
||
import type { Weekday } from '@/lib/types/Weekday';
|
||
import Input from '@/ui/Input';
|
||
import RangeField from '@/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<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 (
|
||
<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',
|
||
] 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 (
|
||
<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 && lastRace
|
||
? `${getMonthLabel(firstRace.getMonth())}–${getMonthLabel(
|
||
lastRace.getMonth(),
|
||
)}`
|
||
: '—'}
|
||
</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,
|
||
...(opt.id === 'everyNWeeks'
|
||
? { intervalWeeks: 2 }
|
||
: {}),
|
||
})
|
||
}
|
||
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 })}
|
||
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 })}
|
||
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 })}
|
||
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' && (() => {
|
||
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}
|
||
/>
|
||
);
|
||
})()}
|
||
</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>
|
||
);
|
||
} |