diff --git a/apps/website/components/leagues/CreateLeagueWizard.tsx b/apps/website/components/leagues/CreateLeagueWizard.tsx index dee72ea7d..4f0c79100 100644 --- a/apps/website/components/leagues/CreateLeagueWizard.tsx +++ b/apps/website/components/leagues/CreateLeagueWizard.tsx @@ -10,7 +10,11 @@ import { Award, CheckCircle2, ChevronLeft, - ChevronRight + ChevronRight, + Loader2, + AlertCircle, + Sparkles, + Check, } from 'lucide-react'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; @@ -33,7 +37,7 @@ import { import { LeagueDropSection } from './LeagueDropSection'; import { LeagueTimingsSection } from './LeagueTimingsSection'; -type Step = 1 | 2 | 3 | 4 | 5 | 6; +type Step = 1 | 2 | 3 | 4 | 5; interface WizardErrors { basics?: { @@ -109,11 +113,6 @@ export default function CreateLeagueWizard() { const [form, setForm] = useState(() => createDefaultForm(), ); - /** - * Local-only weekend template selection for Step 3. - * This does not touch domain models; it only seeds timing defaults. - */ - const [weekendTemplate, setWeekendTemplate] = useState(''); useEffect(() => { async function loadPresets() { @@ -229,7 +228,7 @@ export default function CreateLeagueWizard() { if (!validateStep(step)) { return; } - setStep((prev) => (prev < 6 ? ((prev + 1) as Step) : prev)); + setStep((prev) => (prev < 5 ? ((prev + 1) as Step) : prev)); }; const goToPreviousStep = () => { @@ -244,8 +243,7 @@ export default function CreateLeagueWizard() { !validateStep(1) || !validateStep(2) || !validateStep(3) || - !validateStep(4) || - !validateStep(5) + !validateStep(4) ) { setStep(1); return; @@ -326,77 +324,77 @@ export default function CreateLeagueWizard() { const currentPreset = presets.find((p) => p.id === form.scoring.patternId) ?? null; - const handleWeekendTemplateChange = (template: string) => { - setWeekendTemplate(template); - + // Handler for scoring preset selection - updates timing defaults based on preset + const handleScoringPresetChange = (patternId: string) => { + const lowerPresetId = patternId.toLowerCase(); + setForm((prev) => { const timings = prev.timings ?? {}; - if (template === 'feature') { - return { - ...prev, - timings: { - ...timings, - practiceMinutes: 20, - qualifyingMinutes: 30, - sprintRaceMinutes: undefined, - mainRaceMinutes: 40, - sessionCount: 1, - }, + let updatedTimings = { ...timings }; + + // Auto-configure session durations based on preset type + if (lowerPresetId.includes('sprint') || lowerPresetId.includes('double')) { + updatedTimings = { + ...updatedTimings, + practiceMinutes: 15, + qualifyingMinutes: 20, + sprintRaceMinutes: 20, + mainRaceMinutes: 35, + sessionCount: 2, + }; + } else if (lowerPresetId.includes('endurance') || lowerPresetId.includes('long')) { + updatedTimings = { + ...updatedTimings, + practiceMinutes: 30, + qualifyingMinutes: 30, + sprintRaceMinutes: undefined, + mainRaceMinutes: 90, + sessionCount: 1, + }; + } else { + // Standard/feature format + updatedTimings = { + ...updatedTimings, + practiceMinutes: 20, + qualifyingMinutes: 30, + sprintRaceMinutes: undefined, + mainRaceMinutes: 40, + sessionCount: 1, }; } - if (template === 'sprintFeature') { - return { - ...prev, - timings: { - ...timings, - practiceMinutes: 15, - qualifyingMinutes: 20, - sprintRaceMinutes: 20, - mainRaceMinutes: 35, - sessionCount: 2, - }, - }; - } - if (template === 'endurance') { - return { - ...prev, - timings: { - ...timings, - practiceMinutes: 30, - qualifyingMinutes: 30, - sprintRaceMinutes: undefined, - mainRaceMinutes: 90, - sessionCount: 1, - }, - }; - } - return prev; + + return { + ...prev, + scoring: { + ...prev.scoring, + patternId, + customScoringEnabled: false, + }, + timings: updatedTimings, + }; }); }; const steps = [ - { id: 1 as Step, label: 'Basics', icon: FileText }, - { id: 2 as Step, label: 'Structure', icon: Users }, - { id: 3 as Step, label: 'Schedule', icon: Calendar }, - { id: 4 as Step, label: 'Scoring', icon: Trophy }, - { id: 5 as Step, label: 'Championships', icon: Award }, - { id: 6 as Step, label: 'Review', icon: CheckCircle2 }, + { id: 1 as Step, label: 'Basics', icon: FileText, shortLabel: 'Name' }, + { id: 2 as Step, label: 'Structure', icon: Users, shortLabel: 'Mode' }, + { id: 3 as Step, label: 'Schedule', icon: Calendar, shortLabel: 'Time' }, + { id: 4 as Step, label: 'Scoring', icon: Trophy, shortLabel: 'Points' }, + { id: 5 as Step, label: 'Review', icon: CheckCircle2, shortLabel: 'Done' }, ]; const getStepTitle = (currentStep: Step): string => { switch (currentStep) { case 1: - return 'Step 1 — Basics'; + return 'Name your league'; case 2: - return 'Step 2 — Structure'; + return 'Choose the structure'; case 3: - return 'Step 3 — Schedule & timings'; + return 'Set the schedule'; case 4: - return 'Step 4 — Scoring pattern'; + return 'Scoring & championships'; case 5: - return 'Step 5 — Championships & drops'; - case 6: - return 'Step 6 — Review & confirm'; + return 'Review & create'; default: return ''; } @@ -405,215 +403,249 @@ export default function CreateLeagueWizard() { const getStepSubtitle = (currentStep: Step): string => { switch (currentStep) { case 1: - return 'Give your league a clear name, description, and visibility.'; + return 'Give your league a memorable name and choose who can join.'; case 2: - return 'Choose whether this is a solo or team-based championship.'; + return 'Will drivers compete individually or as part of teams?'; case 3: - return 'Roughly outline how long your weekends and season should run.'; + return 'Configure session durations and plan your season calendar.'; case 4: - return 'Pick a scoring pattern that matches your weekends.'; + return 'Select a scoring preset, enable championships, and set drop rules.'; case 5: - return 'Decide which championships to track and how drops behave.'; - case 6: - return 'Double-check the summary before creating your new league.'; + return 'Everything looks good? Launch your new league!'; default: return ''; } }; + const currentStepData = steps.find((s) => s.id === step); + const CurrentStepIcon = currentStepData?.icon ?? FileText; + return ( -
- {/* Header */} -
- - Create a new league - -

- Configure your league in {steps.length} simple steps -

-
- - {/* Progress indicators */} -
-
- {steps.map((wizardStep, index) => { - const isCompleted = wizardStep.id < step; - const isCurrent = wizardStep.id === step; - const StepIcon = wizardStep.icon; - - return ( -
-
- {index > 0 && ( -
- )} -
- {isCompleted ? ( - - ) : ( - - )} -
-
- - {wizardStep.label} - -
- ); - })} + + {/* Header with icon */} +
+
+
+ +
+
+ + Create a new league + +

+ Set up your racing series in {steps.length} easy steps +

+
- {/* Main content card */} - - {/* Decorative gradient */} -
- -
- {/* Step header */} -
-
-
- {(() => { - const currentStepData = steps.find((s) => s.id === step); - if (!currentStepData) return null; - const Icon = currentStepData.icon; - return ; - })()} -
-
- - {getStepTitle(step)} - -

- {getStepSubtitle(step)} -

-
- - Step {step} of {steps.length} - -
+ {/* Desktop Progress Bar */} +
+
+ {/* Background track */} +
+ {/* Progress fill */} +
+ +
+ {steps.map((wizardStep) => { + const isCompleted = wizardStep.id < step; + const isCurrent = wizardStep.id === step; + const StepIcon = wizardStep.icon; + + return ( +
+
+ {isCompleted ? ( + + ) : ( + + )} +
+
+

+ {wizardStep.label} +

+
+
+ ); + })}
+
+
-
+ {/* Mobile Progress */} +
+
+
+ + {currentStepData?.label} +
+ + {step}/{steps.length} + +
+
+
+
+ {/* Step dots */} +
+ {steps.map((s) => ( +
+ ))} +
+
- {/* Step content */} -
+ {/* Main Card */} + + {/* Top gradient accent */} +
- {step === 1 && ( + {/* Step header */} +
+
+ +
+
+ + {getStepTitle(step)} + +

+ {getStepSubtitle(step)} +

+
+
+ Step + {step} + / {steps.length} +
+
+ + {/* Divider */} +
+ + {/* Step content with min-height for consistency */} +
+ {step === 1 && ( +
- )} +
+ )} - {step === 2 && ( + {step === 2 && ( +
- )} +
+ )} - {step === 3 && ( + {step === 3 && ( +
- )} +
+ )} - {step === 4 && ( -
- - setForm((prev) => ({ - ...prev, - scoring: { - ...prev.scoring, - patternId, - customScoringEnabled: false, - }, - })) - } - onToggleCustomScoring={() => - setForm((prev) => ({ - ...prev, - scoring: { - ...prev.scoring, - customScoringEnabled: !prev.scoring.customScoringEnabled, - }, - })) - } - /> + {step === 4 && ( +
+ {/* Scoring Pattern Selection */} + + setForm((prev) => ({ + ...prev, + scoring: { + ...prev.scoring, + customScoringEnabled: !prev.scoring.customScoringEnabled, + }, + })) + } + /> + + {/* Divider */} +
+ + {/* Championships & Drop Rules side by side on larger screens */} +
+ +
- )} - - {step === 5 && ( -
-
- + + {errors.submit && ( +
+ +

{errors.submit}

-
- + )} +
+ )} + + {step === 5 && ( +
+ + {errors.submit && ( +
+ +

{errors.submit}

- - {errors.submit && ( -
-
⚠️
-
{errors.submit}
-
- )} -
- )} - - {step === 6 && ( -
- - {errors.submit && ( -
-
⚠️
-
{errors.submit}
-
- )} -
- )} -
+ )} +
+ )}
- {/* Navigation buttons */} -
+ {/* Navigation */} +
-
- {step < 6 && ( + +
+ {/* Mobile step dots */} +
+ {steps.map((s) => ( +
+ ))} +
+ + {step < 5 ? ( - )} - {step === 6 && ( + ) : ( )}
+ + {/* Helper text */} +

+ You can edit all settings after creating your league +

); } \ No newline at end of file diff --git a/apps/website/components/leagues/LeagueDropSection.tsx b/apps/website/components/leagues/LeagueDropSection.tsx index 48ffb7a6c..c1596ea93 100644 --- a/apps/website/components/leagues/LeagueDropSection.tsx +++ b/apps/website/components/leagues/LeagueDropSection.tsx @@ -1,9 +1,169 @@ 'use client'; -import { TrendingDown, Info } from 'lucide-react'; +import { useState, useRef, useEffect } from 'react'; +import { TrendingDown, Check, HelpCircle, X, Zap } from 'lucide-react'; +import { createPortal } from 'react-dom'; import type { LeagueConfigFormModel } from '@gridpilot/racing/application'; -import Input from '@/components/ui/Input'; -import SegmentedControl from '@/components/ui/SegmentedControl'; + +// ============================================================================ +// INFO FLYOUT (duplicated for self-contained component) +// ============================================================================ + +interface InfoFlyoutProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; + anchorRef: React.RefObject; +} + +function InfoFlyout({ isOpen, onClose, title, children, anchorRef }: InfoFlyoutProps) { + const [position, setPosition] = useState({ top: 0, left: 0 }); + const [mounted, setMounted] = useState(false); + const flyoutRef = useRef(null); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (isOpen && anchorRef.current && mounted) { + const rect = anchorRef.current.getBoundingClientRect(); + const flyoutWidth = Math.min(380, window.innerWidth - 40); + const flyoutHeight = 450; + const padding = 16; + + let left = rect.right + 12; + let top = rect.top; + + if (left + flyoutWidth > window.innerWidth - padding) { + left = rect.left - flyoutWidth - 12; + } + if (left < padding) { + left = Math.max(padding, (window.innerWidth - flyoutWidth) / 2); + } + + top = rect.top - flyoutHeight / 3; + if (top + flyoutHeight > window.innerHeight - padding) { + top = window.innerHeight - flyoutHeight - padding; + } + if (top < padding) top = padding; + + left = Math.max(padding, Math.min(left, window.innerWidth - flyoutWidth - padding)); + + setPosition({ top, left }); + } + }, [isOpen, anchorRef, mounted]); + + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + const handleClickOutside = (e: MouseEvent) => { + if (flyoutRef.current && !flyoutRef.current.contains(e.target as Node)) { + onClose(); + } + }; + + if (isOpen) { + document.addEventListener('keydown', handleEscape); + document.addEventListener('mousedown', handleClickOutside); + } + return () => { + document.removeEventListener('keydown', handleEscape); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return createPortal( +
+
+
+ + {title} +
+ +
+
+ {children} +
+
, + document.body + ); +} + +function InfoButton({ onClick, buttonRef }: { onClick: () => void; buttonRef: React.RefObject }) { + return ( + + ); +} + +// Drop Rules Mockup +function DropRulesMockup() { + const results = [ + { round: 'R1', pts: 25, dropped: false }, + { round: 'R2', pts: 18, dropped: false }, + { round: 'R3', pts: 4, dropped: true }, + { round: 'R4', pts: 15, dropped: false }, + { round: 'R5', pts: 12, dropped: false }, + { round: 'R6', pts: 0, dropped: true }, + ]; + + const total = results.filter(r => !r.dropped).reduce((sum, r) => sum + r.pts, 0); + const wouldBe = results.reduce((sum, r) => sum + r.pts, 0); + + return ( +
+
+ Best 4 of 6 Results +
+
+ {results.map((r, i) => ( +
+
{r.round}
+
+ {r.pts} +
+
+ ))} +
+
+ Total counted: + {total} pts +
+
+ Without drops: + {wouldBe} pts +
+
+ ); +} interface LeagueDropSectionProps { form: LeagueConfigFormModel; @@ -11,6 +171,74 @@ interface LeagueDropSectionProps { readOnly?: boolean; } +type DropStrategy = 'none' | 'bestNResults' | 'dropWorstN'; + +// Drop rule info content +const DROP_RULE_INFO: Record = { + none: { + title: 'All Results Count', + description: 'Every race result affects the championship standings with no exceptions.', + details: [ + 'All race results count toward final standings', + 'No safety net for bad races or DNFs', + 'Rewards consistency across entire season', + 'Best for shorter seasons (4-6 races)', + ], + example: '8 races × your points = your total', + }, + bestNResults: { + title: 'Best N Results', + description: 'Only your top N race results count toward the championship.', + details: [ + 'Choose how many of your best races count', + 'Extra races become "bonus" opportunities', + 'Protects against occasional bad days', + 'Encourages trying even when behind', + ], + example: 'Best 6 of 8 races count', + }, + dropWorstN: { + title: 'Drop Worst N Results', + description: 'Your N worst race results are excluded from championship calculations.', + details: [ + 'Automatically removes your worst performances', + 'Great for handling DNFs or incidents', + 'All other races count normally', + 'Common in real-world championships', + ], + example: 'Drop 2 worst → 6 of 8 count', + }, +}; + +const DROP_OPTIONS: Array<{ + value: DropStrategy; + label: string; + emoji: string; + description: string; + defaultN?: number; +}> = [ + { + value: 'none', + label: 'All count', + emoji: '✓', + description: 'Every race counts', + }, + { + value: 'bestNResults', + label: 'Best N', + emoji: '🏆', + description: 'Only best results', + defaultN: 6, + }, + { + value: 'dropWorstN', + label: 'Drop worst', + emoji: '🗑️', + description: 'Exclude worst races', + defaultN: 2, + }, +]; + export function LeagueDropSection({ form, onChange, @@ -18,183 +246,233 @@ export function LeagueDropSection({ }: LeagueDropSectionProps) { const disabled = readOnly || !onChange; const dropPolicy = form.dropPolicy; + const [showDropFlyout, setShowDropFlyout] = useState(false); + const [activeDropRuleFlyout, setActiveDropRuleFlyout] = useState(null); + const dropInfoRef = useRef(null); + const dropRuleRefs = useRef>({ + none: null, + bestNResults: null, + dropWorstN: null, + }); - const updateDropPolicy = ( - patch: Partial, - ) => { - if (!onChange) return; + const handleStrategyChange = (strategy: DropStrategy) => { + if (disabled || !onChange) return; + + const option = DROP_OPTIONS.find((o) => o.value === strategy); onChange({ ...form, dropPolicy: { - ...dropPolicy, - ...patch, + strategy, + n: strategy === 'none' ? undefined : (dropPolicy.n ?? option?.defaultN), }, }); }; - const handleStrategyChange = ( - strategy: LeagueConfigFormModel['dropPolicy']['strategy'], - ) => { - if (strategy === 'none') { - updateDropPolicy({ strategy: 'none', n: undefined }); - } else if (strategy === 'bestNResults') { - const n = dropPolicy.n ?? 6; - updateDropPolicy({ strategy: 'bestNResults', n }); - } else if (strategy === 'dropWorstN') { - const n = dropPolicy.n ?? 2; - updateDropPolicy({ strategy: 'dropWorstN', n }); - } - }; - - const handleNChange = (value: string) => { - const parsed = parseInt(value, 10); - updateDropPolicy({ - n: Number.isNaN(parsed) || parsed <= 0 ? undefined : parsed, + const handleNChange = (delta: number) => { + if (disabled || !onChange || dropPolicy.strategy === 'none') return; + const current = dropPolicy.n ?? 1; + const newValue = Math.max(1, current + delta); + onChange({ + ...form, + dropPolicy: { + ...dropPolicy, + n: newValue, + }, }); }; - const getSuggestedN = () => { - const rounds = form.timings.roundsPlanned; - if (!rounds || rounds <= 0) return null; - - if (dropPolicy.strategy === 'bestNResults') { - // Suggest keeping 70-80% of rounds - const suggestion = Math.max(1, Math.floor(rounds * 0.75)); - return { value: suggestion, explanation: `Keep best ${suggestion} of ${rounds} rounds (75%)` }; - } else if (dropPolicy.strategy === 'dropWorstN') { - // Suggest dropping 1-2 rounds for every 8-10 rounds - const suggestion = Math.max(1, Math.floor(rounds / 8)); - return { value: suggestion, explanation: `Drop worst ${suggestion} of ${rounds} rounds` }; - } - return null; - }; - - const computeSummary = () => { - if (dropPolicy.strategy === 'none') { - return 'All results will count towards the championship.'; - } - if (dropPolicy.strategy === 'bestNResults') { - const n = dropPolicy.n; - if (typeof n === 'number' && n > 0) { - return `Best ${n} results will count; others are ignored.`; - } - return 'Best N results will count; others are ignored.'; - } - if (dropPolicy.strategy === 'dropWorstN') { - const n = dropPolicy.n; - if (typeof n === 'number' && n > 0) { - return `Worst ${n} results will be dropped from the standings.`; - } - return 'Worst N results will be dropped from the standings.'; - } - return 'All results will count towards the championship.'; - }; - - const currentStrategyValue = - dropPolicy.strategy === 'none' - ? 'all' - : dropPolicy.strategy === 'bestNResults' - ? 'bestN' - : 'dropWorstN'; - - const suggestedN = getSuggestedN(); + const needsN = dropPolicy.strategy !== 'none'; return (
-
-
- -

Drop rule

+ {/* Section header */} +
+
+
-

- Protect drivers from bad races by dropping worst results or counting only the best ones -

-
- -
- { - if (disabled) return; - if (value === 'all') { - handleStrategyChange('none'); - } else if (value === 'bestN') { - handleStrategyChange('bestNResults'); - } else if (value === 'dropWorstN') { - handleStrategyChange('dropWorstN'); - } - }} - /> - - {dropPolicy.strategy === 'none' && ( -
-

- All count: Every race result affects the championship. Best for shorter seasons or when consistency is key. -

+
+
+

Drop Rules

+ setShowDropFlyout(true)} />
- )} +

Protect from bad races

+
- {(dropPolicy.strategy === 'bestNResults' || - dropPolicy.strategy === 'dropWorstN') && ( -
- {suggestedN && ( -
-

- Suggested: {suggestedN.explanation} -

- -
- )} + {/* Drop Rules Flyout */} + setShowDropFlyout(false)} + title="Drop Rules Explained" + anchorRef={dropInfoRef} + > +
+

+ Drop rules allow drivers to exclude their worst results from championship calculations. + This protects against mechanical failures, bad luck, or occasional poor performances. +

+ +
+
Visual Example
+ +
-
+
+
Drop Strategies
+
+
+ +
+
All Count
+
Every race affects standings. Best for short seasons.
+
+
+
+ 🏆 +
+
Best N Results
+
Only your top N races count. Extra races are optional.
+
+
+
+ 🗑️ +
+
Drop Worst N
+
Exclude your N worst results. Forgives bad days.
+
+
+
+
+ +
- -
- - 0 - ? String(dropPolicy.n) - : '' - } - onChange={(e) => handleNChange(e.target.value)} - disabled={disabled} - min={1} - className="max-w-[140px]" - /> -

- {dropPolicy.strategy === 'bestNResults' - ? 'Only your best N results will count towards the championship. Great for long seasons.' - : 'Your worst N results will be excluded from the championship. Helps forgive bad days.'} -

+ +
+ Pro tip: For an 8-round season, + "Best 6" or "Drop 2" are popular choices.
- )} + -
-
- -
{computeSummary()}
-
+ {/* Strategy buttons + N stepper inline */} +
+ {DROP_OPTIONS.map((option) => { + const isSelected = dropPolicy.strategy === option.value; + const ruleInfo = DROP_RULE_INFO[option.value]; + return ( +
+ + + + {/* Drop Rule Info Flyout */} + setActiveDropRuleFlyout(null)} + title={ruleInfo.title} + anchorRef={{ current: dropRuleRefs.current[option.value] }} + > +
+

{ruleInfo.description}

+ +
+
How It Works
+
    + {ruleInfo.details.map((detail, idx) => ( +
  • + + {detail} +
  • + ))} +
+
+ +
+
+ {option.emoji} +
+
Example
+
{ruleInfo.example}
+
+
+
+
+
+
+ ); + })} + + {/* N Stepper - only show when needed */} + {needsN && ( +
+ N = + +
+ {dropPolicy.n ?? 1} +
+ +
+ )}
+ + {/* Explanation text */} +

+ {dropPolicy.strategy === 'none' && 'Every race result affects the championship standings.'} + {dropPolicy.strategy === 'bestNResults' && `Only your best ${dropPolicy.n ?? 1} results will count.`} + {dropPolicy.strategy === 'dropWorstN' && `Your worst ${dropPolicy.n ?? 1} results will be excluded.`} +

); } \ No newline at end of file diff --git a/apps/website/components/leagues/LeagueReviewSummary.tsx b/apps/website/components/leagues/LeagueReviewSummary.tsx index 20cc208e5..2f0616702 100644 --- a/apps/website/components/leagues/LeagueReviewSummary.tsx +++ b/apps/website/components/leagues/LeagueReviewSummary.tsx @@ -1,7 +1,26 @@ 'use client'; -import { FileText, Users, Calendar, Trophy, Award, Info } from 'lucide-react'; -import Card from '@/components/ui/Card'; +import { + FileText, + Users, + Calendar, + Trophy, + Award, + Rocket, + Eye, + EyeOff, + Gamepad2, + User, + UsersRound, + Clock, + Flag, + Zap, + Timer, + TrendingDown, + Check, + Globe, + Medal, +} from 'lucide-react'; import type { LeagueConfigFormModel } from '@gridpilot/racing/application'; import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider'; @@ -9,243 +28,326 @@ interface LeagueReviewSummaryProps { form: LeagueConfigFormModel; presets: LeagueScoringPresetDTO[]; } + +// Individual review card component +function ReviewCard({ + icon: Icon, + iconColor = 'text-primary-blue', + bgColor = 'bg-primary-blue/10', + title, + children, +}: { + icon: React.ElementType; + iconColor?: string; + bgColor?: string; + title: string; + children: React.ReactNode; +}) { + return ( +
+
+
+ +
+

