Compare commits
9 Commits
feat/backe
...
feature/pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2dcf67396 | ||
| 1ffc3a65eb | |||
|
|
7845d8d961 | ||
| 0ddf37683a | |||
|
|
92cf94aa5b | ||
| 36e103dd41 | |||
|
|
a332703e2f | ||
| 1d89e2bce2 | |||
|
|
800fbfa560 |
@@ -1,4 +1,5 @@
|
||||
# ─── PostgreSQL ───────────────────────────────────────────────
|
||||
# Не обязательны, если CALENDAR_RUN_MOCK_DB=1 (только HTTP API без БД).
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=calendar_run
|
||||
@@ -6,9 +7,13 @@ DB_USER=calendar_user
|
||||
DB_PASSWORD=calendar_pass
|
||||
|
||||
# ─── Backend API ──────────────────────────────────────────────
|
||||
# Port the API server listens on
|
||||
# Порт: сначала читается PORT (если задан), иначе API_PORT, иначе 3001.
|
||||
# PORT=3001
|
||||
API_PORT=3001
|
||||
|
||||
# ─── Dev/CI: без PostgreSQL для smoke API (не для migrate/seed) ─
|
||||
# CALENDAR_RUN_MOCK_DB=1
|
||||
|
||||
# ─── CORS ─────────────────────────────────────────────────────
|
||||
# Allowed origin for the frontend (Vite dev server default)
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
|
||||
36
.github/workflows/ci.yml
vendored
Normal file
36
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: |
|
||||
backend/package-lock.json
|
||||
frontend/package-lock.json
|
||||
|
||||
- name: Backend — install, build, test
|
||||
working-directory: backend
|
||||
env:
|
||||
CALENDAR_RUN_MOCK_DB: "1"
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
npm test
|
||||
|
||||
- name: Frontend — install, build
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,3 +2,5 @@ node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
*plan*
|
||||
*PLAN*
|
||||
229
PLAN.md
229
PLAN.md
@@ -1,202 +1,61 @@
|
||||
# План: календарь забегов (SPA + API + PostgreSQL)
|
||||
# Calendar Run — план продукта
|
||||
|
||||
Консолидированный план реализации с актуальными решениями. После инициализации git — работать в отдельной ветке (например `feature/race-calendar-app`).
|
||||
Монорепозиторий: **backend** (Express + PostgreSQL) и **frontend** (React + Vite). Цель — календарь стартов с метриками бегуна: планирование, результаты, PR и сравнение.
|
||||
|
||||
## 1. Цели продукта
|
||||
## Вне объёма (намеренно)
|
||||
|
||||
1. **Расписание по месяцу** — выбор месяца/года, список забегов.
|
||||
2. **Расписание на год** — календарная сетка года, отметки на датах со стартами; по клику на дату — модалка/панель со списком забегов и переход на карточку.
|
||||
3. Добавлять забеги **запланированные** и **уже прошедшие**.
|
||||
4. Для **прошедших** — ввод/редактирование **результата** и **стартового номера**; поля можно дописать позже.
|
||||
5. **Личные рекорды** на главной по дистанциям: 1 км, 5 км, 10 км, 15 км, 21.1 км, 42.2 км — обновляются, если в забеге указан более быстрый результат на «подходящей» дистанции.
|
||||
6. **Старты (организатор):** дата и время старта, расписание кластеров, выдача номеров — **ручной ввод**; обязательна возможность указать **официальную ссылку** на страницу организатора (автопарсинг сайтов не входит в объём).
|
||||
7. **Авторизация не требуется.**
|
||||
- Авторизация, мультипользовательность, личные кабинеты.
|
||||
- Парсинг сайтов организаторов и автозагрузка результатов.
|
||||
- Отдача статики SPA с того же процесса, что и API (фронт — отдельный Vite/build).
|
||||
|
||||
## 2. Исходные данные в репозитории
|
||||
## Модель данных `Race` (API — camelCase)
|
||||
|
||||
- Файл `import/races_2026_calendar.csv` — колонки `date`, `month`, `day`, `event`, `distance_km`.
|
||||
- Назначение CSV: **один раз** — как вход для **seed-скрипта**, который пишет строки в PostgreSQL.
|
||||
- **В рантайме** ни фронт, ни API этот CSV **не читают**. После успешного seed файл может оставаться в репо только как архив/референс.
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `id` | string | Стабильный ключ, например `{YYYY-MM-DD}-{slug}` |
|
||||
| `date` | string | `YYYY-MM-DD` |
|
||||
| `title` | string | Название |
|
||||
| `distanceKm` | number | Дистанция, км |
|
||||
| `status` | `planned` \| `registered` \| `completed` \| null | Жизненный цикл старта |
|
||||
| `officialUrl` | string \| null | Сайт организатора |
|
||||
| `startTime` | string \| null | Время старта (строка, напр. `09:30`) |
|
||||
| `clusterSchedule` | string \| null | Расписание кластеров |
|
||||
| `bibPickup` | string \| null | Выдача номеров |
|
||||
| `bibNumber` | string \| null | Стартовый номер |
|
||||
| `finishTime` | string \| null | Результат `H:MM:SS` или `MM:SS` |
|
||||
| `finishPlace` | string \| null | Место на финише (текст: «3», «3/120» и т.п.) |
|
||||
| `notes` | string \| null | Заметки |
|
||||
| `createdAt` | string | ISO, read-only |
|
||||
| `updatedAt` | string \| null | ISO, read-only |
|
||||
|
||||
## 3. Стек и структура репозитория
|
||||
PostgreSQL: `snake_case` столбцы, маппинг в [`backend/src/mappers/race.ts`](backend/src/mappers/race.ts).
|
||||
|
||||
- **Монорепозиторий:** `frontend/` (Vite + React 18 + TypeScript + react-router-dom) и `backend/` (Node.js + Fastify или Express).
|
||||
- **БД:** PostgreSQL; схема — SQL-миграции в `backend/` (или согласованный каталог миграций).
|
||||
- **Локально:** `docker-compose.yml` с сервисом Postgres.
|
||||
- **Стили:** CSS + **BEM** + CSS variables (минимальная дизайн-система: цвета, отступы, типографика).
|
||||
- **Даты/время:** `date-fns` или `Intl`, русская локаль там, где нужно.
|
||||
## HTTP API (минимум)
|
||||
|
||||
### 3.1 Переменные окружения
|
||||
- `GET /health` — liveness без БД.
|
||||
- `GET /ready` — readiness (подключение к БД; в режиме mock считается доступной — только для dev/CI).
|
||||
- `GET /races` — список; query: `year`, `month` (целые; `month` 1–12).
|
||||
- `GET /races/:id`, `POST /races`, `PATCH /races/:id`, `DELETE /races/:id`.
|
||||
|
||||
**Только на сервере API (никогда не во фронт-бандле):**
|
||||
Ошибки: JSON, единый стиль (`validation_error`, `not_found`, `conflict`, `database_unavailable`). Подробности — [`docs/backend-api-for-frontend.md`](docs/backend-api-for-frontend.md).
|
||||
|
||||
```env
|
||||
DB_HOST=
|
||||
DB_PORT=
|
||||
DB_NAME=
|
||||
DB_USER=
|
||||
DB_PASSWORD=
|
||||
```
|
||||
## Seed
|
||||
|
||||
Дополнительно по необходимости: `PORT` или `API_PORT`, `NODE_ENV`, `CORS_ORIGIN`.
|
||||
- Файл [`import/races_2026_calendar.csv`](import/races_2026_calendar.csv).
|
||||
- Стабильный `id`, upsert по `id`. Повторный запуск безопасен.
|
||||
|
||||
**Фронтенд (Vite):** только публичный адрес API, например:
|
||||
## Режим без PostgreSQL (dev/CI)
|
||||
|
||||
```env
|
||||
VITE_API_BASE_URL=http://localhost:3001
|
||||
```
|
||||
Переменная `CALENDAR_RUN_MOCK_DB=1` (или `true`): HTTP-обработчики используют заглушку пула **без** реальной БД. **Не использовать** для `npm run db:migrate` и `npm run seed` — нужен настоящий Postgres и `DB_*`.
|
||||
|
||||
В коде: `import.meta.env.VITE_API_BASE_URL`.
|
||||
## Frontend (SPA)
|
||||
|
||||
Шаблон без секретов — корневой `.env.example`; описание — в `docs/frontend.md` и `docs/backend.md`.
|
||||
- Маршруты: дашборд (`/`), список стартов (`/races`), карточка (`/races/:id`).
|
||||
- Дашборд: ближайший старт, последний результат, PR, сезон, PR по ключевым дистанциям, сравнение завершённых стартов, при необходимости — лёгкая визуализация прогресса.
|
||||
- Список: будущие / прошедшие; фильтрация по году и месяцу через API.
|
||||
- Стили: BEM и дизайн-токены; ориентир по духу — [`agent-frontend-ui-instructions.md`](agent-frontend-ui-instructions.md).
|
||||
|
||||
## 4. Источник правды и поток данных
|
||||
## Критерии готовности текущей итерации
|
||||
|
||||
### 4.1 Рантайм
|
||||
|
||||
Единственный источник правды для календаря и карточек в работающем приложении — **PostgreSQL**. SPA общается **только с HTTP API**.
|
||||
|
||||
**localStorage не используется** для хранения забегов или «дельт».
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
User[User]
|
||||
SPA[React SPA]
|
||||
API[Node API]
|
||||
DB[(PostgreSQL)]
|
||||
User --> SPA
|
||||
SPA -->|"fetch VITE_API_BASE_URL"| API
|
||||
API -->|"DB_* connection"| DB
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 4.2 Вне рантайма (разово)
|
||||
|
||||
**Seed-скрипт** (запуск вручную при развёртывании/обновлении стартового набора):
|
||||
|
||||
- Читает `import/*.csv` и/или опционально `public/data/races.json`.
|
||||
- Выполняет `INSERT` / upsert в таблицу `races`.
|
||||
- Не вызывается из SPA и не выполняется на каждом HTTP-запросе.
|
||||
|
||||
Промежуточная ступень **CSV → races.json для работы SPA не обязательна**: seed может писать в БД напрямую из CSV и/или из JSON.
|
||||
|
||||
### 4.3 Разовый перенос CSV → БД
|
||||
|
||||
- Парсинг: заголовок; кавычки в `event`; `distance_km` — число; `date` — `YYYY-MM-DD`.
|
||||
- После переноса приложение в обычном режиме **не использует** `import/races_2026_calendar.csv`. Новые старты — через UI/API (или отдельная админ-фича импорта, если появится позже).
|
||||
|
||||
## 5. Модель данных (БД и API)
|
||||
|
||||
Один набор полей — в таблице, в JSON тел запросов/ответов API и в опциональном файле для seed. Согласовать **camelCase в API** и **snake_case в SQL** в `docs/backend.md`.
|
||||
|
||||
|
||||
| Поле | Обяз. | Примечание |
|
||||
| ----------------- | ----- | ---------------------------------------------------- |
|
||||
| `id` | да | Стабильный ключ, напр. `2026-05-03-kazan-marathon` |
|
||||
| `date` | да | День старта `YYYY-MM-DD` |
|
||||
| `title` | да | Название |
|
||||
| `distanceKm` | да | Число км |
|
||||
| `status` | нет | `planned` / `completed`; иначе можно вывести из даты |
|
||||
| `officialUrl` | нет | Ссылка на организатора |
|
||||
| `startTime` | нет | Напр. `09:30` |
|
||||
| `clusterSchedule` | нет | Многострочный текст |
|
||||
| `bibPickup` | нет | Выдача номеров |
|
||||
| `bibNumber` | нет | Стартовый номер |
|
||||
| `finishTime` | нет | Время H:MM:SS / HH:MM:SS; для PR — перевод в секунды |
|
||||
| `notes` | нет | Заметки |
|
||||
|
||||
|
||||
Опционально в миграциях: `created_at`, `updated_at`.
|
||||
|
||||
**Операции:** `GET` список/фильтры по году-месяцу при необходимости, `GET :id`, `POST`, `PATCH :id`, при необходимости `DELETE` — зафиксировать в `docs/backend.md`.
|
||||
|
||||
## 6. Поведение SPA
|
||||
|
||||
### 6.1 Форма «Добавить забег»
|
||||
|
||||
- Обязательно: дата, название, дистанция.
|
||||
- Если дата **строго до сегодня** (локальная дата пользователя) — показать **результат** и **стартовый номер** (необязательны при первом сохранении).
|
||||
- Если дата в будущем — поля результата скрыты.
|
||||
- Опционально: чекбокс «Уже прошёл» для случая **сегодняшней** даты, чтобы открыть поля результата.
|
||||
- Сохранение: `**POST`** на API → запись в БД.
|
||||
|
||||
### 6.2 Страница забега
|
||||
|
||||
- Все поля модели; для прошедших — акцент на результат и номер.
|
||||
- Редактирование результата и номера **в любой момент**: `**PATCH`** на API, затем обновление состояния на клиенте (refetch / инвалидация кэша).
|
||||
|
||||
### 6.3 Личные рекорды
|
||||
|
||||
- Дистанции: 1, 5, 10, 15, 21.1, 42.2 км.
|
||||
- Учитывать забеги с заполненным `finishTime` и дистанцией в пределах допуска к целевой (напр. 21.0975 → 21.1, 42.195 → 42.2).
|
||||
- «Чужие» дистанции (2 км, 6 км, 30 км…) в таблицу PR по умолчанию **не** включаются.
|
||||
- Расчёт на клиенте по данным, полученным с API.
|
||||
|
||||
### 6.4 Экраны
|
||||
|
||||
1. Главная — PR, навигация «Месяц» / «Год», ближайшие старты.
|
||||
2. Месяц — селектор, список.
|
||||
3. Год — сетка, маркеры, клик по дате → модалка со списком → карточка.
|
||||
4. Карточка забега, форма добавления.
|
||||
5. Доступность модалки: фокус, Esc, контраст.
|
||||
|
||||
## 7. Документация
|
||||
|
||||
Создать каталог `**docs/`**:
|
||||
|
||||
|
||||
| Файл | Назначение |
|
||||
| ------------------ | ------------------------------------------------------- |
|
||||
| `docs/backend.md` | Docker, `DB`_*, миграции, seed, REST API, CORS |
|
||||
| `docs/frontend.md` | Структура `frontend/`, `VITE`_*, сборка, контракт с API |
|
||||
| `docs/ux-spa.md` | Сценарии, экраны, BEM, кратко про a11y |
|
||||
|
||||
|
||||
Корневой `**README.md`**: описание проекта, быстрый старт, **ссылки на три файла выше**.
|
||||
|
||||
## 8. Риски и ограничения
|
||||
|
||||
- `**DB`_* и пароли** — только в окружении сервера API; в git — только `.env.example` без реальных секретов.
|
||||
- Актуальные данные после работы в UI — в **БД**; файлы в git не синхронизируются с правками автоматически. Надёжный бэкап — **резервное копирование PostgreSQL**.
|
||||
- Деплой: Postgres + процесс API с env + статическая раздача фронта (или иная схема хостинга — описать в README).
|
||||
- Данные организаторов (расписание стартов) — **вручную** + ссылка; без парсинга чужих сайтов в объёме первой версии.
|
||||
|
||||
## 9. Ключевые пути после реализации
|
||||
|
||||
- `docker-compose.yml`, `.env.example`
|
||||
- `backend/` — сервер, миграции, роуты
|
||||
- `scripts/` или `backend/scripts/` — **разовый** seed из CSV/JSON
|
||||
- `import/races_2026_calendar.csv` — только вход seed (не рантайм)
|
||||
- `public/data/races.json` — опциональный вход seed
|
||||
- `frontend/src/api/` — клиент API
|
||||
- `frontend/src/pages/`, `frontend/src/components/` — UI (BEM)
|
||||
- `frontend/src/lib/distances.ts` — PR
|
||||
- `docs/*.md`, `README.md`
|
||||
|
||||
## 10. Чеклист задач (implementation todos)
|
||||
|
||||
1. Монорепо: `frontend/` + `backend/`, BEM, токены, роутер.
|
||||
2. Postgres в docker-compose, миграции таблицы `races`, бэкенд читает `DB`_*.
|
||||
3. REST CRUD + разовый seed (CSV и/или JSON) → БД.
|
||||
4. Клиент API на фронте, типы, загрузка данных для экранов и PR.
|
||||
5. Экраны месяц и год, модалка по дате.
|
||||
6. Форма добавления с условными полями; карточка с PATCH результата/номера.
|
||||
7. Поля организатора на карточке.
|
||||
8. A11y модалки, мобильная вёрстка, смоук-сценарии.
|
||||
9. `docs/` + README + `.env.example`.
|
||||
|
||||
## 11. Идеи на будущее (тематика бега)
|
||||
|
||||
- Недельный объём / простой план подготовки.
|
||||
- Импорт/экспорт JSON для бэкапа через API.
|
||||
- Фильтры (город, дистанция), погода по дню старта.
|
||||
- График динамики PR.
|
||||
- Загрузка GPX / тренировки.
|
||||
- Браузерные напоминания за N дней до старта.
|
||||
- Сравнение результатов одной дистанции по годам.
|
||||
- PWA для офлайн-просмотра (read-only кэш не заменяет БД без продуманной синхронизации).
|
||||
|
||||
---
|
||||
|
||||
*Документ создан как единый актуальный план в корне репозитория. При расхождениях с черновиками в IDE приоритет у этого файла.*
|
||||
- Документация согласована с кодом: [`README.md`](README.md), [`docs/backend.md`](docs/backend.md), [`docs/backend-api-for-frontend.md`](docs/backend-api-for-frontend.md).
|
||||
- Миграции и seed воспроизводимы; контракт API покрыт smoke-тестами в CI при необходимости с mock-БД.
|
||||
|
||||
58
README.md
58
README.md
@@ -1,24 +1,56 @@
|
||||
# Calendar Run
|
||||
|
||||
Calendar Run is a races calendar project with a backend API for storing and querying race events.
|
||||
Calendar Run is a races calendar project: a **backend API** (Express + PostgreSQL) and a **React SPA** for viewing and analyzing your race schedule.
|
||||
|
||||
## Quick Start
|
||||
Product scope and data model: [PLAN.md](PLAN.md).
|
||||
|
||||
## Backend — quick start
|
||||
|
||||
1. Install dependencies:
|
||||
- `cd backend`
|
||||
- `npm install`
|
||||
2. Configure environment variables in `backend/.env`:
|
||||
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`
|
||||
- Optional API port: `PORT` (priority) or `API_PORT`
|
||||
- **No PostgreSQL (CI / local smoke):** set `CALENDAR_RUN_MOCK_DB=1` (or `true`). Real `DB_`* values are not required; the API uses in-memory stubs for SQL used by the HTTP routes. Do not use mock mode for `npm run db:migrate` or `npm run seed`.
|
||||
3. Build and run backend:
|
||||
- `npm run build`
|
||||
- `npm run dev`
|
||||
2. Configure environment variables. Copy the root template and edit:
|
||||
|
||||
By default, the API listens on port `3001` if `PORT` and `API_PORT` are not set.
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
## Backend And API Docs
|
||||
Use the **repository root** `.env` (the backend loads it via `backend/src/config.ts`).
|
||||
|
||||
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` — required unless mock mode is on.
|
||||
- API port: `PORT` (takes precedence) or `API_PORT` (default `3001`).
|
||||
- **No PostgreSQL (CI / local smoke):** set `CALENDAR_RUN_MOCK_DB=1` (or `true`). Real `DB_*` values are not required; the API uses in-memory stubs for SQL used by the HTTP routes. **Do not** use mock mode for `npm run db:migrate` or `npm run seed`.
|
||||
3. With a real database: from repo root, `docker-compose up -d`, then `cd backend && npm run db:migrate && npm run seed`.
|
||||
4. Build and run API:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Frontend — quick start
|
||||
|
||||
1. Configure the API URL for Vite (file is read from `frontend/`):
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `VITE_API_BASE_URL` if the API is not on `http://localhost:3001`.
|
||||
|
||||
2. Install and run:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Default app URL: `http://localhost:5173`. The backend `CORS_ORIGIN` must match this origin (see root `.env.example`).
|
||||
|
||||
## Docs
|
||||
|
||||
- [Backend API for Frontend](docs/backend-api-for-frontend.md)
|
||||
- [Backend Agent Instruction](docs/agent-backend-instruction.md)
|
||||
- [Backend Sync Fix Instruction](docs/agent-fix-backend-sync-instruction.md)
|
||||
- [Backend operations](docs/backend.md)
|
||||
- [Backend agent instruction](docs/agent-backend-instruction.md)
|
||||
- [Backend sync fix instruction](docs/agent-fix-backend-sync-instruction.md)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE races ADD COLUMN IF NOT EXISTS finish_place TEXT;
|
||||
|
||||
ALTER TABLE races DROP CONSTRAINT IF EXISTS races_status_check;
|
||||
ALTER TABLE races ADD CONSTRAINT races_status_check
|
||||
CHECK (status IS NULL OR status IN ('planned', 'registered', 'completed'));
|
||||
849
backend/package-lock.json
generated
849
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,8 @@
|
||||
"dev": "ts-node src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"db:migrate": "ts-node src/migrate.ts",
|
||||
"seed": "ts-node src/seed.ts"
|
||||
"seed": "ts-node src/seed.ts",
|
||||
"test": "CALENDAR_RUN_MOCK_DB=1 tsx --test test/app.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
@@ -21,7 +22,10 @@
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/pg": "^8.11.10",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
17
backend/src/app.ts
Normal file
17
backend/src/app.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import { config } from "./config";
|
||||
import healthRouter from "./routes/health";
|
||||
import racesRouter from "./routes/races";
|
||||
|
||||
export function createApp(): express.Express {
|
||||
const app = express();
|
||||
|
||||
app.use(cors({ origin: config.corsOrigin, methods: ["GET", "POST", "PATCH", "DELETE"] }));
|
||||
app.use(express.json());
|
||||
|
||||
app.use(healthRouter);
|
||||
app.use(racesRouter);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -29,6 +29,7 @@ function mockRowFromInsert(sql: string, params: unknown[]): RaceRow {
|
||||
bib_pickup: null,
|
||||
bib_number: null,
|
||||
finish_time: null,
|
||||
finish_place: null,
|
||||
notes: null,
|
||||
created_at: now,
|
||||
updated_at: null,
|
||||
@@ -51,6 +52,7 @@ function mockRowFromInsert(sql: string, params: unknown[]): RaceRow {
|
||||
bib_pickup: row.bib_pickup != null ? String(row.bib_pickup) : null,
|
||||
bib_number: row.bib_number != null ? String(row.bib_number) : null,
|
||||
finish_time: row.finish_time != null ? String(row.finish_time) : null,
|
||||
finish_place: row.finish_place != null ? String(row.finish_place) : null,
|
||||
notes: row.notes != null ? String(row.notes) : null,
|
||||
created_at: now,
|
||||
updated_at: null,
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import { config } from "./config";
|
||||
import healthRouter from "./routes/health";
|
||||
import racesRouter from "./routes/races";
|
||||
import { createApp } from "./app";
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(cors({ origin: config.corsOrigin, methods: ["GET", "POST", "PATCH", "DELETE"] }));
|
||||
app.use(express.json());
|
||||
|
||||
app.use(healthRouter);
|
||||
app.use(racesRouter);
|
||||
const app = createApp();
|
||||
|
||||
app.listen(config.apiPort, () => {
|
||||
console.log(`[api] Listening on http://localhost:${config.apiPort}`);
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface RaceRow {
|
||||
bib_pickup: string | null;
|
||||
bib_number: string | null;
|
||||
finish_time: string | null;
|
||||
finish_place: string | null;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
@@ -29,6 +30,7 @@ export interface RaceDto {
|
||||
bibPickup: string | null;
|
||||
bibNumber: string | null;
|
||||
finishTime: string | null;
|
||||
finishPlace: string | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string | null;
|
||||
@@ -48,6 +50,7 @@ export function rowToDto(row: RaceRow): RaceDto {
|
||||
bibPickup: row.bib_pickup,
|
||||
bibNumber: row.bib_number,
|
||||
finishTime: row.finish_time,
|
||||
finishPlace: row.finish_place,
|
||||
notes: row.notes,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
@@ -66,6 +69,7 @@ const FIELD_MAP: Record<string, string> = {
|
||||
bibPickup: "bib_pickup",
|
||||
bibNumber: "bib_number",
|
||||
finishTime: "finish_time",
|
||||
finishPlace: "finish_place",
|
||||
notes: "notes",
|
||||
};
|
||||
|
||||
|
||||
39
backend/test/app.test.ts
Normal file
39
backend/test/app.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../src/app";
|
||||
|
||||
const app = createApp();
|
||||
|
||||
test("GET /health returns ok", async () => {
|
||||
const res = await request(app).get("/health").expect(200);
|
||||
assert.equal(res.body.status, "ok");
|
||||
});
|
||||
|
||||
test("GET /ready succeeds with mock database", async () => {
|
||||
const res = await request(app).get("/ready").expect(200);
|
||||
assert.equal(res.body.status, "ready");
|
||||
assert.equal(res.body.db, "connected");
|
||||
});
|
||||
|
||||
test("GET /races rejects invalid year", async () => {
|
||||
const res = await request(app).get("/races?year=bad").expect(400);
|
||||
assert.equal(res.body.error, "validation_error");
|
||||
assert.ok(Array.isArray(res.body.details));
|
||||
});
|
||||
|
||||
test("GET /races rejects month out of range", async () => {
|
||||
const res = await request(app).get("/races?month=13").expect(400);
|
||||
assert.equal(res.body.error, "validation_error");
|
||||
});
|
||||
|
||||
test("GET /races accepts year and month", async () => {
|
||||
const res = await request(app).get("/races?year=2026&month=5").expect(200);
|
||||
assert.ok(Array.isArray(res.body));
|
||||
});
|
||||
|
||||
test("GET /races/:id returns not_found", async () => {
|
||||
const res = await request(app).get("/races/does-not-exist").expect(404);
|
||||
assert.equal(res.body.error, "not_found");
|
||||
assert.ok(Array.isArray(res.body.details));
|
||||
});
|
||||
@@ -10,7 +10,7 @@
|
||||
2. **Не блокировать работу отсутствием живой БД у исполнителя:**
|
||||
- код миграций и seed должен быть **валидным и согласованным** с PLAN;
|
||||
- при старте API при невозможности подключиться к БД — **ясное сообщение в лог** и **корректный HTTP-ответ** на зависящих от БД маршрутах (например 503 с телом `{"error":"database_unavailable",...}`) **или** падение процесса на старте с понятной ошибкой (выбрать одну стратегию и описать её в `docs/backend.md`).
|
||||
3. **Не выдумывать обход БД** (in-memory «режим без Postgres»), если это не согласовано отдельно — фронт и план рассчитаны на PostgreSQL.
|
||||
3. **Режим без Postgres для dev/CI** согласован с [PLAN.md](../PLAN.md) и `docs/backend.md`: переменная `CALENDAR_RUN_MOCK_DB=1` (или `true`) включает in-memory заглушку пула **только** для HTTP-слоя. Для **`npm run db:migrate`** и **`npm run seed`** нужен реальный PostgreSQL и `DB_*`; mock для миграций/seed не используется.
|
||||
4. Автотесты, требующие Docker/Postgres, помечать как **опциональные** или давать инструкцию «как прогнать локально», не считая провал из-за отсутствия БД у агента блокером для merge кода.
|
||||
|
||||
---
|
||||
@@ -90,7 +90,7 @@
|
||||
|
||||
**В JSON API (запрос/ответ)** — **camelCase**, как в PLAN:
|
||||
|
||||
`id`, `date`, `title`, `distanceKm`, `status`, `officialUrl`, `startTime`, `clusterSchedule`, `bibPickup`, `bibNumber`, `finishTime`, `notes`.
|
||||
`id`, `date`, `title`, `distanceKm`, `status` (`planned` \| `registered` \| `completed`), `officialUrl`, `startTime`, `clusterSchedule`, `bibPickup`, `bibNumber`, `finishTime`, `finishPlace`, `notes`.
|
||||
|
||||
**В PostgreSQL** — **snake_case**, например:
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
| `bib_pickup` | `TEXT` |
|
||||
| `bib_number` | `TEXT` |
|
||||
| `finish_time` | `TEXT` |
|
||||
| `finish_place` | `TEXT` |
|
||||
| `notes` | `TEXT` |
|
||||
| `created_at` | `TIMESTAMPTZ` DEFAULT now() |
|
||||
| `updated_at` | `TIMESTAMPTZ` |
|
||||
|
||||
@@ -87,6 +87,7 @@ GET /races?year=2026&month=5
|
||||
"bibPickup": null,
|
||||
"bibNumber": null,
|
||||
"finishTime": null,
|
||||
"finishPlace": null,
|
||||
"notes": null,
|
||||
"createdAt": "2026-03-31T12:00:00.000Z",
|
||||
"updatedAt": null
|
||||
@@ -102,10 +103,10 @@ GET /races?year=2026&month=5
|
||||
|
||||
**Ответ 200:** объект `Race` (см. модель ниже).
|
||||
|
||||
**Ответ 404:**
|
||||
**Ответ 404:** тело JSON, поле `details` — массив пояснений (можно показывать в UI или игнорировать).
|
||||
|
||||
```json
|
||||
{ "error": "not_found" }
|
||||
{ "error": "not_found", "details": ["Race not found"] }
|
||||
```
|
||||
|
||||
---
|
||||
@@ -129,6 +130,7 @@ GET /races?year=2026&month=5
|
||||
"bibPickup": null,
|
||||
"bibNumber": null,
|
||||
"finishTime": null,
|
||||
"finishPlace": null,
|
||||
"notes": null
|
||||
}
|
||||
```
|
||||
@@ -160,12 +162,13 @@ GET /races?year=2026&month=5
|
||||
```json
|
||||
{
|
||||
"finishTime": "1:45:30",
|
||||
"finishPlace": "12/340",
|
||||
"bibNumber": "1234",
|
||||
"status": "completed"
|
||||
}
|
||||
```
|
||||
|
||||
**Допустимые поля:** `date`, `title`, `distanceKm`, `status`, `officialUrl`, `startTime`, `clusterSchedule`, `bibPickup`, `bibNumber`, `finishTime`, `notes`.
|
||||
**Допустимые поля:** `date`, `title`, `distanceKm`, `status`, `officialUrl`, `startTime`, `clusterSchedule`, `bibPickup`, `bibNumber`, `finishTime`, `finishPlace`, `notes`.
|
||||
|
||||
**Ответ 200:** обновлённый объект `Race`.
|
||||
|
||||
@@ -178,7 +181,7 @@ GET /races?year=2026&month=5
|
||||
**Ответ 404:**
|
||||
|
||||
```json
|
||||
{ "error": "not_found" }
|
||||
{ "error": "not_found", "details": ["Race not found"] }
|
||||
```
|
||||
|
||||
---
|
||||
@@ -192,7 +195,7 @@ GET /races?year=2026&month=5
|
||||
**Ответ 404:**
|
||||
|
||||
```json
|
||||
{ "error": "not_found" }
|
||||
{ "error": "not_found", "details": ["Race not found"] }
|
||||
```
|
||||
|
||||
## 4. Модель `Race` (camelCase)
|
||||
@@ -203,13 +206,14 @@ GET /races?year=2026&month=5
|
||||
| `date` | string | да | да | `YYYY-MM-DD` |
|
||||
| `title` | string | да | да | Название забега |
|
||||
| `distanceKm` | number | да | да | Дистанция в км |
|
||||
| `status` | string \| null | нет | да | `"planned"` / `"completed"` |
|
||||
| `status` | string \| null | нет | да | `"planned"` / `"registered"` / `"completed"` |
|
||||
| `officialUrl` | string \| null | нет | да | URL организатора |
|
||||
| `startTime` | string \| null | нет | да | Время старта, напр. `"09:30"` |
|
||||
| `clusterSchedule` | string \| null | нет | да | Расписание кластеров |
|
||||
| `bibPickup` | string \| null | нет | да | Выдача номеров |
|
||||
| `bibNumber` | string \| null | нет | да | Стартовый номер |
|
||||
| `finishTime` | string \| null | нет | да | Финишное время `H:MM:SS` |
|
||||
| `finishTime` | string \| null | нет | да | Финишное время `H:MM:SS` или `MM:SS` |
|
||||
| `finishPlace` | string \| null | нет | да | Место на финише (произвольная строка) |
|
||||
| `notes` | string \| null | нет | да | Заметки |
|
||||
| `createdAt` | string | — | — | ISO timestamp (read-only) |
|
||||
| `updatedAt` | string \| null | — | — | ISO timestamp (read-only) |
|
||||
@@ -229,6 +233,6 @@ Seed-скрипт (`npm run seed` в `backend/`) выполняет **upsert**
|
||||
## 7. Поведение при недоступной БД
|
||||
|
||||
- `GET /health` — всегда `200` (не проверяет БД).
|
||||
- `GET /ready` — `503 { "error": "database_unavailable", "db": "disconnected" }`.
|
||||
- `GET /ready` — при недоступной БД: `503 { "error": "database_unavailable", "db": "disconnected" }`. В режиме **`CALENDAR_RUN_MOCK_DB`** (dev/CI без Postgres) readiness возвращает успех без реального подключения — см. `docs/backend.md`.
|
||||
- Все остальные маршруты — `503 { "error": "database_unavailable" }`.
|
||||
- В логах сервера: строка ошибки с контекстом маршрута.
|
||||
|
||||
@@ -62,27 +62,31 @@ npm run build # компиляция в dist/
|
||||
npm start # запуск из dist/
|
||||
```
|
||||
|
||||
API слушает порт из `API_PORT` (по умолчанию `3001`).
|
||||
API слушает порт: **`PORT`** (если задан), иначе **`API_PORT`**, иначе **`3001`**.
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
| Переменная | Описание | По умолчанию |
|
||||
|---|---|---|
|
||||
| `DB_HOST` | Хост PostgreSQL | — (обязательна) |
|
||||
| `DB_PORT` | Порт PostgreSQL | — (обязательна) |
|
||||
| `DB_NAME` | Имя базы данных | — (обязательна) |
|
||||
| `DB_USER` | Пользователь БД | — (обязательна) |
|
||||
| `DB_PASSWORD` | Пароль БД | — (обязательна) |
|
||||
| `DB_HOST` | Хост PostgreSQL | — (обязательна без mock, см. ниже) |
|
||||
| `DB_PORT` | Порт PostgreSQL | — (обязательна без mock) |
|
||||
| `DB_NAME` | Имя базы данных | — (обязательна без mock) |
|
||||
| `DB_USER` | Пользователь БД | — (обязательна без mock) |
|
||||
| `DB_PASSWORD` | Пароль БД | — (обязательна без mock) |
|
||||
| `CALENDAR_RUN_MOCK_DB` | `1` или `true` — режим без PostgreSQL для HTTP API (dev/CI) | выкл. |
|
||||
| `PORT` | Порт API (приоритетнее `API_PORT`) | — |
|
||||
| `API_PORT` | Порт API-сервера | `3001` |
|
||||
| `CORS_ORIGIN` | Разрешённый origin для CORS | `http://localhost:5173` |
|
||||
|
||||
При отсутствии любой из `DB_*` процесс падает при старте с сообщением `Missing required environment variable: <NAME>`.
|
||||
**Без mock:** при отсутствии любой из `DB_*` процесс падает при старте: `Missing required environment variable: <NAME>`.
|
||||
|
||||
**С `CALENDAR_RUN_MOCK_DB=1`:** переменные `DB_*` не обязательны; реальный пул не поднимается. **Не использовать** mock для `npm run db:migrate` и `npm run seed` — нужен настоящий Postgres и корректные `DB_*`.
|
||||
|
||||
## Поведение при недоступной БД
|
||||
|
||||
- **Старт сервера** — проходит успешно (env валидирован, Express слушает порт).
|
||||
- **`GET /health`** — всегда `200 { "status": "ok" }` (liveness, без обращения к БД).
|
||||
- **`GET /ready`** — пробует подключиться к БД; возвращает `200` если ОК, `503 { "error": "database_unavailable" }` если нет.
|
||||
- **`GET /ready`** — при обычном режиме пробует подключиться к БД; `200` если ОК, `503 { "error": "database_unavailable", ... }` если нет. В режиме **`CALENDAR_RUN_MOCK_DB`** readiness считается успешным без реального подключения (удобно для CI/smoke API).
|
||||
- **Все остальные маршруты** при ошибке БД возвращают `503 { "error": "database_unavailable" }`.
|
||||
|
||||
## Структура каталога
|
||||
@@ -90,11 +94,13 @@ API слушает порт из `API_PORT` (по умолчанию `3001`).
|
||||
```
|
||||
backend/
|
||||
├── migrations/
|
||||
│ └── 001_create_races.sql
|
||||
│ ├── 001_create_races.sql
|
||||
│ └── 002_finish_place_and_registered_status.sql
|
||||
├── src/
|
||||
│ ├── app.ts # фабрика Express (тесты)
|
||||
│ ├── config.ts # загрузка и валидация env
|
||||
│ ├── db.ts # pg Pool
|
||||
│ ├── index.ts # точка входа Express
|
||||
│ ├── db.ts # pg Pool или mock
|
||||
│ ├── index.ts # запуск сервера
|
||||
│ ├── migrate.ts # раннер миграций
|
||||
│ ├── seed.ts # разовый импорт CSV
|
||||
│ ├── mappers/
|
||||
|
||||
2
frontend/.env.example
Normal file
2
frontend/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
# Base URL of the Calendar Run API (must match CORS_ORIGIN on the backend)
|
||||
VITE_API_BASE_URL=http://localhost:3001
|
||||
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@@ -52,7 +52,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1169,7 +1168,6 @@
|
||||
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -1187,7 +1185,6 @@
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -1257,7 +1254,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
@@ -1534,7 +1530,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -1547,7 +1542,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -1730,7 +1724,6 @@
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
|
||||
@@ -18,13 +18,17 @@ function normalizeRace(input: unknown): Race {
|
||||
isString(race?.date) &&
|
||||
isString(race?.title) &&
|
||||
typeof race?.distanceKm === "number" &&
|
||||
(race?.status === null || race?.status === "planned" || race?.status === "completed") &&
|
||||
(race?.status === null ||
|
||||
race?.status === "planned" ||
|
||||
race?.status === "registered" ||
|
||||
race?.status === "completed") &&
|
||||
isNullableString(race?.officialUrl) &&
|
||||
isNullableString(race?.startTime) &&
|
||||
isNullableString(race?.clusterSchedule) &&
|
||||
isNullableString(race?.bibPickup) &&
|
||||
isNullableString(race?.bibNumber) &&
|
||||
isNullableString(race?.finishTime) &&
|
||||
isNullableString(race?.finishPlace) &&
|
||||
isNullableString(race?.notes) &&
|
||||
isString(race?.createdAt) &&
|
||||
(race?.updatedAt === null || isString(race?.updatedAt));
|
||||
@@ -49,6 +53,7 @@ function normalizeRace(input: unknown): Race {
|
||||
bibPickup: race.bibPickup,
|
||||
bibNumber: race.bibNumber,
|
||||
finishTime: race.finishTime,
|
||||
finishPlace: race.finishPlace,
|
||||
notes: race.notes,
|
||||
createdAt: race.createdAt,
|
||||
updatedAt: race.updatedAt,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type RaceStatus = "planned" | "completed";
|
||||
export type RaceStatus = "planned" | "registered" | "completed";
|
||||
|
||||
export interface Race {
|
||||
id: string;
|
||||
@@ -12,6 +12,7 @@ export interface Race {
|
||||
bibPickup: string | null;
|
||||
bibNumber: string | null;
|
||||
finishTime: string | null;
|
||||
finishPlace: string | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string | null;
|
||||
@@ -34,6 +35,7 @@ export interface CreateRacePayload {
|
||||
bibPickup?: string | null;
|
||||
bibNumber?: string | null;
|
||||
finishTime?: string | null;
|
||||
finishPlace?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createBrowserRouter } from "react-router-dom";
|
||||
import { AppLayout } from "./layouts/AppLayout";
|
||||
import { DashboardPage } from "../pages/DashboardPage";
|
||||
import { RacesPage } from "../pages/RacesPage";
|
||||
import { RaceDetailsPage } from "../pages/RaceDetailsPage";
|
||||
|
||||
export const appRouter = createBrowserRouter([
|
||||
{
|
||||
@@ -9,7 +10,8 @@ export const appRouter = createBrowserRouter([
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{ index: true, element: <DashboardPage /> },
|
||||
{ path: "races", element: <RacesPage /> }
|
||||
]
|
||||
}
|
||||
{ path: "races", element: <RacesPage /> },
|
||||
{ path: "races/:raceId", element: <RaceDetailsPage /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
73
frontend/src/components/PaceTrendChart.tsx
Normal file
73
frontend/src/components/PaceTrendChart.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Race } from "../api";
|
||||
import { formatRaceDate, isCloseDistance, parseFinishTimeToSeconds } from "../lib";
|
||||
|
||||
type PaceTrendChartProps = {
|
||||
races: Race[];
|
||||
distanceKm: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Minimal SVG sparkline: finish time (minutes) over chronological completed races
|
||||
* at the selected distance. Lower time = higher point (better).
|
||||
*/
|
||||
export function PaceTrendChart(props: PaceTrendChartProps): JSX.Element {
|
||||
const { races, distanceKm } = props;
|
||||
|
||||
const series = races
|
||||
.filter(
|
||||
(race) =>
|
||||
race.status === "completed" &&
|
||||
isCloseDistance(race.distanceKm, distanceKm) &&
|
||||
parseFinishTimeToSeconds(race.finishTime) != null,
|
||||
)
|
||||
.sort(
|
||||
(a, b) => new Date(`${a.date}T00:00:00`).getTime() - new Date(`${b.date}T00:00:00`).getTime(),
|
||||
)
|
||||
.map((race) => {
|
||||
const seconds = parseFinishTimeToSeconds(race.finishTime)!;
|
||||
return { race, minutes: seconds / 60 };
|
||||
});
|
||||
|
||||
if (series.length < 2) {
|
||||
return (
|
||||
<p className="pace-chart__empty">
|
||||
Нужно минимум два завершённых старта с временем на выбранной дистанции.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const minutes = series.map((s) => s.minutes);
|
||||
const minM = Math.min(...minutes);
|
||||
const maxM = Math.max(...minutes);
|
||||
const range = maxM - minM || 1;
|
||||
const n = series.length;
|
||||
|
||||
const pad = 4;
|
||||
const w = 100;
|
||||
const h = 36;
|
||||
const innerW = w - pad * 2;
|
||||
const innerH = h - pad * 2;
|
||||
|
||||
const points = series
|
||||
.map((s, i) => {
|
||||
const x = pad + (n === 1 ? innerW / 2 : (i / (n - 1)) * innerW);
|
||||
const norm = (maxM - s.minutes) / range;
|
||||
const y = pad + (1 - norm) * innerH;
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
const last = series[series.length - 1]!;
|
||||
|
||||
return (
|
||||
<div className="pace-chart">
|
||||
<svg className="pace-chart__svg" viewBox={`0 0 ${w} ${h}`} role="img" aria-label="Динамика времени на дистанции">
|
||||
<polyline className="pace-chart__line" fill="none" points={points} />
|
||||
</svg>
|
||||
<p className="pace-chart__caption">
|
||||
Последний пункт: {formatRaceDate(last.race.date)} — {last.race.finishTime} (
|
||||
{last.minutes.toFixed(1)} мин)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export {};
|
||||
export { PaceTrendChart } from "./PaceTrendChart";
|
||||
|
||||
@@ -1 +1,13 @@
|
||||
export {};
|
||||
export {
|
||||
formatDistance,
|
||||
formatRaceDate,
|
||||
getPaceLabel,
|
||||
getRaceCountdownLabel,
|
||||
getRaceStatusClassName,
|
||||
getRaceStatusLabel,
|
||||
isCloseDistance,
|
||||
parseFinishTimeToSeconds,
|
||||
sortByDateAsc,
|
||||
sortByDateDesc,
|
||||
splitRacesByDate,
|
||||
} from "./raceMetrics";
|
||||
|
||||
128
frontend/src/lib/raceMetrics.ts
Normal file
128
frontend/src/lib/raceMetrics.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { Race } from "../api";
|
||||
|
||||
const MS_IN_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
function parseRaceDate(date: string): Date {
|
||||
return new Date(`${date}T00:00:00`);
|
||||
}
|
||||
|
||||
export function parseFinishTimeToSeconds(value: string | null): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = value.split(":").map((part) => Number(part));
|
||||
if (parts.some((part) => Number.isNaN(part) || part < 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parts.length === 2) {
|
||||
const [minutes, seconds] = parts;
|
||||
return minutes * 60 + seconds;
|
||||
}
|
||||
|
||||
if (parts.length === 3) {
|
||||
const [hours, minutes, seconds] = parts;
|
||||
return hours * 3600 + minutes * 60 + seconds;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatDistance(distanceKm: number): string {
|
||||
return `${distanceKm.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} км`;
|
||||
}
|
||||
|
||||
export function formatRaceDate(date: string): string {
|
||||
return parseRaceDate(date).toLocaleDateString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function sortByDateAsc(races: Race[]): Race[] {
|
||||
return [...races].sort((left, right) => parseRaceDate(left.date).getTime() - parseRaceDate(right.date).getTime());
|
||||
}
|
||||
|
||||
export function sortByDateDesc(races: Race[]): Race[] {
|
||||
return [...races].sort((left, right) => parseRaceDate(right.date).getTime() - parseRaceDate(left.date).getTime());
|
||||
}
|
||||
|
||||
export function splitRacesByDate(races: Race[], now: Date = new Date()): { upcoming: Race[]; past: Race[] } {
|
||||
const today = new Date(now);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const upcoming: Race[] = [];
|
||||
const past: Race[] = [];
|
||||
|
||||
for (const race of races) {
|
||||
if (parseRaceDate(race.date).getTime() >= today.getTime()) {
|
||||
upcoming.push(race);
|
||||
} else {
|
||||
past.push(race);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
upcoming: sortByDateAsc(upcoming),
|
||||
past: sortByDateDesc(past),
|
||||
};
|
||||
}
|
||||
|
||||
export function getRaceCountdownLabel(date: string, now: Date = new Date()): string {
|
||||
const today = new Date(now);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const target = parseRaceDate(date);
|
||||
const days = Math.ceil((target.getTime() - today.getTime()) / MS_IN_DAY);
|
||||
|
||||
if (days <= 0) {
|
||||
return "сегодня";
|
||||
}
|
||||
if (days === 1) {
|
||||
return "через 1 день";
|
||||
}
|
||||
if (days < 5) {
|
||||
return `через ${days} дня`;
|
||||
}
|
||||
return `через ${days} дней`;
|
||||
}
|
||||
|
||||
export function isCloseDistance(left: number, right: number): boolean {
|
||||
return Math.abs(left - right) < 0.05;
|
||||
}
|
||||
|
||||
export function getPaceLabel(finishTime: string | null, distanceKm: number): string | null {
|
||||
const totalSeconds = parseFinishTimeToSeconds(finishTime);
|
||||
if (!totalSeconds || distanceKm <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const paceSeconds = Math.round(totalSeconds / distanceKm);
|
||||
const paceMinutes = Math.floor(paceSeconds / 60);
|
||||
const paceRemainder = paceSeconds % 60;
|
||||
|
||||
return `${String(paceMinutes).padStart(2, "0")}:${String(paceRemainder).padStart(2, "0")} /км`;
|
||||
}
|
||||
|
||||
export function getRaceStatusClassName(status: Race["status"]): string {
|
||||
const base = "race-card__status";
|
||||
if (status === "completed") {
|
||||
return `${base} ${base}--completed`;
|
||||
}
|
||||
if (status === "registered") {
|
||||
return `${base} ${base}--registered`;
|
||||
}
|
||||
return `${base} ${base}--planned`;
|
||||
}
|
||||
|
||||
export function getRaceStatusLabel(status: Race["status"]): string {
|
||||
if (status === "completed") {
|
||||
return "пробежал";
|
||||
}
|
||||
if (status === "registered") {
|
||||
return "зарегистрирован";
|
||||
}
|
||||
return "планирую";
|
||||
}
|
||||
@@ -1,8 +1,299 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Race } from "../api";
|
||||
import { ApiError, getRaces } from "../api";
|
||||
import { PaceTrendChart } from "../components";
|
||||
import {
|
||||
formatDistance,
|
||||
formatRaceDate,
|
||||
getRaceCountdownLabel,
|
||||
getPaceLabel,
|
||||
isCloseDistance,
|
||||
parseFinishTimeToSeconds,
|
||||
splitRacesByDate,
|
||||
} from "../lib";
|
||||
|
||||
const PR_DISTANCES = [5, 10, 21.1, 42.2] as const;
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.message;
|
||||
}
|
||||
return "Не удалось загрузить данные dashboard.";
|
||||
}
|
||||
|
||||
export function DashboardPage(): JSX.Element {
|
||||
const [races, setRaces] = useState<Race[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [chartDistanceKm, setChartDistanceKm] = useState<number>(10);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadDashboardData(): Promise<void> {
|
||||
try {
|
||||
const items = await getRaces();
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
setRaces(items);
|
||||
setErrorMessage(null);
|
||||
} catch (error) {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
setErrorMessage(getErrorMessage(error));
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadDashboardData();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const dashboardMetrics = useMemo(() => {
|
||||
const { upcoming, past } = splitRacesByDate(races);
|
||||
const completed = races.filter((race) => race.status === "completed");
|
||||
|
||||
const nextRace = upcoming[0] ?? null;
|
||||
const lastResult = past.find((race) => race.status === "completed") ?? null;
|
||||
|
||||
let personalRecord: Race | null = null;
|
||||
let personalRecordSeconds = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const race of completed) {
|
||||
const finishSeconds = parseFinishTimeToSeconds(race.finishTime);
|
||||
if (!finishSeconds) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = finishSeconds / race.distanceKm;
|
||||
if (candidate < personalRecordSeconds) {
|
||||
personalRecordSeconds = candidate;
|
||||
personalRecord = race;
|
||||
}
|
||||
}
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const seasonRaces = races.filter((race) => new Date(`${race.date}T00:00:00`).getFullYear() === currentYear);
|
||||
const seasonCompleted = seasonRaces.filter((race) => race.status === "completed");
|
||||
|
||||
return {
|
||||
nextRace,
|
||||
lastResult,
|
||||
personalRecord,
|
||||
seasonTotal: seasonRaces.length,
|
||||
seasonCompletedCount: seasonCompleted.length,
|
||||
};
|
||||
}, [races]);
|
||||
|
||||
const personalRecordsByDistance = useMemo(() => {
|
||||
return PR_DISTANCES.map((distanceKm) => {
|
||||
const candidates = races.filter((race) => {
|
||||
return (
|
||||
race.status === "completed" &&
|
||||
isCloseDistance(race.distanceKm, distanceKm) &&
|
||||
Boolean(parseFinishTimeToSeconds(race.finishTime))
|
||||
);
|
||||
});
|
||||
|
||||
let bestRace: Race | null = null;
|
||||
let bestPace = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const race of candidates) {
|
||||
const totalSeconds = parseFinishTimeToSeconds(race.finishTime);
|
||||
if (!totalSeconds) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const paceSeconds = totalSeconds / race.distanceKm;
|
||||
if (paceSeconds < bestPace) {
|
||||
bestPace = paceSeconds;
|
||||
bestRace = race;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
distanceKm,
|
||||
bestRace,
|
||||
};
|
||||
});
|
||||
}, [races]);
|
||||
|
||||
const comparisonRows = useMemo(() => {
|
||||
return races
|
||||
.filter((race) => race.status === "completed")
|
||||
.map((race) => ({
|
||||
id: race.id,
|
||||
year: new Date(`${race.date}T00:00:00`).getFullYear(),
|
||||
title: race.title,
|
||||
distance: formatDistance(race.distanceKm),
|
||||
finishTime: race.finishTime ?? "время не указано",
|
||||
pace: getPaceLabel(race.finishTime, race.distanceKm) ?? "не удалось вычислить",
|
||||
place: race.finishPlace?.trim() ? race.finishPlace : "нет данных",
|
||||
}))
|
||||
.sort((left, right) => right.year - left.year || left.title.localeCompare(right.title, "ru-RU"));
|
||||
}, [races]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="page page--dashboard" aria-busy="true">
|
||||
<h1 className="page__title">Dashboard</h1>
|
||||
<p className="page__subtitle">Загружаем ваши старты...</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
return (
|
||||
<section className="page page--dashboard" role="alert">
|
||||
<h1 className="page__title">Dashboard</h1>
|
||||
<p className="page__subtitle page__subtitle--error">{errorMessage}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page page--dashboard">
|
||||
<h1 className="page__title">Dashboard</h1>
|
||||
<p className="page__subtitle">Overview cards and quick actions will be added in the next task.</p>
|
||||
<p className="page__subtitle">Ключевые метрики по вашему календарю стартов.</p>
|
||||
|
||||
<div className="dashboard-grid" aria-label="Ключевые метрики">
|
||||
<article className="dashboard-card">
|
||||
<h2 className="dashboard-card__title">Ближайший старт</h2>
|
||||
{dashboardMetrics.nextRace ? (
|
||||
<>
|
||||
<p className="dashboard-card__value">{dashboardMetrics.nextRace.title}</p>
|
||||
<p className="dashboard-card__meta">
|
||||
{formatRaceDate(dashboardMetrics.nextRace.date)} · {formatDistance(dashboardMetrics.nextRace.distanceKm)}
|
||||
</p>
|
||||
<p className="dashboard-card__hint">{getRaceCountdownLabel(dashboardMetrics.nextRace.date)}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="dashboard-card__empty">Нет запланированных стартов.</p>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="dashboard-card">
|
||||
<h2 className="dashboard-card__title">Последний результат</h2>
|
||||
{dashboardMetrics.lastResult ? (
|
||||
<>
|
||||
<p className="dashboard-card__value">{dashboardMetrics.lastResult.finishTime ?? "время не указано"}</p>
|
||||
<p className="dashboard-card__meta">
|
||||
{dashboardMetrics.lastResult.title} · {formatDistance(dashboardMetrics.lastResult.distanceKm)}
|
||||
</p>
|
||||
<p className="dashboard-card__hint">{formatRaceDate(dashboardMetrics.lastResult.date)}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="dashboard-card__empty">Пока нет завершённых стартов.</p>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="dashboard-card">
|
||||
<h2 className="dashboard-card__title">Личный рекорд</h2>
|
||||
{dashboardMetrics.personalRecord ? (
|
||||
<>
|
||||
<p className="dashboard-card__value">{dashboardMetrics.personalRecord.finishTime ?? "время не указано"}</p>
|
||||
<p className="dashboard-card__meta">
|
||||
{dashboardMetrics.personalRecord.title} · {formatDistance(dashboardMetrics.personalRecord.distanceKm)}
|
||||
</p>
|
||||
<p className="dashboard-card__hint">Лучший темп среди завершённых стартов.</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="dashboard-card__empty">Недостаточно данных для PR.</p>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="dashboard-card">
|
||||
<h2 className="dashboard-card__title">Сезон</h2>
|
||||
<p className="dashboard-card__value">{dashboardMetrics.seasonTotal}</p>
|
||||
<p className="dashboard-card__meta">стартов в этом году</p>
|
||||
<p className="dashboard-card__hint">Завершено: {dashboardMetrics.seasonCompletedCount}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<section className="dashboard-section" aria-label="Динамика времени на дистанции">
|
||||
<h2 className="dashboard-section__title">Прогресс по времени</h2>
|
||||
<p className="dashboard-section__intro">
|
||||
Линия по завершённым стартам выбранной дистанции: выше — лучше время (короче гонка).
|
||||
</p>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Дистанция для графика</span>
|
||||
<select
|
||||
className="races-filter__select"
|
||||
value={String(chartDistanceKm)}
|
||||
onChange={(event) => {
|
||||
setChartDistanceKm(Number(event.target.value));
|
||||
}}
|
||||
>
|
||||
{PR_DISTANCES.map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{formatDistance(d)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<PaceTrendChart races={races} distanceKm={chartDistanceKm} />
|
||||
</section>
|
||||
|
||||
<section className="dashboard-section" aria-label="Личные рекорды по дистанциям">
|
||||
<h2 className="dashboard-section__title">PR по дистанциям</h2>
|
||||
<div className="dashboard-grid dashboard-grid--pr">
|
||||
{personalRecordsByDistance.map((item) => (
|
||||
<article key={item.distanceKm} className="dashboard-card">
|
||||
<h3 className="dashboard-card__title">{formatDistance(item.distanceKm)}</h3>
|
||||
{item.bestRace ? (
|
||||
<>
|
||||
<p className="dashboard-card__value">{item.bestRace.finishTime ?? "время не указано"}</p>
|
||||
<p className="dashboard-card__meta">{item.bestRace.title}</p>
|
||||
<p className="dashboard-card__hint">{formatRaceDate(item.bestRace.date)}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="dashboard-card__empty">Нет завершённых стартов для этой дистанции.</p>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="dashboard-section" aria-label="Сравнение завершённых стартов">
|
||||
<h2 className="dashboard-section__title">Сравнение стартов</h2>
|
||||
{comparisonRows.length > 0 ? (
|
||||
<div className="comparison-table-wrapper">
|
||||
<table className="comparison-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Год</th>
|
||||
<th>Старт</th>
|
||||
<th>Дистанция</th>
|
||||
<th>Время</th>
|
||||
<th>Темп</th>
|
||||
<th>Место</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{comparisonRows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{row.year}</td>
|
||||
<td>{row.title}</td>
|
||||
<td>{row.distance}</td>
|
||||
<td>{row.finishTime}</td>
|
||||
<td>{row.pace}</td>
|
||||
<td>{row.place}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="dashboard-card__empty">Нет completed-стартов для сравнения.</p>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
163
frontend/src/pages/RaceDetailsPage.tsx
Normal file
163
frontend/src/pages/RaceDetailsPage.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { ApiError, getRaceById } from "../api";
|
||||
import {
|
||||
formatDistance,
|
||||
formatRaceDate,
|
||||
getPaceLabel,
|
||||
getRaceStatusClassName,
|
||||
getRaceStatusLabel,
|
||||
} from "../lib";
|
||||
import type { Race } from "../api";
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.message;
|
||||
}
|
||||
return "Не удалось загрузить карточку старта.";
|
||||
}
|
||||
|
||||
export function RaceDetailsPage(): JSX.Element {
|
||||
const { raceId } = useParams<{ raceId: string }>();
|
||||
const [race, setRace] = useState<Race | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadRace(): Promise<void> {
|
||||
if (!raceId) {
|
||||
setErrorMessage("Не найден идентификатор старта.");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const item = await getRaceById(raceId);
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
setRace(item);
|
||||
setErrorMessage(null);
|
||||
} catch (error) {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
setErrorMessage(getErrorMessage(error));
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadRace();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [raceId]);
|
||||
|
||||
const paceLabel = useMemo(() => {
|
||||
if (!race || race.status !== "completed") {
|
||||
return null;
|
||||
}
|
||||
return getPaceLabel(race.finishTime, race.distanceKm);
|
||||
}, [race]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="page page--race-details" aria-busy="true">
|
||||
<h1 className="page__title">Карточка старта</h1>
|
||||
<p className="page__subtitle">Загружаем данные старта...</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (errorMessage || !race) {
|
||||
return (
|
||||
<section className="page page--race-details" role="alert">
|
||||
<h1 className="page__title">Карточка старта</h1>
|
||||
<p className="page__subtitle page__subtitle--error">{errorMessage ?? "Старт не найден."}</p>
|
||||
<Link className="page-link" to="/races">
|
||||
Вернуться к списку стартов
|
||||
</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const isCompleted = race.status === "completed";
|
||||
|
||||
return (
|
||||
<section className="page page--race-details">
|
||||
<div className="race-details-header">
|
||||
<div className="race-details-header__main">
|
||||
<h1 className="page__title">{race.title}</h1>
|
||||
<p className="page__subtitle">
|
||||
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}
|
||||
</p>
|
||||
</div>
|
||||
<span className={getRaceStatusClassName(race.status)}>{getRaceStatusLabel(race.status)}</span>
|
||||
</div>
|
||||
|
||||
<div className="race-details-grid">
|
||||
<article className="race-details-card">
|
||||
<h2 className="race-details-card__title">Основная информация</h2>
|
||||
<dl className="race-details-meta">
|
||||
<div className="race-details-meta__item">
|
||||
<dt className="race-details-meta__key">Дата</dt>
|
||||
<dd className="race-details-meta__value">{formatRaceDate(race.date)}</dd>
|
||||
</div>
|
||||
<div className="race-details-meta__item">
|
||||
<dt className="race-details-meta__key">Дистанция</dt>
|
||||
<dd className="race-details-meta__value">{formatDistance(race.distanceKm)}</dd>
|
||||
</div>
|
||||
<div className="race-details-meta__item">
|
||||
<dt className="race-details-meta__key">Статус</dt>
|
||||
<dd className="race-details-meta__value">{getRaceStatusLabel(race.status)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
<article className="race-details-card">
|
||||
<h2 className="race-details-card__title">Completed-метрики</h2>
|
||||
{isCompleted ? (
|
||||
<dl className="race-details-meta">
|
||||
<div className="race-details-meta__item">
|
||||
<dt className="race-details-meta__key">Время</dt>
|
||||
<dd className="race-details-meta__value">{race.finishTime ?? "время не указано"}</dd>
|
||||
</div>
|
||||
<div className="race-details-meta__item">
|
||||
<dt className="race-details-meta__key">Темп</dt>
|
||||
<dd className="race-details-meta__value">{paceLabel ?? "не удалось вычислить"}</dd>
|
||||
</div>
|
||||
<div className="race-details-meta__item">
|
||||
<dt className="race-details-meta__key">Место</dt>
|
||||
<dd className="race-details-meta__value">
|
||||
{race.finishPlace?.trim() ? race.finishPlace : "не указано"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="race-details-meta__item">
|
||||
<dt className="race-details-meta__key">Стартовый номер</dt>
|
||||
<dd className="race-details-meta__value">{race.bibNumber ?? "не указан"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : (
|
||||
<p className="race-details-card__empty">
|
||||
Метрики появятся после завершения старта и ввода результата.
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article className="race-details-card race-details-card--notes">
|
||||
<h2 className="race-details-card__title">Заметки</h2>
|
||||
<p className="race-details-card__notes">{race.notes?.trim() ? race.notes : "Заметок пока нет."}</p>
|
||||
</article>
|
||||
|
||||
<Link className="page-link" to="/races">
|
||||
Назад к календарю стартов
|
||||
</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,196 @@
|
||||
export function RacesPage(): JSX.Element {
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { Race, RacesQuery } from "../api";
|
||||
import { ApiError, getRaces } from "../api";
|
||||
import {
|
||||
formatDistance,
|
||||
formatRaceDate,
|
||||
getRaceStatusClassName,
|
||||
getRaceStatusLabel,
|
||||
splitRacesByDate,
|
||||
} from "../lib";
|
||||
|
||||
const MONTH_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "", label: "Все месяцы" },
|
||||
{ value: "1", label: "Январь" },
|
||||
{ value: "2", label: "Февраль" },
|
||||
{ value: "3", label: "Март" },
|
||||
{ value: "4", label: "Апрель" },
|
||||
{ value: "5", label: "Май" },
|
||||
{ value: "6", label: "Июнь" },
|
||||
{ value: "7", label: "Июль" },
|
||||
{ value: "8", label: "Август" },
|
||||
{ value: "9", label: "Сентябрь" },
|
||||
{ value: "10", label: "Октябрь" },
|
||||
{ value: "11", label: "Ноябрь" },
|
||||
{ value: "12", label: "Декабрь" },
|
||||
];
|
||||
|
||||
function yearSelectOptions(): number[] {
|
||||
const current = new Date().getFullYear();
|
||||
const start = current - 2;
|
||||
const end = current + 4;
|
||||
const years: number[] = [];
|
||||
for (let y = start; y <= end; y += 1) {
|
||||
years.push(y);
|
||||
}
|
||||
return years;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.message;
|
||||
}
|
||||
return "Не удалось загрузить календарь стартов.";
|
||||
}
|
||||
|
||||
function RaceList(props: { title: string; races: Race[] }): JSX.Element {
|
||||
const { title, races } = props;
|
||||
|
||||
return (
|
||||
<section className="page page--races">
|
||||
<h1 className="page__title">Races</h1>
|
||||
<p className="page__subtitle">Upcoming and completed race lists will be added in the next task.</p>
|
||||
<section className="race-list" aria-label={title}>
|
||||
<h2 className="race-list__title">{title}</h2>
|
||||
{races.length > 0 ? (
|
||||
<ul className="race-list__items">
|
||||
{races.map((race) => (
|
||||
<li key={race.id} className="race-card">
|
||||
<div className="race-card__main">
|
||||
<p className="race-card__title">
|
||||
<Link className="race-card__link" to={`/races/${race.id}`}>
|
||||
{race.title}
|
||||
</Link>
|
||||
</p>
|
||||
<p className="race-card__meta">
|
||||
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}
|
||||
</p>
|
||||
</div>
|
||||
<span className={getRaceStatusClassName(race.status)}>{getRaceStatusLabel(race.status)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="race-list__empty">Пока нет данных в этом разделе.</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function RacesPage(): JSX.Element {
|
||||
const [races, setRaces] = useState<Race[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [yearFilter, setYearFilter] = useState<string>("");
|
||||
const [monthFilter, setMonthFilter] = useState<string>("");
|
||||
|
||||
const listQuery = useMemo((): RacesQuery | undefined => {
|
||||
const q: RacesQuery = {};
|
||||
if (yearFilter !== "") {
|
||||
const y = parseInt(yearFilter, 10);
|
||||
if (!Number.isNaN(y)) {
|
||||
q.year = y;
|
||||
}
|
||||
}
|
||||
if (monthFilter !== "") {
|
||||
const m = parseInt(monthFilter, 10);
|
||||
if (!Number.isNaN(m)) {
|
||||
q.month = m;
|
||||
}
|
||||
}
|
||||
return Object.keys(q).length > 0 ? q : undefined;
|
||||
}, [yearFilter, monthFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadRaces(): Promise<void> {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const items = await getRaces(listQuery);
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
setRaces(items);
|
||||
setErrorMessage(null);
|
||||
} catch (error) {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
setErrorMessage(getErrorMessage(error));
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadRaces();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [listQuery]);
|
||||
|
||||
const { upcoming, past } = useMemo(() => splitRacesByDate(races), [races]);
|
||||
|
||||
if (errorMessage && races.length === 0 && !isLoading) {
|
||||
return (
|
||||
<section className="page page--races" role="alert">
|
||||
<h1 className="page__title">Календарь стартов</h1>
|
||||
<p className="page__subtitle page__subtitle--error">{errorMessage}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page page--races">
|
||||
<h1 className="page__title">Календарь стартов</h1>
|
||||
<p className="page__subtitle">Будущие и прошедшие старты в одном месте.</p>
|
||||
|
||||
<div className="races-filter" role="search" aria-label="Фильтр по дате">
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Год</span>
|
||||
<select
|
||||
className="races-filter__select"
|
||||
value={yearFilter}
|
||||
onChange={(event) => {
|
||||
setYearFilter(event.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="">Все года</option>
|
||||
{yearSelectOptions().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>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="page__subtitle" aria-busy="true">
|
||||
Загружаем данные...
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="race-lists">
|
||||
<RaceList title="Будущие" races={upcoming} />
|
||||
<RaceList title="Прошедшие" races={past} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,3 +91,329 @@ a {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.page__subtitle--error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
margin-top: var(--space-6);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-5);
|
||||
background: #fcfdff;
|
||||
}
|
||||
|
||||
.dashboard-card__title {
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: var(--font-size-body);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.dashboard-card__value {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-h2);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.dashboard-card__meta {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.dashboard-card__hint,
|
||||
.dashboard-card__empty {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-caption);
|
||||
}
|
||||
|
||||
.dashboard-section {
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
.dashboard-section__title {
|
||||
margin: 0 0 var(--space-4);
|
||||
font-size: var(--font-size-h2);
|
||||
}
|
||||
|
||||
.dashboard-section__intro {
|
||||
margin: calc(var(--space-2) * -1) 0 var(--space-4);
|
||||
max-width: 42rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-caption);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dashboard-grid--pr {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.comparison-table-wrapper {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: #fcfdff;
|
||||
}
|
||||
|
||||
.comparison-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 720px;
|
||||
}
|
||||
|
||||
.comparison-table th,
|
||||
.comparison-table td {
|
||||
padding: var(--space-3);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.comparison-table th {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-caption);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.comparison-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.race-lists {
|
||||
margin-top: var(--space-6);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.race-list {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-5);
|
||||
background: #fcfdff;
|
||||
}
|
||||
|
||||
.race-list__title {
|
||||
margin: 0 0 var(--space-4);
|
||||
font-size: var(--font-size-h2);
|
||||
}
|
||||
|
||||
.race-list__items {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.race-list__empty {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.race-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-3);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.race-card__title {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.race-card__link:hover,
|
||||
.race-card__link:focus-visible {
|
||||
text-decoration: underline;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.race-card__meta {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-caption);
|
||||
}
|
||||
|
||||
.race-card__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: var(--font-size-caption);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.race-card__status--planned {
|
||||
background: #edf3ff;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.race-card__status--completed {
|
||||
background: #ecf8f1;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.race-card__status--registered {
|
||||
background: #fff4e0;
|
||||
color: #8a5a00;
|
||||
}
|
||||
|
||||
.races-filter {
|
||||
margin-top: var(--space-5);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-4);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.races-filter__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
.races-filter__label {
|
||||
font-size: var(--font-size-caption);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.races-filter__select {
|
||||
font: inherit;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.pace-chart {
|
||||
margin-top: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-4);
|
||||
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__caption {
|
||||
margin: var(--space-3) 0 0;
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.pace-chart__empty {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-body);
|
||||
}
|
||||
|
||||
.page-link {
|
||||
margin-top: var(--space-4);
|
||||
display: inline-flex;
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-link:hover,
|
||||
.page-link:focus-visible {
|
||||
text-decoration: underline;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.race-details-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.race-details-grid {
|
||||
margin-top: var(--space-6);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.race-details-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-5);
|
||||
background: #fcfdff;
|
||||
}
|
||||
|
||||
.race-details-card--notes {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.race-details-card__title {
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: var(--font-size-body);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.race-details-card__empty,
|
||||
.race-details-card__notes {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.race-details-meta {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.race-details-meta__item {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.race-details-meta__key {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-caption);
|
||||
}
|
||||
|
||||
.race-details-meta__value {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dashboard-grid,
|
||||
.race-lists,
|
||||
.race-details-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.race-details-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user