Compare commits
21 Commits
fix/auto-r
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c77efe76a | |||
| 4f61d9a106 | |||
| d2c54614af | |||
| 36c2042da5 | |||
| 9cde2ec55f | |||
| 69cfd3bf3e | |||
| 186accdcfb | |||
| 880d57dc59 | |||
| 570004f5be | |||
| 1b0e524e0e | |||
| 47f90c5389 | |||
| 1b8235fa6e | |||
| b9b98d2164 | |||
|
|
4e6b283aa0 | ||
| b4caf731eb | |||
|
|
821f9167ca | ||
| e882e8ded6 | |||
|
|
a8b87fc6b1 | ||
| 46998a1a67 | |||
|
|
2d1390ae63 | ||
| f6ad7b924a |
133
CHANGELOG.md
133
CHANGELOG.md
@@ -5,23 +5,40 @@
|
|||||||
Дата означает дату попадания изменения в `main`; она не заменяет дату production-деплоя.
|
Дата означает дату попадания изменения в `main`; она не заменяет дату production-деплоя.
|
||||||
Планируемые версии не вносятся сюда до выпуска — они находятся в [docs/product-backlog.md](docs/product-backlog.md).
|
Планируемые версии не вносятся сюда до выпуска — они находятся в [docs/product-backlog.md](docs/product-backlog.md).
|
||||||
|
|
||||||
## В работе
|
|
||||||
|
|
||||||
### Backend 1.5.0
|
|
||||||
|
|
||||||
#### Added
|
|
||||||
|
|
||||||
- Управление паролем и сессиями: проверка текущего пароля, список активных сессий, отзыв выбранной или всех остальных сессий.
|
|
||||||
|
|
||||||
### Frontend 0.8.0
|
|
||||||
|
|
||||||
#### Added
|
|
||||||
|
|
||||||
- Экран «Аккаунт»: подтверждённый email, смена пароля и управление другими активными сессиями.
|
|
||||||
- На дашборде появились фильтруемые «Последние старты» с пагинацией и переходом в старт, ссылки из карточек рекордов и интерактивный график прогресса.
|
|
||||||
|
|
||||||
## Backend
|
## Backend
|
||||||
|
|
||||||
|
### [1.6.0] — 2026-07-13
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- Фильтрация стартов по статусу, периоду и дистанции; backfill и обновление автоматически получаемых обложек.
|
||||||
|
|
||||||
|
### [1.5.3] — 2026-07-12
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- Миграции базы данных автоматически выполняются перед запуском backend-контейнера.
|
||||||
|
|
||||||
|
### [1.5.2] — 2026-07-12
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- Настраиваемое имя публичного профиля и раздельная видимость будущих и завершённых стартов.
|
||||||
|
- Публичные результаты финиша с учётом настроек видимости владельца.
|
||||||
|
|
||||||
|
### [1.5.1] — 2026-07-12
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- Публичные страницы пользователей и API опубликованного календаря стартов.
|
||||||
|
|
||||||
|
### [1.5.0] — 2026-07-12
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- Проверка и смена текущего пароля.
|
||||||
|
- Список активных сессий, отзыв выбранной сессии или всех остальных сессий.
|
||||||
|
|
||||||
### [1.4.2] — 2026-07-12
|
### [1.4.2] — 2026-07-12
|
||||||
|
|
||||||
#### Changed
|
#### Changed
|
||||||
@@ -91,6 +108,73 @@
|
|||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
|
### [0.10.2] — 2026-08-14
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- По умолчанию завершённые старты отображаются от самых свежих к более старым; явно выбранная сортировка применяется без переопределения.
|
||||||
|
|
||||||
|
### [0.10.1] — 2026-08-13
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
- На странице `/races` завершённые старты сортируются от ранних к поздним; рядом с разделами отображается количество стартов.
|
||||||
|
- График прогресса на дашборде показывает темп в мин/км: шкала идёт от медленного темпа внизу к нулю сверху.
|
||||||
|
- Подписи графика обновлены с времени на темп.
|
||||||
|
|
||||||
|
### [0.10.0] — 2026-08-13
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
- Основные фильтры календаря оставлены в hero-блоке, а расширенные фильтры перенесены в компактную раскрываемую панель со счётчиком активных условий.
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- В конфигурацию TypeScript для Vite добавлены DOM-типы.
|
||||||
|
|
||||||
|
### [0.9.3] — 2026-07-13
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
- Фильтры календаря сворачиваются и показывают число активных условий; hero-блок дашборда стал компактнее.
|
||||||
|
|
||||||
|
### [0.9.1] — 2026-07-13
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- Зарегистрированы необходимые модули AG Charts для корректного отображения графика.
|
||||||
|
|
||||||
|
### [0.9.0] — 2026-07-13
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- Поиск, фильтры и сортировка истории стартов с синхронизацией параметров в URL.
|
||||||
|
- Экспорт выбранных стартов в CSV и iCalendar.
|
||||||
|
- Интерактивный график прогресса на AG Charts.
|
||||||
|
- Фильтрация стартов по статусу, диапазону дат и дистанции.
|
||||||
|
|
||||||
|
### [0.8.2] — 2026-07-12
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- Имена публичных профилей и отдельные настройки видимости будущих и завершённых стартов.
|
||||||
|
- Отображение опубликованных результатов на публичной странице.
|
||||||
|
|
||||||
|
### [0.8.1] — 2026-07-12
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- Публичная страница пользователя с опубликованным календарём стартов.
|
||||||
|
- Управление видимостью публичного профиля в аккаунте.
|
||||||
|
|
||||||
|
### [0.8.0] — 2026-07-12
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
- Экран «Аккаунт»: подтверждённый email, смена пароля и управление активными сессиями.
|
||||||
|
- Фильтруемые «Последние старты» с пагинацией и переходом в старт на дашборде.
|
||||||
|
- Ссылки из карточек рекордов и интерактивный график прогресса.
|
||||||
|
|
||||||
### [0.7.1] — 2026-07-12
|
### [0.7.1] — 2026-07-12
|
||||||
|
|
||||||
#### Changed
|
#### Changed
|
||||||
@@ -143,6 +227,25 @@
|
|||||||
|
|
||||||
- Редизайн дашборда и визуального представления стартов.
|
- Редизайн дашборда и визуального представления стартов.
|
||||||
|
|
||||||
|
### [0.4.3] — 2026-04-13
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- В календаре скрыты всплывающие карточки для дней без стартов.
|
||||||
|
|
||||||
|
### [0.4.2] — 2026-04-13
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- Анимация наведения применяется ко всей строке старта, а не только к внутренней ссылке.
|
||||||
|
|
||||||
|
### [0.4.1] — 2026-04-13
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- Старт автоматически получает статус «Пробежал» при вводе корректного времени финиша.
|
||||||
|
- Исправлены переходы с дашборда и поведение списка и календаря стартов.
|
||||||
|
|
||||||
### [0.4.0] — 2026-04-13
|
### [0.4.0] — 2026-04-13
|
||||||
|
|
||||||
#### Added
|
#### Added
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -4,15 +4,15 @@
|
|||||||
|
|
||||||
## Переменные окружения
|
## Переменные окружения
|
||||||
|
|
||||||
Один шаблон для локальной разработки и для Docker-стека: `**[.env.example](.env.example)`** → скопируйте в `**.env**` в корне репозитория.
|
Один шаблон для локальной разработки и Docker-стека: [.env.example](.env.example) → скопируйте в `.env` в корне репозитория.
|
||||||
|
|
||||||
Там же перечислены `**DB_HOST**`, `**DB_PORT**`, `**DB_NAME**`, `**DB_USER**`, `**DB_PASSWORD**` (подключение бэкенда к БД), `**PORT**` / `**API_PORT**` и `**CORS_ORIGIN**`.
|
Там же перечислены `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` (подключение бэкенда к БД), `PORT` / `API_PORT` и `CORS_ORIGIN`.
|
||||||
|
|
||||||
## Backend — локально
|
## Backend — локально
|
||||||
|
|
||||||
1. `cd backend && npm install`
|
1. `cd backend && npm ci`
|
||||||
2. Корень: `cp .env.example .env`, задайте `DB_*` (и при необходимости `CORS_ORIGIN`).
|
2. Корень: `cp .env.example .env`, задайте `DB_*` (и при необходимости `CORS_ORIGIN`).
|
||||||
3. Postgres: из корня `docker compose up -d` (см. `[docker-compose.yml](docker-compose.yml)`) — в compose используются те же `DB_NAME`, `DB_USER`, `DB_PASSWORD` из `.env`.
|
3. Postgres: из корня `docker compose up -d` (см. [docker-compose.yml](docker-compose.yml)) — в compose используются те же `DB_NAME`, `DB_USER`, `DB_PASSWORD` из `.env`.
|
||||||
4. `cd backend && npm run db:migrate && npm run seed`
|
4. `cd backend && npm run db:migrate && npm run seed`
|
||||||
5. Dev-режим: `npm run dev`
|
5. Dev-режим: `npm run dev`
|
||||||
6. Или production: `npm run build && npm start`
|
6. Или production: `npm run build && npm start`
|
||||||
@@ -22,15 +22,15 @@
|
|||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
npm install
|
npm ci
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Фронт всегда отправляет запросы на относительный префикс `**/api**`. В dev это проксирует Vite на `http://localhost:3001`, в Docker/проде — nginx фронта проксирует на `runners-calendar-backend:3000`. У бэкенда `CORS_ORIGIN` должен совпадать с origin приложения (например `http://localhost:5173`).
|
Фронт всегда отправляет запросы на относительный префикс `/api`. В dev это проксирует Vite на `http://localhost:3001`, в Docker/проде — nginx фронта проксирует на `runners-calendar-backend:3000`. У бэкенда `CORS_ORIGIN` должен совпадать с origin приложения (например `http://localhost:5173`).
|
||||||
|
|
||||||
## Docker: backend + frontend рядом с Postgres
|
## Docker: backend + frontend рядом с Postgres
|
||||||
|
|
||||||
Используйте `[docker-compose.stack.yml](docker-compose.stack.yml)`: общая **внешняя** сеть с контейнером Postgres (как в вашей инфраструктуре). В корне должен быть `**.env`** (из `.env.example`): `DB_HOST` — имя сервиса/контейнера Postgres в этой сети, `DB_PORT=5432`, плюс остальные `DB_*` и `**CORS_ORIGIN=http://localhost:3033**`, если заходите на фронт с хоста на порту 3033.
|
Используйте [docker-compose.stack.yml](docker-compose.stack.yml): общая **внешняя** сеть с контейнером Postgres (как в вашей инфраструктуре). В корне должен быть `.env` (из `.env.example`): `DB_HOST` — имя сервиса/контейнера Postgres в этой сети, `DB_PORT=5432`, плюс остальные `DB_*` и `CORS_ORIGIN=http://localhost:3033`, если заходите на фронт с хоста на порту 3033.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.stack.yml up -d --build
|
docker compose -f docker-compose.stack.yml up -d --build
|
||||||
@@ -38,9 +38,11 @@ docker compose -f docker-compose.stack.yml exec runners-calendar-backend node di
|
|||||||
docker compose -f docker-compose.stack.yml exec runners-calendar-backend node dist/seed.js
|
docker compose -f docker-compose.stack.yml exec runners-calendar-backend node dist/seed.js
|
||||||
```
|
```
|
||||||
|
|
||||||
Фронт в браузере обращается к API по префиксу `**/api**` (nginx в образе фронта проксирует на сервис `runners-calendar-backend` в той же сети).
|
Фронт в браузере обращается к API по префиксу `/api` (nginx в образе фронта проксирует на сервис `runners-calendar-backend` в той же сети).
|
||||||
|
|
||||||
## Документация API и бэкенда
|
## Документация
|
||||||
|
|
||||||
- [Шпаргалка API для фронта](docs/backend-api-for-frontend.md)
|
- [Шпаргалка API для фронта](docs/backend-api-for-frontend.md)
|
||||||
- [Эксплуатация backend](docs/backend.md)
|
- [Эксплуатация backend](docs/backend.md)
|
||||||
|
- [Продуктовый бэклог](docs/product-backlog.md)
|
||||||
|
- [Changelog](CHANGELOG.md)
|
||||||
|
|||||||
2
backend/migrations/007_cover_image_source.sql
Normal file
2
backend/migrations/007_cover_image_source.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE races
|
||||||
|
ADD COLUMN IF NOT EXISTS cover_image_manual BOOLEAN NOT NULL DEFAULT TRUE;
|
||||||
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "calendar-run-backend",
|
"name": "calendar-run-backend",
|
||||||
"version": "1.5.3",
|
"version": "1.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "calendar-run-backend",
|
"name": "calendar-run-backend",
|
||||||
"version": "1.5.3",
|
"version": "1.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "calendar-run-backend",
|
"name": "calendar-run-backend",
|
||||||
"version": "1.5.3",
|
"version": "1.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"db:migrate": "ts-node src/migrate.ts",
|
"db:migrate": "ts-node src/migrate.ts",
|
||||||
"seed": "ts-node src/seed.ts",
|
"seed": "ts-node src/seed.ts",
|
||||||
|
"backfill:covers": "ts-node src/backfillRaceCovers.ts",
|
||||||
"test": "tsx --import ./test/setup.ts --test test/app.test.ts"
|
"test": "tsx --import ./test/setup.ts --test test/app.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
55
backend/src/backfillRaceCovers.ts
Normal file
55
backend/src/backfillRaceCovers.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { pool } from "./db";
|
||||||
|
import { extractRaceCoverImage } from "./raceCoverImage";
|
||||||
|
|
||||||
|
type Candidate = { id: string; official_url: string };
|
||||||
|
|
||||||
|
export type CoverBackfillStats = {
|
||||||
|
candidates: number;
|
||||||
|
restored: number;
|
||||||
|
skipped: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function backfillRaceCovers(): Promise<CoverBackfillStats> {
|
||||||
|
const { rows } = await pool.query<Candidate>(
|
||||||
|
`SELECT id, official_url
|
||||||
|
FROM races
|
||||||
|
WHERE cover_image_url IS NULL
|
||||||
|
AND official_url IS NOT NULL
|
||||||
|
AND BTRIM(official_url) <> ''
|
||||||
|
ORDER BY race_date ASC, id ASC`,
|
||||||
|
);
|
||||||
|
const stats: CoverBackfillStats = { candidates: rows.length, restored: 0, skipped: 0 };
|
||||||
|
|
||||||
|
// ponytail: sequential network calls keep memory and outbound load bounded; parallelize per batch if volume requires it.
|
||||||
|
for (const row of rows) {
|
||||||
|
const coverImageUrl = await extractRaceCoverImage(row.official_url);
|
||||||
|
if (!coverImageUrl) {
|
||||||
|
stats.skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE races
|
||||||
|
SET cover_image_url = $1, cover_image_manual = FALSE, updated_at = NOW()
|
||||||
|
WHERE id = $2 AND cover_image_url IS NULL`,
|
||||||
|
[coverImageUrl, row.id],
|
||||||
|
);
|
||||||
|
if (result.rowCount === 1) {
|
||||||
|
stats.restored += 1;
|
||||||
|
} else {
|
||||||
|
stats.skipped += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
backfillRaceCovers()
|
||||||
|
.then((stats) => console.log(JSON.stringify(stats)))
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error("[backfill:covers] FAILED:", error instanceof Error ? error.message : error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(() => pool.end());
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ export interface RaceRow {
|
|||||||
status: string | null;
|
status: string | null;
|
||||||
official_url: string | null;
|
official_url: string | null;
|
||||||
cover_image_url: string | null;
|
cover_image_url: string | null;
|
||||||
|
cover_image_manual: boolean;
|
||||||
start_time: string | null;
|
start_time: string | null;
|
||||||
cluster_schedule: string | null;
|
cluster_schedule: string | null;
|
||||||
bib_pickup: string | null;
|
bib_pickup: string | null;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { clearSessionCookie, requireAuth, setSessionCookie } from "../authMiddle
|
|||||||
import { isValidPassword, normalizeEmail } from "../security";
|
import { isValidPassword, normalizeEmail } from "../security";
|
||||||
import { verifyTurnstileToken } from "../turnstile";
|
import { verifyTurnstileToken } from "../turnstile";
|
||||||
|
|
||||||
const router = Router();
|
const router: Router = Router();
|
||||||
|
|
||||||
const genericOk = { ok: true };
|
const genericOk = { ok: true };
|
||||||
const genericAuthError = { error: "invalid_credentials", details: ["Invalid email or password"] };
|
const genericAuthError = { error: "invalid_credentials", details: ["Invalid email or password"] };
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Router, Request, Response } from "express";
|
|||||||
import { checkDbConnection } from "../db";
|
import { checkDbConnection } from "../db";
|
||||||
import { getBackendVersion } from "../version";
|
import { getBackendVersion } from "../version";
|
||||||
|
|
||||||
const router = Router();
|
const router: Router = Router();
|
||||||
|
|
||||||
router.get("/health", (_req: Request, res: Response) => {
|
router.get("/health", (_req: Request, res: Response) => {
|
||||||
res.json({ status: "ok", version: getBackendVersion() });
|
res.json({ status: "ok", version: getBackendVersion() });
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { rowToDto, bodyToColumns, RaceRow } from "../mappers/race";
|
|||||||
import { extractRaceCoverImage } from "../raceCoverImage";
|
import { extractRaceCoverImage } from "../raceCoverImage";
|
||||||
import { requireAuth } from "../authMiddleware";
|
import { requireAuth } from "../authMiddleware";
|
||||||
|
|
||||||
const router = Router();
|
const router: Router = Router();
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
|
||||||
type ValidationErrorBody = {
|
type ValidationErrorBody = {
|
||||||
@@ -136,12 +136,77 @@ function parseOptionalIntegerQuery(
|
|||||||
return { value: parsed };
|
return { value: parsed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseOptionalNumberQuery(
|
||||||
|
value: unknown,
|
||||||
|
fieldName: string,
|
||||||
|
min?: number,
|
||||||
|
max?: number,
|
||||||
|
): { value?: number; error?: string } {
|
||||||
|
if (value == null) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (typeof value !== "string" || value.trim() === "") {
|
||||||
|
return { error: `${fieldName} must be a number` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return { error: `${fieldName} must be a number` };
|
||||||
|
}
|
||||||
|
if (min != null && parsed < min) {
|
||||||
|
return { error: `${fieldName} must be between ${min} and ${max}` };
|
||||||
|
}
|
||||||
|
if (max != null && parsed > max) {
|
||||||
|
return { error: `${fieldName} must be between ${min} and ${max}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { value: parsed };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalDateQuery(value: unknown, fieldName: string): { value?: string; error?: string } {
|
||||||
|
if (value == null) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||||
|
return { error: `${fieldName} must be YYYY-MM-DD` };
|
||||||
|
}
|
||||||
|
const parsed = new Date(`${value}T00:00:00.000Z`);
|
||||||
|
if (Number.isNaN(parsed.valueOf()) || parsed.toISOString().slice(0, 10) !== value) {
|
||||||
|
return { error: `${fieldName} must be a valid calendar date` };
|
||||||
|
}
|
||||||
|
return { value };
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── GET /races ──────────────────────────────────────────── */
|
/* ─── GET /races ──────────────────────────────────────────── */
|
||||||
|
|
||||||
router.get("/races", async (req: Request, res: Response) => {
|
router.get("/races", async (req: Request, res: Response) => {
|
||||||
const yearResult = parseOptionalIntegerQuery(req.query.year, "year");
|
const yearResult = parseOptionalIntegerQuery(req.query.year, "year");
|
||||||
const monthResult = parseOptionalIntegerQuery(req.query.month, "month", 1, 12);
|
const monthResult = parseOptionalIntegerQuery(req.query.month, "month", 1, 12);
|
||||||
const details = [yearResult.error, monthResult.error].filter(Boolean) as string[];
|
const statusResult = req.query.status == null
|
||||||
|
? {}
|
||||||
|
: typeof req.query.status === "string" && ["planned", "registered", "completed"].includes(req.query.status)
|
||||||
|
? { value: req.query.status }
|
||||||
|
: { error: "status must be planned, registered or completed" };
|
||||||
|
const dateFromResult = parseOptionalDateQuery(req.query.dateFrom, "dateFrom");
|
||||||
|
const dateToResult = parseOptionalDateQuery(req.query.dateTo, "dateTo");
|
||||||
|
const distanceMinResult = parseOptionalNumberQuery(req.query.distanceMin, "distanceMin", 0.001, 999.999);
|
||||||
|
const distanceMaxResult = parseOptionalNumberQuery(req.query.distanceMax, "distanceMax", 0.001, 999.999);
|
||||||
|
const details = [
|
||||||
|
yearResult.error,
|
||||||
|
monthResult.error,
|
||||||
|
statusResult.error,
|
||||||
|
dateFromResult.error,
|
||||||
|
dateToResult.error,
|
||||||
|
distanceMinResult.error,
|
||||||
|
distanceMaxResult.error,
|
||||||
|
].filter(Boolean) as string[];
|
||||||
|
|
||||||
|
if (dateFromResult.value && dateToResult.value && dateFromResult.value > dateToResult.value) {
|
||||||
|
details.push("dateFrom must not be after dateTo");
|
||||||
|
}
|
||||||
|
if (distanceMinResult.value != null && distanceMaxResult.value != null && distanceMinResult.value > distanceMaxResult.value) {
|
||||||
|
details.push("distanceMin must not be greater than distanceMax");
|
||||||
|
}
|
||||||
|
|
||||||
if (details.length > 0) {
|
if (details.length > 0) {
|
||||||
validationError(res, details);
|
validationError(res, details);
|
||||||
@@ -164,6 +229,26 @@ router.get("/races", async (req: Request, res: Response) => {
|
|||||||
conditions.push(`EXTRACT(MONTH FROM race_date) = $${idx++}`);
|
conditions.push(`EXTRACT(MONTH FROM race_date) = $${idx++}`);
|
||||||
params.push(monthResult.value);
|
params.push(monthResult.value);
|
||||||
}
|
}
|
||||||
|
if (statusResult.value != null) {
|
||||||
|
conditions.push(`status = $${idx++}`);
|
||||||
|
params.push(statusResult.value);
|
||||||
|
}
|
||||||
|
if (dateFromResult.value != null) {
|
||||||
|
conditions.push(`race_date >= $${idx++}`);
|
||||||
|
params.push(dateFromResult.value);
|
||||||
|
}
|
||||||
|
if (dateToResult.value != null) {
|
||||||
|
conditions.push(`race_date <= $${idx++}`);
|
||||||
|
params.push(dateToResult.value);
|
||||||
|
}
|
||||||
|
if (distanceMinResult.value != null) {
|
||||||
|
conditions.push(`distance_km >= $${idx++}`);
|
||||||
|
params.push(distanceMinResult.value);
|
||||||
|
}
|
||||||
|
if (distanceMaxResult.value != null) {
|
||||||
|
conditions.push(`distance_km <= $${idx++}`);
|
||||||
|
params.push(distanceMaxResult.value);
|
||||||
|
}
|
||||||
|
|
||||||
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||||
const sql = `SELECT * FROM races ${where} ORDER BY race_date ASC`;
|
const sql = `SELECT * FROM races ${where} ORDER BY race_date ASC`;
|
||||||
@@ -220,6 +305,8 @@ router.post("/races", async (req: Request, res: Response) => {
|
|||||||
const { columns, values } = bodyToColumns(payload as Record<string, unknown>);
|
const { columns, values } = bodyToColumns(payload as Record<string, unknown>);
|
||||||
columns.unshift("owner_user_id");
|
columns.unshift("owner_user_id");
|
||||||
values.unshift(req.auth!.user.id);
|
values.unshift(req.auth!.user.id);
|
||||||
|
columns.push("cover_image_manual");
|
||||||
|
values.push(hasManualCover);
|
||||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
const sql = `INSERT INTO races (${columns.join(", ")}) VALUES (${placeholders}) RETURNING *`;
|
const sql = `INSERT INTO races (${columns.join(", ")}) VALUES (${placeholders}) RETURNING *`;
|
||||||
|
|
||||||
@@ -251,15 +338,43 @@ router.patch("/races/:id", async (req: Request, res: Response) => {
|
|||||||
validationError(res, schemaDetails(parsed.error));
|
validationError(res, schemaDetails(parsed.error));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { columns, values } = bodyToColumns(parsed.data as Record<string, unknown>);
|
|
||||||
|
|
||||||
const sets = columns.map((col, i) => `${col} = $${i + 1}`);
|
|
||||||
sets.push(`updated_at = NOW()`);
|
|
||||||
values.push(id);
|
|
||||||
values.push(req.auth!.user.id);
|
|
||||||
const sql = `UPDATE races SET ${sets.join(", ")} WHERE id = $${values.length - 1} AND owner_user_id = $${values.length} RETURNING *`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const currentResult = await pool.query<Pick<RaceRow, "official_url" | "cover_image_url" | "cover_image_manual">>(
|
||||||
|
"SELECT official_url, cover_image_url, cover_image_manual FROM races WHERE id = $1 AND owner_user_id = $2",
|
||||||
|
[id, req.auth!.user.id],
|
||||||
|
);
|
||||||
|
if (currentResult.rows.length === 0) {
|
||||||
|
res.status(404).json({ error: "not_found", details: ["Race not found"] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = { ...parsed.data };
|
||||||
|
const officialUrlChanged = Object.prototype.hasOwnProperty.call(payload, "officialUrl");
|
||||||
|
const current = currentResult.rows[0]!;
|
||||||
|
const hasCoverField = Object.prototype.hasOwnProperty.call(payload, "coverImageUrl");
|
||||||
|
const sameAsExistingCover = typeof payload.coverImageUrl === "string" && payload.coverImageUrl === current.cover_image_url;
|
||||||
|
const manualCoverChanged = typeof payload.coverImageUrl === "string" && payload.coverImageUrl.trim() !== "" && !sameAsExistingCover;
|
||||||
|
let coverImageManual = current.cover_image_manual;
|
||||||
|
if (hasCoverField && !sameAsExistingCover) {
|
||||||
|
coverImageManual = manualCoverChanged;
|
||||||
|
}
|
||||||
|
const officialUrl = payload.officialUrl === undefined ? current.official_url : payload.officialUrl;
|
||||||
|
if (officialUrlChanged && !current.cover_image_manual && !manualCoverChanged && typeof officialUrl === "string" && officialUrl.trim() !== "") {
|
||||||
|
payload.coverImageUrl = await extractRaceCoverImage(officialUrl);
|
||||||
|
coverImageManual = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { columns, values } = bodyToColumns(payload as Record<string, unknown>);
|
||||||
|
if (hasCoverField || officialUrlChanged) {
|
||||||
|
columns.push("cover_image_manual");
|
||||||
|
values.push(coverImageManual);
|
||||||
|
}
|
||||||
|
const sets = columns.map((col, i) => `${col} = $${i + 1}`);
|
||||||
|
sets.push(`updated_at = NOW()`);
|
||||||
|
values.push(id);
|
||||||
|
values.push(req.auth!.user.id);
|
||||||
|
const sql = `UPDATE races SET ${sets.join(", ")} WHERE id = $${values.length - 1} AND owner_user_id = $${values.length} RETURNING *`;
|
||||||
|
|
||||||
const { rows } = await pool.query<RaceRow>(sql, values);
|
const { rows } = await pool.query<RaceRow>(sql, values);
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
res.status(404).json({ error: "not_found", details: ["Race not found"] });
|
res.status(404).json({ error: "not_found", details: ["Race not found"] });
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
|||||||
import { after, test } from "node:test";
|
import { after, test } from "node:test";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { buildHelmetOptions, createApp } from "../src/app";
|
import { buildHelmetOptions, createApp } from "../src/app";
|
||||||
|
import { backfillRaceCovers } from "../src/backfillRaceCovers";
|
||||||
import {
|
import {
|
||||||
cleanupExpiredAuthRows,
|
cleanupExpiredAuthRows,
|
||||||
createResetToken,
|
createResetToken,
|
||||||
@@ -161,6 +162,29 @@ test("GET /api/races accepts year and month", async () => {
|
|||||||
assert.ok(Array.isArray(res.body));
|
assert.ok(Array.isArray(res.body));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("GET /api/races filters by status, date and distance", async () => {
|
||||||
|
const { agent, csrfToken } = await authAgent();
|
||||||
|
await agent.post("/api/races").set("X-CSRF-Token", csrfToken).send({
|
||||||
|
slug: "2026-05-01-filter-planned", date: "2026-05-01", title: "Planned 5", distanceKm: 5, status: "planned",
|
||||||
|
}).expect(201);
|
||||||
|
await agent.post("/api/races").set("X-CSRF-Token", csrfToken).send({
|
||||||
|
slug: "2026-06-01-filter-completed", date: "2026-06-01", title: "Completed 10", distanceKm: 10, status: "completed",
|
||||||
|
}).expect(201);
|
||||||
|
await agent.post("/api/races").set("X-CSRF-Token", csrfToken).send({
|
||||||
|
slug: "2026-07-01-filter-completed", date: "2026-07-01", title: "Completed 21", distanceKm: 21.1, status: "completed",
|
||||||
|
}).expect(201);
|
||||||
|
|
||||||
|
const res = await agent.get("/api/races?status=completed&dateFrom=2026-06-01&dateTo=2026-06-30&distanceMax=10").expect(200);
|
||||||
|
assert.deepEqual(res.body.map((race: { title: string }) => race.title), ["Completed 10"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /api/races rejects inverted filter ranges", async () => {
|
||||||
|
const { agent } = await authAgent();
|
||||||
|
const res = await agent.get("/api/races?dateFrom=2026-07-01&dateTo=2026-06-01").expect(400);
|
||||||
|
assert.equal(res.body.error, "validation_error");
|
||||||
|
assert.ok(res.body.details.some((detail: string) => detail.includes("dateFrom")));
|
||||||
|
});
|
||||||
|
|
||||||
test("GET /api/races/:id rejects a non-UUID id", async () => {
|
test("GET /api/races/:id rejects a non-UUID id", async () => {
|
||||||
const { agent } = await authAgent();
|
const { agent } = await authAgent();
|
||||||
const res = await agent.get("/api/races/does-not-exist").expect(400);
|
const res = await agent.get("/api/races/does-not-exist").expect(400);
|
||||||
@@ -460,6 +484,39 @@ test("POST /api/races stores manual coverImageUrl", async () => {
|
|||||||
assert.equal(res.body.coverImageUrl, coverImageUrl);
|
assert.equal(res.body.coverImageUrl, coverImageUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("PATCH /api/races refreshes an automatic cover but preserves a manual cover", async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let imageName = "first.jpg";
|
||||||
|
globalThis.fetch = async () => new Response(`<meta property="og:image" content="/${imageName}">`, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/html" },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { agent, csrfToken } = await authAgent();
|
||||||
|
const created = await agent.post("/api/races").set("X-CSRF-Token", csrfToken).send({
|
||||||
|
slug: "2026-06-06-auto-refresh", date: "2026-06-06", title: "Auto Refresh", distanceKm: 10, officialUrl: "https://example.com/first",
|
||||||
|
}).expect(201);
|
||||||
|
imageName = "second.jpg";
|
||||||
|
const refreshed = await agent.patch(`/api/races/${created.body.id}`).set("X-CSRF-Token", csrfToken).send({
|
||||||
|
officialUrl: "https://example.com/second",
|
||||||
|
}).expect(200);
|
||||||
|
assert.equal(refreshed.body.coverImageUrl, "https://example.com/second.jpg");
|
||||||
|
|
||||||
|
const manual = await agent.patch(`/api/races/${created.body.id}`).set("X-CSRF-Token", csrfToken).send({
|
||||||
|
officialUrl: "https://example.com/third", coverImageUrl: "https://example.com/manual.jpg",
|
||||||
|
}).expect(200);
|
||||||
|
imageName = "fourth.jpg";
|
||||||
|
const preserved = await agent.patch(`/api/races/${created.body.id}`).set("X-CSRF-Token", csrfToken).send({
|
||||||
|
officialUrl: "https://example.com/fourth",
|
||||||
|
}).expect(200);
|
||||||
|
assert.equal(manual.body.coverImageUrl, "https://example.com/manual.jpg");
|
||||||
|
assert.equal(preserved.body.coverImageUrl, "https://example.com/manual.jpg");
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("POST /api/races rejects unknown and malformed fields", async () => {
|
test("POST /api/races rejects unknown and malformed fields", async () => {
|
||||||
const { agent, csrfToken } = await authAgent();
|
const { agent, csrfToken } = await authAgent();
|
||||||
const res = await agent
|
const res = await agent
|
||||||
@@ -526,6 +583,58 @@ test("POST /api/races auto extracts coverImageUrl from officialUrl", async () =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("PATCH /api/races auto extracts a missing cover when officialUrl changes", async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async () => new Response("", { status: 404 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { agent, csrfToken } = await authAgent();
|
||||||
|
const created = await agent
|
||||||
|
.post("/api/races")
|
||||||
|
.set("X-CSRF-Token", csrfToken)
|
||||||
|
.send({ slug: "2026-06-04-patch-cover", date: "2026-06-04", title: "Patch Cover", distanceKm: 10 })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
globalThis.fetch = async () => new Response('<meta property="og:image" content="/patched.jpg">', {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/html" },
|
||||||
|
});
|
||||||
|
const updated = await agent
|
||||||
|
.patch(`/api/races/${created.body.id}`)
|
||||||
|
.set("X-CSRF-Token", csrfToken)
|
||||||
|
.send({ officialUrl: "https://example.com/patched" })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
assert.equal(updated.body.coverImageUrl, "https://example.com/patched.jpg");
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cover backfill restores only races without a cover", async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async () => new Response('<meta property="og:image" content="/backfill.jpg">', {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/html" },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { agent, csrfToken } = await authAgent();
|
||||||
|
await agent.post("/api/races").set("X-CSRF-Token", csrfToken).send({
|
||||||
|
slug: "2026-06-05-backfill", date: "2026-06-05", title: "Backfill", distanceKm: 10,
|
||||||
|
}).expect(201);
|
||||||
|
await pool.query("UPDATE races SET official_url = $1 WHERE slug = $2", ["https://example.com/backfill", "2026-06-05-backfill"]);
|
||||||
|
|
||||||
|
const stats = await backfillRaceCovers();
|
||||||
|
assert.ok(stats.candidates >= 1);
|
||||||
|
assert.ok(stats.restored >= 1);
|
||||||
|
const result = await pool.query<{ cover_image_url: string }>("SELECT cover_image_url FROM races WHERE slug = $1", ["2026-06-05-backfill"]);
|
||||||
|
assert.equal(result.rows[0]?.cover_image_url, "https://example.com/backfill.jpg");
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("POST /api/races succeeds when cover extraction fails", async () => {
|
test("POST /api/races succeeds when cover extraction fails", async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
globalThis.fetch = async () => {
|
globalThis.fetch = async () => {
|
||||||
|
|||||||
@@ -85,8 +85,10 @@ Liveness без проверки БД:
|
|||||||
### `GET /api/races`
|
### `GET /api/races`
|
||||||
|
|
||||||
Возвращает только забеги текущего пользователя, отсортированные по дате. Опциональные
|
Возвращает только забеги текущего пользователя, отсортированные по дате. Опциональные
|
||||||
query-параметры: `year` (целое число) и `month` (1–12); без результатов возвращается
|
query-параметры: `year` (целое число), `month` (1–12), `status` (`planned`,
|
||||||
`[]`.
|
`registered` или `completed`), `dateFrom`/`dateTo` (`YYYY-MM-DD`) и
|
||||||
|
`distanceMin`/`distanceMax` (километры, от `0.001` до `999.999`). Диапазоны не
|
||||||
|
могут быть перевёрнуты; без результатов возвращается `[]`.
|
||||||
|
|
||||||
### `GET /api/races/:id`
|
### `GET /api/races/:id`
|
||||||
|
|
||||||
@@ -124,6 +126,13 @@ query-параметры: `year` (целое число) и `month` (1–12); б
|
|||||||
|
|
||||||
Удаляет забег. Успех: `204` без тела.
|
Удаляет забег. Успех: `204` без тела.
|
||||||
|
|
||||||
|
При изменении `officialUrl` backend best-effort получает обложку, если у старта
|
||||||
|
ещё нет обложки. Ручная `coverImageUrl` не перезаписывается.
|
||||||
|
|
||||||
|
Одноразовое восстановление обложек существующих стартов запускается командой
|
||||||
|
`npm run backfill:covers`. Команда идемпотентна и печатает только агрегированную
|
||||||
|
статистику.
|
||||||
|
|
||||||
## Модель `Race`
|
## Модель `Race`
|
||||||
|
|
||||||
| Поле | Тип | POST | PATCH | Ограничение |
|
| Поле | Тип | POST | PATCH | Ограничение |
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
|
|
||||||
| Контур | Версия | Уже работает |
|
| Контур | Версия | Уже работает |
|
||||||
|---|---:|---|
|
|---|---:|---|
|
||||||
| Backend | `1.5.0` | Express + PostgreSQL, миграции и seed, строгий CRUD личных стартов, cookie-сессии, CSRF, подтверждение email, Turnstile, SMTP, смена пароля и управление сессиями, SSRF-защита обложек, `/health`, `/ready`, `/meta`, CI с PostgreSQL. |
|
| Backend | `1.6.0` | Express + PostgreSQL, миграции и seed, строгий CRUD личных стартов, фильтры истории, cookie-сессии, CSRF, подтверждение email, Turnstile, SMTP, смена пароля и управление сессиями, SSRF-защита и восстановление обложек, `/health`, `/ready`, `/meta`, CI с PostgreSQL. |
|
||||||
| Frontend | `0.8.0` | Защищённые маршруты и auth-экраны, экран аккаунта, нативные поля даты/старта, удобный ввод результатов, дашборд с последними стартами и интерактивным графиком, версия FE/BE в футере. |
|
| Frontend | `0.9.0` | Защищённые маршруты и auth-экраны, экран аккаунта, поиск/фильтры/экспорт истории, нативные поля даты/старта, удобный ввод результатов, дашборд с последними стартами и графиком AG Charts, версия FE/BE в футере. |
|
||||||
|
|
||||||
## Правила версионирования и выпуска
|
## Правила версионирования и выпуска
|
||||||
|
|
||||||
@@ -21,9 +21,9 @@
|
|||||||
| Очередь | BE | FE | Цель | Условие выпуска |
|
| Очередь | BE | FE | Цель | Условие выпуска |
|
||||||
|---|---:|---:|---|---|
|
|---|---:|---:|---|---|
|
||||||
| P0 | `1.4.2` | `0.7.1` | Исправить риски текущего релиза и привести контракт в соответствие коду. | Нет невалидных записей, SSRF-защита, критические сценарии проверяются на PostgreSQL. |
|
| P0 | `1.4.2` | `0.7.1` | Исправить риски текущего релиза и привести контракт в соответствие коду. | Нет невалидных записей, SSRF-защита, критические сценарии проверяются на PostgreSQL. |
|
||||||
| P1 | `1.5.0` | `0.8.0` | Дать пользователю контроль над аккаунтом и данными, улучшить работу с историей стартов. | Управление сессиями и экспорт доступны, фильтры не ломают существующий список. |
|
| P1 | `1.6.0` | `0.9.0` | Дать пользователю контроль над аккаунтом и данными, улучшить работу с историей стартов. | Управление сессиями и экспорт доступны, фильтры не ломают существующий список. |
|
||||||
| P2 | `1.6.0` | `0.9.0` | Подготовка к старту: чек-листы, напоминания и календарная интеграция. | Напоминание отправляется не более одного раза, подписка/токен отзываемы. |
|
| P2 | `1.7.0` | `1.0.0` | Подготовка к старту: чек-листы, напоминания и календарная интеграция. | Напоминание отправляется не более одного раза, подписка/токен отзываемы. |
|
||||||
| P3 | `1.7.0` | `1.0.0` | Сделать персональный календарь эксплуатационно готовым. | Восстановление из backup проверено, есть E2E критического пути и аудит доступности. |
|
| P3 | `1.8.0` | `1.1.0` | Сделать персональный календарь эксплуатационно готовым. | Восстановление из backup проверено, есть E2E критического пути и аудит доступности. |
|
||||||
|
|
||||||
`2.0.0` не планируется заранее: его поводом может быть только подтверждённая потребность в совместном доступе, командах или публичных стартах — это изменит модель владельцев и доступа.
|
`2.0.0` не планируется заранее: его поводом может быть только подтверждённая потребность в совместном доступе, командах или публичных стартах — это изменит модель владельцев и доступа.
|
||||||
|
|
||||||
@@ -51,30 +51,33 @@
|
|||||||
| FE-071-05 | P0 | Завершать подтверждение email понятным экраном успеха | После успешной ссылки `/verify-email?token=…` заголовок явно сообщает, что email подтверждён; ввод email, инструкция и кнопка повторной отправки не показываются. Форма повторной отправки остаётся только для недействительной/отсутствующей ссылки. |
|
| FE-071-05 | P0 | Завершать подтверждение email понятным экраном успеха | После успешной ссылки `/verify-email?token=…` заголовок явно сообщает, что email подтверждён; ввод email, инструкция и кнопка повторной отправки не показываются. Форма повторной отправки остаётся только для недействительной/отсутствующей ссылки. |
|
||||||
| FE-071-06 | P0 | Упростить ввод результата забега | Время вводится отдельными цифровыми полями «часы», «минуты», «секунды» без ручного ввода `:`. Часы — целое число ≥ 0, минуты и секунды — от 0 до 59; пустые минуты/секунды автоматически нормализуются в `0`. Итоговое время не показывает незаполненные старшие части: без часов — `MM:SS`, без часов и минут — `SS`. Место вводится числами «место» и «всего участников» (второе необязательно), проверяется, что место ≥ 1 и не больше общего числа; в текущий `finishPlace` без изменения API сохраняется `место/всего` либо только место. Старые значения `finishPlace` не теряются до редактирования результата. |
|
| FE-071-06 | P0 | Упростить ввод результата забега | Время вводится отдельными цифровыми полями «часы», «минуты», «секунды» без ручного ввода `:`. Часы — целое число ≥ 0, минуты и секунды — от 0 до 59; пустые минуты/секунды автоматически нормализуются в `0`. Итоговое время не показывает незаполненные старшие части: без часов — `MM:SS`, без часов и минут — `SS`. Место вводится числами «место» и «всего участников» (второе необязательно), проверяется, что место ≥ 1 и не больше общего числа; в текущий `finishPlace` без изменения API сохраняется `место/всего` либо только место. Старые значения `finishPlace` не теряются до редактирования результата. |
|
||||||
|
|
||||||
## P1 — BE 1.5.0 / FE 0.8.0
|
## P1 — BE 1.6.0 / FE 0.9.0
|
||||||
|
|
||||||
### Backend 1.5.0
|
### Backend 1.6.0
|
||||||
|
|
||||||
| ID | Приоритет | Задача | Результат / критерий приёмки |
|
| ID | Приоритет | Задача | Результат / критерий приёмки |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| BE-150-01 | Готово | Управление паролем и сессиями | Выполнено в `1.5.0`: авторизованный пользователь меняет пароль после проверки текущего; все другие сессии отзываются. Есть список активных сессий с отзывом выбранной и «выйти везде». Токены и пароли в ответах/логах не появляются. |
|
| BE-150-01 | Готово | Управление паролем и сессиями | Выполнено в `1.5.0`: авторизованный пользователь меняет пароль после проверки текущего; все другие сессии отзываются. Есть список активных сессий с отзывом выбранной и «выйти везде». Токены и пароли в ответах/логах не появляются. |
|
||||||
| BE-150-02 | P1 | Удаление аккаунта и данных | Явно подтверждённый запрос удаляет аккаунт, его сессии и личные старты транзакционно; повторный запрос идемпотентен. Перед реализацией согласовать необходимость периода восстановления. |
|
| BE-150-02 | P1 | Удаление аккаунта и данных | Явно подтверждённый запрос удаляет аккаунт, его сессии и личные старты транзакционно; повторный запрос идемпотентен. Перед реализацией согласовать необходимость периода восстановления. |
|
||||||
| BE-150-03 | P1 | Обратносуместимая фильтрация стартов | Добавить документированные фильтры `status`, диапазон дат и диапазон дистанции, не меняя текущий ответ-массив `GET /races`. Валидация параметров и покрытие индексами — по фактическому query plan. |
|
| BE-150-03 | Готово | Обратносуместимая фильтрация стартов | Добавлены документированные фильтры `status`, диапазон дат и диапазон дистанции без изменения ответа-массива `GET /races`, с валидацией параметров. |
|
||||||
|
| BE-150-04 | Готово | Восстановить обложки существующих стартов | Добавлен идемпотентный backfill и обновление обложки при изменении официальной страницы. SSRF-защищённая логика переиспользуется; ручные URL не перезаписываются, результат содержит агрегированную статистику. |
|
||||||
|
|
||||||
### Frontend 0.8.0
|
### Frontend 0.9.0
|
||||||
|
|
||||||
| ID | Приоритет | Задача | Результат / критерий приёмки |
|
| ID | Приоритет | Задача | Результат / критерий приёмки |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| FE-080-01 | Готово | Экран «Аккаунт» | Выполнено в `0.8.0`: пользователь видит подтверждённый email, меняет пароль и видит/отзывает другие сессии. Удаление аккаунта остаётся отдельной задачей BE-150-02. |
|
| FE-080-01 | Готово | Экран «Аккаунт» | Выполнено в `0.8.0`: пользователь видит подтверждённый email, меняет пароль и видит/отзывает другие сессии. Удаление аккаунта остаётся отдельной задачей BE-150-02. |
|
||||||
| FE-080-02 | P1 | Поиск, фильтры и сортировка истории | Фильтры по статусу, периоду и дистанции синхронизированы с URL; поиск по названию работает без новой зависимости. Годы в селекторе строятся из данных, а не ограничены окном «-2/+4». |
|
| FE-080-02 | Готово | Поиск, фильтры и сортировка истории | Фильтры по статусу, периоду и дистанции, поиск по названию и сортировка синхронизированы с URL. Годы в селекторе строятся из данных. |
|
||||||
| FE-080-03 | P1 | Выгрузка личных данных | Из текущей отфильтрованной выборки пользователь скачивает CSV и одноразовый `.ics` в браузере. Сервер не нужен: данные уже принадлежат пользователю и загружены в UI. |
|
| FE-080-03 | Готово | Выгрузка личных данных | Из текущей отфильтрованной выборки пользователь скачивает CSV и одноразовый `.ics` в браузере; сервер не нужен. |
|
||||||
| FE-080-04 | Готово | Сделать на дашборде список последних стартов | Выполнено в `0.8.0`: «Последние старты» показывают до 5 завершённых стартов с клиентской пагинацией, фильтрами по дистанции и году и доступным переходом в старт. |
|
| FE-080-04 | Готово | Сделать на дашборде список последних стартов | Выполнено в `0.8.0`: «Последние старты» показывают до 5 завершённых стартов с клиентской пагинацией, фильтрами по дистанции и году и доступным переходом в старт. |
|
||||||
| FE-080-05 | Готово | Связать рекорды по дистанциям со стартами | Выполнено в `0.8.0`: карточка рекорда с найденным стартом ведёт на `/races/:id` и использует существующую анимацию связанных карточек. |
|
| FE-080-05 | Готово | Связать рекорды по дистанциям со стартами | Выполнено в `0.8.0`: карточка рекорда с найденным стартом ведёт на `/races/:id` и использует существующую анимацию связанных карточек. |
|
||||||
| FE-080-06 | Готово | Сделать график прогресса информативным и интерактивным | Выполнено в `0.8.0`: график имеет подписанные оси, компактные подписи, tooltip по наведению/фокусу и доступный переход в старт. |
|
| FE-080-06 | Готово | Сделать график прогресса информативным и интерактивным | Выполнено в `0.8.0`: график имеет подписанные оси, компактные подписи, tooltip по наведению/фокусу и доступный переход в старт. |
|
||||||
|
| FE-080-07 | Готово | Вернуть управление официальной страницей старта | Поле «Официальная страница старта» доступно для любого старта, включая прошедший; для прошедших скрываются только неактуальные поля расписания. |
|
||||||
|
| FE-080-08 | Готово | Перевести график прогресса на AG Charts | Самописный SVG заменён на AG Charts; сохранены данные, оси, tooltip, клавиатурная доступность, переход по точке, адаптивность и пустое состояние. |
|
||||||
|
|
||||||
## P2 — BE 1.6.0 / FE 0.9.0
|
## P2 — BE 1.7.0 / FE 1.0.0
|
||||||
|
|
||||||
### Backend 1.6.0
|
### Backend 1.7.0
|
||||||
|
|
||||||
| ID | Приоритет | Задача | Результат / критерий приёмки |
|
| ID | Приоритет | Задача | Результат / критерий приёмки |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -82,17 +85,18 @@
|
|||||||
| BE-160-02 | P1 | Надёжные email-напоминания | Настройки пользователя и очередь напоминаний хранятся в БД; отправка идемпотентна, ошибки ретраятся ограниченно и логируются без PII. Расписание запуска — внешний cron/worker, а не `setInterval` в каждом web-инстансе. |
|
| BE-160-02 | P1 | Надёжные email-напоминания | Настройки пользователя и очередь напоминаний хранятся в БД; отправка идемпотентна, ошибки ретраятся ограниченно и логируются без PII. Расписание запуска — внешний cron/worker, а не `setInterval` в каждом web-инстансе. |
|
||||||
| BE-160-03 | P2 | Подписка на персональный iCalendar | Выдать отдельный отзывной секретный URL только для read-only календаря пользователя. Нужны лимитирование, отсутствие email/заметок в ICS и отзыв/ротация токена. |
|
| BE-160-03 | P2 | Подписка на персональный iCalendar | Выдать отдельный отзывной секретный URL только для read-only календаря пользователя. Нужны лимитирование, отсутствие email/заметок в ICS и отзыв/ротация токена. |
|
||||||
|
|
||||||
### Frontend 0.9.0
|
### Frontend 1.0.0
|
||||||
|
|
||||||
| ID | Приоритет | Задача | Результат / критерий приёмки |
|
| ID | Приоритет | Задача | Результат / критерий приёмки |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| FE-090-01 | P1 | Чек-лист в карточке старта | Пользователь добавляет, завершает и переставляет задачи подготовки; на дашборде видит ближайшие незавершённые пункты. Пустое состояние объясняет, что делать. |
|
| FE-090-01 | P1 | Чек-лист в карточке старта | Пользователь добавляет, завершает и переставляет задачи подготовки; на дашборде видит ближайшие незавершённые пункты. Пустое состояние объясняет, что делать. |
|
||||||
| FE-090-02 | P1 | Настройки напоминаний | Опт-ин по email и выбор сроков до старта; понятно отображаются часовой пояс и факт отключения рассылки. |
|
| FE-090-02 | P1 | Настройки напоминаний | Опт-ин по email и выбор сроков до старта; понятно отображаются часовой пояс и факт отключения рассылки. |
|
||||||
| FE-090-03 | P2 | Управление iCalendar-подпиской | Экран показывает URL только по явному действию, даёт скопировать, отозвать и выпустить новый. Одноразовый экспорт из `0.8.0` остаётся доступным без подписки. |
|
| FE-090-03 | P2 | Управление iCalendar-подпиской | Экран показывает URL только по явному действию, даёт скопировать, отозвать и выпустить новый. Одноразовый экспорт из `0.8.0` остаётся доступным без подписки. |
|
||||||
|
| FE-090-04 | P2 | Исправить полную TypeScript-проверку frontend | `tsc -b` проходит без ошибки `Cannot find name 'Worker'` в типах Vite; конфигурации browser/node остаются корректно разделены, проверка исходников не ослабляется. |
|
||||||
|
|
||||||
## P3 — BE 1.7.0 / FE 1.0.0
|
## P3 — BE 1.8.0 / FE 1.1.0
|
||||||
|
|
||||||
### Backend 1.7.0
|
### Backend 1.8.0
|
||||||
|
|
||||||
| ID | Приоритет | Задача | Результат / критерий приёмки |
|
| ID | Приоритет | Задача | Результат / критерий приёмки |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -100,7 +104,7 @@
|
|||||||
| BE-170-02 | P0 | Наблюдаемость без утечки данных | Структурированные логи с request ID, готовность БД, счётчики ошибок и alert на недоступность. Email, пароли, токены, cookie и текст заметок не логируются. |
|
| BE-170-02 | P0 | Наблюдаемость без утечки данных | Структурированные логи с request ID, готовность БД, счётчики ошибок и alert на недоступность. Email, пароли, токены, cookie и текст заметок не логируются. |
|
||||||
| BE-170-03 | P1 | Машиночитаемый API-контракт | OpenAPI-спецификация синхронизирована с маршрутам и используется в CI для проверки ключевых ответов. Это предотвращает повторное расхождение документации и реализации. |
|
| BE-170-03 | P1 | Машиночитаемый API-контракт | OpenAPI-спецификация синхронизирована с маршрутам и используется в CI для проверки ключевых ответов. Это предотвращает повторное расхождение документации и реализации. |
|
||||||
|
|
||||||
### Frontend 1.0.0
|
### Frontend 1.1.0
|
||||||
|
|
||||||
| ID | Приоритет | Задача | Результат / критерий приёмки |
|
| ID | Приоритет | Задача | Результат / критерий приёмки |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -130,3 +134,14 @@
|
|||||||
2. `BE-142-01` и `BE-142-02`: зафиксировать настоящий контракт и закрыть trust boundary.
|
2. `BE-142-01` и `BE-142-02`: зафиксировать настоящий контракт и закрыть trust boundary.
|
||||||
3. `BE-142-03`: убрать SSRF-риск до любого расширения автозагрузки обложек.
|
3. `BE-142-03`: убрать SSRF-риск до любого расширения автозагрузки обложек.
|
||||||
4. `FE-071-01`–`FE-071-06`: устранить неверную аналитику и ошибки критических форм, включая понятное завершение подтверждения email и ввод результата.
|
4. `FE-071-01`–`FE-071-06`: устранить неверную аналитику и ошибки критических форм, включая понятное завершение подтверждения email и ввод результата.
|
||||||
|
|
||||||
|
## Выполненный спринт
|
||||||
|
|
||||||
|
**Цель:** пользователь быстро находит старты, восстанавливает данные истории, выгружает результат и видит прогресс в поддерживаемой chart-визуализации.
|
||||||
|
|
||||||
|
1. `BE-150-04` + `FE-080-07`: восстановление обложек существующих стартов и управление официальной страницей, включая прошедшие старты. Ручные обложки не перезаписываются; используется существующая SSRF-защищённая логика.
|
||||||
|
2. `BE-150-03` + `FE-080-02`: фильтры по статусу, периоду и дистанции, поиск по названию, сортировка и синхронизация с URL. Обновить API-документацию и контрактные тесты.
|
||||||
|
3. `FE-080-03`: экспорт текущей отфильтрованной выборки в CSV и одноразовый `.ics` без нового backend и зависимостей.
|
||||||
|
4. `FE-080-08`: заменить самописный SVG-график прогресса на AG Charts. Сохранить текущие данные и метрики, подписи осей, tooltip, клавиатурную доступность, переход по точке в старт, адаптивность и пустое состояние. Не менять API и не добавлять новую обёртку поверх `PaceTrendChart`.
|
||||||
|
|
||||||
|
`BE-150-02` в спринт не брать до решения: удаление аккаунта сразу или с 30-дневным восстановлением.
|
||||||
|
|||||||
50
frontend/package-lock.json
generated
50
frontend/package-lock.json
generated
@@ -1,13 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "calendar-run-frontend",
|
"name": "calendar-run-frontend",
|
||||||
"version": "0.8.2",
|
"version": "0.10.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "calendar-run-frontend",
|
"name": "calendar-run-frontend",
|
||||||
"version": "0.8.2",
|
"version": "0.10.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"ag-charts-community": "^14.0.0",
|
||||||
|
"ag-charts-react": "^14.0.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.30.1"
|
"react-router-dom": "^6.30.1"
|
||||||
@@ -1221,6 +1223,50 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ag-charts-community": {
|
||||||
|
"version": "14.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ag-charts-community/-/ag-charts-community-14.0.0.tgz",
|
||||||
|
"integrity": "sha512-u/O7T/EGxj8s6cZjbYU1CaRObnjMzdfXZVido9uXbeAwBkT4rxXF6OWSq1SkkSrZD1+pwusHn3cbul4Ly3yzPA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ag-charts-core": "14.0.0",
|
||||||
|
"ag-charts-locale": "14.0.0",
|
||||||
|
"ag-charts-types": "14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ag-charts-core": {
|
||||||
|
"version": "14.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ag-charts-core/-/ag-charts-core-14.0.0.tgz",
|
||||||
|
"integrity": "sha512-xcFSQ0Ces8JvH5RpKoZIjrFzdvju91gIXODj33Byg6ShwkixrHyLaCf17addfSsqerSSdMkw0iiceD9afU83UQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ag-charts-types": "14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ag-charts-locale": {
|
||||||
|
"version": "14.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ag-charts-locale/-/ag-charts-locale-14.0.0.tgz",
|
||||||
|
"integrity": "sha512-14mGnsQgG7VzdQwt7Jq2MFPGPtO8y6jAECN4nae7t3fJAPq162mmrgDaqpwHdAZxKPkMNte98DUQ8ULrtSbUpQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/ag-charts-react": {
|
||||||
|
"version": "14.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ag-charts-react/-/ag-charts-react-14.0.0.tgz",
|
||||||
|
"integrity": "sha512-PTwfdmyFbePzaKHuGWfB2o8KNXO5wR5fOCyUCRqdUNnCEK8cBPwlDsfe28CRsevQSLPZqTNpW2y5qJhHC4q6EQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ag-charts-community": "14.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ag-charts-types": {
|
||||||
|
"version": "14.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-14.0.0.tgz",
|
||||||
|
"integrity": "sha512-89X4J/OeOTzTp505126JayGEn3k6QUKz3UHSR0/ggoVZYzVqtMN13k/B136S1+YBwgnzyJ/NARnnIRTPWsWG9g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.15",
|
"version": "2.10.15",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.15.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.15.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "calendar-run-frontend",
|
"name": "calendar-run-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.8.2",
|
"version": "0.10.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"ag-charts-community": "^14.0.0",
|
||||||
|
"ag-charts-react": "^14.0.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.30.1"
|
"react-router-dom": "^6.30.1"
|
||||||
|
|||||||
@@ -47,24 +47,25 @@ function normalizeRace(input: unknown): Race {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validRace = race as Race;
|
||||||
return {
|
return {
|
||||||
id: race.id,
|
id: validRace.id,
|
||||||
slug: race.slug,
|
slug: validRace.slug,
|
||||||
date: race.date,
|
date: validRace.date,
|
||||||
title: race.title,
|
title: validRace.title,
|
||||||
distanceKm: race.distanceKm,
|
distanceKm: validRace.distanceKm,
|
||||||
status: race.status,
|
status: validRace.status,
|
||||||
officialUrl: race.officialUrl,
|
officialUrl: validRace.officialUrl,
|
||||||
coverImageUrl: race.coverImageUrl ?? null,
|
coverImageUrl: validRace.coverImageUrl ?? null,
|
||||||
startTime: race.startTime,
|
startTime: validRace.startTime,
|
||||||
clusterSchedule: race.clusterSchedule,
|
clusterSchedule: validRace.clusterSchedule,
|
||||||
bibPickup: race.bibPickup,
|
bibPickup: validRace.bibPickup,
|
||||||
bibNumber: race.bibNumber,
|
bibNumber: validRace.bibNumber,
|
||||||
finishTime: race.finishTime,
|
finishTime: validRace.finishTime,
|
||||||
finishPlace: race.finishPlace,
|
finishPlace: validRace.finishPlace,
|
||||||
notes: race.notes,
|
notes: validRace.notes,
|
||||||
createdAt: race.createdAt,
|
createdAt: validRace.createdAt,
|
||||||
updatedAt: race.updatedAt,
|
updatedAt: validRace.updatedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +81,21 @@ function buildRacesQuery(query?: RacesQuery): string {
|
|||||||
if (typeof query.month === "number") {
|
if (typeof query.month === "number") {
|
||||||
params.set("month", String(query.month));
|
params.set("month", String(query.month));
|
||||||
}
|
}
|
||||||
|
if (query.status) {
|
||||||
|
params.set("status", query.status);
|
||||||
|
}
|
||||||
|
if (query.dateFrom) {
|
||||||
|
params.set("dateFrom", query.dateFrom);
|
||||||
|
}
|
||||||
|
if (query.dateTo) {
|
||||||
|
params.set("dateTo", query.dateTo);
|
||||||
|
}
|
||||||
|
if (typeof query.distanceMin === "number") {
|
||||||
|
params.set("distanceMin", String(query.distanceMin));
|
||||||
|
}
|
||||||
|
if (typeof query.distanceMax === "number") {
|
||||||
|
params.set("distanceMax", String(query.distanceMax));
|
||||||
|
}
|
||||||
|
|
||||||
const serialized = params.toString();
|
const serialized = params.toString();
|
||||||
return serialized ? `?${serialized}` : "";
|
return serialized ? `?${serialized}` : "";
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export interface Race {
|
|||||||
export interface RacesQuery {
|
export interface RacesQuery {
|
||||||
year?: number;
|
year?: number;
|
||||||
month?: number;
|
month?: number;
|
||||||
|
status?: RaceStatus;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
distanceMin?: number;
|
||||||
|
distanceMax?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateRacePayload {
|
export interface CreateRacePayload {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { AgCharts } from "ag-charts-react";
|
||||||
|
import type { AgChartOptions } from "ag-charts-community";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import type { Race } from "../api";
|
import type { Race } from "../api";
|
||||||
import { formatRaceDate, getPaceLabel, isCloseDistance, parseFinishTimeToSeconds, parseRaceDate } from "../lib";
|
import { formatRaceDate, getPaceLabel, isCloseDistance, parseFinishTimeToSeconds, parseRaceDate } from "../lib";
|
||||||
@@ -8,100 +10,105 @@ type PaceTrendChartProps = {
|
|||||||
distanceKm: number;
|
distanceKm: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatAxisTime(minutes: number): string {
|
type PaceDatum = {
|
||||||
const totalSeconds = Math.round(minutes * 60);
|
id: string;
|
||||||
const hours = Math.floor(totalSeconds / 3600);
|
date: string;
|
||||||
const rest = totalSeconds % 3600;
|
dateLabel: string;
|
||||||
return hours ? `${hours}:${String(Math.floor(rest / 60)).padStart(2, "0")}` : `${Math.floor(rest / 60)}:${String(rest % 60).padStart(2, "0")}`;
|
paceSeconds: number;
|
||||||
|
race: Race;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatAxisPace(seconds: number): string {
|
||||||
|
const roundedSeconds = Math.round(seconds);
|
||||||
|
return `${Math.floor(roundedSeconds / 60)}:${String(roundedSeconds % 60).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatShortDate(date: string): string {
|
function formatShortDate(date: string): string {
|
||||||
return parseRaceDate(date).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
|
return parseRaceDate(date).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Линейный график: время финиша по завершённым стартам выбранной дистанции. */
|
function cssToken(name: string, fallback: string): string {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Линейный график: темп по завершённым стартам выбранной дистанции. */
|
||||||
export function PaceTrendChart({ races, distanceKm }: PaceTrendChartProps): JSX.Element {
|
export function PaceTrendChart({ races, distanceKm }: PaceTrendChartProps): JSX.Element {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const series = useMemo<PaceDatum[]>(
|
||||||
const series = races
|
() => races
|
||||||
.filter((race) => race.status === "completed" && isCloseDistance(race.distanceKm, distanceKm) && parseFinishTimeToSeconds(race.finishTime) != null)
|
.filter((race) => race.status === "completed" && isCloseDistance(race.distanceKm, distanceKm) && parseFinishTimeToSeconds(race.finishTime) != null)
|
||||||
.sort((a, b) => parseRaceDate(a.date).getTime() - parseRaceDate(b.date).getTime())
|
.sort((a, b) => parseRaceDate(a.date).getTime() - parseRaceDate(b.date).getTime())
|
||||||
.map((race) => ({ race, minutes: parseFinishTimeToSeconds(race.finishTime)! / 60 }));
|
.map((race) => ({
|
||||||
|
id: race.id,
|
||||||
|
date: race.date,
|
||||||
|
dateLabel: formatShortDate(race.date),
|
||||||
|
paceSeconds: parseFinishTimeToSeconds(race.finishTime)! / race.distanceKm,
|
||||||
|
race,
|
||||||
|
})),
|
||||||
|
[distanceKm, races],
|
||||||
|
);
|
||||||
|
|
||||||
|
const options = useMemo<AgChartOptions<PaceDatum>>(() => ({
|
||||||
|
data: series,
|
||||||
|
autoSize: true,
|
||||||
|
minHeight: 280,
|
||||||
|
padding: { top: 16, right: 20, bottom: 16, left: 16 },
|
||||||
|
background: { fill: "transparent" },
|
||||||
|
animation: { enabled: false },
|
||||||
|
keyboard: { enabled: true, tabIndex: 0, initialFocus: "data-start" },
|
||||||
|
axes: {
|
||||||
|
x: { type: "category", position: "bottom", title: { text: "Дата старта" } },
|
||||||
|
y: {
|
||||||
|
type: "number",
|
||||||
|
position: "left",
|
||||||
|
reverse: true,
|
||||||
|
min: 0,
|
||||||
|
title: { text: "Темп, мин/км" },
|
||||||
|
label: { formatter: ({ value }) => formatAxisPace(Number(value)) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
type: "line",
|
||||||
|
xKey: "dateLabel",
|
||||||
|
xName: "Дата старта",
|
||||||
|
yKey: "paceSeconds",
|
||||||
|
yName: "Темп",
|
||||||
|
stroke: cssToken("--color-accent", "#1168d8"),
|
||||||
|
strokeWidth: 3,
|
||||||
|
marker: {
|
||||||
|
enabled: true,
|
||||||
|
size: 8,
|
||||||
|
fill: cssToken("--color-surface", "#ffffff"),
|
||||||
|
stroke: cssToken("--color-accent", "#1168d8"),
|
||||||
|
strokeWidth: 2,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
renderer: ({ datum }) => ({
|
||||||
|
title: datum.race.title,
|
||||||
|
data: [
|
||||||
|
{ label: "Дата", value: formatRaceDate(datum.date) },
|
||||||
|
{ label: "Время", value: datum.race.finishTime ?? "—" },
|
||||||
|
{ label: "Темп", value: getPaceLabel(datum.race.finishTime, datum.race.distanceKm) ?? "—" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
listeners: {
|
||||||
|
seriesNodeClick: ({ datum }) => navigate(`/races/${datum.race.id}`),
|
||||||
|
},
|
||||||
|
}), [navigate, series]);
|
||||||
|
|
||||||
if (series.length < 2) {
|
if (series.length < 2) {
|
||||||
return <p className="pace-chart__empty">Нужно минимум два завершённых старта с временем на выбранной дистанции.</p>;
|
return <p className="pace-chart__empty">Нужно минимум два завершённых старта с временем на выбранной дистанции.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const values = series.map((item) => item.minutes);
|
|
||||||
const min = Math.min(...values);
|
|
||||||
const max = Math.max(...values);
|
|
||||||
const range = max - min || 1;
|
|
||||||
const w = 100;
|
|
||||||
const h = 58;
|
|
||||||
const margin = { top: 6, right: 4, bottom: 14, left: 17 };
|
|
||||||
const innerW = w - margin.left - margin.right;
|
|
||||||
const innerH = h - margin.top - margin.bottom;
|
|
||||||
const pointAt = (item: (typeof series)[number], index: number) => ({
|
|
||||||
x: margin.left + (index / (series.length - 1)) * innerW,
|
|
||||||
y: margin.top + ((max - item.minutes) / range) * innerH,
|
|
||||||
});
|
|
||||||
const points = series.map(pointAt);
|
|
||||||
const line = points.map(({ x, y }) => `${x},${y}`).join(" ");
|
|
||||||
const yTicks = [max, (max + min) / 2, min];
|
|
||||||
const labelStep = Math.max(1, Math.ceil(series.length / 6));
|
|
||||||
const active = series.find((item) => item.race.id === activeId) ?? null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pace-chart">
|
<div className="pace-chart" role="group" aria-label="Динамика темпа на дистанции. Выберите точку, чтобы открыть старт.">
|
||||||
<svg className="pace-chart__svg" viewBox={`0 0 ${w} ${h}`} role="group" aria-label="Динамика времени на дистанции. Нажмите точку, чтобы открыть старт.">
|
<AgCharts options={options} />
|
||||||
<text className="pace-chart__axis-title" x="2" y={h / 2} transform={`rotate(-90 2 ${h / 2})`}>Время</text>
|
<p className="pace-chart__hint">Наведите на точку или выберите её клавиатурой.</p>
|
||||||
{yTicks.map((tick) => {
|
|
||||||
const y = margin.top + ((max - tick) / range) * innerH;
|
|
||||||
return (
|
|
||||||
<g key={tick}>
|
|
||||||
<line className="pace-chart__grid-line" x1={margin.left} y1={y} x2={w - margin.right} y2={y} />
|
|
||||||
<text className="pace-chart__axis-label" x={margin.left - 2} y={y + 1} textAnchor="end">{formatAxisTime(tick)}</text>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<line className="pace-chart__axis" x1={margin.left} y1={margin.top} x2={margin.left} y2={h - margin.bottom} />
|
|
||||||
<line className="pace-chart__axis" x1={margin.left} y1={h - margin.bottom} x2={w - margin.right} y2={h - margin.bottom} />
|
|
||||||
<polyline className="pace-chart__line" fill="none" points={line} />
|
|
||||||
{series.map((item, index) => {
|
|
||||||
const point = points[index]!;
|
|
||||||
const isLabeled = index % labelStep === 0 || index === series.length - 1;
|
|
||||||
return (
|
|
||||||
<g
|
|
||||||
key={item.race.id}
|
|
||||||
className="pace-chart__point"
|
|
||||||
tabIndex={0}
|
|
||||||
role="button"
|
|
||||||
aria-label={`Открыть ${item.race.title}: ${item.race.finishTime}, ${getPaceLabel(item.race.finishTime, item.race.distanceKm)}`}
|
|
||||||
onMouseEnter={() => setActiveId(item.race.id)}
|
|
||||||
onMouseLeave={() => setActiveId(null)}
|
|
||||||
onFocus={() => setActiveId(item.race.id)}
|
|
||||||
onBlur={() => setActiveId(null)}
|
|
||||||
onClick={() => navigate(`/races/${item.race.id}`)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
event.preventDefault();
|
|
||||||
navigate(`/races/${item.race.id}`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<circle className={index === series.length - 1 ? "pace-chart__dot pace-chart__dot--last" : "pace-chart__dot"} cx={point.x} cy={point.y} r="1.05" />
|
|
||||||
{isLabeled ? <text className="pace-chart__point-label" x={point.x} y={h - margin.bottom + 5} textAnchor="middle">{formatShortDate(item.race.date)}</text> : null}
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<text className="pace-chart__axis-title" x={w / 2} y={h - 1} textAnchor="middle">Дата старта</text>
|
|
||||||
</svg>
|
|
||||||
{active ? (
|
|
||||||
<div className="pace-chart__tooltip" role="status">
|
|
||||||
<strong>{active.race.title}</strong>
|
|
||||||
<span>{formatRaceDate(active.race.date)} · {active.race.finishTime} · {getPaceLabel(active.race.finishTime, active.race.distanceKm)}</span>
|
|
||||||
</div>
|
|
||||||
) : <p className="pace-chart__hint">Наведите на точку или выберите её клавиатурой.</p>}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,3 +19,4 @@ export {
|
|||||||
export { buildMonthCells, groupRacesByYmd, toYmd, WEEKDAY_LABELS_SHORT_RU } from "./calendarUtils";
|
export { buildMonthCells, groupRacesByYmd, toYmd, WEEKDAY_LABELS_SHORT_RU } from "./calendarUtils";
|
||||||
export { getRaceVisual } from "./raceVisuals";
|
export { getRaceVisual } from "./raceVisuals";
|
||||||
export type { RaceVisualVariant } from "./raceVisuals";
|
export type { RaceVisualVariant } from "./raceVisuals";
|
||||||
|
export { downloadTextFile, racesToCsv, racesToIcs } from "./raceExport";
|
||||||
|
|||||||
60
frontend/src/lib/raceExport.ts
Normal file
60
frontend/src/lib/raceExport.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import type { Race } from "../api";
|
||||||
|
|
||||||
|
function escapeCsvCell(value: string | number | null): string {
|
||||||
|
const text = value == null ? "" : String(value);
|
||||||
|
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeIcsText(value: string): string {
|
||||||
|
return value.replace(/[\\;,\n]/g, (character) => ({ "\\": "\\\\", ";": "\\;", ",": "\\,", "\n": "\\n" })[character]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIcsDate(date: string): string {
|
||||||
|
return date.slice(0, 10).replace(/-/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOneDay(date: string): string {
|
||||||
|
const next = new Date(`${date.slice(0, 10)}T00:00:00Z`);
|
||||||
|
next.setUTCDate(next.getUTCDate() + 1);
|
||||||
|
return next.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function racesToCsv(races: Race[]): string {
|
||||||
|
const headers = ["Дата", "Название", "Дистанция (км)", "Статус", "Время", "Место", "Официальная страница"];
|
||||||
|
const rows = races.map((race) => [
|
||||||
|
race.date,
|
||||||
|
race.title,
|
||||||
|
race.distanceKm,
|
||||||
|
race.status,
|
||||||
|
race.finishTime,
|
||||||
|
race.finishPlace,
|
||||||
|
race.officialUrl,
|
||||||
|
]);
|
||||||
|
return `\uFEFF${[headers, ...rows].map((row) => row.map(escapeCsvCell).join(",")).join("\r\n")}\r\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function racesToIcs(races: Race[]): string {
|
||||||
|
const events = races.map((race) => {
|
||||||
|
const start = race.date.slice(0, 10);
|
||||||
|
const end = addOneDay(start);
|
||||||
|
return [
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
`UID:${race.id}@calendar-run`,
|
||||||
|
`DTSTART;VALUE=DATE:${toIcsDate(start)}`,
|
||||||
|
`DTEND;VALUE=DATE:${toIcsDate(end)}`,
|
||||||
|
`SUMMARY:${escapeIcsText(race.title)}`,
|
||||||
|
`DESCRIPTION:${escapeIcsText(`${race.distanceKm} км${race.officialUrl ? `\n${race.officialUrl}` : ""}`)}`,
|
||||||
|
"END:VEVENT",
|
||||||
|
].join("\r\n");
|
||||||
|
});
|
||||||
|
return ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Calendar Run//Races//RU", ...events, "END:VCALENDAR", ""].join("\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadTextFile(filename: string, content: string, type: string): void {
|
||||||
|
const url = URL.createObjectURL(new Blob([content], { type }));
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
link.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { AllCommunityModule, ModuleRegistry } from "ag-charts-community";
|
||||||
import { RouterProvider } from "react-router-dom";
|
import { RouterProvider } from "react-router-dom";
|
||||||
import { AuthProvider } from "./app/auth/AuthContext";
|
import { AuthProvider } from "./app/auth/AuthContext";
|
||||||
import { appRouter } from "./app/router";
|
import { appRouter } from "./app/router";
|
||||||
import "./styles/tokens.css";
|
import "./styles/tokens.css";
|
||||||
import "./styles/global.css";
|
import "./styles/global.css";
|
||||||
|
|
||||||
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
|||||||
@@ -192,7 +192,6 @@ export function DashboardPage(): JSX.Element {
|
|||||||
>
|
>
|
||||||
<div className="dashboard-hero__content">
|
<div className="dashboard-hero__content">
|
||||||
<p className="dashboard-hero__eyebrow">Календарь сезона</p>
|
<p className="dashboard-hero__eyebrow">Календарь сезона</p>
|
||||||
<h1 className="dashboard-hero__title">Беговой штаб</h1>
|
|
||||||
<p className="dashboard-hero__text">
|
<p className="dashboard-hero__text">
|
||||||
Планируйте старты, держите фокус на ближайшей гонке и сравнивайте прогресс по дистанциям.
|
Планируйте старты, держите фокус на ближайшей гонке и сравнивайте прогресс по дистанциям.
|
||||||
</p>
|
</p>
|
||||||
@@ -304,10 +303,10 @@ export function DashboardPage(): JSX.Element {
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="dashboard-section" aria-label="Динамика времени на дистанции">
|
<section className="dashboard-section" aria-label="Динамика темпа на дистанции">
|
||||||
<h2 className="dashboard-section__title">Прогресс по времени</h2>
|
<h2 className="dashboard-section__title">Прогресс по темпу</h2>
|
||||||
<p className="dashboard-section__intro">
|
<p className="dashboard-section__intro">
|
||||||
Линия по завершённым стартам выбранной дистанции: выше — лучше время (короче гонка).
|
Линия по завершённым стартам выбранной дистанции: выше — темп быстрее (меньше минут на километр).
|
||||||
</p>
|
</p>
|
||||||
<label className="races-filter__field">
|
<label className="races-filter__field">
|
||||||
<span className="races-filter__label">Дистанция для графика</span>
|
<span className="races-filter__label">Дистанция для графика</span>
|
||||||
|
|||||||
@@ -412,19 +412,17 @@ 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
|
className="race-form__input"
|
||||||
className="race-form__input"
|
type="url"
|
||||||
type="url"
|
name="officialUrl"
|
||||||
name="officialUrl"
|
value={form.officialUrl}
|
||||||
value={form.officialUrl}
|
onChange={handleChange}
|
||||||
onChange={handleChange}
|
placeholder="https://…"
|
||||||
placeholder="https://…"
|
/>
|
||||||
/>
|
</label>
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<label className="race-form__field">
|
<label className="race-form__field">
|
||||||
<span className="race-form__label">URL обложки</span>
|
<span className="race-form__label">URL обложки</span>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
import type { Race, RacesQuery } from "../api";
|
import type { Race, RaceStatus, RacesQuery } from "../api";
|
||||||
import { ApiError, getRaces } from "../api";
|
import { ApiError, getRaces } from "../api";
|
||||||
import { RacesCalendar } from "../components/RacesCalendar";
|
import { RacesCalendar } from "../components/RacesCalendar";
|
||||||
import {
|
import {
|
||||||
@@ -10,8 +10,10 @@ import {
|
|||||||
getRaceStatusClassName,
|
getRaceStatusClassName,
|
||||||
getRaceStatusLabel,
|
getRaceStatusLabel,
|
||||||
parseRaceDate,
|
parseRaceDate,
|
||||||
sortByDateAsc,
|
|
||||||
sortByDateDesc,
|
sortByDateDesc,
|
||||||
|
downloadTextFile,
|
||||||
|
racesToCsv,
|
||||||
|
racesToIcs,
|
||||||
} from "../lib";
|
} from "../lib";
|
||||||
|
|
||||||
const MONTH_OPTIONS: { value: string; label: string }[] = [
|
const MONTH_OPTIONS: { value: string; label: string }[] = [
|
||||||
@@ -34,16 +36,13 @@ const VIEW_STORAGE_KEY = "races-view-mode";
|
|||||||
|
|
||||||
type ViewMode = "list" | "calendar";
|
type ViewMode = "list" | "calendar";
|
||||||
type RaceListTab = "upcoming" | "completed";
|
type RaceListTab = "upcoming" | "completed";
|
||||||
|
type RaceSort = "dateAsc" | "dateDesc" | "titleAsc" | "distanceAsc";
|
||||||
|
type RaceSortOption = RaceSort | "";
|
||||||
|
|
||||||
function yearSelectOptions(): number[] {
|
function yearSelectOptions(races: Race[], selectedYear = ""): number[] {
|
||||||
const current = new Date().getFullYear();
|
const current = new Date().getFullYear();
|
||||||
const start = current - 2;
|
const selected = Number(selectedYear);
|
||||||
const end = current + 4;
|
return [...new Set([current, ...races.map((race) => parseRaceDate(race.date).getFullYear()), ...(Number.isInteger(selected) ? [selected] : [])])].sort((a, b) => a - b);
|
||||||
const years: number[] = [];
|
|
||||||
for (let y = start; y <= end; y += 1) {
|
|
||||||
years.push(y);
|
|
||||||
}
|
|
||||||
return years;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getErrorMessage(error: unknown): string {
|
function getErrorMessage(error: unknown): string {
|
||||||
@@ -67,7 +66,10 @@ function RaceList(props: { title: string; races: Race[]; variant: RaceListTab })
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`race-list race-list--${variant}`} aria-label={title}>
|
<section className={`race-list race-list--${variant}`} aria-label={title}>
|
||||||
<h2 className="race-list__title">{title}</h2>
|
<h2 className="race-list__title">
|
||||||
|
{title}
|
||||||
|
<span className="race-list__count">{races.length}</span>
|
||||||
|
</h2>
|
||||||
{races.length > 0 ? (
|
{races.length > 0 ? (
|
||||||
<ul className="race-list__items">
|
<ul className="race-list__items">
|
||||||
{races.map((race) => {
|
{races.map((race) => {
|
||||||
@@ -133,13 +135,24 @@ function RaceList(props: { title: string; races: Race[]; variant: RaceListTab })
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RacesPage(): JSX.Element {
|
export function RacesPage(): JSX.Element {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [races, setRaces] = useState<Race[]>([]);
|
const [races, setRaces] = useState<Race[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [yearFilter, setYearFilter] = useState<string>("");
|
const [yearFilter, setYearFilter] = useState<string>(() => searchParams.get("year") ?? "");
|
||||||
const [monthFilter, setMonthFilter] = useState<string>("");
|
const [monthFilter, setMonthFilter] = useState<string>(() => searchParams.get("month") ?? "");
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>(() => readInitialViewMode());
|
const [statusFilter, setStatusFilter] = useState<RaceStatus | "">(() => (searchParams.get("status") as RaceStatus | null) ?? "");
|
||||||
|
const [dateFrom, setDateFrom] = useState<string>(() => searchParams.get("dateFrom") ?? "");
|
||||||
|
const [dateTo, setDateTo] = useState<string>(() => searchParams.get("dateTo") ?? "");
|
||||||
|
const [distanceMin, setDistanceMin] = useState<string>(() => searchParams.get("distanceMin") ?? "");
|
||||||
|
const [distanceMax, setDistanceMax] = useState<string>(() => searchParams.get("distanceMax") ?? "");
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>(() => searchParams.get("q") ?? "");
|
||||||
|
const [sortOrder, setSortOrder] = useState<RaceSortOption>(() => (searchParams.get("sort") as RaceSort | null) ?? "");
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>(() => searchParams.get("view") === "calendar" ? "calendar" : readInitialViewMode());
|
||||||
const [activeListTab, setActiveListTab] = useState<RaceListTab>("upcoming");
|
const [activeListTab, setActiveListTab] = useState<RaceListTab>("upcoming");
|
||||||
|
const [isAdvancedFiltersOpen, setIsAdvancedFiltersOpen] = useState<boolean>(() =>
|
||||||
|
["dateFrom", "dateTo", "distanceMin", "distanceMax", "sort"].some((key) => searchParams.has(key)),
|
||||||
|
);
|
||||||
|
|
||||||
const setViewModePersist = useCallback((mode: ViewMode) => {
|
const setViewModePersist = useCallback((mode: ViewMode) => {
|
||||||
setViewMode(mode);
|
setViewMode(mode);
|
||||||
@@ -160,28 +173,45 @@ export function RacesPage(): JSX.Element {
|
|||||||
}, [setViewModePersist]);
|
}, [setViewModePersist]);
|
||||||
|
|
||||||
const listQuery = useMemo((): RacesQuery | undefined => {
|
const listQuery = useMemo((): RacesQuery | undefined => {
|
||||||
|
const q: RacesQuery = {};
|
||||||
if (viewMode === "calendar") {
|
if (viewMode === "calendar") {
|
||||||
const y = yearFilter !== "" ? parseInt(yearFilter, 10) : new Date().getFullYear();
|
const y = yearFilter !== "" ? parseInt(yearFilter, 10) : new Date().getFullYear();
|
||||||
if (!Number.isNaN(y)) {
|
|
||||||
return { year: y };
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const q: RacesQuery = {};
|
|
||||||
if (yearFilter !== "") {
|
|
||||||
const y = parseInt(yearFilter, 10);
|
|
||||||
if (!Number.isNaN(y)) {
|
if (!Number.isNaN(y)) {
|
||||||
q.year = y;
|
q.year = y;
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
if (monthFilter !== "") {
|
if (yearFilter !== "") {
|
||||||
const m = parseInt(monthFilter, 10);
|
const y = parseInt(yearFilter, 10);
|
||||||
if (!Number.isNaN(m)) {
|
if (!Number.isNaN(y)) {
|
||||||
q.month = m;
|
q.year = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (monthFilter !== "") {
|
||||||
|
const m = parseInt(monthFilter, 10);
|
||||||
|
if (!Number.isNaN(m)) {
|
||||||
|
q.month = m;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (statusFilter) {
|
||||||
|
q.status = statusFilter;
|
||||||
|
}
|
||||||
|
if (dateFrom) {
|
||||||
|
q.dateFrom = dateFrom;
|
||||||
|
}
|
||||||
|
if (dateTo) {
|
||||||
|
q.dateTo = dateTo;
|
||||||
|
}
|
||||||
|
const min = Number(distanceMin);
|
||||||
|
const max = Number(distanceMax);
|
||||||
|
if (distanceMin && Number.isFinite(min)) {
|
||||||
|
q.distanceMin = min;
|
||||||
|
}
|
||||||
|
if (distanceMax && Number.isFinite(max)) {
|
||||||
|
q.distanceMax = max;
|
||||||
|
}
|
||||||
return Object.keys(q).length > 0 ? q : undefined;
|
return Object.keys(q).length > 0 ? q : undefined;
|
||||||
}, [viewMode, yearFilter, monthFilter]);
|
}, [dateFrom, dateTo, distanceMax, distanceMin, monthFilter, statusFilter, viewMode, yearFilter]);
|
||||||
|
|
||||||
const displayYear = useMemo(() => {
|
const displayYear = useMemo(() => {
|
||||||
if (yearFilter !== "") {
|
if (yearFilter !== "") {
|
||||||
@@ -223,12 +253,40 @@ export function RacesPage(): JSX.Element {
|
|||||||
};
|
};
|
||||||
}, [listQuery]);
|
}, [listQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const next = new URLSearchParams();
|
||||||
|
if (viewMode === "calendar") next.set("view", viewMode);
|
||||||
|
if (yearFilter) next.set("year", yearFilter);
|
||||||
|
if (monthFilter) next.set("month", monthFilter);
|
||||||
|
if (statusFilter) next.set("status", statusFilter);
|
||||||
|
if (dateFrom) next.set("dateFrom", dateFrom);
|
||||||
|
if (dateTo) next.set("dateTo", dateTo);
|
||||||
|
if (distanceMin) next.set("distanceMin", distanceMin);
|
||||||
|
if (distanceMax) next.set("distanceMax", distanceMax);
|
||||||
|
if (searchQuery) next.set("q", searchQuery);
|
||||||
|
if (sortOrder) next.set("sort", sortOrder);
|
||||||
|
setSearchParams(next, { replace: true });
|
||||||
|
}, [dateFrom, dateTo, distanceMax, distanceMin, monthFilter, searchQuery, setSearchParams, sortOrder, statusFilter, viewMode, yearFilter]);
|
||||||
|
|
||||||
|
const filteredRaces = useMemo(() => {
|
||||||
|
const needle = searchQuery.trim().toLocaleLowerCase("ru-RU");
|
||||||
|
const result = races.filter((race) => !needle || race.title.toLocaleLowerCase("ru-RU").includes(needle));
|
||||||
|
return result.sort((left, right) => {
|
||||||
|
if (sortOrder === "titleAsc") return left.title.localeCompare(right.title, "ru");
|
||||||
|
if (sortOrder === "distanceAsc") return left.distanceKm - right.distanceKm || left.title.localeCompare(right.title, "ru");
|
||||||
|
const direction = sortOrder === "dateDesc" ? -1 : 1;
|
||||||
|
return direction * (parseRaceDate(left.date).getTime() - parseRaceDate(right.date).getTime());
|
||||||
|
});
|
||||||
|
}, [races, searchQuery, sortOrder]);
|
||||||
|
|
||||||
const { upcoming, completed } = useMemo(
|
const { upcoming, completed } = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
upcoming: sortByDateAsc(races.filter((race) => race.status !== "completed")),
|
upcoming: filteredRaces.filter((race) => race.status !== "completed"),
|
||||||
completed: sortByDateDesc(races.filter((race) => race.status === "completed")),
|
completed: sortOrder
|
||||||
|
? filteredRaces.filter((race) => race.status === "completed")
|
||||||
|
: sortByDateDesc(filteredRaces.filter((race) => race.status === "completed")),
|
||||||
}),
|
}),
|
||||||
[races],
|
[filteredRaces, sortOrder],
|
||||||
);
|
);
|
||||||
const statusMessage = useMemo(() => {
|
const statusMessage = useMemo(() => {
|
||||||
if (errorMessage && !isLoading) {
|
if (errorMessage && !isLoading) {
|
||||||
@@ -249,6 +307,13 @@ export function RacesPage(): JSX.Element {
|
|||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
const activeAdvancedFilterCount = [
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
distanceMin,
|
||||||
|
distanceMax,
|
||||||
|
sortOrder,
|
||||||
|
].filter(Boolean).length;
|
||||||
|
|
||||||
if (errorMessage && races.length === 0 && !isLoading) {
|
if (errorMessage && races.length === 0 && !isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -261,7 +326,7 @@ export function RacesPage(): JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page page--races">
|
<section className="page page--races">
|
||||||
<section className="races-hero" aria-label="Календарь стартов">
|
<section className={`races-hero${isAdvancedFiltersOpen ? " races-hero--filters-open" : ""}`} aria-label="Календарь стартов">
|
||||||
<div className="races-hero__content">
|
<div className="races-hero__content">
|
||||||
<p className="races-hero__eyebrow">Сезонная афиша</p>
|
<p className="races-hero__eyebrow">Сезонная афиша</p>
|
||||||
<h1 className="page__title">Календарь стартов</h1>
|
<h1 className="page__title">Календарь стартов</h1>
|
||||||
@@ -283,45 +348,156 @@ export function RacesPage(): JSX.Element {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="races-hero__filters">
|
{!isAdvancedFiltersOpen ? (
|
||||||
<div className="races-filter" role="search" aria-label="Фильтр по дате">
|
<div className="races-hero__filters">
|
||||||
<label className="races-filter__field">
|
<div className="races-filter" role="search" aria-label="Основные фильтры">
|
||||||
<span className="races-filter__label">Год</span>
|
<div className="races-filter__primary">
|
||||||
<select
|
<label className="races-filter__field">
|
||||||
className="races-filter__select"
|
<span className="races-filter__label">Год</span>
|
||||||
value={viewMode === "list" ? yearFilter : yearFilter || String(displayYear)}
|
<select
|
||||||
onChange={(event) => {
|
className="races-filter__select"
|
||||||
setYearFilter(event.target.value);
|
value={viewMode === "list" ? yearFilter : yearFilter || String(displayYear)}
|
||||||
}}
|
onChange={(event) => {
|
||||||
|
setYearFilter(event.target.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{viewMode === "list" ? <option value="">Все года</option> : null}
|
||||||
|
{yearSelectOptions(races, yearFilter).map((y) => (
|
||||||
|
<option key={y} value={String(y)}>
|
||||||
|
{y}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="races-filter__field">
|
||||||
|
<span className="races-filter__label">Месяц</span>
|
||||||
|
<select
|
||||||
|
className="races-filter__select"
|
||||||
|
value={monthFilter}
|
||||||
|
onChange={(event) => {
|
||||||
|
setMonthFilter(event.target.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{MONTH_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value || "all"} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="races-filter__field">
|
||||||
|
<span className="races-filter__label">Статус</span>
|
||||||
|
<select className="races-filter__select" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as RaceStatus | "")}>
|
||||||
|
<option value="">Все статусы</option>
|
||||||
|
<option value="planned">Планирую</option>
|
||||||
|
<option value="registered">Зарегистрирован</option>
|
||||||
|
<option value="completed">Пробежал</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="races-filter__field">
|
||||||
|
<span className="races-filter__label">Название</span>
|
||||||
|
<input className="races-filter__input" type="search" value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} placeholder="Название старта" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="races-filter__advanced-summary"
|
||||||
|
aria-expanded={false}
|
||||||
|
onClick={() => setIsAdvancedFiltersOpen(true)}
|
||||||
>
|
>
|
||||||
{viewMode === "list" ? <option value="">Все года</option> : null}
|
<span>Дополнительные фильтры{activeAdvancedFilterCount > 0 ? ` · ${activeAdvancedFilterCount} активн.` : ""}</span>
|
||||||
{yearSelectOptions().map((y) => (
|
</button>
|
||||||
<option key={y} value={String(y)}>
|
</div>
|
||||||
{y}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="races-filter__field">
|
|
||||||
<span className="races-filter__label">Месяц</span>
|
|
||||||
<select
|
|
||||||
className="races-filter__select"
|
|
||||||
value={monthFilter}
|
|
||||||
onChange={(event) => {
|
|
||||||
setMonthFilter(event.target.value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{MONTH_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value || "all"} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{isAdvancedFiltersOpen ? (
|
||||||
|
<section className="races-filter-panel" aria-label="Расширенные фильтры стартов">
|
||||||
|
<div className="races-filter-panel__header">
|
||||||
|
<div>
|
||||||
|
<p className="races-filter-panel__eyebrow">Расширенный поиск</p>
|
||||||
|
<h2 className="races-filter-panel__title">Фильтры стартов</h2>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn--secondary races-filter-panel__close" aria-expanded={true} onClick={() => setIsAdvancedFiltersOpen(false)}>
|
||||||
|
Свернуть фильтры
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="races-filter races-filter--panel" role="search" aria-label="Все фильтры стартов">
|
||||||
|
<div className="races-filter__panel-fields">
|
||||||
|
<label className="races-filter__field">
|
||||||
|
<span className="races-filter__label">Год</span>
|
||||||
|
<select
|
||||||
|
className="races-filter__select"
|
||||||
|
value={viewMode === "list" ? yearFilter : yearFilter || String(displayYear)}
|
||||||
|
onChange={(event) => setYearFilter(event.target.value)}
|
||||||
|
>
|
||||||
|
{viewMode === "list" ? <option value="">Все года</option> : null}
|
||||||
|
{yearSelectOptions(races, yearFilter).map((y) => (
|
||||||
|
<option key={y} value={String(y)}>
|
||||||
|
{y}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="races-filter__field">
|
||||||
|
<span className="races-filter__label">Месяц</span>
|
||||||
|
<select className="races-filter__select" value={monthFilter} onChange={(event) => setMonthFilter(event.target.value)}>
|
||||||
|
{MONTH_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value || "all"} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="races-filter__field">
|
||||||
|
<span className="races-filter__label">Статус</span>
|
||||||
|
<select className="races-filter__select" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as RaceStatus | "")}>
|
||||||
|
<option value="">Все статусы</option>
|
||||||
|
<option value="planned">Планирую</option>
|
||||||
|
<option value="registered">Зарегистрирован</option>
|
||||||
|
<option value="completed">Пробежал</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="races-filter__field">
|
||||||
|
<span className="races-filter__label">Название</span>
|
||||||
|
<input className="races-filter__input" type="search" value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} placeholder="Название старта" />
|
||||||
|
</label>
|
||||||
|
<label className="races-filter__field">
|
||||||
|
<span className="races-filter__label">Дата от</span>
|
||||||
|
<input className="races-filter__input" type="date" value={dateFrom} onChange={(event) => setDateFrom(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="races-filter__field">
|
||||||
|
<span className="races-filter__label">Дата до</span>
|
||||||
|
<input className="races-filter__input" type="date" value={dateTo} onChange={(event) => setDateTo(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="races-filter__field">
|
||||||
|
<span className="races-filter__label">Дистанция от, км</span>
|
||||||
|
<input className="races-filter__input" type="number" min="0.001" step="0.001" value={distanceMin} onChange={(event) => setDistanceMin(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="races-filter__field">
|
||||||
|
<span className="races-filter__label">Дистанция до, км</span>
|
||||||
|
<input className="races-filter__input" type="number" min="0.001" step="0.001" value={distanceMax} onChange={(event) => setDistanceMax(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="races-filter__field">
|
||||||
|
<span className="races-filter__label">Сортировка</span>
|
||||||
|
<select className="races-filter__select" value={sortOrder} onChange={(event) => setSortOrder(event.target.value as RaceSortOption)}>
|
||||||
|
<option value="">По умолчанию</option>
|
||||||
|
<option value="dateAsc">Дата: сначала ранние</option>
|
||||||
|
<option value="dateDesc">Дата: сначала поздние</option>
|
||||||
|
<option value="titleAsc">Название: А–Я</option>
|
||||||
|
<option value="distanceAsc">Дистанция: по возрастанию</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="races-filter__actions">
|
||||||
|
<button type="button" className="btn btn--secondary" disabled={filteredRaces.length === 0} onClick={() => downloadTextFile("calendar-run.csv", racesToCsv(filteredRaces), "text/csv;charset=utf-8")}>Скачать CSV</button>
|
||||||
|
<button type="button" className="btn btn--secondary" disabled={filteredRaces.length === 0} onClick={() => downloadTextFile("calendar-run.ics", racesToIcs(filteredRaces), "text/calendar;charset=utf-8")}>Скачать календарь</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="races-status" aria-live="polite">
|
<div className="races-status" aria-live="polite">
|
||||||
<p
|
<p
|
||||||
className={statusClassName}
|
className={statusClassName}
|
||||||
@@ -367,7 +543,7 @@ export function RacesPage(): JSX.Element {
|
|||||||
<RacesCalendar
|
<RacesCalendar
|
||||||
displayYear={displayYear}
|
displayYear={displayYear}
|
||||||
monthFilter={monthFilter}
|
monthFilter={monthFilter}
|
||||||
races={races}
|
races={filteredRaces}
|
||||||
onMonthFilterChange={setMonthFilter}
|
onMonthFilterChange={setMonthFilter}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -370,10 +370,19 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.race-list__title {
|
.race-list__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--space-2);
|
||||||
margin: 0 0 var(--space-4);
|
margin: 0 0 var(--space-4);
|
||||||
font-size: var(--font-size-h2);
|
font-size: var(--font-size-h2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.race-list__count {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.race-list__items {
|
.race-list__items {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -542,7 +551,8 @@ a {
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.races-filter__select {
|
.races-filter__select,
|
||||||
|
.races-filter__input {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@@ -551,6 +561,13 @@ a {
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.races-filter__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
.pace-chart {
|
.pace-chart {
|
||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
@@ -559,73 +576,9 @@ a {
|
|||||||
background: #fcfdff;
|
background: #fcfdff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pace-chart__svg {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 560px;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pace-chart__line {
|
|
||||||
stroke: var(--color-accent);
|
|
||||||
stroke-width: 1.5;
|
|
||||||
vector-effect: non-scaling-stroke;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pace-chart__axis {
|
|
||||||
stroke: rgba(14, 31, 45, 0.42);
|
|
||||||
stroke-width: 0.75;
|
|
||||||
vector-effect: non-scaling-stroke;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pace-chart__axis-label,
|
|
||||||
.pace-chart__axis-title,
|
|
||||||
.pace-chart__point-label {
|
|
||||||
fill: var(--color-text-muted);
|
|
||||||
font-size: 2.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pace-chart__axis-title {
|
|
||||||
font-size: 2.8px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pace-chart__point-label {
|
|
||||||
font-size: 2.25px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pace-chart__point {
|
|
||||||
cursor: pointer;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pace-chart__point:focus-visible .pace-chart__dot,
|
|
||||||
.pace-chart__point:hover .pace-chart__dot {
|
|
||||||
stroke-width: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pace-chart__tooltip,
|
|
||||||
.pace-chart__hint {
|
.pace-chart__hint {
|
||||||
margin: var(--space-3) 0 0;
|
margin: var(--space-3) 0 0;
|
||||||
font-size: var(--font-size-caption);
|
font-size: var(--font-size-caption);
|
||||||
}
|
|
||||||
|
|
||||||
.pace-chart__tooltip {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-1);
|
|
||||||
padding: var(--space-3);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: rgba(255, 255, 255, 0.78);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pace-chart__hint {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pace-chart__caption {
|
|
||||||
margin: var(--space-3) 0 0;
|
|
||||||
font-size: var(--font-size-caption);
|
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1590,49 +1543,6 @@ body {
|
|||||||
#ffffff;
|
#ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pace-chart__svg {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pace-chart__grid-line {
|
|
||||||
stroke: rgba(14, 31, 45, 0.12);
|
|
||||||
stroke-width: 0.8;
|
|
||||||
vector-effect: non-scaling-stroke;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pace-chart__line {
|
|
||||||
stroke-width: 2.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pace-chart__dot {
|
|
||||||
fill: #ffffff;
|
|
||||||
stroke: var(--color-accent);
|
|
||||||
stroke-width: 1;
|
|
||||||
vector-effect: non-scaling-stroke;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pace-chart__dot--last {
|
|
||||||
fill: var(--color-lime);
|
|
||||||
stroke: var(--color-bg-deep);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pace-chart__stats {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pace-chart__caption {
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: rgba(255, 255, 255, 0.72);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pace-chart__caption--best {
|
|
||||||
color: #0e5d3d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.races-hero {
|
.races-hero {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.58fr);
|
grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.58fr);
|
||||||
@@ -1663,10 +1573,131 @@ body {
|
|||||||
backdrop-filter: blur(14px);
|
backdrop-filter: blur(14px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.races-hero--filters-open {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
min-height: 15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.races-hero--filters-open .races-hero__content {
|
||||||
|
max-width: 42rem;
|
||||||
|
}
|
||||||
|
|
||||||
.races-hero__filters .races-filter {
|
.races-hero__filters .races-filter {
|
||||||
|
display: block;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.races-filter__primary,
|
||||||
|
.races-filter__advanced-fields {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.races-filter__primary {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.races-filter__primary .races-filter__field,
|
||||||
|
.races-filter__advanced-fields .races-filter__field,
|
||||||
|
.races-filter__panel-fields .races-filter__field {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.races-filter__advanced-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
padding-right: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
gap: var(--space-3);
|
||||||
|
color: #ffffff;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-left: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.races-filter__advanced-summary::after {
|
||||||
|
content: "+";
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.42);
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.races-filter__advanced[open] .races-filter__advanced-summary::after {
|
||||||
|
content: "−";
|
||||||
|
}
|
||||||
|
|
||||||
|
.races-filter__advanced-summary:focus-visible {
|
||||||
|
outline: 2px solid var(--color-lime);
|
||||||
|
outline-offset: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.races-filter-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-5);
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
padding: var(--space-6);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-surface);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.races-filter-panel__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.races-filter-panel__eyebrow {
|
||||||
|
margin: 0 0 var(--space-1);
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.races-filter-panel__title {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-bg-deep);
|
||||||
|
font-size: var(--font-size-h2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.races-filter-panel .races-filter {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.races-filter__panel-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.races-filter-panel .races-filter__select,
|
||||||
|
.races-filter-panel .races-filter__input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.races-hero__filters .races-filter__label {
|
.races-hero__filters .races-filter__label {
|
||||||
color: rgba(255, 255, 255, 0.78);
|
color: rgba(255, 255, 255, 0.78);
|
||||||
}
|
}
|
||||||
@@ -1676,6 +1707,15 @@ body {
|
|||||||
background: rgba(255, 255, 255, 0.94);
|
background: rgba(255, 255, 255, 0.94);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.races-hero__filters .races-filter__input {
|
||||||
|
border-color: rgba(255, 255, 255, 0.24);
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.races-filter-panel .races-filter__label {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.races-view-toggle {
|
.races-view-toggle {
|
||||||
border-color: rgba(255, 255, 255, 0.24);
|
border-color: rgba(255, 255, 255, 0.24);
|
||||||
background: rgba(7, 25, 39, 0.28);
|
background: rgba(7, 25, 39, 0.28);
|
||||||
@@ -2104,6 +2144,10 @@ body {
|
|||||||
background-position: 62% center;
|
background-position: 62% center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.races-filter__panel-fields {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.race-details-header {
|
.race-details-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -2136,6 +2180,19 @@ body {
|
|||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.races-filter-panel {
|
||||||
|
padding: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.races-filter-panel__header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.races-filter-panel__close {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-hero__title {
|
.dashboard-hero__title {
|
||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
}
|
}
|
||||||
@@ -2153,6 +2210,10 @@ body {
|
|||||||
.races-cal__year {
|
.races-cal__year {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.races-filter__panel-fields {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"lib": ["ES2020"],
|
"lib": ["ES2020", "DOM"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user