{title}

+
+ {children} +
+ ); +} + +// Info row component for consistent layout +function InfoRow({ + icon: Icon, + label, + value, + valueClass = '', +}: { + icon?: React.ElementType; + label: string; + value: React.ReactNode; + valueClass?: string; +}) { + return ( +
+
+ {Icon && } + {label} +
+
{value}
+
+ ); +} + +// Badge component for enabled features +function FeatureBadge({ + icon: Icon, + label, + enabled, + color = 'primary-blue', +}: { + icon: React.ElementType; + label: string; + enabled: boolean; + color?: string; +}) { + if (!enabled) return null; + return ( + + + {label} + + ); +} export default function LeagueReviewSummary({ form, presets }: LeagueReviewSummaryProps) { const { basics, structure, timings, scoring, championships, dropPolicy } = form; const modeLabel = structure.mode === 'solo' - ? 'Drivers only (solo)' - : 'Teams with fixed drivers per team'; + ? 'Solo drivers' + : 'Team-based'; - const capacitySentence = (() => { + const modeDescription = + structure.mode === 'solo' + ? 'Individual competition' + : 'Teams with fixed rosters'; + + const capacityValue = (() => { if (structure.mode === 'solo') { - if (typeof structure.maxDrivers === 'number') { - return `Up to ${structure.maxDrivers} drivers`; - } - return 'Capacity not fully specified'; + return typeof structure.maxDrivers === 'number' ? structure.maxDrivers : '—'; } - const parts: string[] = []; - if (typeof structure.maxTeams === 'number') { - parts.push(`Teams: ${structure.maxTeams}`); - } - if (typeof structure.driversPerTeam === 'number') { - parts.push(`Drivers per team: ${structure.driversPerTeam}`); - } - if (typeof structure.maxDrivers === 'number') { - parts.push(`Max grid: ${structure.maxDrivers}`); - } - if (parts.length === 0) { - return '—'; - } - return parts.join(', '); + return typeof structure.maxTeams === 'number' ? structure.maxTeams : '—'; })(); + + const capacityLabel = structure.mode === 'solo' ? 'drivers' : 'teams'; const formatMinutes = (value: number | undefined) => { if (typeof value !== 'number' || value <= 0) return '—'; return `${value} min`; }; - const dropRuleSentence = (() => { + const getDropRuleInfo = () => { if (dropPolicy.strategy === 'none') { - return 'All results will count towards the championship.'; + return { emoji: '✓', label: 'All count', description: 'Every race counts' }; } if (dropPolicy.strategy === 'bestNResults') { - if (typeof dropPolicy.n === 'number' && dropPolicy.n > 0) { - return `Best ${dropPolicy.n} results will count; others are ignored.`; - } - return 'Best N results will count; others are ignored.'; + return { + emoji: '🏆', + label: `Best ${dropPolicy.n ?? 'N'}`, + description: `Only best ${dropPolicy.n ?? 'N'} results count`, + }; } if (dropPolicy.strategy === 'dropWorstN') { - if (typeof dropPolicy.n === 'number' && dropPolicy.n > 0) { - return `Worst ${dropPolicy.n} results will be dropped from the standings.`; - } - return 'Worst N results will be dropped from the standings.'; + return { + emoji: '🗑️', + label: `Drop ${dropPolicy.n ?? 'N'}`, + description: `Worst ${dropPolicy.n ?? 'N'} dropped`, + }; } - return 'All results will count towards the championship.'; - })(); + return { emoji: '✓', label: 'All count', description: 'Every race counts' }; + }; + + const dropRuleInfo = getDropRuleInfo(); - const preset = - presets.find((p) => p.id === scoring.patternId) ?? null; + const preset = presets.find((p) => p.id === scoring.patternId) ?? null; - const scoringPresetName = preset ? preset.name : scoring.patternId ? 'Preset not found' : '—'; - const scoringPatternSummary = preset?.sessionSummary ?? '—'; - const dropPolicySummary = dropRuleSentence; - - const enabledChampionshipsLabels: string[] = []; - if (championships.enableDriverChampionship) enabledChampionshipsLabels.push('Driver'); - if (championships.enableTeamChampionship) enabledChampionshipsLabels.push('Team'); - if (championships.enableNationsChampionship) enabledChampionshipsLabels.push('Nations Cup'); - if (championships.enableTrophyChampionship) enabledChampionshipsLabels.push('Trophy'); - - const championshipsSummary = - enabledChampionshipsLabels.length === 0 - ? 'None enabled yet.' - : enabledChampionshipsLabels.join(', '); + const getScoringEmoji = () => { + if (!preset) return '🏁'; + const name = preset.name.toLowerCase(); + if (name.includes('sprint') || name.includes('double')) return '⚡'; + if (name.includes('endurance') || name.includes('long')) return '🏆'; + if (name.includes('club') || name.includes('casual')) return '🏅'; + return '🏁'; + }; + const visibilityIcon = basics.visibility === 'public' ? Eye : EyeOff; const visibilityLabel = basics.visibility === 'public' ? 'Public' : 'Private'; - const gameLabel = 'iRacing'; + + // Calculate total weekend duration + const totalWeekendMinutes = (timings.practiceMinutes ?? 0) + + (timings.qualifyingMinutes ?? 0) + + (timings.sprintRaceMinutes ?? 0) + + (timings.mainRaceMinutes ?? 0); return (
-
-
- -
-

