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