feat(frontend): race form, start time selects, calendar views, day page
Some checks failed
CI / build-and-test (pull_request) Has been cancelled

- Hide org schedule fields when editing a past race; isRaceDateInPast helper
- StartTimeSelects (HH:mm:ss) and optional ?date= prefill on new race
- Full-card link to edit for races needing result entry; shadow token
- List/calendar toggle (sessionStorage); year grid and month focus views
- Date hover popover and /races/day/:ymd page with Add button
- Docs plan-korrektirovok-starty.md and startTime API note; client 0.4.0

Made-with: Cursor
This commit is contained in:
Vaka.pro
2026-04-13 22:07:37 +03:00
parent b997dcb01e
commit 3c6baa66a1
16 changed files with 1193 additions and 69 deletions

View File

@@ -0,0 +1,157 @@
import { useCallback, useMemo } from "react";
function pad2(n: number): string {
return String(n).padStart(2, "0");
}
function parseToParts(value: string): { h: number | null; m: number | null; s: number | null } {
const t = value.trim();
if (!t) {
return { h: null, m: null, s: null };
}
const parts = t.split(":").map((p) => p.trim());
if (parts.length === 2) {
const h = Number(parts[0]);
const m = Number(parts[1]);
if (Number.isInteger(h) && Number.isInteger(m) && h >= 0 && h <= 23 && m >= 0 && m <= 59) {
return { h, m, s: 0 };
}
}
if (parts.length >= 3) {
const h = Number(parts[0]);
const m = Number(parts[1]);
const s = Number(parts[2]);
if (
Number.isInteger(h) &&
Number.isInteger(m) &&
Number.isInteger(s) &&
h >= 0 &&
h <= 23 &&
m >= 0 &&
m <= 59 &&
s >= 0 &&
s <= 59
) {
return { h, m, s };
}
}
return { h: null, m: null, s: null };
}
function partsToString(h: number | null, m: number | null, s: number | null): string {
if (h === null || m === null || s === null) {
return "";
}
return `${pad2(h)}:${pad2(m)}:${pad2(s)}`;
}
const HOURS = Array.from({ length: 24 }, (_, i) => i);
const MIN_SEC = Array.from({ length: 60 }, (_, i) => i);
interface StartTimeSelectsProps {
value: string;
onChange: (next: string) => void;
disabled?: boolean;
}
export function StartTimeSelects(props: StartTimeSelectsProps): JSX.Element {
const { value, onChange, disabled } = props;
const { h, m, s } = useMemo(() => parseToParts(value), [value]);
const emit = useCallback(
(nextH: number | null, nextM: number | null, nextS: number | null) => {
onChange(partsToString(nextH, nextM, nextS));
},
[onChange],
);
const hourVal = h === null ? "" : String(h);
const minVal = m === null ? "" : String(m);
const secVal = s === null ? "" : String(s);
return (
<div className="race-form__time-picker">
<label className="race-form__time-picker__unit">
<span className="race-form__time-picker__label">Часы</span>
<select
className="race-form__input race-form__time-picker__select"
aria-label="Часы"
disabled={disabled}
value={hourVal}
onChange={(e) => {
const v = e.target.value;
if (v === "") {
emit(null, null, null);
return;
}
const nh = Number(v);
emit(nh, m ?? 0, s ?? 0);
}}
>
<option value=""></option>
{HOURS.map((n) => (
<option key={n} value={String(n)}>
{pad2(n)}
</option>
))}
</select>
</label>
<span className="race-form__time-picker__sep" aria-hidden>
:
</span>
<label className="race-form__time-picker__unit">
<span className="race-form__time-picker__label">Минуты</span>
<select
className="race-form__input race-form__time-picker__select"
aria-label="Минуты"
disabled={disabled}
value={minVal}
onChange={(e) => {
const v = e.target.value;
if (v === "") {
emit(null, null, null);
return;
}
const nm = Number(v);
emit(h ?? 0, nm, s ?? 0);
}}
>
<option value=""></option>
{MIN_SEC.map((n) => (
<option key={n} value={String(n)}>
{pad2(n)}
</option>
))}
</select>
</label>
<span className="race-form__time-picker__sep" aria-hidden>
:
</span>
<label className="race-form__time-picker__unit">
<span className="race-form__time-picker__label">Секунды</span>
<select
className="race-form__input race-form__time-picker__select"
aria-label="Секунды"
disabled={disabled}
value={secVal}
onChange={(e) => {
const v = e.target.value;
if (v === "") {
emit(null, null, null);
return;
}
const ns = Number(v);
emit(h ?? 0, m ?? 0, ns);
}}
>
<option value=""></option>
{MIN_SEC.map((n) => (
<option key={n} value={String(n)}>
{pad2(n)}
</option>
))}
</select>
</label>
</div>
);
}