Review your league configuration

-

- Double-check all settings before creating your league. You can modify most of these later. + {/* Hero Banner */} +

+ {/* Background decoration */} +
+
+ +
+
+ +
+
+

+ {basics.name || 'Your New League'} +

+

+ {basics.description || 'Ready to launch your racing series!'}

+
+ + {basics.visibility === 'public' ? : } + {visibilityLabel} + + + + iRacing + + + {structure.mode === 'solo' ? : } + {modeLabel} + +
- -
- {/* 1. Basics & visibility */} -
-
- -

- Basics & visibility -

-
-
-
-
Name
-
{basics.name || '—'}
-
-
-
Visibility
-
{visibilityLabel}
-
-
-
Game
-
{gameLabel}
-
- {basics.description && ( -
-
Description
-
{basics.description}
-
- )} -
-
- - {/* 2. Structure & capacity */} -
-
- -

- Structure & capacity -

-
-
-
-
Mode
-
{modeLabel}
-
-
-
Capacity
-
{capacitySentence}
-
-
-
- - {/* 3. Schedule & timings */} -
-
- -

- Schedule & timings -

-
-
-
-
Planned rounds
-
{typeof timings.roundsPlanned === 'number' ? timings.roundsPlanned : '—'}
-
-
-
Sessions per weekend
-
{timings.sessionCount ?? '—'}
-
-
-
Practice
-
{formatMinutes(timings.practiceMinutes)}
-
-
-
Qualifying
-
{formatMinutes(timings.qualifyingMinutes)}
-
-
-
Sprint
-
{formatMinutes(timings.sprintRaceMinutes)}
-
-
-
Main race
-
{formatMinutes(timings.mainRaceMinutes)}
-
-
-
- - {/* 4. Scoring & drops */} -
-
- -

- Scoring & drops -

-
-
-
-
Scoring pattern
-
{scoringPresetName}
-
-
-
Pattern summary
-
{scoringPatternSummary}
-
-
-
Drop rule
-
{dropPolicySummary}
-
- {scoring.customScoringEnabled && ( -
-
Custom scoring
-
Custom scoring flagged
-
- )} -
-
- - {/* 5. Championships */} -
-
- -

- Championships -

-
-
-
-
Enabled championships
-
- {enabledChampionshipsLabels.length > 0 ? ( - enabledChampionshipsLabels.map((label) => ( - - - {label} - - )) - ) : ( - None enabled yet - )} -
-
-
-
+ {/* Stats Grid */} +
+ {/* Capacity */} +
+
+ +
+
{capacityValue}
+
{capacityLabel}
- + + {/* Rounds */} +
+
+ +
+
{timings.roundsPlanned ?? '—'}
+
rounds
+
+ + {/* Weekend Duration */} +
+
+ +
+
{totalWeekendMinutes > 0 ? `${totalWeekendMinutes}` : '—'}
+
min/weekend
+
+ + {/* Championships */} +
+
+ +
+
+ {[championships.enableDriverChampionship, championships.enableTeamChampionship, championships.enableNationsChampionship, championships.enableTrophyChampionship].filter(Boolean).length} +
+
championships
+
+
+ + {/* Detail Cards Grid */} +
+ {/* Schedule Card */} + +
+ {timings.practiceMinutes && timings.practiceMinutes > 0 && ( + + )} + + {timings.sprintRaceMinutes && timings.sprintRaceMinutes > 0 && ( + + )} + +
+
+ + {/* Scoring Card */} + +
+ {/* Scoring Preset */} +
+ {getScoringEmoji()} +
+
{preset?.name ?? 'Custom'}
+
{preset?.sessionSummary ?? 'Custom scoring enabled'}
+
+ {scoring.customScoringEnabled && ( + Custom + )} +
+ + {/* Drop Rule */} +
+
+ {dropRuleInfo.emoji} +
+
+
{dropRuleInfo.label}
+
{dropRuleInfo.description}
+
+
+
+
+
+ + {/* Championships Section */} + +
+ {championships.enableDriverChampionship && ( + + + Driver Championship + + + )} + {championships.enableTeamChampionship && ( + + + Team Championship + + + )} + {championships.enableNationsChampionship && ( + + + Nations Cup + + + )} + {championships.enableTrophyChampionship && ( + + + Trophy Championship + + + )} + {![championships.enableDriverChampionship, championships.enableTeamChampionship, championships.enableNationsChampionship, championships.enableTrophyChampionship].some(Boolean) && ( + No championships enabled + )} +
+
+ + {/* Ready to launch message */} +
+
+
+ +
+
+

