This commit is contained in:
2025-12-05 12:24:38 +01:00
parent fb509607c1
commit 5a9cd28d5b
47 changed files with 5456 additions and 228 deletions

View File

@@ -0,0 +1,751 @@
'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>
);
}