Compare commits

..

9 Commits

Author SHA1 Message Date
Vaka.pro
a581ffaaff fix(frontend): hide calendar popups on empty dates
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
Keep race popovers limited to dates with events so empty days no longer show misleading hover details.

Made-with: Cursor
2026-04-13 22:58:36 +03:00
429a2924d7 Merge pull request 'fix(frontend): animate full race list row on hover (li, not inner link)' (#24) from fix/race-list-card-hover into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #24
2026-04-13 19:51:07 +00:00
Vaka.pro
afb0f7ef31 fix(frontend): animate full race list row on hover (li, not inner link)
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
Move scale/shadow transition to .race-card--action; keyboard focus ring on link.
Version 0.4.2.

Made-with: Cursor
2026-04-13 22:47:06 +03:00
92c2360feb Merge pull request 'fix(frontend): auto-completed on finish time, dashboard links, list/calendar UX' (#23) from fix/dashboard-form-races-ux into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #23
2026-04-13 19:36:10 +00:00
Vaka.pro
4ea8faf16f fix(frontend): auto-completed on finish time, dashboard links, list/calendar UX
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
- Set status to completed when finish time parses (input + submit)
- Dashboard: last personal record by recent date+time; links on top 3 cards
- Hover scale+shadow on all dashboard-card; linked card padding via BEM
- Race list: full row links to race detail; same hover as before
- Calendar year grid: 3 columns, 2 on tablet, 1 on narrow
- Version 0.4.1

Made-with: Cursor
2026-04-13 22:34:39 +03:00
74f059593e Merge pull request 'feat(frontend): race form, start time selects, calendar views, day page' (#22) from feat/race-ui-plan-implementation into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #22
2026-04-13 19:09:11 +00:00
Vaka.pro
3c6baa66a1 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
2026-04-13 22:07:37 +03:00
b997dcb01e Merge pull request 'chore: bump patch versions; remove temp request logging' (#21) from fix/docker-api-upstream-ambiguity into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #21
2026-04-12 15:56:52 +00:00
c337823fa8 Merge pull request 'fix(docker): use unique Compose service name for API upstream' (#20) from fix/docker-api-upstream-ambiguity into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #20
2026-04-12 15:43:25 +00:00
17 changed files with 1287 additions and 113 deletions

View File

@@ -207,7 +207,7 @@ GET /api/races?year=2026&month=5
| `distanceKm` | number | да | да | Дистанция в км | | `distanceKm` | number | да | да | Дистанция в км |
| `status` | string \| null | нет | да | `"planned"` / `"registered"` / `"completed"` | | `status` | string \| null | нет | да | `"planned"` / `"registered"` / `"completed"` |
| `officialUrl` | string \| null | нет | да | URL организатора | | `officialUrl` | string \| null | нет | да | URL организатора |
| `startTime` | string \| null | нет | да | Время старта, напр. `"09:30"` | | `startTime` | string \| null | нет | да | Время старта, напр. `"09:30"` или `"09:30:00"` (часы:минуты:секунды) |
| `clusterSchedule` | string \| null | нет | да | Расписание кластеров | | `clusterSchedule` | string \| null | нет | да | Расписание кластеров |
| `bibPickup` | string \| null | нет | да | Выдача номеров | | `bibPickup` | string \| null | нет | да | Выдача номеров |
| `bibNumber` | string \| null | нет | да | Стартовый номер | | `bibNumber` | string \| null | нет | да | Стартовый номер |

View File

@@ -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`) |

View File

@@ -1,12 +1,12 @@
{ {
"name": "calendar-run-frontend", "name": "calendar-run-frontend",
"version": "0.3.1", "version": "0.4.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "calendar-run-frontend", "name": "calendar-run-frontend",
"version": "0.3.1", "version": "0.4.3",
"dependencies": { "dependencies": {
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",

View File

@@ -1,7 +1,7 @@
{ {
"name": "calendar-run-frontend", "name": "calendar-run-frontend",
"private": true, "private": true,
"version": "0.3.1", "version": "0.4.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -4,6 +4,7 @@ import { DashboardPage } from "../pages/DashboardPage";
import { RacesPage } from "../pages/RacesPage"; import { RacesPage } from "../pages/RacesPage";
import { RaceDetailsPage } from "../pages/RaceDetailsPage"; import { RaceDetailsPage } from "../pages/RaceDetailsPage";
import { RaceFormPage } from "../pages/RaceFormPage"; import { RaceFormPage } from "../pages/RaceFormPage";
import { RaceDayPage } from "../pages/RaceDayPage";
export const appRouter = createBrowserRouter([ export const appRouter = createBrowserRouter([
{ {
@@ -13,6 +14,7 @@ export const appRouter = createBrowserRouter([
{ index: true, element: <DashboardPage /> }, { index: true, element: <DashboardPage /> },
{ path: "races", element: <RacesPage /> }, { path: "races", element: <RacesPage /> },
{ path: "races/new", element: <RaceFormPage /> }, { path: "races/new", element: <RaceFormPage /> },
{ path: "races/day/:ymd", element: <RaceDayPage /> },
{ path: "races/:raceId", element: <RaceDetailsPage /> }, { path: "races/:raceId", element: <RaceDetailsPage /> },
{ path: "races/:raceId/edit", element: <RaceFormPage /> }, { path: "races/:raceId/edit", element: <RaceFormPage /> },
], ],

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(hasRaces ? ymd : null);
}}
onMouseLeave={scheduleClose}
>
<button
type="button"
className="races-cal__day-btn"
onClick={() => {
navigate(`/races/day/${ymd}`);
}}
onFocus={() => {
cancelClose();
setOpenYmd(hasRaces ? ymd : null);
}}
onBlur={(e) => {
const next = e.relatedTarget as Node | null;
if (next && e.currentTarget.closest(".races-cal__cell")?.contains(next)) {
return;
}
scheduleClose();
}}
>
{day}
</button>
{isOpen && hasRaces ? (
<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 { PaceTrendChart } from "./PaceTrendChart";
export { RacesCalendar } from "./RacesCalendar";
export { StartTimeSelects } from "./StartTimeSelects";

View File

@@ -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<string, Race[]> {
const map = new Map<string, Race[]>();
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;
}

View File

@@ -6,6 +6,7 @@ export {
getRaceStatusClassName, getRaceStatusClassName,
getRaceStatusLabel, getRaceStatusLabel,
isCloseDistance, isCloseDistance,
isRaceDateInPast,
parseFinishTimeToSeconds, parseFinishTimeToSeconds,
parseRaceDate, parseRaceDate,
raceNeedsResultEntry, raceNeedsResultEntry,
@@ -13,3 +14,5 @@ export {
sortByDateDesc, sortByDateDesc,
splitRacesByDate, splitRacesByDate,
} from "./raceMetrics"; } from "./raceMetrics";
export { buildMonthCells, groupRacesByYmd, toYmd, WEEKDAY_LABELS_SHORT_RU } from "./calendarUtils";

View File

@@ -12,6 +12,13 @@ export function parseRaceDate(date: string): Date {
return parsed; 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 { export function parseFinishTimeToSeconds(value: string | null): number | null {
if (!value) { if (!value) {
return null; return null;

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import type { Race } from "../api"; import type { Race } from "../api";
import { ApiError, getRaces } from "../api"; import { ApiError, getRaces } from "../api";
import { PaceTrendChart } from "../components"; import { PaceTrendChart } from "../components";
@@ -61,26 +62,14 @@ export function DashboardPage(): JSX.Element {
const dashboardMetrics = useMemo(() => { const dashboardMetrics = useMemo(() => {
const { upcoming, past } = splitRacesByDate(races); const { upcoming, past } = splitRacesByDate(races);
const completed = races.filter((race) => race.status === "completed");
const nextRace = upcoming[0] ?? null; const nextRace = upcoming[0] ?? null;
const lastResult = past.find((race) => race.status === "completed") ?? null; const lastResult = past.find((race) => race.status === "completed") ?? null;
let personalRecord: Race | null = null; const lastPersonalRecord =
let personalRecordSeconds = Number.POSITIVE_INFINITY; past.find(
(race) => race.status === "completed" && parseFinishTimeToSeconds(race.finishTime) !== null,
for (const race of completed) { ) ?? null;
const finishSeconds = parseFinishTimeToSeconds(race.finishTime);
if (!finishSeconds) {
continue;
}
const candidate = finishSeconds / race.distanceKm;
if (candidate < personalRecordSeconds) {
personalRecordSeconds = candidate;
personalRecord = race;
}
}
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const seasonRaces = races.filter((race) => parseRaceDate(race.date).getFullYear() === currentYear); const seasonRaces = races.filter((race) => parseRaceDate(race.date).getFullYear() === currentYear);
@@ -89,7 +78,7 @@ export function DashboardPage(): JSX.Element {
return { return {
nextRace, nextRace,
lastResult, lastResult,
personalRecord, lastPersonalRecord,
seasonTotal: seasonRaces.length, seasonTotal: seasonRaces.length,
seasonCompletedCount: seasonCompleted.length, seasonCompletedCount: seasonCompleted.length,
}; };
@@ -167,48 +156,79 @@ export function DashboardPage(): JSX.Element {
<p className="page__subtitle">Ключевые метрики по вашему календарю стартов.</p> <p className="page__subtitle">Ключевые метрики по вашему календарю стартов.</p>
<div className="dashboard-grid" aria-label="Ключевые метрики"> <div className="dashboard-grid" aria-label="Ключевые метрики">
<article className="dashboard-card"> <article
<h2 className="dashboard-card__title">Ближайший старт</h2> className={`dashboard-card${dashboardMetrics.nextRace ? " dashboard-card--linked" : ""}`}
>
{dashboardMetrics.nextRace ? ( {dashboardMetrics.nextRace ? (
<> <Link
className="dashboard-card__link-surface"
to={`/races/${dashboardMetrics.nextRace.id}`}
aria-label={`Ближайший старт: ${dashboardMetrics.nextRace.title}`}
>
<h2 className="dashboard-card__title">Ближайший старт</h2>
<p className="dashboard-card__value">{dashboardMetrics.nextRace.title}</p> <p className="dashboard-card__value">{dashboardMetrics.nextRace.title}</p>
<p className="dashboard-card__meta"> <p className="dashboard-card__meta">
{formatRaceDate(dashboardMetrics.nextRace.date)} · {formatDistance(dashboardMetrics.nextRace.distanceKm)} {formatRaceDate(dashboardMetrics.nextRace.date)} ·{" "}
{formatDistance(dashboardMetrics.nextRace.distanceKm)}
</p> </p>
<p className="dashboard-card__hint">{getRaceCountdownLabel(dashboardMetrics.nextRace.date)}</p> <p className="dashboard-card__hint">{getRaceCountdownLabel(dashboardMetrics.nextRace.date)}</p>
</> </Link>
) : ( ) : (
<>
<h2 className="dashboard-card__title">Ближайший старт</h2>
<p className="dashboard-card__empty">Нет запланированных стартов.</p> <p className="dashboard-card__empty">Нет запланированных стартов.</p>
</>
)} )}
</article> </article>
<article className="dashboard-card"> <article
<h2 className="dashboard-card__title">Последний результат</h2> className={`dashboard-card${dashboardMetrics.lastResult ? " dashboard-card--linked" : ""}`}
>
{dashboardMetrics.lastResult ? ( {dashboardMetrics.lastResult ? (
<> <Link
className="dashboard-card__link-surface"
to={`/races/${dashboardMetrics.lastResult.id}`}
aria-label={`Последний результат: ${dashboardMetrics.lastResult.title}`}
>
<h2 className="dashboard-card__title">Последний результат</h2>
<p className="dashboard-card__value">{dashboardMetrics.lastResult.finishTime ?? "время не указано"}</p> <p className="dashboard-card__value">{dashboardMetrics.lastResult.finishTime ?? "время не указано"}</p>
<p className="dashboard-card__meta"> <p className="dashboard-card__meta">
{dashboardMetrics.lastResult.title} · {formatDistance(dashboardMetrics.lastResult.distanceKm)} {dashboardMetrics.lastResult.title} · {formatDistance(dashboardMetrics.lastResult.distanceKm)}
</p> </p>
<p className="dashboard-card__hint">{formatRaceDate(dashboardMetrics.lastResult.date)}</p> <p className="dashboard-card__hint">{formatRaceDate(dashboardMetrics.lastResult.date)}</p>
</> </Link>
) : ( ) : (
<>
<h2 className="dashboard-card__title">Последний результат</h2>
<p className="dashboard-card__empty">Пока нет завершённых стартов.</p> <p className="dashboard-card__empty">Пока нет завершённых стартов.</p>
</>
)} )}
</article> </article>
<article className="dashboard-card"> <article
<h2 className="dashboard-card__title">Личный рекорд</h2> className={`dashboard-card${dashboardMetrics.lastPersonalRecord ? " dashboard-card--linked" : ""}`}
{dashboardMetrics.personalRecord ? ( >
<> {dashboardMetrics.lastPersonalRecord ? (
<p className="dashboard-card__value">{dashboardMetrics.personalRecord.finishTime ?? "время не указано"}</p> <Link
<p className="dashboard-card__meta"> className="dashboard-card__link-surface"
{dashboardMetrics.personalRecord.title} · {formatDistance(dashboardMetrics.personalRecord.distanceKm)} to={`/races/${dashboardMetrics.lastPersonalRecord.id}`}
aria-label={`Последний личный рекорд: ${dashboardMetrics.lastPersonalRecord.title}`}
>
<h2 className="dashboard-card__title">Последний личный рекорд</h2>
<p className="dashboard-card__value">
{dashboardMetrics.lastPersonalRecord.finishTime ?? "время не указано"}
</p> </p>
<p className="dashboard-card__hint">Лучший темп среди завершённых стартов.</p> <p className="dashboard-card__meta">
</> {dashboardMetrics.lastPersonalRecord.title} ·{" "}
{formatDistance(dashboardMetrics.lastPersonalRecord.distanceKm)}
</p>
<p className="dashboard-card__hint">{formatRaceDate(dashboardMetrics.lastPersonalRecord.date)}</p>
</Link>
) : ( ) : (
<p className="dashboard-card__empty">Недостаточно данных для личного рекорда.</p> <>
<h2 className="dashboard-card__title">Последний личный рекорд</h2>
<p className="dashboard-card__empty">Нет завершённых стартов с финишным временем.</p>
</>
)} )}
</article> </article>

View File

@@ -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<Race[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState<string | null>(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<void> {
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 (
<section className="page page--race-day">
<h1 className="page__title">Некорректная дата</h1>
<p className="page__subtitle">
<Link className="page-link" to="/races">
Вернуться к календарю стартов
</Link>
</p>
</section>
);
}
return (
<section className="page page--race-day">
<p className="page__subtitle">
<Link className="page-link" to="/races">
Календарь стартов
</Link>
</p>
<h1 className="page__title">{heading}</h1>
{errorMessage ? (
<p className="page__subtitle page__subtitle--error" role="alert">
{errorMessage}
</p>
) : null}
{isLoading ? (
<p className="page__subtitle" aria-busy="true">
Загружаем
</p>
) : null}
{!isLoading && !errorMessage && races.length === 0 ? (
<p className="page__subtitle">На эту дату стартов нет.</p>
) : null}
{!isLoading && races.length > 0 ? (
<ul className="race-day__list">
{races.map((race) => (
<li key={race.id} className="race-day__item">
<Link className="race-day__link" to={`/races/${race.id}`}>
{race.title}
</Link>
<span className="race-day__meta">
{formatDistance(race.distanceKm)} ·{" "}
<span className={getRaceStatusClassName(race.status, race.date)}>
{getRaceStatusLabel(race.status, race.date)}
</span>
</span>
</li>
))}
</ul>
) : null}
<div className="race-day__actions">
<Link className="btn btn--primary" to={`/races/new?date=${validYmd}`}>
Добавить
</Link>
</div>
</section>
);
}

View File

@@ -1,7 +1,9 @@
import { useCallback, useEffect, useState } from "react"; 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 { ApiError, createRace, getRaceById, updateRace } from "../api";
import type { CreateRacePayload, Race, RaceStatus, UpdateRacePayload } from "../api"; import type { CreateRacePayload, Race, RaceStatus, UpdateRacePayload } from "../api";
import { StartTimeSelects } from "../components/StartTimeSelects";
import { isRaceDateInPast, parseFinishTimeToSeconds } from "../lib";
function slugify(text: string): string { function slugify(text: string): string {
return text return text
@@ -94,6 +96,7 @@ function validateForm(form: FormData): string[] {
export function RaceFormPage(): JSX.Element { export function RaceFormPage(): JSX.Element {
const { raceId } = useParams<{ raceId: string }>(); const { raceId } = useParams<{ raceId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const isEditMode = Boolean(raceId); const isEditMode = Boolean(raceId);
const [form, setForm] = useState<FormData>(EMPTY_FORM); const [form, setForm] = useState<FormData>(EMPTY_FORM);
@@ -135,10 +138,29 @@ export function RaceFormPage(): JSX.Element {
}; };
}, [raceId]); }, [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( const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => { (event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = event.target; const { name, value } = event.target;
setForm((prev) => ({ ...prev, [name]: value })); setForm((prev) => {
const next = { ...prev, [name]: value };
if (name === "finishTime") {
const trimmed = value.trim();
if (trimmed !== "" && parseFinishTimeToSeconds(trimmed) !== null) {
return { ...next, status: "completed" };
}
}
return next;
});
}, },
[], [],
); );
@@ -157,10 +179,16 @@ export function RaceFormPage(): JSX.Element {
setIsSaving(true); setIsSaving(true);
try { try {
const statusValue: RaceStatus | null = const finishTrimmed = form.finishTime.trim();
const hasParsedFinish =
finishTrimmed !== "" && parseFinishTimeToSeconds(finishTrimmed) !== null;
let statusValue: RaceStatus | null =
form.status === "planned" || form.status === "registered" || form.status === "completed" form.status === "planned" || form.status === "registered" || form.status === "completed"
? form.status ? form.status
: null; : null;
if (hasParsedFinish) {
statusValue = "completed";
}
if (isEditMode && raceId) { if (isEditMode && raceId) {
const payload: UpdateRacePayload = { const payload: UpdateRacePayload = {
@@ -214,6 +242,7 @@ export function RaceFormPage(): JSX.Element {
[form, isEditMode, raceId, navigate], [form, isEditMode, raceId, navigate],
); );
const hideOrgScheduleFields = isEditMode && isRaceDateInPast(form.date);
const pageTitle = isEditMode ? "Редактирование старта" : "Новый старт"; const pageTitle = isEditMode ? "Редактирование старта" : "Новый старт";
if (isLoading) { if (isLoading) {
@@ -303,6 +332,7 @@ export function RaceFormPage(): JSX.Element {
<fieldset className="race-form__group"> <fieldset className="race-form__group">
<legend className="race-form__legend">Организация</legend> <legend className="race-form__legend">Организация</legend>
{hideOrgScheduleFields ? null : (
<label className="race-form__field"> <label className="race-form__field">
<span className="race-form__label">Сайт организатора</span> <span className="race-form__label">Сайт организатора</span>
<input <input
@@ -314,19 +344,21 @@ export function RaceFormPage(): JSX.Element {
placeholder="https://…" placeholder="https://…"
/> />
</label> </label>
)}
<label className="race-form__field"> {hideOrgScheduleFields ? null : (
<div className="race-form__field">
<span className="race-form__label">Время старта</span> <span className="race-form__label">Время старта</span>
<input <StartTimeSelects
className="race-form__input"
type="text"
name="startTime"
value={form.startTime} value={form.startTime}
onChange={handleChange} onChange={(next) => {
placeholder="09:30" setForm((prev) => ({ ...prev, startTime: next }));
}}
/> />
</label> </div>
)}
{hideOrgScheduleFields ? null : (
<label className="race-form__field"> <label className="race-form__field">
<span className="race-form__label">Расписание кластеров</span> <span className="race-form__label">Расписание кластеров</span>
<input <input
@@ -337,7 +369,9 @@ export function RaceFormPage(): JSX.Element {
onChange={handleChange} onChange={handleChange}
/> />
</label> </label>
)}
{hideOrgScheduleFields ? null : (
<label className="race-form__field"> <label className="race-form__field">
<span className="race-form__label">Выдача номеров</span> <span className="race-form__label">Выдача номеров</span>
<input <input
@@ -348,6 +382,7 @@ export function RaceFormPage(): JSX.Element {
onChange={handleChange} onChange={handleChange}
/> />
</label> </label>
)}
<label className="race-form__field"> <label className="race-form__field">
<span className="race-form__label">Стартовый номер</span> <span className="race-form__label">Стартовый номер</span>

View File

@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import type { Race, RacesQuery } from "../api"; import type { Race, RacesQuery } from "../api";
import { ApiError, getRaces } from "../api"; import { ApiError, getRaces } from "../api";
import { RacesCalendar } from "../components/RacesCalendar";
import { import {
formatDistance, formatDistance,
formatRaceDate, formatRaceDate,
@@ -26,6 +27,10 @@ const MONTH_OPTIONS: { value: string; label: string }[] = [
{ value: "12", label: "Декабрь" }, { value: "12", label: "Декабрь" },
]; ];
const VIEW_STORAGE_KEY = "races-view-mode";
type ViewMode = "list" | "calendar";
function yearSelectOptions(): number[] { function yearSelectOptions(): number[] {
const current = new Date().getFullYear(); const current = new Date().getFullYear();
const start = current - 2; const start = current - 2;
@@ -44,6 +49,15 @@ function getErrorMessage(error: unknown): string {
return "Не удалось загрузить календарь стартов."; return "Не удалось загрузить календарь стартов.";
} }
function readInitialViewMode(): ViewMode {
try {
const v = sessionStorage.getItem(VIEW_STORAGE_KEY);
return v === "calendar" ? "calendar" : "list";
} catch {
return "list";
}
}
function RaceList(props: { title: string; races: Race[] }): JSX.Element { function RaceList(props: { title: string; races: Race[] }): JSX.Element {
const { title, races } = props; const { title, races } = props;
@@ -53,18 +67,24 @@ function RaceList(props: { title: string; races: Race[] }): JSX.Element {
{races.length > 0 ? ( {races.length > 0 ? (
<ul className="race-list__items"> <ul className="race-list__items">
{races.map((race) => ( {races.map((race) => (
<li key={race.id} className="race-card"> <li key={race.id} className="race-card race-card--action">
<Link
className="race-card__link-surface"
to={`/races/${race.id}`}
aria-label={`Старт: ${race.title}`}
>
<div className="race-card__main"> <div className="race-card__main">
<p className="race-card__title"> <p className="race-card__title">
<Link className="race-card__link" to={`/races/${race.id}`}> <span className="race-card__title-text">{race.title}</span>
{race.title}
</Link>
</p> </p>
<p className="race-card__meta"> <p className="race-card__meta">
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)} {formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}
</p> </p>
</div> </div>
<span className={getRaceStatusClassName(race.status, race.date)}>{getRaceStatusLabel(race.status, race.date)}</span> <span className={getRaceStatusClassName(race.status, race.date)}>
{getRaceStatusLabel(race.status, race.date)}
</span>
</Link>
</li> </li>
))} ))}
</ul> </ul>
@@ -81,8 +101,34 @@ export function RacesPage(): JSX.Element {
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [yearFilter, setYearFilter] = useState<string>(""); const [yearFilter, setYearFilter] = useState<string>("");
const [monthFilter, setMonthFilter] = useState<string>(""); const [monthFilter, setMonthFilter] = useState<string>("");
const [viewMode, setViewMode] = useState<ViewMode>(() => readInitialViewMode());
const setViewModePersist = useCallback((mode: ViewMode) => {
setViewMode(mode);
try {
sessionStorage.setItem(VIEW_STORAGE_KEY, mode);
} catch {
/* ignore */
}
}, []);
const handleViewList = useCallback(() => {
setViewModePersist("list");
}, [setViewModePersist]);
const handleViewCalendar = useCallback(() => {
setViewModePersist("calendar");
setYearFilter((prev) => (prev === "" ? String(new Date().getFullYear()) : prev));
}, [setViewModePersist]);
const listQuery = useMemo((): RacesQuery | undefined => { const listQuery = useMemo((): RacesQuery | undefined => {
if (viewMode === "calendar") {
const y = yearFilter !== "" ? parseInt(yearFilter, 10) : new Date().getFullYear();
if (!Number.isNaN(y)) {
return { year: y };
}
return undefined;
}
const q: RacesQuery = {}; const q: RacesQuery = {};
if (yearFilter !== "") { if (yearFilter !== "") {
const y = parseInt(yearFilter, 10); const y = parseInt(yearFilter, 10);
@@ -97,7 +143,15 @@ export function RacesPage(): JSX.Element {
} }
} }
return Object.keys(q).length > 0 ? q : undefined; return Object.keys(q).length > 0 ? q : undefined;
}, [yearFilter, monthFilter]); }, [viewMode, yearFilter, monthFilter]);
const displayYear = useMemo(() => {
if (yearFilter !== "") {
const y = parseInt(yearFilter, 10);
return Number.isNaN(y) ? new Date().getFullYear() : y;
}
return new Date().getFullYear();
}, [yearFilter]);
useEffect(() => { useEffect(() => {
const ac = new AbortController(); const ac = new AbortController();
@@ -147,6 +201,23 @@ export function RacesPage(): JSX.Element {
<h1 className="page__title">Календарь стартов</h1> <h1 className="page__title">Календарь стартов</h1>
<p className="page__subtitle">Будущие и прошедшие старты в одном месте.</p> <p className="page__subtitle">Будущие и прошедшие старты в одном месте.</p>
<div className="races-view-toggle" role="group" aria-label="Вид отображения">
<button
type="button"
className={`races-view-toggle__btn${viewMode === "list" ? " races-view-toggle__btn--active" : ""}`}
onClick={handleViewList}
>
Список
</button>
<button
type="button"
className={`races-view-toggle__btn${viewMode === "calendar" ? " races-view-toggle__btn--active" : ""}`}
onClick={handleViewCalendar}
>
Календарь
</button>
</div>
{errorMessage && !isLoading ? ( {errorMessage && !isLoading ? (
<p className="page__subtitle page__subtitle--error" role="alert" style={{ marginTop: "var(--space-4)" }}> <p className="page__subtitle page__subtitle--error" role="alert" style={{ marginTop: "var(--space-4)" }}>
{errorMessage} {errorMessage}
@@ -158,12 +229,12 @@ export function RacesPage(): JSX.Element {
<span className="races-filter__label">Год</span> <span className="races-filter__label">Год</span>
<select <select
className="races-filter__select" className="races-filter__select"
value={yearFilter} value={viewMode === "list" ? yearFilter : yearFilter || String(displayYear)}
onChange={(event) => { onChange={(event) => {
setYearFilter(event.target.value); setYearFilter(event.target.value);
}} }}
> >
<option value="">Все года</option> {viewMode === "list" ? <option value="">Все года</option> : null}
{yearSelectOptions().map((y) => ( {yearSelectOptions().map((y) => (
<option key={y} value={String(y)}> <option key={y} value={String(y)}>
{y} {y}
@@ -189,16 +260,31 @@ export function RacesPage(): JSX.Element {
</label> </label>
</div> </div>
{viewMode === "calendar" && monthFilter === "" ? (
<p className="page__subtitle races-cal__filter-hint">Выберите месяц, чтобы увидеть его крупным планом.</p>
) : null}
{isLoading ? ( {isLoading ? (
<p className="page__subtitle" aria-busy="true"> <p className="page__subtitle" aria-busy="true">
Загружаем данные... Загружаем данные...
</p> </p>
) : null} ) : null}
{viewMode === "list" ? (
<div className="race-lists"> <div className="race-lists">
<RaceList title="Будущие" races={upcoming} /> <RaceList title="Будущие" races={upcoming} />
<RaceList title="Прошедшие" races={past} /> <RaceList title="Прошедшие" races={past} />
</div> </div>
) : (
<div className="races-cal-wrap">
<RacesCalendar
displayYear={displayYear}
monthFilter={monthFilter}
races={races}
onMonthFilterChange={setMonthFilter}
/>
</div>
)}
</section> </section>
); );
} }

View File

@@ -128,6 +128,35 @@ a {
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: var(--space-5); padding: var(--space-5);
background: #fcfdff; background: #fcfdff;
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
}
.dashboard-card:hover,
.dashboard-card:focus-within {
transform: scale(1.02);
box-shadow: var(--shadow-card-lift);
}
.dashboard-card--linked {
padding: 0;
}
.dashboard-card__link-surface {
display: flex;
flex-direction: column;
height: 100%;
padding: var(--space-5);
border-radius: var(--radius-md);
color: inherit;
text-decoration: none;
outline: none;
}
.dashboard-card__link-surface:hover,
.dashboard-card__link-surface:focus-visible {
outline: none;
} }
.dashboard-card__title { .dashboard-card__title {
@@ -254,12 +283,6 @@ a {
font-weight: 600; font-weight: 600;
} }
.race-card__link:hover,
.race-card__link:focus-visible {
text-decoration: underline;
outline: none;
}
.race-card__meta { .race-card__meta {
margin: var(--space-2) 0 0; margin: var(--space-2) 0 0;
color: var(--color-text-muted); color: var(--color-text-muted);
@@ -295,6 +318,42 @@ a {
outline: 1px solid var(--color-warning); outline: 1px solid var(--color-warning);
} }
.race-card--action {
padding: 0;
overflow: visible;
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
}
.race-card--action:hover,
.race-card--action:focus-within {
transform: scale(1.02);
box-shadow: var(--shadow-card-lift);
}
.race-card__link-surface {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
width: 100%;
padding: var(--space-3);
border-radius: var(--radius-sm);
color: inherit;
text-decoration: none;
outline: none;
}
.race-card__link-surface:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.race-card__title-text {
font-weight: 600;
}
.race-details-past-hint { .race-details-past-hint {
margin: 0 0 var(--space-4); margin: 0 0 var(--space-4);
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
@@ -598,6 +657,35 @@ a {
min-height: 5rem; min-height: 5rem;
} }
.race-form__time-picker {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--space-2);
}
.race-form__time-picker__unit {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.race-form__time-picker__label {
font-size: var(--font-size-caption);
font-weight: 600;
color: var(--color-text-muted);
}
.race-form__time-picker__select {
min-width: 4.25rem;
}
.race-form__time-picker__sep {
color: var(--color-text-muted);
font-weight: 600;
padding-bottom: var(--space-2);
}
.race-form__actions { .race-form__actions {
display: flex; display: flex;
gap: var(--space-3); gap: var(--space-3);
@@ -618,6 +706,286 @@ a {
margin: var(--space-1) 0; margin: var(--space-1) 0;
} }
.races-view-toggle {
display: inline-flex;
margin-top: var(--space-5);
padding: var(--space-1);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: var(--color-surface);
gap: var(--space-1);
}
.races-view-toggle__btn {
font: inherit;
font-size: var(--font-size-caption);
font-weight: 600;
padding: var(--space-2) var(--space-4);
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
}
.races-view-toggle__btn:hover,
.races-view-toggle__btn:focus-visible {
color: var(--color-text);
outline: none;
}
.races-view-toggle__btn--active {
background: #eef2f6;
color: var(--color-text);
}
.races-cal-wrap {
margin-top: var(--space-6);
}
.races-cal__filter-hint {
margin-top: var(--space-3);
color: var(--color-text-muted);
font-size: var(--font-size-caption);
}
.races-cal__hint {
margin: 0 0 var(--space-4);
color: var(--color-text-muted);
font-size: var(--font-size-caption);
}
.races-cal__year {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-5);
}
.races-cal__month {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3);
background: var(--color-surface);
}
.races-cal__month--compact .races-cal__month-title {
font-size: var(--font-size-caption);
}
.races-cal__month--compact .races-cal__weekday {
font-size: 0.65rem;
}
.races-cal__month--compact .races-cal__day-btn {
font-size: var(--font-size-caption);
min-height: 1.65rem;
}
.races-cal__month-title {
margin: 0 0 var(--space-2);
font-size: var(--font-size-body);
font-weight: 600;
color: var(--color-text);
}
.races-cal__weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
margin-bottom: var(--space-1);
}
.races-cal__weekday {
font-size: var(--font-size-caption);
font-weight: 600;
color: var(--color-text-muted);
text-align: center;
}
.races-cal__cells {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.races-cal__cell {
position: relative;
min-height: 2rem;
}
.races-cal__cell--empty {
min-height: 0;
}
.races-cal__cell--has-race .races-cal__day-btn {
font-weight: 700;
color: var(--color-accent);
}
.races-cal__cell--open {
z-index: 2;
}
.races-cal__day-btn {
width: 100%;
height: 100%;
min-height: 2rem;
font: inherit;
font-size: var(--font-size-caption);
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
cursor: pointer;
}
.races-cal__day-btn:hover,
.races-cal__day-btn:focus-visible {
background: #eef2f6;
border-color: var(--color-border);
outline: none;
}
.races-cal__cell--has-race .races-cal__day-btn:hover,
.races-cal__cell--has-race .races-cal__day-btn:focus-visible {
background: #e8f0fc;
border-color: var(--color-accent);
}
.races-cal__popover {
position: absolute;
top: calc(100% + var(--space-1));
left: 50%;
transform: translateX(-50%);
min-width: 11rem;
max-width: 16rem;
padding: var(--space-3);
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
background: var(--color-surface);
box-shadow: var(--shadow-card-lift);
z-index: 5;
}
.races-cal__popover-list {
margin: 0 0 var(--space-2);
padding: 0;
list-style: none;
}
.races-cal__popover-item {
margin: 0 0 var(--space-1);
}
.races-cal__popover-link {
color: var(--color-accent);
font-weight: 600;
font-size: var(--font-size-caption);
text-decoration: none;
}
.races-cal__popover-link:hover,
.races-cal__popover-link:focus-visible {
text-decoration: underline;
outline: none;
}
.races-cal__popover-empty {
margin: 0 0 var(--space-2);
font-size: var(--font-size-caption);
color: var(--color-text-muted);
}
.races-cal__popover-add {
width: 100%;
justify-content: center;
text-align: center;
font-size: var(--font-size-caption);
padding: var(--space-2);
}
.races-cal__month-focus .races-cal__month {
max-width: 28rem;
margin-top: var(--space-4);
}
.races-cal__month-nav {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-4);
}
.races-cal__month-nav-item {
font: inherit;
font-size: var(--font-size-caption);
padding: var(--space-1) var(--space-2);
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-accent);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.races-cal__month-nav-item:hover,
.races-cal__month-nav-item:focus-visible {
outline: none;
color: var(--color-text);
}
.races-cal__month-nav-item--active {
font-weight: 700;
text-decoration: none;
background: #eef2f6;
color: var(--color-text);
}
.races-cal__month-nav-item--all {
color: var(--color-text-muted);
}
.race-day__list {
margin: var(--space-5) 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.race-day__item {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
}
.race-day__link {
font-weight: 600;
color: var(--color-accent);
text-decoration: none;
}
.race-day__link:hover,
.race-day__link:focus-visible {
text-decoration: underline;
outline: none;
}
.race-day__meta {
font-size: var(--font-size-caption);
color: var(--color-text-muted);
}
.race-day__actions {
margin-top: var(--space-5);
}
/* ─── Responsive ───────────────────────────────────────── */ /* ─── Responsive ───────────────────────────────────────── */
@media (max-width: 900px) { @media (max-width: 900px) {
@@ -627,6 +995,10 @@ a {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.races-cal__year {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.race-details-header { .race-details-header {
flex-direction: column; flex-direction: column;
} }
@@ -636,3 +1008,9 @@ a {
align-items: stretch; align-items: stretch;
} }
} }
@media (max-width: 560px) {
.races-cal__year {
grid-template-columns: 1fr;
}
}

View File

@@ -27,4 +27,6 @@
--radius-sm: 0.375rem; --radius-sm: 0.375rem;
--radius-md: 0.75rem; --radius-md: 0.75rem;
--radius-lg: 1rem; --radius-lg: 1rem;
--shadow-card-lift: 0 6px 20px rgba(19, 32, 43, 0.12);
} }