Ready to launch!

+

+ Click "Create League" to launch your racing series. You can modify all settings later. +

+
+
+
); } \ No newline at end of file diff --git a/apps/website/components/leagues/LeagueScoringSection.tsx b/apps/website/components/leagues/LeagueScoringSection.tsx index e7ba0cea8..1fa6dd712 100644 --- a/apps/website/components/leagues/LeagueScoringSection.tsx +++ b/apps/website/components/leagues/LeagueScoringSection.tsx @@ -1,10 +1,283 @@ 'use client'; -import { Trophy, Award, Star, Target } from 'lucide-react'; +import { useState, useRef, useEffect } from 'react'; +import { Trophy, Award, Check, Zap, Settings, Globe, Medal, Plus, Minus, RotateCcw, HelpCircle, X } from 'lucide-react'; +import { createPortal } from 'react-dom'; import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider'; import type { LeagueConfigFormModel } from '@gridpilot/racing/application'; -import Button from '@/components/ui/Button'; -import PresetCard from '@/components/ui/PresetCard'; + +// ============================================================================ +// INFO FLYOUT COMPONENT +// ============================================================================ + +interface InfoFlyoutProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; + anchorRef: React.RefObject; +} + +function InfoFlyout({ isOpen, onClose, title, children, anchorRef }: InfoFlyoutProps) { + const [position, setPosition] = useState({ top: 0, left: 0 }); + const [mounted, setMounted] = useState(false); + const flyoutRef = useRef(null); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (isOpen && anchorRef.current && mounted) { + const rect = anchorRef.current.getBoundingClientRect(); + const flyoutWidth = Math.min(380, window.innerWidth - 40); + const flyoutHeight = 450; + const padding = 16; + + // Try to position to the right first + let left = rect.right + 12; + let top = rect.top; + + // If goes off right edge, try left side + if (left + flyoutWidth > window.innerWidth - padding) { + left = rect.left - flyoutWidth - 12; + } + + // If still off screen (left side), center horizontally + if (left < padding) { + left = Math.max(padding, (window.innerWidth - flyoutWidth) / 2); + } + + // Vertical positioning - try to center on button, but stay in viewport + top = rect.top - flyoutHeight / 3; + + // Adjust if goes off bottom edge + if (top + flyoutHeight > window.innerHeight - padding) { + top = window.innerHeight - flyoutHeight - padding; + } + + // Ensure not above viewport + if (top < padding) top = padding; + + // Final bounds check + left = Math.max(padding, Math.min(left, window.innerWidth - flyoutWidth - padding)); + + setPosition({ top, left }); + } + }, [isOpen, anchorRef, mounted]); + + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + const handleClickOutside = (e: MouseEvent) => { + if (flyoutRef.current && !flyoutRef.current.contains(e.target as Node)) { + onClose(); + } + }; + + if (isOpen) { + document.addEventListener('keydown', handleEscape); + document.addEventListener('mousedown', handleClickOutside); + } + return () => { + document.removeEventListener('keydown', handleEscape); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return createPortal( +
+ {/* Header */} +
+
+ + {title} +
+ +
+ {/* Content */} +
+ {children} +
+
, + document.body + ); +} + +function InfoButton({ onClick, buttonRef }: { onClick: () => void; buttonRef: React.RefObject }) { + return ( + + ); +} + +// ============================================================================ +// MOCKUP COMPONENTS FOR FLYOUTS +// ============================================================================ + +function PointsSystemMockup() { + const positions = [ + { pos: 1, pts: 25, color: 'bg-yellow-500' }, + { pos: 2, pts: 18, color: 'bg-gray-300' }, + { pos: 3, pts: 15, color: 'bg-amber-600' }, + { pos: 4, pts: 12, color: 'bg-charcoal-outline' }, + { pos: 5, pts: 10, color: 'bg-charcoal-outline' }, + ]; + + return ( +
+
+ Position + Points +
+ {positions.map((p) => ( +
+
+ P{p.pos} +
+
+
+
+
+ {p.pts} +
+
+ ))} +
+ ... + down to P10 = 1 point +
+
+ ); +} + +function BonusPointsMockup() { + const bonuses = [ + { emoji: '🏎️', label: 'Pole Position', pts: '+1', desc: 'Fastest in qualifying' }, + { emoji: '⚡', label: 'Fastest Lap', pts: '+1', desc: 'Best race lap time' }, + { emoji: '🥇', label: 'Led a Lap', pts: '+1', desc: 'Led at least one lap' }, + ]; + + return ( +
+ {bonuses.map((b, i) => ( +
+ {b.emoji} +
+
{b.label}
+
{b.desc}
+
+ {b.pts} +
+ ))} +
+ ); +} + +function ChampionshipMockup() { + const standings = [ + { pos: 1, name: 'M. Verstappen', pts: 575, delta: null }, + { pos: 2, name: 'L. Norris', pts: 412, delta: -163 }, + { pos: 3, name: 'C. Leclerc', pts: 389, delta: -186 }, + ]; + + return ( +
+
+ + Driver Championship +
+
+ {standings.map((s) => ( +
+
+ {s.pos} +
+
{s.name}
+
{s.pts}
+ {s.delta && ( +
{s.delta}
+ )} +
+ ))} +
+
+ Points accumulated across all races +
+
+ ); +} + +function DropRulesMockup() { + const results = [ + { round: 'R1', pts: 25, dropped: false }, + { round: 'R2', pts: 18, dropped: false }, + { round: 'R3', pts: 4, dropped: true }, + { round: 'R4', pts: 15, dropped: false }, + { round: 'R5', pts: 12, dropped: false }, + { round: 'R6', pts: 0, dropped: true }, + ]; + + const total = results.filter(r => !r.dropped).reduce((sum, r) => sum + r.pts, 0); + const wouldBe = results.reduce((sum, r) => sum + r.pts, 0); + + return ( +
+
+ Best 4 of 6 Results +
+
+ {results.map((r, i) => ( +
+
{r.round}
+
+ {r.pts} +
+
+ ))} +
+
+ Total counted: + {total} pts +
+
+ Without drops: + {wouldBe} pts +
+
+ ); +} interface LeagueScoringSectionProps { form: LeagueConfigFormModel; @@ -28,8 +301,24 @@ interface ScoringPatternSectionProps { patternError?: string; onChangePatternId?: (patternId: string) => void; onToggleCustomScoring?: () => void; + onUpdateCustomPoints?: (points: CustomPointsConfig) => void; } +// Custom points configuration for inline editor +export interface CustomPointsConfig { + racePoints: number[]; + poleBonusPoints: number; + fastestLapPoints: number; + leaderLapPoints: number; +} + +const DEFAULT_CUSTOM_POINTS: CustomPointsConfig = { + racePoints: [25, 18, 15, 12, 10, 8, 6, 4, 2, 1], + poleBonusPoints: 1, + fastestLapPoints: 1, + leaderLapPoints: 0, +}; + interface ChampionshipsSectionProps { form: LeagueConfigFormModel; onChange?: (form: LeagueConfigFormModel) => void; @@ -46,85 +335,35 @@ export function LeagueScoringSection({ }: LeagueScoringSectionProps) { const disabled = readOnly || !onChange; - const updateScoring = ( - patch: Partial, - ) => { - if (!onChange) return; + const handleSelectPreset = (presetId: string) => { + if (disabled || !onChange) return; onChange({ ...form, scoring: { ...form.scoring, - ...patch, + patternId: presetId, + customScoringEnabled: false, }, }); }; - const updateChampionships = ( - patch: Partial, - ) => { - if (!onChange) return; - onChange({ - ...form, - championships: { - ...form.championships, - ...patch, - }, - }); - }; - - const handleSelectPreset = (presetId: string) => { - if (disabled) return; - updateScoring({ - patternId: presetId, - customScoringEnabled: false, - }); - }; - const handleToggleCustomScoring = () => { - if (disabled) return; - const current = !!form.scoring.customScoringEnabled; - updateScoring({ - customScoringEnabled: !current, + if (disabled || !onChange) return; + onChange({ + ...form, + scoring: { + ...form.scoring, + customScoringEnabled: !form.scoring.customScoringEnabled, + }, }); }; - - const currentPreset = - presets.find((p) => p.id === form.scoring.patternId) ?? null; - - const isTeamsMode = form.structure.mode === 'fixedTeams'; - - const renderPrimaryChampionshipLabel = () => { - if (!currentPreset) { - return '—'; - } - switch (currentPreset.primaryChampionshipType) { - case 'driver': - return 'Driver championship'; - case 'team': - return 'Team championship'; - case 'nations': - return 'Nations championship'; - case 'trophy': - return 'Trophy championship'; - default: - return currentPreset.primaryChampionshipType; - } - }; - - const selectedPreset = - currentPreset ?? - (presets.length > 0 - ? presets.find((p) => p.id === form.scoring.patternId) ?? null - : null); const patternPanel = ( handleSelectPreset(id) : undefined - } + onChangePatternId={!readOnly && onChange ? handleSelectPreset : undefined} onToggleCustomScoring={disabled ? undefined : handleToggleCustomScoring} /> ); @@ -150,8 +389,60 @@ export function LeagueScoringSection({ } /** - * Step 4 – scoring pattern preset picker used by the wizard. + * Step 4 – Super simple scoring preset picker. + * Flat layout, no collapsibles, easy to understand. */ +// Preset info content for individual presets +function getPresetInfoContent(presetName: string): { title: string; description: string; details: string[] } { + const name = presetName.toLowerCase(); + if (name.includes('sprint')) { + return { + title: 'Sprint + Feature Format', + description: 'A two-race weekend format with a shorter sprint race and a longer feature race.', + details: [ + 'Sprint race typically awards reduced points (e.g., 8-6-4-3-2-1)', + 'Feature race awards full points (e.g., 25-18-15-12-10-8-6-4-2-1)', + 'Grid for feature often based on sprint results', + 'Great for competitive leagues with time for multiple races', + ], + }; + } + if (name.includes('endurance') || name.includes('long')) { + return { + title: 'Endurance Format', + description: 'Long-form racing focused on consistency and strategy over raw pace.', + details: [ + 'Single race per weekend, longer duration (60-90+ minutes)', + 'Higher points for finishing (rewards reliability)', + 'Often includes mandatory pit stops', + 'Best for serious leagues with dedicated racers', + ], + }; + } + if (name.includes('club') || name.includes('casual')) { + return { + title: 'Club/Casual Format', + description: 'Relaxed format perfect for community leagues and casual racing.', + details: [ + 'Simple points structure, easy to understand', + 'Typically single race per weekend', + 'Lower stakes, focus on participation', + 'Great for beginners or mixed-skill leagues', + ], + }; + } + return { + title: 'Standard Race Format', + description: 'Traditional single-race weekend with standard F1-style points.', + details: [ + 'Points: 25-18-15-12-10-8-6-4-2-1 for top 10', + 'Bonus points for pole position and fastest lap', + 'One race per weekend', + 'The most common format used in sim racing', + ], + }; +} + export function ScoringPatternSection({ scoring, presets, @@ -159,154 +450,469 @@ export function ScoringPatternSection({ patternError, onChangePatternId, onToggleCustomScoring, + onUpdateCustomPoints, }: ScoringPatternSectionProps) { const disabled = readOnly || !onChangePatternId; - const currentPreset = - presets.find((p) => p.id === scoring.patternId) ?? null; + const currentPreset = presets.find((p) => p.id === scoring.patternId) ?? null; + const isCustom = scoring.customScoringEnabled; - const renderPrimaryLabel = (preset: LeagueScoringPresetDTO) => { - switch (preset.primaryChampionshipType) { - case 'driver': - return 'Driver focus'; - case 'team': - return 'Team focus'; - case 'nations': - return 'Nations focus'; - case 'trophy': - return 'Trophy / cup focus'; - default: - return preset.primaryChampionshipType; - } + // Local state for custom points editor + const [customPoints, setCustomPoints] = useState(DEFAULT_CUSTOM_POINTS); + + const updateRacePoints = (index: number, delta: number) => { + setCustomPoints((prev) => { + const newPoints = [...prev.racePoints]; + newPoints[index] = Math.max(0, (newPoints[index] ?? 0) + delta); + const updated = { ...prev, racePoints: newPoints }; + onUpdateCustomPoints?.(updated); + return updated; + }); }; - const handleSelect = (presetId: string) => { - if (disabled) return; - onChangePatternId?.(presetId); + const addPosition = () => { + setCustomPoints((prev) => { + const lastPoints = prev.racePoints[prev.racePoints.length - 1] ?? 0; + const updated = { ...prev, racePoints: [...prev.racePoints, Math.max(0, lastPoints - 1)] }; + onUpdateCustomPoints?.(updated); + return updated; + }); }; + const removePosition = () => { + if (customPoints.racePoints.length <= 3) return; + setCustomPoints((prev) => { + const updated = { ...prev, racePoints: prev.racePoints.slice(0, -1) }; + onUpdateCustomPoints?.(updated); + return updated; + }); + }; + + const updateBonus = (key: keyof Omit, delta: number) => { + setCustomPoints((prev) => { + const updated = { ...prev, [key]: Math.max(0, prev[key] + delta) }; + onUpdateCustomPoints?.(updated); + return updated; + }); + }; + + const resetToDefaults = () => { + setCustomPoints(DEFAULT_CUSTOM_POINTS); + onUpdateCustomPoints?.(DEFAULT_CUSTOM_POINTS); + }; + + const getPresetEmoji = (preset: LeagueScoringPresetDTO) => { + const name = preset.name.toLowerCase(); + if (name.includes('sprint') || name.includes('double')) return '⚡'; + if (name.includes('endurance') || name.includes('long')) return '🏆'; + if (name.includes('club') || name.includes('casual')) return '🏅'; + return '🏁'; + }; + + const getPresetDescription = (preset: LeagueScoringPresetDTO) => { + const name = preset.name.toLowerCase(); + if (name.includes('sprint')) return 'Sprint + Feature race'; + if (name.includes('endurance')) return 'Long-form endurance'; + if (name.includes('club')) return 'Casual league format'; + return preset.sessionSummary; + }; + + // Flyout state + const [showPointsFlyout, setShowPointsFlyout] = useState(false); + const [showBonusFlyout, setShowBonusFlyout] = useState(false); + const [activePresetFlyout, setActivePresetFlyout] = useState(null); + const pointsInfoRef = useRef(null); + const bonusInfoRef = useRef(null); + const presetInfoRefs = useRef>({}); + return ( -
-
-
- -

Scoring pattern

+
+ {/* Section header */} +
+
+
-

- Choose a preset that matches your race weekend format -

-
- - {presets.length === 0 ? ( -

No presets available.

- ) : ( -
- {presets.map((preset) => ( - handleSelect(preset.id)} - /> - ))} +
+
+

Points System

+ setShowPointsFlyout(true)} /> +
+

Choose how points are awarded

- )} +
+ {/* Points System Flyout */} + setShowPointsFlyout(false)} + title="How Points Work" + anchorRef={pointsInfoRef} + > +
+

