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,248 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import type { Race } from "../api";
import { buildMonthCells, groupRacesByYmd, toYmd, WEEKDAY_LABELS_SHORT_RU } from "../lib";
const MONTH_NAMES_RU_SHORT = [
"янв.",
"февр.",
"мар.",
"апр.",
"мая",
"июн.",
"июл.",
"авг.",
"сент.",
"окт.",
"нояб.",
"дек.",
];
const POPOVER_LEAVE_MS = 140;
interface RacesCalendarProps {
displayYear: number;
monthFilter: string;
races: Race[];
onMonthFilterChange: (value: string) => void;
}
function DayPopover(props: {
ymd: string;
races: Race[];
onCancelClose: () => void;
onScheduleClose: () => void;
}): JSX.Element {
const { ymd, races, onCancelClose, onScheduleClose } = props;
const hasRaces = races.length > 0;
return (
<div
className="races-cal__popover"
role="tooltip"
onMouseEnter={onCancelClose}
onMouseLeave={onScheduleClose}
>
{hasRaces ? (
<ul className="races-cal__popover-list">
{races.map((r) => (
<li key={r.id} className="races-cal__popover-item">
<Link className="races-cal__popover-link" to={`/races/${r.id}`} onClick={onCancelClose}>
{r.title}
</Link>
</li>
))}
</ul>
) : (
<p className="races-cal__popover-empty">Стартов нет</p>
)}
<Link
className="btn btn--secondary races-cal__popover-add"
to={`/races/new?date=${ymd}`}
onClick={onCancelClose}
>
Добавить
</Link>
</div>
);
}
function CalendarMonthBlock(props: {
year: number;
monthIndex: number;
racesByYmd: Map<string, Race[]>;
compact: boolean;
navigate: ReturnType<typeof useNavigate>;
openYmd: string | null;
setOpenYmd: (v: string | null) => void;
scheduleClose: () => void;
cancelClose: () => void;
}): JSX.Element {
const {
year,
monthIndex,
racesByYmd,
compact,
navigate,
openYmd,
setOpenYmd,
scheduleClose,
cancelClose,
} = props;
const cells = useMemo(() => buildMonthCells(year, monthIndex), [year, monthIndex]);
const title = `${MONTH_NAMES_RU_SHORT[monthIndex]} ${year}`;
const blockClass = compact ? "races-cal__month races-cal__month--compact" : "races-cal__month";
return (
<div className={blockClass}>
<h3 className="races-cal__month-title">{title}</h3>
<div className="races-cal__weekdays" aria-hidden>
{WEEKDAY_LABELS_SHORT_RU.map((d) => (
<span key={d} className="races-cal__weekday">
{d}
</span>
))}
</div>
<div className="races-cal__cells">
{cells.map((day, idx) => {
if (day === null) {
return <div key={`e-${idx}`} className="races-cal__cell races-cal__cell--empty" />;
}
const ymd = toYmd(year, monthIndex, day);
const dayRaces = racesByYmd.get(ymd) ?? [];
const hasRaces = dayRaces.length > 0;
const isOpen = openYmd === ymd;
return (
<div
key={ymd}
className={`races-cal__cell${hasRaces ? " races-cal__cell--has-race" : ""}${isOpen ? " races-cal__cell--open" : ""}`}
onMouseEnter={() => {
cancelClose();
setOpenYmd(ymd);
}}
onMouseLeave={scheduleClose}
>
<button
type="button"
className="races-cal__day-btn"
onClick={() => {
navigate(`/races/day/${ymd}`);
}}
onFocus={() => {
cancelClose();
setOpenYmd(ymd);
}}
onBlur={(e) => {
const next = e.relatedTarget as Node | null;
if (next && e.currentTarget.closest(".races-cal__cell")?.contains(next)) {
return;
}
scheduleClose();
}}
>
{day}
</button>
{isOpen ? (
<DayPopover
ymd={ymd}
races={dayRaces}
onCancelClose={cancelClose}
onScheduleClose={scheduleClose}
/>
) : null}
</div>
);
})}
</div>
</div>
);
}
export function RacesCalendar(props: RacesCalendarProps): JSX.Element {
const { displayYear, monthFilter, races, onMonthFilterChange } = props;
const navigate = useNavigate();
const [openYmd, setOpenYmd] = useState<string | null>(null);
const leaveTimerRef = useRef<number | null>(null);
const cancelClose = useCallback(() => {
if (leaveTimerRef.current !== null) {
window.clearTimeout(leaveTimerRef.current);
leaveTimerRef.current = null;
}
}, []);
const scheduleClose = useCallback(() => {
cancelClose();
leaveTimerRef.current = window.setTimeout(() => {
setOpenYmd(null);
leaveTimerRef.current = null;
}, POPOVER_LEAVE_MS);
}, [cancelClose]);
const racesByYmd = useMemo(() => groupRacesByYmd(races), [races]);
const focusedMonthIndex = monthFilter === "" ? null : parseInt(monthFilter, 10) - 1;
return (
<div className="races-cal">
<p className="races-cal__hint">Наведите на дату краткая информация. Клик страница дня.</p>
{focusedMonthIndex === null || Number.isNaN(focusedMonthIndex) ? (
<div className="races-cal__year">
{MONTH_NAMES_RU_SHORT.map((_, mi) => (
<CalendarMonthBlock
key={mi}
year={displayYear}
monthIndex={mi}
racesByYmd={racesByYmd}
compact
navigate={navigate}
openYmd={openYmd}
setOpenYmd={setOpenYmd}
scheduleClose={scheduleClose}
cancelClose={cancelClose}
/>
))}
</div>
) : (
<div className="races-cal__month-focus">
<nav className="races-cal__month-nav" aria-label="Месяцы года">
{MONTH_NAMES_RU_SHORT.map((label, mi) => (
<button
key={label}
type="button"
className={`races-cal__month-nav-item${mi === focusedMonthIndex ? " races-cal__month-nav-item--active" : ""}`}
onClick={() => {
onMonthFilterChange(String(mi + 1));
}}
>
{label}
</button>
))}
<button
type="button"
className="races-cal__month-nav-item races-cal__month-nav-item--all"
onClick={() => {
onMonthFilterChange("");
}}
>
Весь год
</button>
</nav>
<CalendarMonthBlock
year={displayYear}
monthIndex={focusedMonthIndex}
racesByYmd={racesByYmd}
compact={false}
navigate={navigate}
openYmd={openYmd}
setOpenYmd={setOpenYmd}
scheduleClose={scheduleClose}
cancelClose={cancelClose}
/>
</div>
)}
</div>
);
}

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>
);
}

View File

@@ -1 +1,3 @@
export { PaceTrendChart } from "./PaceTrendChart";
export { RacesCalendar } from "./RacesCalendar";
export { StartTimeSelects } from "./StartTimeSelects";