wip
This commit is contained in:
751
apps/website/components/leagues/LeagueTimingsSection.tsx
Normal file
751
apps/website/components/leagues/LeagueTimingsSection.tsx
Normal 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 don’t 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user