+ Points are awarded based on finishing position. Higher positions earn more points, + which accumulate across the season to determine championship standings. +

+ +
+
Example: F1-Style Points
+ +
+ +
+
+ +
+ Pro tip: Sprint formats + award points in both races, typically with reduced points for the sprint. +
+
+
+
+
+ + {/* Two-column layout: Presets | Custom */} +
+ {/* Preset options */} +
+ {presets.length === 0 ? ( +

+ Loading presets... +

+ ) : ( + presets.map((preset) => { + const isSelected = !isCustom && scoring.patternId === preset.id; + const presetInfo = getPresetInfoContent(preset.name); + return ( +
+ + + + {/* Preset Info Flyout */} + setActivePresetFlyout(null)} + title={presetInfo.title} + anchorRef={{ current: presetInfoRefs.current[preset.id] ?? null }} + > +
+

{presetInfo.description}

+ +
+
Key Features
+
    + {presetInfo.details.map((detail, idx) => ( +
  • + + {detail} +
  • + ))} +
+
+ + {preset.bonusSummary && ( +
+
+ +
+ Bonus points: {preset.bonusSummary} +
+
+
+ )} +
+
+
+ ); + }) + )} +
+ + {/* Custom scoring option */} +
+ +
+
+ + {/* Error message */} {patternError && (

{patternError}

)} -
-
- -
Selected pattern
-
- {currentPreset ? ( -
+ {/* Custom scoring editor - inline, no placeholder */} + {isCustom && ( +
+ {/* Header with reset button */} +
- - {currentPreset.name} - + + Custom Points Table + setShowBonusFlyout(true)} />
-
-
- + + {/* Bonus Points Flyout */} + setShowBonusFlyout(false)} + title="Bonus Points Explained" + anchorRef={bonusInfoRef} + > +
+

+ Bonus points reward exceptional performances beyond just finishing position. + They add strategic depth and excitement to your championship. +

+
-
Sessions
-
{currentPreset.sessionSummary}
+
Available Bonuses
+ +
+ +
+
+ +
+ Example: A driver finishing + P1 with pole and fastest lap would earn 25 + 1 + 1 = 27 points. +
+
-
- -
-
Points focus
-
{renderPrimaryLabel(currentPreset)}
-
+ + + +
+ + {/* Race position points */} +
+
+ Finish position points +
+ +
-
- -
-
Default drops
-
{currentPreset.dropPolicySummary}
+
+ +
+ {customPoints.racePoints.map((pts, idx) => ( +
+ P{idx + 1} +
+ +
+ {pts} +
+ +
-
+ ))}
- ) : ( -

- No pattern selected yet. Pick a card above to define your scoring style. -

- )} -
-
-
-

- Custom scoring (advanced) -

-

- In this alpha, presets still define the actual scoring; this flag marks intent only. -

+ {/* Bonus points */} +
+ {[ + { key: 'poleBonusPoints' as const, label: 'Pole', emoji: '🏎️' }, + { key: 'fastestLapPoints' as const, label: 'Fast lap', emoji: '⚡' }, + { key: 'leaderLapPoints' as const, label: 'Led lap', emoji: '🥇' }, + ].map((bonus) => ( +
+ {bonus.emoji} {bonus.label} +
+ +
+ {customPoints[bonus.key]} +
+ +
+
+ ))} +
- - {readOnly ? ( - - {scoring.customScoringEnabled - ? 'Custom scoring flagged' - : 'Using preset scoring'} - - ) : ( - - )} -
-
+ )} +
); } /** - * Step 5 – championships-only panel used by the wizard. + * Championships section - simple inline toggle list. + * No collapsibles, flat and easy to scan. */ +// Championship info content +const CHAMPIONSHIP_INFO: Record = { + enableDriverChampionship: { + title: 'Driver Championship', + description: 'Track individual driver performance across all races in the season.', + details: [ + 'Each driver accumulates points based on race finishes', + 'The driver with most points at season end wins', + 'Standard in all racing leagues', + 'Shows overall driver skill and consistency', + ], + }, + enableTeamChampionship: { + title: 'Team Championship', + description: 'Combine points from all drivers within a team for team standings.', + details: [ + 'All drivers\' points count toward team total', + 'Rewards having consistent performers across the roster', + 'Creates team strategy opportunities', + 'Only available in Teams mode leagues', + ], + }, + enableNationsChampionship: { + title: 'Nations Cup', + description: 'Group drivers by nationality for international competition.', + details: [ + 'Drivers represent their country automatically', + 'Points pooled by nationality', + 'Adds international rivalry element', + 'Great for diverse, international leagues', + ], + }, + enableTrophyChampionship: { + title: 'Trophy Championship', + description: 'A special category championship for specific classes or groups.', + details: [ + 'Custom category you define (e.g., Am drivers, rookies)', + 'Separate standings from main championship', + 'Encourages participation from all skill levels', + 'Can be used for gentleman drivers, newcomers, etc.', + ], + }, +}; + export function ChampionshipsSection({ form, onChange, @@ -314,225 +920,212 @@ export function ChampionshipsSection({ }: ChampionshipsSectionProps) { const disabled = readOnly || !onChange; const isTeamsMode = form.structure.mode === 'fixedTeams'; + const [showChampFlyout, setShowChampFlyout] = useState(false); + const [activeChampFlyout, setActiveChampFlyout] = useState(null); + const champInfoRef = useRef(null); + const champItemRefs = useRef>({}); - const updateChampionships = ( - patch: Partial, - ) => { + const updateChampionship = (key: keyof LeagueConfigFormModel['championships'], value: boolean) => { if (!onChange) return; onChange({ ...form, championships: { ...form.championships, - ...patch, + [key]: value, }, }); }; + const championships = [ + { + key: 'enableDriverChampionship' as const, + icon: Trophy, + label: 'Driver Standings', + description: 'Track individual driver points', + enabled: form.championships.enableDriverChampionship, + available: true, + }, + { + key: 'enableTeamChampionship' as const, + icon: Award, + label: 'Team Standings', + description: 'Combined team points', + enabled: form.championships.enableTeamChampionship, + available: isTeamsMode, + unavailableHint: 'Teams mode only', + }, + { + key: 'enableNationsChampionship' as const, + icon: Globe, + label: 'Nations Cup', + description: 'By nationality', + enabled: form.championships.enableNationsChampionship, + available: true, + }, + { + key: 'enableTrophyChampionship' as const, + icon: Medal, + label: 'Trophy Cup', + description: 'Special category', + enabled: form.championships.enableTrophyChampionship, + available: true, + }, + ]; + return ( -
-
-
- -

