diff --git a/docs/backend-api-for-frontend.md b/docs/backend-api-for-frontend.md
index b8813ea..7b1041b 100644
--- a/docs/backend-api-for-frontend.md
+++ b/docs/backend-api-for-frontend.md
@@ -207,7 +207,7 @@ GET /api/races?year=2026&month=5
| `distanceKm` | number | да | да | Дистанция в км |
| `status` | string \| null | нет | да | `"planned"` / `"registered"` / `"completed"` |
| `officialUrl` | string \| null | нет | да | URL организатора |
-| `startTime` | string \| null | нет | да | Время старта, напр. `"09:30"` |
+| `startTime` | string \| null | нет | да | Время старта, напр. `"09:30"` или `"09:30:00"` (часы:минуты:секунды) |
| `clusterSchedule` | string \| null | нет | да | Расписание кластеров |
| `bibPickup` | string \| null | нет | да | Выдача номеров |
| `bibNumber` | string \| null | нет | да | Стартовый номер |
diff --git a/docs/plan-korrektirovok-starty.md b/docs/plan-korrektirovok-starty.md
new file mode 100644
index 0000000..2678e62
--- /dev/null
+++ b/docs/plan-korrektirovok-starty.md
@@ -0,0 +1,52 @@
+# План корректировок: форма старта, время, календарь стартов
+
+Краткое описание реализованных изменений в клиенте **runners-calendar** (версия клиента см. в футере приложения).
+
+## 1. Форма старта (редактирование прошедшего события)
+
+При **редактировании** старта, чья **дата уже в прошлом**, в блоке «Организация» скрыты поля, неактуальные после забега:
+
+- сайт организатора;
+- время старта;
+- расписание кластеров;
+- выдача номеров.
+
+Значения по-прежнему хранятся в состоянии формы и отправляются при сохранении (не затираются). Утилита: `isRaceDateInPast` в `frontend/src/lib/raceMetrics.ts`.
+
+## 2. Время старта
+
+Вместо свободного текста — три селекта (часы, минуты, секунды), компонент `StartTimeSelects` в `frontend/src/components/StartTimeSelects.tsx`. Сохраняется строка `HH:mm:ss` или пусто → `null` в API. Поддерживается разбор старых значений `HH:mm` при загрузке.
+
+## 3. Список на странице «Календарь стартов»
+
+Для стартов со статусом **«внесите результат»** вся карточка — ссылка на `/races/:id/edit` с лёгким увеличением и тенью при наведении/фокусе (токен `--shadow-card-lift`).
+
+## 4. Виды: список и календарь
+
+- Переключатель **Список / Календарь**, выбор сохраняется в `sessionStorage` (`races-view-mode`).
+- **Календарь:** загрузка гонок за выбранный **год** (без фильтра месяца в запросе), отображение сетки месяцев.
+- При выборе **месяца** в фильтре — крупная сетка этого месяца и компактная навигация по остальным месяцам + «Весь год».
+
+## 5. Ячейка даты в календаре
+
+- Наведение или фокус: всплывающая панель — либо «Стартов нет» и кнопка **Добавить** (`/races/new?date=YYYY-MM-DD`), либо список стартов со ссылками на карточки и **Добавить**.
+- Клик по числу — страница дня `/races/day/:ymd`.
+
+## 6. Страница дня
+
+Маршрут `races/day/:ymd`: список стартов на дату, пустое состояние, кнопка **Добавить** с предзаполнением даты через query.
+
+## 7. Новый старт с датой из календаря
+
+`RaceFormPage` читает query-параметр `?date=YYYY-MM-DD` при создании старта.
+
+## Основные файлы
+
+| Область | Файлы |
+|--------|--------|
+| Метрики даты | `frontend/src/lib/raceMetrics.ts`, `frontend/src/lib/calendarUtils.ts` |
+| Форма | `frontend/src/pages/RaceFormPage.tsx`, `frontend/src/components/StartTimeSelects.tsx` |
+| Список и календарь | `frontend/src/pages/RacesPage.tsx`, `frontend/src/components/RacesCalendar.tsx` |
+| День | `frontend/src/pages/RaceDayPage.tsx`, `frontend/src/app/router.tsx` |
+| Стили | `frontend/src/styles/global.css`, `frontend/src/styles/tokens.css` |
+| API-док | `docs/backend-api-for-frontend.md` (формат `startTime`) |
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index cf608e6..10a354d 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "calendar-run-frontend",
- "version": "0.3.1",
+ "version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "calendar-run-frontend",
- "version": "0.3.1",
+ "version": "0.4.0",
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
diff --git a/frontend/package.json b/frontend/package.json
index 1e6af99..a2a58cd 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "calendar-run-frontend",
"private": true,
- "version": "0.3.1",
+ "version": "0.4.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx
index d802e2e..307e461 100644
--- a/frontend/src/app/router.tsx
+++ b/frontend/src/app/router.tsx
@@ -4,6 +4,7 @@ import { DashboardPage } from "../pages/DashboardPage";
import { RacesPage } from "../pages/RacesPage";
import { RaceDetailsPage } from "../pages/RaceDetailsPage";
import { RaceFormPage } from "../pages/RaceFormPage";
+import { RaceDayPage } from "../pages/RaceDayPage";
export const appRouter = createBrowserRouter([
{
@@ -13,6 +14,7 @@ export const appRouter = createBrowserRouter([
{ index: true, element: },
{ path: "races", element: },
{ path: "races/new", element: },
+ { path: "races/day/:ymd", element: },
{ path: "races/:raceId", element: },
{ path: "races/:raceId/edit", element: },
],
diff --git a/frontend/src/components/RacesCalendar.tsx b/frontend/src/components/RacesCalendar.tsx
new file mode 100644
index 0000000..895a264
--- /dev/null
+++ b/frontend/src/components/RacesCalendar.tsx
@@ -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 (
+
+ {hasRaces ? (
+
+ {races.map((r) => (
+ -
+
+ {r.title}
+
+
+ ))}
+
+ ) : (
+
Стартов нет
+ )}
+
+ Добавить
+
+
+ );
+}
+
+function CalendarMonthBlock(props: {
+ year: number;
+ monthIndex: number;
+ racesByYmd: Map;
+ compact: boolean;
+ navigate: ReturnType;
+ 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 (
+
+
{title}
+
+ {WEEKDAY_LABELS_SHORT_RU.map((d) => (
+
+ {d}
+
+ ))}
+
+
+ {cells.map((day, idx) => {
+ if (day === null) {
+ return
;
+ }
+ const ymd = toYmd(year, monthIndex, day);
+ const dayRaces = racesByYmd.get(ymd) ?? [];
+ const hasRaces = dayRaces.length > 0;
+ const isOpen = openYmd === ymd;
+
+ return (
+
{
+ cancelClose();
+ setOpenYmd(ymd);
+ }}
+ onMouseLeave={scheduleClose}
+ >
+
+ {isOpen ? (
+
+ ) : null}
+
+ );
+ })}
+
+
+ );
+}
+
+export function RacesCalendar(props: RacesCalendarProps): JSX.Element {
+ const { displayYear, monthFilter, races, onMonthFilterChange } = props;
+ const navigate = useNavigate();
+ const [openYmd, setOpenYmd] = useState(null);
+ const leaveTimerRef = useRef(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 (
+
+
Наведите на дату — краткая информация. Клик — страница дня.
+ {focusedMonthIndex === null || Number.isNaN(focusedMonthIndex) ? (
+
+ {MONTH_NAMES_RU_SHORT.map((_, mi) => (
+
+ ))}
+
+ ) : (
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/StartTimeSelects.tsx b/frontend/src/components/StartTimeSelects.tsx
new file mode 100644
index 0000000..89996fb
--- /dev/null
+++ b/frontend/src/components/StartTimeSelects.tsx
@@ -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 (
+
+
+
+ :
+
+
+
+ :
+
+
+
+ );
+}
diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts
index 07eecb3..7de53f0 100644
--- a/frontend/src/components/index.ts
+++ b/frontend/src/components/index.ts
@@ -1 +1,3 @@
export { PaceTrendChart } from "./PaceTrendChart";
+export { RacesCalendar } from "./RacesCalendar";
+export { StartTimeSelects } from "./StartTimeSelects";
diff --git a/frontend/src/lib/calendarUtils.ts b/frontend/src/lib/calendarUtils.ts
new file mode 100644
index 0000000..2eb0aa7
--- /dev/null
+++ b/frontend/src/lib/calendarUtils.ts
@@ -0,0 +1,49 @@
+import type { Race } from "../api";
+
+export const WEEKDAY_LABELS_SHORT_RU: string[] = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
+
+/** Monday-based week: Mon=0 … Sun=6 */
+export function mondayIndexFromDate(d: Date): number {
+ return (d.getDay() + 6) % 7;
+}
+
+/** Grid cells for one month: `null` = empty, `1..31` = day of month. Padded to full weeks, at least 6 rows. */
+export function buildMonthCells(year: number, monthIndex: number): (number | null)[] {
+ const first = new Date(year, monthIndex, 1);
+ const lead = mondayIndexFromDate(first);
+ const dim = new Date(year, monthIndex + 1, 0).getDate();
+ const cells: (number | null)[] = [];
+ for (let i = 0; i < lead; i += 1) {
+ cells.push(null);
+ }
+ for (let day = 1; day <= dim; day += 1) {
+ cells.push(day);
+ }
+ while (cells.length % 7 !== 0) {
+ cells.push(null);
+ }
+ while (cells.length < 42) {
+ cells.push(null);
+ }
+ return cells;
+}
+
+export function toYmd(year: number, monthIndex: number, day: number): string {
+ const m = String(monthIndex + 1).padStart(2, "0");
+ const d = String(day).padStart(2, "0");
+ return `${year}-${m}-${d}`;
+}
+
+export function groupRacesByYmd(races: Race[]): Map {
+ const map = new Map();
+ for (const race of races) {
+ const ymd = race.date.slice(0, 10);
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(ymd)) {
+ continue;
+ }
+ const list = map.get(ymd) ?? [];
+ list.push(race);
+ map.set(ymd, list);
+ }
+ return map;
+}
diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts
index 9ed3e2e..c8c1359 100644
--- a/frontend/src/lib/index.ts
+++ b/frontend/src/lib/index.ts
@@ -6,6 +6,7 @@ export {
getRaceStatusClassName,
getRaceStatusLabel,
isCloseDistance,
+ isRaceDateInPast,
parseFinishTimeToSeconds,
parseRaceDate,
raceNeedsResultEntry,
@@ -13,3 +14,5 @@ export {
sortByDateDesc,
splitRacesByDate,
} from "./raceMetrics";
+
+export { buildMonthCells, groupRacesByYmd, toYmd, WEEKDAY_LABELS_SHORT_RU } from "./calendarUtils";
diff --git a/frontend/src/lib/raceMetrics.ts b/frontend/src/lib/raceMetrics.ts
index 1bdba62..0e988c6 100644
--- a/frontend/src/lib/raceMetrics.ts
+++ b/frontend/src/lib/raceMetrics.ts
@@ -12,6 +12,13 @@ export function parseRaceDate(date: string): Date {
return parsed;
}
+/** Дата старта (календарный день) строго раньше сегодняшней полуночи по локали. */
+export function isRaceDateInPast(raceDate: string, now: Date = new Date()): boolean {
+ const today = new Date(now);
+ today.setHours(0, 0, 0, 0);
+ return parseRaceDate(raceDate).getTime() < today.getTime();
+}
+
export function parseFinishTimeToSeconds(value: string | null): number | null {
if (!value) {
return null;
diff --git a/frontend/src/pages/RaceDayPage.tsx b/frontend/src/pages/RaceDayPage.tsx
new file mode 100644
index 0000000..fbc1738
--- /dev/null
+++ b/frontend/src/pages/RaceDayPage.tsx
@@ -0,0 +1,133 @@
+import { useEffect, useMemo, useState } from "react";
+import { Link, useParams } from "react-router-dom";
+import type { Race } from "../api";
+import { ApiError, getRaces } from "../api";
+import { formatDistance, formatRaceDate, getRaceStatusClassName, getRaceStatusLabel, sortByDateAsc } from "../lib";
+
+function getErrorMessage(error: unknown): string {
+ if (error instanceof ApiError) {
+ return error.message;
+ }
+ return "Не удалось загрузить старты.";
+}
+
+export function RaceDayPage(): JSX.Element {
+ const { ymd } = useParams<{ ymd: string }>();
+ const [races, setRaces] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [errorMessage, setErrorMessage] = useState(null);
+
+ const validYmd = ymd && /^\d{4}-\d{2}-\d{2}$/.test(ymd) ? ymd : null;
+ const year = validYmd ? parseInt(validYmd.slice(0, 4), 10) : NaN;
+
+ useEffect(() => {
+ if (!validYmd || Number.isNaN(year)) {
+ setIsLoading(false);
+ setRaces([]);
+ return;
+ }
+
+ const ac = new AbortController();
+ let mounted = true;
+
+ async function load(): Promise {
+ setIsLoading(true);
+ try {
+ const items = await getRaces({ year }, { signal: ac.signal });
+ if (!mounted || ac.signal.aborted) {
+ return;
+ }
+ const forDay = items.filter((r) => r.date.slice(0, 10) === validYmd);
+ setRaces(sortByDateAsc(forDay));
+ setErrorMessage(null);
+ } catch (e) {
+ if (ac.signal.aborted || !mounted) {
+ return;
+ }
+ setErrorMessage(getErrorMessage(e));
+ setRaces([]);
+ } finally {
+ if (mounted && !ac.signal.aborted) {
+ setIsLoading(false);
+ }
+ }
+ }
+
+ void load();
+ return () => {
+ mounted = false;
+ ac.abort();
+ };
+ }, [validYmd, year]);
+
+ const heading = useMemo(() => {
+ if (!validYmd) {
+ return "Дата не указана";
+ }
+ return formatRaceDate(validYmd);
+ }, [validYmd]);
+
+ if (!validYmd) {
+ return (
+
+ Некорректная дата
+
+
+ Вернуться к календарю стартов
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ ← Календарь стартов
+
+
+ {heading}
+
+ {errorMessage ? (
+
+ {errorMessage}
+
+ ) : null}
+
+ {isLoading ? (
+
+ Загружаем…
+
+ ) : null}
+
+ {!isLoading && !errorMessage && races.length === 0 ? (
+ На эту дату стартов нет.
+ ) : null}
+
+ {!isLoading && races.length > 0 ? (
+
+ {races.map((race) => (
+ -
+
+ {race.title}
+
+
+ {formatDistance(race.distanceKm)} ·{" "}
+
+ {getRaceStatusLabel(race.status, race.date)}
+
+
+
+ ))}
+
+ ) : null}
+
+
+
+ Добавить
+
+
+
+ );
+}
diff --git a/frontend/src/pages/RaceFormPage.tsx b/frontend/src/pages/RaceFormPage.tsx
index 56e3482..05c7bf4 100644
--- a/frontend/src/pages/RaceFormPage.tsx
+++ b/frontend/src/pages/RaceFormPage.tsx
@@ -1,7 +1,9 @@
import { useCallback, useEffect, useState } from "react";
-import { Link, useNavigate, useParams } from "react-router-dom";
+import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { ApiError, createRace, getRaceById, updateRace } from "../api";
import type { CreateRacePayload, Race, RaceStatus, UpdateRacePayload } from "../api";
+import { StartTimeSelects } from "../components/StartTimeSelects";
+import { isRaceDateInPast } from "../lib";
function slugify(text: string): string {
return text
@@ -94,6 +96,7 @@ function validateForm(form: FormData): string[] {
export function RaceFormPage(): JSX.Element {
const { raceId } = useParams<{ raceId: string }>();
const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
const isEditMode = Boolean(raceId);
const [form, setForm] = useState(EMPTY_FORM);
@@ -135,6 +138,16 @@ export function RaceFormPage(): JSX.Element {
};
}, [raceId]);
+ useEffect(() => {
+ if (isEditMode) {
+ return;
+ }
+ const d = searchParams.get("date");
+ if (d && /^\d{4}-\d{2}-\d{2}$/.test(d)) {
+ setForm((prev) => (prev.date === d ? prev : { ...prev, date: d }));
+ }
+ }, [isEditMode, searchParams]);
+
const handleChange = useCallback(
(event: React.ChangeEvent) => {
const { name, value } = event.target;
@@ -214,6 +227,7 @@ export function RaceFormPage(): JSX.Element {
[form, isEditMode, raceId, navigate],
);
+ const hideOrgScheduleFields = isEditMode && isRaceDateInPast(form.date);
const pageTitle = isEditMode ? "Редактирование старта" : "Новый старт";
if (isLoading) {
@@ -303,51 +317,57 @@ export function RaceFormPage(): JSX.Element {