Compare commits
6 Commits
feat/dashb
...
feature/pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2dcf67396 | ||
| 1ffc3a65eb | |||
|
|
7845d8d961 | ||
| 0ddf37683a | |||
|
|
92cf94aa5b | ||
| 36e103dd41 |
@@ -1,4 +1,5 @@
|
|||||||
# ─── PostgreSQL ───────────────────────────────────────────────
|
# ─── PostgreSQL ───────────────────────────────────────────────
|
||||||
|
# Не обязательны, если CALENDAR_RUN_MOCK_DB=1 (только HTTP API без БД).
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
DB_NAME=calendar_run
|
DB_NAME=calendar_run
|
||||||
@@ -6,9 +7,13 @@ DB_USER=calendar_user
|
|||||||
DB_PASSWORD=calendar_pass
|
DB_PASSWORD=calendar_pass
|
||||||
|
|
||||||
# ─── Backend API ──────────────────────────────────────────────
|
# ─── Backend API ──────────────────────────────────────────────
|
||||||
# Port the API server listens on
|
# Порт: сначала читается PORT (если задан), иначе API_PORT, иначе 3001.
|
||||||
|
# PORT=3001
|
||||||
API_PORT=3001
|
API_PORT=3001
|
||||||
|
|
||||||
|
# ─── Dev/CI: без PostgreSQL для smoke API (не для migrate/seed) ─
|
||||||
|
# CALENDAR_RUN_MOCK_DB=1
|
||||||
|
|
||||||
# ─── CORS ─────────────────────────────────────────────────────
|
# ─── CORS ─────────────────────────────────────────────────────
|
||||||
# Allowed origin for the frontend (Vite dev server default)
|
# Allowed origin for the frontend (Vite dev server default)
|
||||||
CORS_ORIGIN=http://localhost:5173
|
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/
|
dist/
|
||||||
.env
|
.env
|
||||||
*.log
|
*.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. Добавлять забеги **запланированные** и **уже прошедшие**.
|
- Отдача статики SPA с того же процесса, что и API (фронт — отдельный Vite/build).
|
||||||
4. Для **прошедших** — ввод/редактирование **результата** и **стартового номера**; поля можно дописать позже.
|
|
||||||
5. **Личные рекорды** на главной по дистанциям: 1 км, 5 км, 10 км, 15 км, 21.1 км, 42.2 км — обновляются, если в забеге указан более быстрый результат на «подходящей» дистанции.
|
|
||||||
6. **Старты (организатор):** дата и время старта, расписание кластеров, выдача номеров — **ручной ввод**; обязательна возможность указать **официальную ссылку** на страницу организатора (автопарсинг сайтов не входит в объём).
|
|
||||||
7. **Авторизация не требуется.**
|
|
||||||
|
|
||||||
## 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).
|
## HTTP API (минимум)
|
||||||
- **БД:** PostgreSQL; схема — SQL-миграции в `backend/` (или согласованный каталог миграций).
|
|
||||||
- **Локально:** `docker-compose.yml` с сервисом Postgres.
|
|
||||||
- **Стили:** CSS + **BEM** + CSS variables (минимальная дизайн-система: цвета, отступы, типографика).
|
|
||||||
- **Даты/время:** `date-fns` или `Intl`, русская локаль там, где нужно.
|
|
||||||
|
|
||||||
### 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
|
## Seed
|
||||||
DB_HOST=
|
|
||||||
DB_PORT=
|
|
||||||
DB_NAME=
|
|
||||||
DB_USER=
|
|
||||||
DB_PASSWORD=
|
|
||||||
```
|
|
||||||
|
|
||||||
Дополнительно по необходимости: `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
|
Переменная `CALENDAR_RUN_MOCK_DB=1` (или `true`): HTTP-обработчики используют заглушку пула **без** реальной БД. **Не использовать** для `npm run db:migrate` и `npm run seed` — нужен настоящий Postgres и `DB_*`.
|
||||||
VITE_API_BASE_URL=http://localhost:3001
|
|
||||||
```
|
|
||||||
|
|
||||||
В коде: `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 Рантайм
|
- Документация согласована с кодом: [`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-БД.
|
||||||
Единственный источник правды для календаря и карточек в работающем приложении — **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 приоритет у этого файла.*
|
|
||||||
|
|||||||
62
README.md
62
README.md
@@ -1,24 +1,56 @@
|
|||||||
# Calendar Run
|
# 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:
|
1. Install dependencies:
|
||||||
- `cd backend`
|
- `cd backend`
|
||||||
- `npm install`
|
- `npm install`
|
||||||
2. Configure environment variables in `backend/.env`:
|
2. Configure environment variables. Copy the root template and edit:
|
||||||
- `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`
|
|
||||||
|
|
||||||
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 API for Frontend](docs/backend-api-for-frontend.md)
|
||||||
- [Backend Agent Instruction](docs/agent-backend-instruction.md)
|
- [Backend operations](docs/backend.md)
|
||||||
- [Backend Sync Fix Instruction](docs/agent-fix-backend-sync-instruction.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",
|
"dev": "ts-node src/index.ts",
|
||||||
"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",
|
||||||
|
"test": "CALENDAR_RUN_MOCK_DB=1 tsx --test test/app.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
@@ -21,7 +22,10 @@
|
|||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/node": "^22.12.0",
|
"@types/node": "^22.12.0",
|
||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.10",
|
||||||
|
"@types/supertest": "^6.0.2",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.3"
|
"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_pickup: null,
|
||||||
bib_number: null,
|
bib_number: null,
|
||||||
finish_time: null,
|
finish_time: null,
|
||||||
|
finish_place: null,
|
||||||
notes: null,
|
notes: null,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: null,
|
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_pickup: row.bib_pickup != null ? String(row.bib_pickup) : null,
|
||||||
bib_number: row.bib_number != null ? String(row.bib_number) : null,
|
bib_number: row.bib_number != null ? String(row.bib_number) : null,
|
||||||
finish_time: row.finish_time != null ? String(row.finish_time) : 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,
|
notes: row.notes != null ? String(row.notes) : null,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: null,
|
updated_at: null,
|
||||||
|
|||||||
@@ -1,16 +1,7 @@
|
|||||||
import express from "express";
|
|
||||||
import cors from "cors";
|
|
||||||
import { config } from "./config";
|
import { config } from "./config";
|
||||||
import healthRouter from "./routes/health";
|
import { createApp } from "./app";
|
||||||
import racesRouter from "./routes/races";
|
|
||||||
|
|
||||||
const app = express();
|
const app = createApp();
|
||||||
|
|
||||||
app.use(cors({ origin: config.corsOrigin, methods: ["GET", "POST", "PATCH", "DELETE"] }));
|
|
||||||
app.use(express.json());
|
|
||||||
|
|
||||||
app.use(healthRouter);
|
|
||||||
app.use(racesRouter);
|
|
||||||
|
|
||||||
app.listen(config.apiPort, () => {
|
app.listen(config.apiPort, () => {
|
||||||
console.log(`[api] Listening on http://localhost:${config.apiPort}`);
|
console.log(`[api] Listening on http://localhost:${config.apiPort}`);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface RaceRow {
|
|||||||
bib_pickup: string | null;
|
bib_pickup: string | null;
|
||||||
bib_number: string | null;
|
bib_number: string | null;
|
||||||
finish_time: string | null;
|
finish_time: string | null;
|
||||||
|
finish_place: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
@@ -29,6 +30,7 @@ export interface RaceDto {
|
|||||||
bibPickup: string | null;
|
bibPickup: string | null;
|
||||||
bibNumber: string | null;
|
bibNumber: string | null;
|
||||||
finishTime: string | null;
|
finishTime: string | null;
|
||||||
|
finishPlace: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
@@ -48,6 +50,7 @@ export function rowToDto(row: RaceRow): RaceDto {
|
|||||||
bibPickup: row.bib_pickup,
|
bibPickup: row.bib_pickup,
|
||||||
bibNumber: row.bib_number,
|
bibNumber: row.bib_number,
|
||||||
finishTime: row.finish_time,
|
finishTime: row.finish_time,
|
||||||
|
finishPlace: row.finish_place,
|
||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
@@ -66,6 +69,7 @@ const FIELD_MAP: Record<string, string> = {
|
|||||||
bibPickup: "bib_pickup",
|
bibPickup: "bib_pickup",
|
||||||
bibNumber: "bib_number",
|
bibNumber: "bib_number",
|
||||||
finishTime: "finish_time",
|
finishTime: "finish_time",
|
||||||
|
finishPlace: "finish_place",
|
||||||
notes: "notes",
|
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. **Не блокировать работу отсутствием живой БД у исполнителя:**
|
2. **Не блокировать работу отсутствием живой БД у исполнителя:**
|
||||||
- код миграций и seed должен быть **валидным и согласованным** с PLAN;
|
- код миграций и seed должен быть **валидным и согласованным** с PLAN;
|
||||||
- при старте API при невозможности подключиться к БД — **ясное сообщение в лог** и **корректный HTTP-ответ** на зависящих от БД маршрутах (например 503 с телом `{"error":"database_unavailable",...}`) **или** падение процесса на старте с понятной ошибкой (выбрать одну стратегию и описать её в `docs/backend.md`).
|
- при старте 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 кода.
|
4. Автотесты, требующие Docker/Postgres, помечать как **опциональные** или давать инструкцию «как прогнать локально», не считая провал из-за отсутствия БД у агента блокером для merge кода.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
|
|
||||||
**В JSON API (запрос/ответ)** — **camelCase**, как в PLAN:
|
**В 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**, например:
|
**В PostgreSQL** — **snake_case**, например:
|
||||||
|
|
||||||
@@ -107,6 +107,7 @@
|
|||||||
| `bib_pickup` | `TEXT` |
|
| `bib_pickup` | `TEXT` |
|
||||||
| `bib_number` | `TEXT` |
|
| `bib_number` | `TEXT` |
|
||||||
| `finish_time` | `TEXT` |
|
| `finish_time` | `TEXT` |
|
||||||
|
| `finish_place` | `TEXT` |
|
||||||
| `notes` | `TEXT` |
|
| `notes` | `TEXT` |
|
||||||
| `created_at` | `TIMESTAMPTZ` DEFAULT now() |
|
| `created_at` | `TIMESTAMPTZ` DEFAULT now() |
|
||||||
| `updated_at` | `TIMESTAMPTZ` |
|
| `updated_at` | `TIMESTAMPTZ` |
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ GET /races?year=2026&month=5
|
|||||||
"bibPickup": null,
|
"bibPickup": null,
|
||||||
"bibNumber": null,
|
"bibNumber": null,
|
||||||
"finishTime": null,
|
"finishTime": null,
|
||||||
|
"finishPlace": null,
|
||||||
"notes": null,
|
"notes": null,
|
||||||
"createdAt": "2026-03-31T12:00:00.000Z",
|
"createdAt": "2026-03-31T12:00:00.000Z",
|
||||||
"updatedAt": null
|
"updatedAt": null
|
||||||
@@ -102,10 +103,10 @@ GET /races?year=2026&month=5
|
|||||||
|
|
||||||
**Ответ 200:** объект `Race` (см. модель ниже).
|
**Ответ 200:** объект `Race` (см. модель ниже).
|
||||||
|
|
||||||
**Ответ 404:**
|
**Ответ 404:** тело JSON, поле `details` — массив пояснений (можно показывать в UI или игнорировать).
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "error": "not_found" }
|
{ "error": "not_found", "details": ["Race not found"] }
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -129,6 +130,7 @@ GET /races?year=2026&month=5
|
|||||||
"bibPickup": null,
|
"bibPickup": null,
|
||||||
"bibNumber": null,
|
"bibNumber": null,
|
||||||
"finishTime": null,
|
"finishTime": null,
|
||||||
|
"finishPlace": null,
|
||||||
"notes": null
|
"notes": null
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -160,12 +162,13 @@ GET /races?year=2026&month=5
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"finishTime": "1:45:30",
|
"finishTime": "1:45:30",
|
||||||
|
"finishPlace": "12/340",
|
||||||
"bibNumber": "1234",
|
"bibNumber": "1234",
|
||||||
"status": "completed"
|
"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`.
|
**Ответ 200:** обновлённый объект `Race`.
|
||||||
|
|
||||||
@@ -178,7 +181,7 @@ GET /races?year=2026&month=5
|
|||||||
**Ответ 404:**
|
**Ответ 404:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "error": "not_found" }
|
{ "error": "not_found", "details": ["Race not found"] }
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -192,7 +195,7 @@ GET /races?year=2026&month=5
|
|||||||
**Ответ 404:**
|
**Ответ 404:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "error": "not_found" }
|
{ "error": "not_found", "details": ["Race not found"] }
|
||||||
```
|
```
|
||||||
|
|
||||||
## 4. Модель `Race` (camelCase)
|
## 4. Модель `Race` (camelCase)
|
||||||
@@ -203,13 +206,14 @@ GET /races?year=2026&month=5
|
|||||||
| `date` | string | да | да | `YYYY-MM-DD` |
|
| `date` | string | да | да | `YYYY-MM-DD` |
|
||||||
| `title` | string | да | да | Название забега |
|
| `title` | string | да | да | Название забега |
|
||||||
| `distanceKm` | number | да | да | Дистанция в км |
|
| `distanceKm` | number | да | да | Дистанция в км |
|
||||||
| `status` | string \| null | нет | да | `"planned"` / `"completed"` |
|
| `status` | string \| null | нет | да | `"planned"` / `"registered"` / `"completed"` |
|
||||||
| `officialUrl` | string \| null | нет | да | URL организатора |
|
| `officialUrl` | string \| null | нет | да | URL организатора |
|
||||||
| `startTime` | string \| null | нет | да | Время старта, напр. `"09:30"` |
|
| `startTime` | string \| null | нет | да | Время старта, напр. `"09:30"` |
|
||||||
| `clusterSchedule` | string \| null | нет | да | Расписание кластеров |
|
| `clusterSchedule` | string \| null | нет | да | Расписание кластеров |
|
||||||
| `bibPickup` | string \| null | нет | да | Выдача номеров |
|
| `bibPickup` | string \| null | нет | да | Выдача номеров |
|
||||||
| `bibNumber` | 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 | нет | да | Заметки |
|
| `notes` | string \| null | нет | да | Заметки |
|
||||||
| `createdAt` | string | — | — | ISO timestamp (read-only) |
|
| `createdAt` | string | — | — | ISO timestamp (read-only) |
|
||||||
| `updatedAt` | string \| null | — | — | ISO timestamp (read-only) |
|
| `updatedAt` | string \| null | — | — | ISO timestamp (read-only) |
|
||||||
@@ -229,6 +233,6 @@ Seed-скрипт (`npm run seed` в `backend/`) выполняет **upsert**
|
|||||||
## 7. Поведение при недоступной БД
|
## 7. Поведение при недоступной БД
|
||||||
|
|
||||||
- `GET /health` — всегда `200` (не проверяет БД).
|
- `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" }`.
|
- Все остальные маршруты — `503 { "error": "database_unavailable" }`.
|
||||||
- В логах сервера: строка ошибки с контекстом маршрута.
|
- В логах сервера: строка ошибки с контекстом маршрута.
|
||||||
|
|||||||
@@ -62,27 +62,31 @@ npm run build # компиляция в dist/
|
|||||||
npm start # запуск из dist/
|
npm start # запуск из dist/
|
||||||
```
|
```
|
||||||
|
|
||||||
API слушает порт из `API_PORT` (по умолчанию `3001`).
|
API слушает порт: **`PORT`** (если задан), иначе **`API_PORT`**, иначе **`3001`**.
|
||||||
|
|
||||||
## Переменные окружения
|
## Переменные окружения
|
||||||
|
|
||||||
| Переменная | Описание | По умолчанию |
|
| Переменная | Описание | По умолчанию |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `DB_HOST` | Хост PostgreSQL | — (обязательна) |
|
| `DB_HOST` | Хост PostgreSQL | — (обязательна без mock, см. ниже) |
|
||||||
| `DB_PORT` | Порт PostgreSQL | — (обязательна) |
|
| `DB_PORT` | Порт PostgreSQL | — (обязательна без mock) |
|
||||||
| `DB_NAME` | Имя базы данных | — (обязательна) |
|
| `DB_NAME` | Имя базы данных | — (обязательна без mock) |
|
||||||
| `DB_USER` | Пользователь БД | — (обязательна) |
|
| `DB_USER` | Пользователь БД | — (обязательна без mock) |
|
||||||
| `DB_PASSWORD` | Пароль БД | — (обязательна) |
|
| `DB_PASSWORD` | Пароль БД | — (обязательна без mock) |
|
||||||
|
| `CALENDAR_RUN_MOCK_DB` | `1` или `true` — режим без PostgreSQL для HTTP API (dev/CI) | выкл. |
|
||||||
|
| `PORT` | Порт API (приоритетнее `API_PORT`) | — |
|
||||||
| `API_PORT` | Порт API-сервера | `3001` |
|
| `API_PORT` | Порт API-сервера | `3001` |
|
||||||
| `CORS_ORIGIN` | Разрешённый origin для CORS | `http://localhost:5173` |
|
| `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 слушает порт).
|
- **Старт сервера** — проходит успешно (env валидирован, Express слушает порт).
|
||||||
- **`GET /health`** — всегда `200 { "status": "ok" }` (liveness, без обращения к БД).
|
- **`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" }`.
|
- **Все остальные маршруты** при ошибке БД возвращают `503 { "error": "database_unavailable" }`.
|
||||||
|
|
||||||
## Структура каталога
|
## Структура каталога
|
||||||
@@ -90,11 +94,13 @@ API слушает порт из `API_PORT` (по умолчанию `3001`).
|
|||||||
```
|
```
|
||||||
backend/
|
backend/
|
||||||
├── migrations/
|
├── migrations/
|
||||||
│ └── 001_create_races.sql
|
│ ├── 001_create_races.sql
|
||||||
|
│ └── 002_finish_place_and_registered_status.sql
|
||||||
├── src/
|
├── src/
|
||||||
|
│ ├── app.ts # фабрика Express (тесты)
|
||||||
│ ├── config.ts # загрузка и валидация env
|
│ ├── config.ts # загрузка и валидация env
|
||||||
│ ├── db.ts # pg Pool
|
│ ├── db.ts # pg Pool или mock
|
||||||
│ ├── index.ts # точка входа Express
|
│ ├── index.ts # запуск сервера
|
||||||
│ ├── migrate.ts # раннер миграций
|
│ ├── migrate.ts # раннер миграций
|
||||||
│ ├── seed.ts # разовый импорт CSV
|
│ ├── seed.ts # разовый импорт CSV
|
||||||
│ ├── mappers/
|
│ ├── 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==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -1169,7 +1168,6 @@
|
|||||||
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
|
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -1187,7 +1185,6 @@
|
|||||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -1257,7 +1254,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.12",
|
"baseline-browser-mapping": "^2.10.12",
|
||||||
"caniuse-lite": "^1.0.30001782",
|
"caniuse-lite": "^1.0.30001782",
|
||||||
@@ -1534,7 +1530,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -1547,7 +1542,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -1730,7 +1724,6 @@
|
|||||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
|
|||||||
@@ -18,13 +18,17 @@ function normalizeRace(input: unknown): Race {
|
|||||||
isString(race?.date) &&
|
isString(race?.date) &&
|
||||||
isString(race?.title) &&
|
isString(race?.title) &&
|
||||||
typeof race?.distanceKm === "number" &&
|
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?.officialUrl) &&
|
||||||
isNullableString(race?.startTime) &&
|
isNullableString(race?.startTime) &&
|
||||||
isNullableString(race?.clusterSchedule) &&
|
isNullableString(race?.clusterSchedule) &&
|
||||||
isNullableString(race?.bibPickup) &&
|
isNullableString(race?.bibPickup) &&
|
||||||
isNullableString(race?.bibNumber) &&
|
isNullableString(race?.bibNumber) &&
|
||||||
isNullableString(race?.finishTime) &&
|
isNullableString(race?.finishTime) &&
|
||||||
|
isNullableString(race?.finishPlace) &&
|
||||||
isNullableString(race?.notes) &&
|
isNullableString(race?.notes) &&
|
||||||
isString(race?.createdAt) &&
|
isString(race?.createdAt) &&
|
||||||
(race?.updatedAt === null || isString(race?.updatedAt));
|
(race?.updatedAt === null || isString(race?.updatedAt));
|
||||||
@@ -49,6 +53,7 @@ function normalizeRace(input: unknown): Race {
|
|||||||
bibPickup: race.bibPickup,
|
bibPickup: race.bibPickup,
|
||||||
bibNumber: race.bibNumber,
|
bibNumber: race.bibNumber,
|
||||||
finishTime: race.finishTime,
|
finishTime: race.finishTime,
|
||||||
|
finishPlace: race.finishPlace,
|
||||||
notes: race.notes,
|
notes: race.notes,
|
||||||
createdAt: race.createdAt,
|
createdAt: race.createdAt,
|
||||||
updatedAt: race.updatedAt,
|
updatedAt: race.updatedAt,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type RaceStatus = "planned" | "completed";
|
export type RaceStatus = "planned" | "registered" | "completed";
|
||||||
|
|
||||||
export interface Race {
|
export interface Race {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -12,6 +12,7 @@ export interface Race {
|
|||||||
bibPickup: string | null;
|
bibPickup: string | null;
|
||||||
bibNumber: string | null;
|
bibNumber: string | null;
|
||||||
finishTime: string | null;
|
finishTime: string | null;
|
||||||
|
finishPlace: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
@@ -34,6 +35,7 @@ export interface CreateRacePayload {
|
|||||||
bibPickup?: string | null;
|
bibPickup?: string | null;
|
||||||
bibNumber?: string | null;
|
bibNumber?: string | null;
|
||||||
finishTime?: string | null;
|
finishTime?: string | null;
|
||||||
|
finishPlace?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createBrowserRouter } from "react-router-dom";
|
|||||||
import { AppLayout } from "./layouts/AppLayout";
|
import { AppLayout } from "./layouts/AppLayout";
|
||||||
import { DashboardPage } from "../pages/DashboardPage";
|
import { DashboardPage } from "../pages/DashboardPage";
|
||||||
import { RacesPage } from "../pages/RacesPage";
|
import { RacesPage } from "../pages/RacesPage";
|
||||||
|
import { RaceDetailsPage } from "../pages/RaceDetailsPage";
|
||||||
|
|
||||||
export const appRouter = createBrowserRouter([
|
export const appRouter = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@@ -9,7 +10,8 @@ export const appRouter = createBrowserRouter([
|
|||||||
element: <AppLayout />,
|
element: <AppLayout />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <DashboardPage /> },
|
{ 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";
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ export {
|
|||||||
formatRaceDate,
|
formatRaceDate,
|
||||||
getPaceLabel,
|
getPaceLabel,
|
||||||
getRaceCountdownLabel,
|
getRaceCountdownLabel,
|
||||||
|
getRaceStatusClassName,
|
||||||
getRaceStatusLabel,
|
getRaceStatusLabel,
|
||||||
|
isCloseDistance,
|
||||||
parseFinishTimeToSeconds,
|
parseFinishTimeToSeconds,
|
||||||
sortByDateAsc,
|
sortByDateAsc,
|
||||||
sortByDateDesc,
|
sortByDateDesc,
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ export function getRaceCountdownLabel(date: string, now: Date = new Date()): str
|
|||||||
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 {
|
export function getPaceLabel(finishTime: string | null, distanceKm: number): string | null {
|
||||||
const totalSeconds = parseFinishTimeToSeconds(finishTime);
|
const totalSeconds = parseFinishTimeToSeconds(finishTime);
|
||||||
if (!totalSeconds || distanceKm <= 0) {
|
if (!totalSeconds || distanceKm <= 0) {
|
||||||
@@ -102,9 +106,23 @@ export function getPaceLabel(finishTime: string | null, distanceKm: number): str
|
|||||||
return `${String(paceMinutes).padStart(2, "0")}:${String(paceRemainder).padStart(2, "0")} /км`;
|
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 {
|
export function getRaceStatusLabel(status: Race["status"]): string {
|
||||||
if (status === "completed") {
|
if (status === "completed") {
|
||||||
return "пробежал";
|
return "пробежал";
|
||||||
}
|
}
|
||||||
|
if (status === "registered") {
|
||||||
|
return "зарегистрирован";
|
||||||
|
}
|
||||||
return "планирую";
|
return "планирую";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import type { Race } from "../api";
|
import type { Race } from "../api";
|
||||||
import { ApiError, getRaces } from "../api";
|
import { ApiError, getRaces } from "../api";
|
||||||
|
import { PaceTrendChart } from "../components";
|
||||||
import {
|
import {
|
||||||
formatDistance,
|
formatDistance,
|
||||||
formatRaceDate,
|
formatRaceDate,
|
||||||
getRaceCountdownLabel,
|
getRaceCountdownLabel,
|
||||||
|
getPaceLabel,
|
||||||
|
isCloseDistance,
|
||||||
parseFinishTimeToSeconds,
|
parseFinishTimeToSeconds,
|
||||||
splitRacesByDate,
|
splitRacesByDate,
|
||||||
} from "../lib";
|
} from "../lib";
|
||||||
|
|
||||||
|
const PR_DISTANCES = [5, 10, 21.1, 42.2] as const;
|
||||||
|
|
||||||
function getErrorMessage(error: unknown): string {
|
function getErrorMessage(error: unknown): string {
|
||||||
if (error instanceof ApiError) {
|
if (error instanceof ApiError) {
|
||||||
return error.message;
|
return error.message;
|
||||||
@@ -20,6 +25,7 @@ export function DashboardPage(): JSX.Element {
|
|||||||
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 [chartDistanceKm, setChartDistanceKm] = useState<number>(10);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
@@ -86,6 +92,54 @@ export function DashboardPage(): JSX.Element {
|
|||||||
};
|
};
|
||||||
}, [races]);
|
}, [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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<section className="page page--dashboard" aria-busy="true">
|
<section className="page page--dashboard" aria-busy="true">
|
||||||
@@ -162,6 +216,84 @@ export function DashboardPage(): JSX.Element {
|
|||||||
<p className="dashboard-card__hint">Завершено: {dashboardMetrics.seasonCompletedCount}</p>
|
<p className="dashboard-card__hint">Завершено: {dashboardMetrics.seasonCompletedCount}</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</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>
|
</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,7 +1,41 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import type { Race } from "../api";
|
import { Link } from "react-router-dom";
|
||||||
|
import type { Race, RacesQuery } from "../api";
|
||||||
import { ApiError, getRaces } from "../api";
|
import { ApiError, getRaces } from "../api";
|
||||||
import { formatDistance, formatRaceDate, getRaceStatusLabel, splitRacesByDate } from "../lib";
|
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 {
|
function getErrorMessage(error: unknown): string {
|
||||||
if (error instanceof ApiError) {
|
if (error instanceof ApiError) {
|
||||||
@@ -21,20 +55,16 @@ function RaceList(props: { title: string; races: Race[] }): JSX.Element {
|
|||||||
{races.map((race) => (
|
{races.map((race) => (
|
||||||
<li key={race.id} className="race-card">
|
<li key={race.id} className="race-card">
|
||||||
<div className="race-card__main">
|
<div className="race-card__main">
|
||||||
<p className="race-card__title">{race.title}</p>
|
<p className="race-card__title">
|
||||||
|
<Link className="race-card__link" to={`/races/${race.id}`}>
|
||||||
|
{race.title}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
<p className="race-card__meta">
|
<p className="race-card__meta">
|
||||||
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}
|
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span className={getRaceStatusClassName(race.status)}>{getRaceStatusLabel(race.status)}</span>
|
||||||
className={
|
|
||||||
race.status === "completed"
|
|
||||||
? "race-card__status race-card__status--completed"
|
|
||||||
: "race-card__status race-card__status--planned"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{getRaceStatusLabel(race.status)}
|
|
||||||
</span>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -49,13 +79,33 @@ export function RacesPage(): JSX.Element {
|
|||||||
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 [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(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
async function loadRaces(): Promise<void> {
|
async function loadRaces(): Promise<void> {
|
||||||
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const items = await getRaces();
|
const items = await getRaces(listQuery);
|
||||||
if (!isMounted) {
|
if (!isMounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -77,20 +127,11 @@ export function RacesPage(): JSX.Element {
|
|||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [listQuery]);
|
||||||
|
|
||||||
const { upcoming, past } = useMemo(() => splitRacesByDate(races), [races]);
|
const { upcoming, past } = useMemo(() => splitRacesByDate(races), [races]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (errorMessage && races.length === 0 && !isLoading) {
|
||||||
return (
|
|
||||||
<section className="page page--races" aria-busy="true">
|
|
||||||
<h1 className="page__title">Календарь стартов</h1>
|
|
||||||
<p className="page__subtitle">Загружаем данные...</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorMessage) {
|
|
||||||
return (
|
return (
|
||||||
<section className="page page--races" role="alert">
|
<section className="page page--races" role="alert">
|
||||||
<h1 className="page__title">Календарь стартов</h1>
|
<h1 className="page__title">Календарь стартов</h1>
|
||||||
@@ -104,6 +145,48 @@ export function RacesPage(): JSX.Element {
|
|||||||
<h1 className="page__title">Календарь стартов</h1>
|
<h1 className="page__title">Календарь стартов</h1>
|
||||||
<p className="page__subtitle">Будущие и прошедшие старты в одном месте.</p>
|
<p className="page__subtitle">Будущие и прошедшие старты в одном месте.</p>
|
||||||
|
|
||||||
|
<div className="races-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">
|
<div className="race-lists">
|
||||||
<RaceList title="Будущие" races={upcoming} />
|
<RaceList title="Будущие" races={upcoming} />
|
||||||
<RaceList title="Прошедшие" races={past} />
|
<RaceList title="Прошедшие" races={past} />
|
||||||
|
|||||||
@@ -135,6 +135,57 @@ a {
|
|||||||
font-size: var(--font-size-caption);
|
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 {
|
.race-lists {
|
||||||
margin-top: var(--space-6);
|
margin-top: var(--space-6);
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -183,6 +234,12 @@ a {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.race-card__link:hover,
|
||||||
|
.race-card__link:focus-visible {
|
||||||
|
text-decoration: underline;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.race-card__meta {
|
.race-card__meta {
|
||||||
margin: var(--space-2) 0 0;
|
margin: var(--space-2) 0 0;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
@@ -209,9 +266,154 @@ a {
|
|||||||
color: var(--color-success);
|
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) {
|
@media (max-width: 900px) {
|
||||||
.dashboard-grid,
|
.dashboard-grid,
|
||||||
.race-lists {
|
.race-lists,
|
||||||
|
.race-details-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.race-details-header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user