Championships

+
+ {/* Section header */} +
+
+
-

- Select which championship standings to track this season -

-
- -
- {/* Driver championship */} -
-
-
- -
-
-
Driver championship
-

- Per-driver season standings across all points-scoring sessions. -

-
+
+
+

Championships

+ setShowChampFlyout(true)} />
-
- {readOnly ? ( - - {form.championships.enableDriverChampionship ? 'On' : 'Off'} - - ) : ( - - )} -
-
- - {/* Team championship */} -
-
-
- -
-
-
Team championship
-

- Aggregated season standings for fixed teams. -

- {!isTeamsMode && ( -

- Enable team mode in Structure to activate this -

- )} -
-
-
- {readOnly ? ( - - {isTeamsMode && form.championships.enableTeamChampionship ? 'On' : 'Off'} - - ) : ( - - )} -
-
- - {/* Nations championship */} -
-
-
- -
-
-
Nations Cup
-

- Standings grouped by drivers' nationality or country flag. -

-
-
-
- {readOnly ? ( - - {form.championships.enableNationsChampionship ? 'On' : 'Off'} - - ) : ( - - )} -
-
- - {/* Trophy championship */} -
-
-
- -
-
-
Trophy / cup
-

- Extra cup-style standings for special categories or invite-only groups. -

-
-
-
- {readOnly ? ( - - {form.championships.enableTrophyChampionship ? 'On' : 'Off'} - - ) : ( - - )} -
-
- -
-

- ℹ️ - For this alpha, only driver standings are fully calculated. These toggles express intent for future seasons. -

+

What standings to track

-
+ + {/* Championships Flyout */} + setShowChampFlyout(false)} + title="Championship Standings" + anchorRef={champInfoRef} + > +
+

+ Championships track cumulative points across all races. You can enable multiple + championship types to run different competitions simultaneously. +

