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

751 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useState } from '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';
type RecurrenceStrategy = NonNullable<LeagueConfigFormModel['timings']>['recurrenceStrategy'];
interface LeagueTimingsSectionProps {
form: LeagueConfigFormModel;
onChange?: (form: LeagueConfigFormModel) => void;
readOnly?: boolean;
errors?: {
qualifyingMinutes?: string;
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;
}
export function LeagueTimingsSection({
form,
onChange,
readOnly,
errors,
title,
weekendTemplate,
onWeekendTemplateChange,
}: LeagueTimingsSectionProps) {
const disabled = readOnly || !onChange;
const timings = form.timings;
const [schedulePreview, setSchedulePreview] =
useState<LeagueSchedulePreviewDTO | null>(null);
const [schedulePreviewError, setSchedulePreviewError] = useState<string | null>(
null,
);
const [isSchedulePreviewLoading, setIsSchedulePreviewLoading] = useState(false);
const updateTimings = (
patch: Partial<NonNullable<LeagueConfigFormModel['timings']>>,
) => {
if (!onChange) return;
onChange({
...form,
timings: {
...timings,
...patch,
},
});
};
const handleRoundsChange = (value: string) => {
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,
});
};
const showSprint =
form.scoring.patternId === 'sprint-main-driver' ||
(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).sort() });
};
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 ?? []),
]);
if (disabled) {
return (
<div className="space-y-4">
<Heading level={3} className="text-lg font-semibold text-white">
{title ?? 'Schedule & timings'}
</Heading>
<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 className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<span className="font-medium text-gray-200">Qualifying:</span>{' '}
<span>{timings.qualifyingMinutes} min</span>
</div>
<div>
<span className="font-medium text-gray-200">Main race:</span>{' '}
<span>{timings.mainRaceMinutes} min</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<span className="font-medium text-gray-200">Practice:</span>{' '}
<span>
{typeof timings.practiceMinutes === 'number'
? `${timings.practiceMinutes} min`
: '—'}
</span>
</div>
{showSprint && (
<div>
<span className="font-medium text-gray-200">Sprint:</span>{' '}
<span>
{typeof timings.sprintRaceMinutes === 'number'
? `${timings.sprintRaceMinutes} min`
: '—'}
</span>
</div>
)}
</div>
<div>
<span className="font-medium text-gray-200">Sessions per weekend:</span>{' '}
<span>{timings.sessionCount}</span>
</div>
<p className="text-xs text-gray-500">
Used for planning and hints; alpha-only and not yet fully wired into
race scheduling.
</p>
</div>
</div>
);
}
const handleNumericMinutesChange = (
field:
| 'practiceMinutes'
| 'qualifyingMinutes'
| 'sprintRaceMinutes'
| 'mainRaceMinutes',
raw: string,
) => {
if (!onChange) return;
const trimmed = raw.trim();
if (trimmed === '') {
updateTimings({ [field]: undefined } as Partial<
NonNullable<LeagueConfigFormModel['timings']>
>);
return;
}
const parsed = parseInt(trimmed, 10);
updateTimings({
[field]:
Number.isNaN(parsed) || parsed <= 0 ? undefined : parsed,
} as Partial<NonNullable<LeagueConfigFormModel['timings']>>);
};
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 ?? '';
return (
<div className="space-y-6">
<Heading level={3} className="text-lg font-semibold text-white">
{title ?? 'Schedule & timings'}
</Heading>
<div className="space-y-6">
{/* Season length block */}
<section className="space-y-3">
<h4 className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Season length
</h4>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Planned rounds
</label>
<div className="w-24">
<Input
type="number"
value={
typeof timings.roundsPlanned === 'number'
? String(timings.roundsPlanned)
: ''
}
onChange={(e) => handleRoundsChange(e.target.value)}
min={1}
error={!!errors?.roundsPlanned}
errorMessage={errors?.roundsPlanned}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Used for planning and drop hints; can be approximate.
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Sessions per weekend
</label>
<div className="w-24">
<Input
type="number"
value={String(timings.sessionCount)}
onChange={(e) => handleSessionCountChange(e.target.value)}
min={1}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Typically 1 for feature-only; 2 for sprint + feature.
</p>
</div>
</div>
</section>
{/* Race schedule block */}
<section className="space-y-4">
<h4 className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Race schedule
</h4>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Season start date
</label>
<div className="max-w-xs">
<Input
type="date"
value={timings.seasonStartDate ?? ''}
onChange={(e) =>
updateTimings({ seasonStartDate: e.target.value || undefined })
}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Round 1 will use this date; later rounds follow your pattern.
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Race start time
</label>
<div className="max-w-xs">
<Input
type="time"
value={timings.raceStartTime ?? ''}
onChange={(e) =>
updateTimings({ raceStartTime: e.target.value || undefined })
}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Local time in your league's timezone.
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Timezone
</label>
<div className="max-w-xs">
<select
className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm sm:leading-6"
value={timings.timezoneId ?? 'Europe/Berlin'}
onChange={(e) =>
updateTimings({ timezoneId: e.target.value || undefined })
}
>
<option value="Europe/Berlin">Europe/Berlin</option>
<option value="Europe/London">Europe/London</option>
<option value="UTC">UTC</option>
<option value="America/New_York">America/New_York</option>
<option value="America/Los_Angeles">America/Los_Angeles</option>
</select>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] items-start">
<div className="space-y-3">
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Cadence
</label>
<SegmentedControl
options={[
{ value: 'weekly', label: 'Weekly' },
{ value: 'everyNWeeks', label: 'Every N weeks' },
{
value: 'monthlyNthWeekday',
label: 'Monthly (beta)',
disabled: true,
},
]}
value={recurrenceStrategy}
onChange={handleRecurrenceChange}
/>
</div>
{recurrenceStrategy === 'everyNWeeks' && (
<div className="flex items-center gap-2 text-sm text-gray-300">
<span>Every</span>
<div className="w-20">
<Input
type="number"
min={1}
value={
typeof timings.intervalWeeks === 'number'
? String(timings.intervalWeeks)
: '2'
}
onChange={(e) => {
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,
});
}}
/>
</div>
<span>weeks</span>
</div>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<label className="block text-sm font-medium text-gray-300">
Race days in a week
</label>
{requiresWeekdaySelection && (
<span className="text-[11px] text-warning-amber">
Select at least one weekday.
</span>
)}
</div>
<div className="flex flex-wrap gap-2">
{(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as Weekday[]).map(
(day) => {
const isActive = weekdays.includes(day);
return (
<button
key={day}
type="button"
onClick={() => handleWeekdayToggle(day)}
className={`px-3 py-1 text-xs font-medium rounded-full border transition-colors ${
isActive
? 'bg-primary-blue text-white border-primary-blue'
: 'bg-iron-gray/80 text-gray-300 border-charcoal-outline hover:bg-charcoal-outline/80 hover:text-white'
}`}
>
{day}
</button>
);
},
)}
</div>
</div>
</div>
<div className="space-y-2 rounded-md border border-charcoal-outline bg-iron-gray/40 p-3">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-xs font-medium text-gray-200">
Schedule summary
</p>
<p className="text-xs text-gray-400">
{schedulePreview?.summary ??
'Set a start date, time, and at least one weekday to preview the schedule.'}
</p>
</div>
{isSchedulePreviewLoading && (
<span className="text-[11px] text-gray-400">Updating…</span>
)}
</div>
<div className="mt-2 space-y-1">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-gray-300">
Schedule preview
</p>
<p className="text-[11px] text-gray-500">
First few rounds with your current settings.
</p>
</div>
{schedulePreviewError && (
<p className="text-[11px] text-warning-amber">
{schedulePreviewError}
</p>
)}
{!schedulePreview && !schedulePreviewError && (
<p className="text-[11px] text-gray-500">
Adjust the fields above to see a preview of your calendar.
</p>
)}
{schedulePreview && (
<div className="mt-1 space-y-1.5 text-xs text-gray-200">
{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 (
<div
key={round.roundNumber}
className="flex items-center justify-between gap-2"
>
<span className="text-gray-300">
Round {round.roundNumber}
</span>
<span className="text-gray-200">
{dateStr}, {timeStr}{' '}
<span className="text-gray-500">
{round.timezoneId}
</span>
</span>
</div>
);
})}
{typeof timings.roundsPlanned === 'number' &&
timings.roundsPlanned > schedulePreview.rounds.length && (
<p className="pt-1 text-[11px] text-gray-500">
+
{timings.roundsPlanned - schedulePreview.rounds.length}{' '}
more rounds scheduled.
</p>
)}
</div>
)}
</div>
</div>
</section>
{/* Weekend template block */}
<section className="space-y-3">
<h4 className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Weekend template
</h4>
<p className="text-xs text-gray-500">
Pick a typical weekend; you can fine-tune durations below.
</p>
<SegmentedControl
options={[
{ value: 'feature', label: 'Feature only' },
{ value: 'sprintFeature', label: 'Sprint + feature' },
{ value: 'endurance', label: 'Endurance' },
]}
value={weekendTemplateValue}
onChange={onWeekendTemplateChange}
/>
<p className="text-[11px] text-gray-500">
Templates set starting values only; you can override any number.
</p>
</section>
{/* Session durations block */}
<section className="space-y-3">
<h4 className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Session durations
</h4>
<p className="text-xs text-gray-500">
Rough lengths for each session type; used for planning only.
</p>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Practice duration (optional)
</label>
<div className="w-24">
<Input
type="number"
value={
typeof timings.practiceMinutes === 'number' &&
timings.practiceMinutes > 0
? String(timings.practiceMinutes)
: ''
}
onChange={(e) =>
handleNumericMinutesChange(
'practiceMinutes',
e.target.value,
)
}
min={0}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Set to 0 or leave empty if you dont plan dedicated practice.
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Qualifying duration
</label>
<div className="w-24">
<Input
type="number"
value={
typeof timings.qualifyingMinutes === 'number' &&
timings.qualifyingMinutes > 0
? String(timings.qualifyingMinutes)
: ''
}
onChange={(e) =>
handleNumericMinutesChange(
'qualifyingMinutes',
e.target.value,
)
}
min={5}
error={!!errors?.qualifyingMinutes}
errorMessage={errors?.qualifyingMinutes}
/>
</div>
</div>
{showSprint && (
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Sprint duration
</label>
<div className="w-24">
<Input
type="number"
value={
typeof timings.sprintRaceMinutes === 'number' &&
timings.sprintRaceMinutes > 0
? String(timings.sprintRaceMinutes)
: ''
}
onChange={(e) =>
handleNumericMinutesChange(
'sprintRaceMinutes',
e.target.value,
)
}
min={0}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Only shown when your scoring pattern includes a sprint race.
</p>
</div>
)}
<div>
<label className="mb-1 block text-sm font-medium text-gray-300">
Main race duration
</label>
<div className="w-24">
<Input
type="number"
value={
typeof timings.mainRaceMinutes === 'number' &&
timings.mainRaceMinutes > 0
? String(timings.mainRaceMinutes)
: ''
}
onChange={(e) =>
handleNumericMinutesChange(
'mainRaceMinutes',
e.target.value,
)
}
min={10}
error={!!errors?.mainRaceMinutes}
errorMessage={errors?.mainRaceMinutes}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Approximate length of your main race.
</p>
</div>
</div>
</section>
</div>
</div>
);
}