+ +
+
Live Standings Example
+ +
+ +
+
Championship Types
+
+ {[ + { icon: Trophy, label: 'Driver', desc: 'Individual points' }, + { icon: Award, label: 'Team', desc: 'Combined drivers' }, + { icon: Globe, label: 'Nations', desc: 'By country' }, + { icon: Medal, label: 'Trophy', desc: 'Special class' }, + ].map((t, i) => ( +
+ +
+
{t.label}
+
{t.desc}
+
+
+ ))} +
+
+
+
+ + {/* Inline toggle grid */} +
+ {championships.map((champ) => { + const Icon = champ.icon; + const isEnabled = champ.enabled && champ.available; + const champInfo = CHAMPIONSHIP_INFO[champ.key]; + + return ( +
+ + + + {/* Championship Item Info Flyout */} + {champInfo && ( + setActiveChampFlyout(null)} + title={champInfo.title} + anchorRef={{ current: champItemRefs.current[champ.key] ?? null }} + > +
+

{champInfo.description}

+ +
+
How It Works
+
    + {champInfo.details.map((detail, idx) => ( +
  • + + {detail} +
  • + ))} +
+
+ + {!champ.available && ( +
+
+ +
+ Note: {champ.unavailableHint}. Switch to Teams mode to enable this championship. +
+
+
+ )} +
+
+ )} +
+ ); + })} +
+
); } \ No newline at end of file diff --git a/apps/website/components/leagues/LeagueTimingsSection.tsx b/apps/website/components/leagues/LeagueTimingsSection.tsx index 0b5502b07..68c079b18 100644 --- a/apps/website/components/leagues/LeagueTimingsSection.tsx +++ b/apps/website/components/leagues/LeagueTimingsSection.tsx @@ -1,15 +1,42 @@ 'use client'; -import { useEffect, useState } from 'react'; -import { Calendar, Clock, MapPin, Zap, Info, Loader2 } from 'lucide-react'; +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 Heading from '@/components/ui/Heading'; import Input from '@/components/ui/Input'; -import SegmentedControl from '@/components/ui/SegmentedControl'; +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['recurrenceStrategy']; @@ -22,39 +49,784 @@ interface LeagueTimingsSectionProps { mainRaceMinutes?: string; roundsPlanned?: string; }; - /** - * Optional override for the section heading. - * When omitted, defaults to "Schedule & timings". - */ title?: string; - /** - * Local wizard-only weekend template identifier. - */ - weekendTemplate?: string; - /** - * Callback when the weekend template selection changes. - */ - onWeekendTemplateChange?: (template: string) => void; } +// ============================================================================ +// 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 ( +
+
+ + Race Day Schedule + + + Starts {effectiveRaceTime}{!raceTime && (default)} +
+ + {/* Timeline visualization - show ALL sessions */} +
+ {allSessions.map((session, i) => { + const isRace = session.type === 'race' || session.type === 'sprint'; + const isActive = session.active; + const activeIndex = isActive ? allSessions.filter((s, idx) => s.active && idx < i).length : -1; + const startTime = isActive ? getStartTime(activeIndex) : null; + + return ( +
+ {/* Status badge */} + {!isActive && ( +
+ Not included +
+ )} + + {/* Time marker */} +
+ + {startTime ?? '—'} + +
+ + {/* Session indicator */} +
+ + {/* Session info */} +
+
+ {session.name} +
+
+ {isActive ? `${session.duration} minutes` : 'Disabled'} +
+
+ + {/* Duration bar */} + {isActive && ( +
+
s.duration))) * 100}%` }} + /> +
+ )} +
+ ); + })} +
+ + {/* Legend */} +
+
+
+ Active race +
+
+
+ Active session +
+
+
+ Not included +
+
+ + {/* Summary */} +
+
+ {activeSessions.length} active sessions + + {activeSessions.filter(s => s.type === 'race' || s.type === 'sprint').length} race{activeSessions.filter(s => s.type === 'race' || s.type === 'sprint').length > 1 ? 's' : ''} +
+
+ + + {Math.floor(totalDuration / 60)}h {totalDuration % 60}m + +
+
+
+ ); +} + +/** Full Year Calendar View */ +function YearCalendarPreview({ + weekdays, + frequency, + rounds, + startDate, + endDate, +}: { + weekdays: Weekday[]; + frequency: RecurrenceStrategy; + rounds: number; + startDate?: string; + endDate?: string; +}) { + // JavaScript getDay(): 0=Sunday, 1=Monday, 2=Tuesday, etc. + const dayMap: Record = { + 'Sun': 0, 'Mon': 1, 'Tue': 2, 'Wed': 3, 'Thu': 4, 'Fri': 5, 'Sat': 6 + }; + + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + + // 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]); + + // Calculate season stats + const firstRace = raceDates[0]; + const lastRace = raceDates[raceDates.length - 1]; + const seasonDurationWeeks = firstRace && lastRace + ? Math.ceil((lastRace.getTime() - firstRace.getTime()) / (7 * 24 * 60 * 60 * 1000)) + : 0; + + return ( +
+
+
+ + Season Calendar +
+ + {raceDates.length} race{raceDates.length !== 1 ? 's' : ''} scheduled + +
+ + {/* Year grid - 3 columns x 4 rows */} +
+ {yearView.map(({ month, monthIndex, year, days }) => { + const hasRaces = days.some(d => d.isRace); + const raceCount = days.filter(d => d.isRace).length; + const uniqueKey = `${year}-${monthIndex}`; + + return ( +
+
+ + {month} {year !== new Date().getFullYear() && {year}} + + {raceCount > 0 && ( + + {raceCount} + + )} +
+ + {/* Mini calendar grid */} +
+ {/* Fill empty days at start - getDay() returns 0 for Sunday, we want Monday first */} + {Array.from({ length: (new Date(year, monthIndex, 1).getDay() + 6) % 7 }).map((_, i) => ( +
+ ))} + + {days.map(({ dayOfMonth, isRace, isStart, isEnd, raceNumber }) => ( +
+ ))} +
+
+ ); + })} +
+ + {/* Season summary */} +
+
+
{rounds}
+
Rounds
+
+
+
{seasonDurationWeeks || '—'}
+
Weeks
+
+
+
+ {firstRace ? `${months[firstRace.getMonth()]}–${months[lastRace?.getMonth() ?? 0]}` : '—'} +
+
Duration
+
+
+ + {/* Legend */} +
+
+
+ Start +
+
+
+ Race +
+ {seasonEnd && ( +
+
+ End +
+ )} +
+
+ No race +
+
+
+ ); +} + +/** Season Stats Overview */ +function SeasonStatsPreview({ + rounds, + weekdays, + frequency, + weekendTemplate, + practiceMin, + qualifyingMin, + sprintMin, + mainRaceMin, +}: { + rounds: number; + weekdays: Weekday[]; + frequency: RecurrenceStrategy; + weekendTemplate: string; + practiceMin: number; + qualifyingMin: number; + sprintMin?: number; + mainRaceMin: number; +}) { + const hasSprint = weekendTemplate === 'sprintFeature'; + const sessionsPerRound = 2 + (hasSprint ? 2 : 1); // practice + quali + race(s) + const totalSessions = rounds * sessionsPerRound; + const raceMinutesPerRound = (hasSprint ? (sprintMin ?? 0) : 0) + mainRaceMin; + const totalRaceMinutes = rounds * raceMinutesPerRound; + const totalMinutesPerRound = practiceMin + qualifyingMin + raceMinutesPerRound; + + // Estimate season duration + const racesPerWeek = frequency === 'weekly' ? weekdays.length : weekdays.length / 2; + const weeksNeeded = Math.ceil(rounds / Math.max(racesPerWeek, 0.5)); + + return ( +
+
+ + Season Statistics +
+ + {/* Visual rounds */} +
+
+ {Array.from({ length: Math.min(rounds, 20) }).map((_, i) => ( +
+ {i + 1} +
+ ))} + {rounds > 20 && ( + +{rounds - 20} + )} +
+
+
+
+ Season start +
+
+
+ Finale +
+
+
+ + {/* Stats grid */} +
+
+
{totalSessions}
+
Total sessions
+
+
+
{Math.round(totalRaceMinutes / 60)}h
+
Racing time
+
+
+
~{weeksNeeded}
+
Weeks duration
+
+
+
{totalMinutesPerRound}
+
min/race day
+
+
+
+ ); +} + +// ============================================================================ +// INLINE EDITABLE ROUNDS COMPONENT +// ============================================================================ + +function InlineEditableRounds({ + value, + onChange, + min, + max, +}: { + value: number; + onChange: (value: number) => void; + min: number; + max: number; +}) { + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(value.toString()); + const inputRef = useRef(null); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + const handleSubmit = () => { + const num = parseInt(editValue, 10); + if (!isNaN(num) && num >= min && num <= max) { + onChange(num); + } else { + setEditValue(value.toString()); + } + setIsEditing(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSubmit(); + } else if (e.key === 'Escape') { + setEditValue(value.toString()); + setIsEditing(false); + } + }; + + return ( +
+ {isEditing ? ( + setEditValue(e.target.value)} + onBlur={handleSubmit} + onKeyDown={handleKeyDown} + className="w-20 text-4xl font-bold text-center text-white bg-transparent border-b-2 border-primary-blue outline-none" + /> + ) : ( + + )} +
+
rounds
+
Click to edit
+
+
+ + +
+
+ ); +} + +// ============================================================================ +// COLLAPSIBLE SECTION COMPONENT +// ============================================================================ + +interface CollapsibleSectionProps { + icon: React.ReactNode; + title: string; + description?: string; + defaultOpen?: boolean; + children: React.ReactNode; +} + +function CollapsibleSection({ + icon, + title, + description, + defaultOpen = false, + children, +}: CollapsibleSectionProps) { + const [isOpen, setIsOpen] = useState(defaultOpen); + + return ( +
+ +
+
+ {children} +
+
+
+ ); +} + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ + export function LeagueTimingsSection({ form, onChange, readOnly, errors, - title, - weekendTemplate, - onWeekendTemplateChange, }: LeagueTimingsSectionProps) { const disabled = readOnly || !onChange; const timings = form.timings; - const [schedulePreview, setSchedulePreview] = - useState(null); - const [schedulePreviewError, setSchedulePreviewError] = useState( - null, - ); - const [isSchedulePreviewLoading, setIsSchedulePreviewLoading] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); + const [previewTab, setPreviewTab] = useState<'day' | 'year' | 'stats'>('day'); const updateTimings = ( patch: Partial>, @@ -69,22 +841,14 @@ export function LeagueTimingsSection({ }); }; - const handleRoundsChange = (value: string) => { + const handleRoundsChange = (value: number) => { if (!onChange) return; - const trimmed = value.trim(); - if (trimmed === '') { - updateTimings({ roundsPlanned: undefined }); - return; - } - const parsed = parseInt(trimmed, 10); - updateTimings({ - roundsPlanned: Number.isNaN(parsed) || parsed <= 0 ? undefined : parsed, - }); + updateTimings({ roundsPlanned: value }); }; + // Show sprint race field if sprintRaceMinutes is set (will be derived from scoring pattern) const showSprint = - form.scoring.patternId === 'sprint-main-driver' || - (typeof timings.sprintRaceMinutes === 'number' && timings.sprintRaceMinutes > 0); + typeof timings.sprintRaceMinutes === 'number' && timings.sprintRaceMinutes > 0; const recurrenceStrategy: RecurrenceStrategy = timings.recurrenceStrategy ?? 'weekly'; @@ -98,651 +862,403 @@ export function LeagueTimingsSection({ } else { current.add(day); } - updateTimings({ weekdays: Array.from(current).sort() }); + updateTimings({ weekdays: Array.from(current) }); }; - const handleRecurrenceChange = (value: string) => { - updateTimings({ - recurrenceStrategy: value as RecurrenceStrategy, - }); - }; - - const requiresWeekdaySelection = - (recurrenceStrategy === 'weekly' || recurrenceStrategy === 'everyNWeeks') && - weekdays.length === 0; - - useEffect(() => { - if (!timings) return; - - const { - seasonStartDate, - raceStartTime, - timezoneId, - recurrenceStrategy: currentStrategy, - intervalWeeks, - weekdays: currentWeekdays, - monthlyOrdinal, - monthlyWeekday, - roundsPlanned, - } = timings; - - const hasCoreFields = - !!seasonStartDate && - !!raceStartTime && - !!timezoneId && - !!currentStrategy && - typeof roundsPlanned === 'number' && - roundsPlanned > 0; - - if (!hasCoreFields) { - setSchedulePreview(null); - setSchedulePreviewError(null); - setIsSchedulePreviewLoading(false); - return; - } - - if ( - (currentStrategy === 'weekly' || currentStrategy === 'everyNWeeks') && - (!currentWeekdays || currentWeekdays.length === 0) - ) { - setSchedulePreview(null); - setSchedulePreviewError(null); - setIsSchedulePreviewLoading(false); - return; - } - - const controller = new AbortController(); - const timeoutId = setTimeout(async () => { - try { - setIsSchedulePreviewLoading(true); - setSchedulePreviewError(null); - - const payload = { - seasonStartDate, - raceStartTime, - timezoneId, - recurrenceStrategy: currentStrategy, - intervalWeeks, - weekdays: currentWeekdays, - monthlyOrdinal, - monthlyWeekday, - plannedRounds: roundsPlanned, - }; - - const response = await fetch('/api/leagues/schedule-preview', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - signal: controller.signal, - }); - - if (!response.ok) { - const message = - response.status === 400 - ? 'Could not compute schedule with current values.' - : 'Failed to load schedule preview.'; - setSchedulePreviewError(message); - return; - } - - const data = (await response.json()) as LeagueSchedulePreviewDTO; - setSchedulePreview(data); - } catch (err) { - if ((err as any).name === 'AbortError') { - return; - } - setSchedulePreviewError('Could not compute schedule with current values.'); - } finally { - setIsSchedulePreviewLoading(false); - } - }, 400); - - return () => { - clearTimeout(timeoutId); - controller.abort(); - }; - }, [ - timings?.seasonStartDate, - timings?.raceStartTime, - timings?.timezoneId, - timings?.recurrenceStrategy, - timings?.intervalWeeks, - timings?.monthlyOrdinal, - timings?.monthlyWeekday, - timings?.roundsPlanned, - // eslint-disable-next-line react-hooks/exhaustive-deps - JSON.stringify(timings?.weekdays ?? []), - ]); - + // Read-only view if (disabled) { return (
- - {title ?? 'Schedule & timings'} - - +
Schedule & timings
Planned rounds:{' '} {timings.roundsPlanned ?? '—'}
- -
-
- Qualifying:{' '} - {timings.qualifyingMinutes} min -
-
- Main race:{' '} - {timings.mainRaceMinutes} min -
-
- -
-
- Practice:{' '} - - {typeof timings.practiceMinutes === 'number' - ? `${timings.practiceMinutes} min` - : '—'} - -
- {showSprint && ( -
- Sprint:{' '} - - {typeof timings.sprintRaceMinutes === 'number' - ? `${timings.sprintRaceMinutes} min` - : '—'} - -
- )} -
- -
- Sessions per weekend:{' '} - {timings.sessionCount} -
- -

- Used for planning and hints; alpha-only and not yet fully wired into - race scheduling. -

); } - const handleNumericMinutesChange = ( - field: - | 'practiceMinutes' - | 'qualifyingMinutes' - | 'sprintRaceMinutes' - | 'mainRaceMinutes', - raw: string, - ) => { - if (!onChange) return; - const trimmed = raw.trim(); - if (trimmed === '') { - updateTimings({ [field]: undefined } as Partial< - NonNullable - >); - return; - } - const parsed = parseInt(trimmed, 10); - updateTimings({ - [field]: - Number.isNaN(parsed) || parsed <= 0 ? undefined : parsed, - } as Partial>); - }; - - const handleSessionCountChange = (raw: string) => { - const trimmed = raw.trim(); - if (trimmed === '') { - updateTimings({ sessionCount: 1 }); - return; - } - const parsed = parseInt(trimmed, 10); - updateTimings({ - sessionCount: Number.isNaN(parsed) || parsed <= 0 ? 1 : parsed, - }); - }; - - const weekendTemplateValue = weekendTemplate ?? ''; + 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 ( -
- {/* Step intro */} -
-
- -

- Quick setup: Pick a weekend template and season length. The detailed schedule configuration is optional—you can set it now or schedule races manually later. -

-
-
- - {/* 1. Weekend template - FIRST */} -
-
- -

- 1. Choose your race weekend format -

-
-

- This determines session counts and sets sensible duration defaults -

- -
- - {/* 2. Season length */} -
-
- -

- 2. How many race rounds? -

-
-
- handleRoundsChange(e.target.value)} - min={1} - error={!!errors?.roundsPlanned} - errorMessage={errors?.roundsPlanned} - className="w-32" - placeholder="e.g., 10" - /> -
- - - +
+ {/* LEFT COLUMN: Configuration */} +
+ {/* Session Durations - Collapsible */} + } + title="Session Durations" + description="Configure practice, qualifying, and race lengths" + defaultOpen={false} + > +
+ updateTimings({ practiceMinutes: v })} + unitLabel="min" + compact + /> + updateTimings({ qualifyingMinutes: v })} + unitLabel="min" + compact + error={errors?.qualifyingMinutes} + /> + {showSprint && ( + updateTimings({ sprintRaceMinutes: v })} + unitLabel="min" + compact + /> + )} + updateTimings({ mainRaceMinutes: v })} + unitLabel="min" + compact + error={errors?.mainRaceMinutes} + />
-

- Used for drop rule suggestions. Can be approximate—you can always add or remove rounds. -

-
-
+ - {/* 3. Optional: Detailed schedule */} -
-
-
-
-
- -

- 3. Automatic schedule (optional) -

-
-

- Configure when races happen automatically, or skip this and schedule rounds manually later -

+ {/* Season Length - Collapsible */} + } + title="Season Length" + description={`${timings.roundsPlanned ?? 8} rounds planned`} + defaultOpen={false} + > + +

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

+
+ + {/* Race Schedule - Collapsible */} + } + title="Race Schedule" + description="Weekly/bi-weekly and race days" + defaultOpen={false} + > + {/* Frequency */} +
+ +
+ {[ + { id: 'weekly', label: 'Weekly' }, + { id: 'everyNWeeks', label: 'Every 2 weeks' }, + ].map((opt) => { + const isSelected = + (opt.id === 'weekly' && recurrenceStrategy === 'weekly') || + (opt.id === 'everyNWeeks' && recurrenceStrategy === 'everyNWeeks'); + return ( + + ); + })}
+ {/* Day selection */} +
+
+ + {weekdays.length === 0 && ( + Select at least one + )} +
+ +
+ {allWeekdays.map(({ day, short }) => { + const isSelected = weekdays.includes(day); + return ( + + ); + })} +
+
+
+ + {/* Season Duration - Collapsible */} + } + title="Season Duration" + description="Start/end dates and time settings" + defaultOpen={false} + >
-
-
- -
+
+
+
- -
- -
- - updateTimings({ raceStartTime: e.target.value || undefined }) - } - /> -
-

- Local time in your league's timezone. -

-
- -
- -
- -
-
-
- -
- - - - {recurrenceStrategy === 'everyNWeeks' && ( -
- Every - { - const raw = e.target.value.trim(); - if (raw === '') { - updateTimings({ intervalWeeks: undefined }); - return; - } - const parsed = parseInt(raw, 10); - updateTimings({ - intervalWeeks: - Number.isNaN(parsed) || parsed <= 0 ? 2 : parsed, - }); - }} - className="w-20" - /> - weeks -
- )} -
- -
-
-
- - {/* Schedule preview */} - {(timings.seasonStartDate && timings.raceStartTime && weekdays.length > 0) && ( -
-
-
- -
-

- Schedule preview -

-

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

-
-
- {isSchedulePreviewLoading && ( - - )} -
- -
- {schedulePreviewError && ( -
- ⚠️ - {schedulePreviewError} -
- )} - - {!schedulePreview && !schedulePreviewError && ( -

- - Adjust the fields above to see a preview of your calendar. + + {timings.seasonStartDate && timings.seasonEndDate && ( +

+ +

+ Races will be evenly distributed between start and end dates on your selected race days.

- )} - - {schedulePreview && ( -
-

- First few rounds with your current settings: -

-
- {schedulePreview.rounds.map((round) => { - const date = new Date(round.scheduledAt); - const dateStr = date.toLocaleDateString(undefined, { - weekday: 'short', - day: 'numeric', - month: 'short', - }); - const timeStr = date.toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - }); - - return ( -
- - Round {round.roundNumber} - - - {dateStr}, {timeStr}{' '} - - {round.timezoneId} - - -
- ); - })} - - {typeof timings.roundsPlanned === 'number' && - timings.roundsPlanned > schedulePreview.rounds.length && ( -

- + {timings.roundsPlanned - schedulePreview.rounds.length} more rounds scheduled -

- )} -
-
- )} -
-
-
- )} -
-
- - {/* 4. Optional: Session duration overrides */} -
- -
-
- -

- 4. Customize session durations (optional) -

-
- - Click to customize - -
-
- -
-

- Your weekend template already set reasonable defaults. Only change these if you need specific timings. -

- -
-
- - 0 - ? String(timings.practiceMinutes) - : '' - } - onChange={(e) => handleNumericMinutesChange('practiceMinutes', e.target.value)} - min={0} - className="w-24" - placeholder="20" - /> -
- -
- - 0 - ? String(timings.qualifyingMinutes) - : '' - } - onChange={(e) => handleNumericMinutesChange('qualifyingMinutes', e.target.value)} - min={5} - error={!!errors?.qualifyingMinutes} - errorMessage={errors?.qualifyingMinutes} - className="w-24" - placeholder="30" - /> -
- - {showSprint && ( -
- - 0 - ? String(timings.sprintRaceMinutes) - : '' - } - onChange={(e) => handleNumericMinutesChange('sprintRaceMinutes', e.target.value)} - min={0} - className="w-24" - placeholder="20" - />
)} -
- +
+ 0 - ? String(timings.mainRaceMinutes) - : '' - } - onChange={(e) => handleNumericMinutesChange('mainRaceMinutes', e.target.value)} - min={10} - error={!!errors?.mainRaceMinutes} - errorMessage={errors?.mainRaceMinutes} - className="w-24" - placeholder="40" + type="time" + value={timings.raceStartTime ?? '20:00'} + onChange={(e) => updateTimings({ raceStartTime: e.target.value || undefined })} + className="bg-iron-gray/30" />
+ + {/* Time Zone Selector */} +
+ +
+ {TIME_ZONES.slice(0, 2).map((tz) => { + const isSelected = (timings.timezoneId ?? 'UTC') === tz.value; + const Icon = tz.icon; + return ( + + ); + })} +
+ + {/* More time zones - expandable */} + + + {showAdvanced && ( +
+ {TIME_ZONES.slice(2).map((tz) => { + const isSelected = (timings.timezoneId ?? 'UTC') === tz.value; + const Icon = tz.icon; + return ( + + ); + })} +
+ )} +
+ +
+ +

+ {(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.' + } +

+
+
+ +
+ + {/* RIGHT COLUMN: Live Preview */} +
+
+
+ {/* Preview header with tabs */} +
+
+ + Preview +
+
+ {[ + { id: 'day', label: 'Race Day', icon: Play }, + { id: 'year', label: 'Calendar', icon: CalendarRange }, + { id: 'stats', label: 'Stats', icon: Trophy }, + ].map((tab) => ( + + ))} +
+
+ + {/* Preview content */} +
+ {previewTab === 'day' && ( + + )} + + {previewTab === 'year' && ( + + )} + + {previewTab === 'stats' && ( + + )} +
+
+ + {/* Helper tip */} +
+ +

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

-
+
); } \ No newline at end of file diff --git a/apps/website/components/ui/RangeField.tsx b/apps/website/components/ui/RangeField.tsx index 3ed9f3a3b..ab090527f 100644 --- a/apps/website/components/ui/RangeField.tsx +++ b/apps/website/components/ui/RangeField.tsx @@ -1,6 +1,6 @@ 'use client'; -import Input from '@/components/ui/Input'; +import { useCallback, useRef, useState, useEffect } from 'react'; interface RangeFieldProps { label: string; @@ -12,14 +12,12 @@ interface RangeFieldProps { helperText?: string; error?: string; disabled?: boolean; - /** - * Optional unit label, defaults to "min". - */ unitLabel?: string; - /** - * Optional override for the right-hand range hint. - */ rangeHint?: string; + /** Show large value display above slider */ + showLargeValue?: boolean; + /** Compact mode - single line */ + compact?: boolean; } export default function RangeField({ @@ -34,97 +32,240 @@ export default function RangeField({ disabled, unitLabel = 'min', rangeHint, + showLargeValue = false, + compact = false, }: RangeFieldProps) { - const clampedValue = Number.isFinite(value) - ? Math.min(Math.max(value, min), max) + const [localValue, setLocalValue] = useState(value); + const [isDragging, setIsDragging] = useState(false); + const sliderRef = useRef(null); + const inputRef = useRef(null); + + // Sync local value with prop when not dragging + useEffect(() => { + if (!isDragging) { + setLocalValue(value); + } + }, [value, isDragging]); + + const clampedValue = Number.isFinite(localValue) + ? Math.min(Math.max(localValue, min), max) : min; - const handleSliderChange = (raw: string) => { - const parsed = parseInt(raw, 10); - if (Number.isNaN(parsed)) { - return; - } - const next = Math.min(Math.max(parsed, min), max); - onChange(next); - }; - - const handleNumberChange = (raw: string) => { - if (raw.trim() === '') { - // Allow the field to clear without jumping the slider; - // keep the previous value until the user types a number. - return; - } - const parsed = parseInt(raw, 10); - if (Number.isNaN(parsed)) { - return; - } - const next = Math.min(Math.max(parsed, min), max); - onChange(next); - }; - - const rangePercent = - ((clampedValue - min) / Math.max(max - min, 1)) * 100; + const rangePercent = ((clampedValue - min) / Math.max(max - min, 1)) * 100; const effectiveRangeHint = - rangeHint ?? - (min === 0 - ? `Up to ${max} ${unitLabel}` - : `${min}–${max} ${unitLabel}`); + rangeHint ?? (min === 0 ? `Up to ${max} ${unitLabel}` : `${min}–${max} ${unitLabel}`); + + const calculateValueFromPosition = useCallback( + (clientX: number) => { + if (!sliderRef.current) return clampedValue; + const rect = sliderRef.current.getBoundingClientRect(); + const percent = Math.min(Math.max((clientX - rect.left) / rect.width, 0), 1); + const rawValue = min + percent * (max - min); + const steppedValue = Math.round(rawValue / step) * step; + return Math.min(Math.max(steppedValue, min), max); + }, + [min, max, step, clampedValue] + ); + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + if (disabled) return; + e.preventDefault(); + setIsDragging(true); + const newValue = calculateValueFromPosition(e.clientX); + setLocalValue(newValue); + onChange(newValue); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + }, + [disabled, calculateValueFromPosition, onChange] + ); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!isDragging || disabled) return; + const newValue = calculateValueFromPosition(e.clientX); + setLocalValue(newValue); + onChange(newValue); + }, + [isDragging, disabled, calculateValueFromPosition, onChange] + ); + + const handlePointerUp = useCallback(() => { + setIsDragging(false); + }, []); + + const handleInputChange = (e: React.ChangeEvent) => { + const raw = e.target.value; + if (raw === '') { + setLocalValue(min); + return; + } + const parsed = parseInt(raw, 10); + if (!Number.isNaN(parsed)) { + const clamped = Math.min(Math.max(parsed, min), max); + setLocalValue(clamped); + onChange(clamped); + } + }; + + const handleInputBlur = () => { + // Ensure value is synced on blur + onChange(clampedValue); + }; + + // Quick preset buttons for common values + const quickPresets = [ + Math.round(min + (max - min) * 0.25), + Math.round(min + (max - min) * 0.5), + Math.round(min + (max - min) * 0.75), + ].filter((v, i, arr) => arr.indexOf(v) === i && v !== clampedValue); + + if (compact) { + return ( +
+
+ +
+
+ {/* Track background */} +
+ {/* Track fill */} +
+ {/* Thumb */} +
+
+
+ {clampedValue} + {unitLabel} +
+
+
+ {error &&

{error}

} +
+ ); + } return ( -
+
- -

- {effectiveRangeHint} -

+ + {effectiveRangeHint}
-
-
-
-
+ {showLargeValue && ( +
+ {clampedValue} + {unitLabel} +
+ )} + + {/* Custom slider */} +
+ {/* Track background */} +
+ + {/* Track fill with gradient */} +
+ + {/* Tick marks */} +
+ {[0, 25, 50, 75, 100].map((tick) => ( +
= tick ? 'bg-white/40' : 'bg-charcoal-outline' + }`} + /> + ))} +
+ + {/* Thumb */} +
+
+ + {/* Value input and quick presets */} +
+
handleSliderChange(e.target.value)} + onChange={handleInputChange} + onBlur={handleInputBlur} disabled={disabled} - className="relative z-10 h-2 w-full appearance-none bg-transparent focus:outline-none accent-primary-blue" + className={` + w-16 px-2 py-1.5 text-sm font-medium text-center rounded-lg + bg-iron-gray border border-charcoal-outline text-white + focus:border-primary-blue focus:ring-1 focus:ring-primary-blue focus:outline-none + transition-colors + ${error ? 'border-warning-amber' : ''} + ${disabled ? 'opacity-50 cursor-not-allowed' : ''} + `} /> -
- -
-
- handleNumberChange(e.target.value)} - min={min} - max={max} - step={step} - disabled={disabled} - className="px-3 py-2 text-sm" - error={!!error} - /> -
{unitLabel}
+ + {quickPresets.length > 0 && ( +
+ {quickPresets.slice(0, 3).map((preset) => ( + + ))} +
+ )}
- {helperText && ( -

{helperText}

- )} - {error && ( -

{error}

- )} + {helperText &&

{helperText}

} + {error &&

{error}

}
); } \ No newline at end of file diff --git a/packages/racing/application/dto/LeagueConfigFormDTO.ts b/packages/racing/application/dto/LeagueConfigFormDTO.ts index c23f6d95e..ada083bc5 100644 --- a/packages/racing/application/dto/LeagueConfigFormDTO.ts +++ b/packages/racing/application/dto/LeagueConfigFormDTO.ts @@ -35,8 +35,9 @@ export interface LeagueTimingsFormDTO { roundsPlanned?: number; seasonStartDate?: string; // ISO date YYYY-MM-DD + seasonEndDate?: string; // ISO date YYYY-MM-DD raceStartTime?: string; // "HH:MM" 24h - timezoneId?: string; // IANA ID, e.g. "Europe/Berlin" + timezoneId?: string; // IANA ID, e.g. "Europe/Berlin", or "track" for track local time recurrenceStrategy?: 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday'; intervalWeeks?: number; weekdays?: import('../../domain/value-objects/Weekday').Weekday[];