feat: align docs with code, finish_place, registered status, UI filters, tests, CI #5

Merged
admin merged 1 commits from feature/plan-docs-product-tests into main 2026-04-06 19:41:20 +00:00
27 changed files with 1410 additions and 286 deletions

View File

@@ -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
View 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

229
PLAN.md
View File

@@ -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` 112).
- `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 приоритет у этого файла.*

View File

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

View File

@@ -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'));

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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;
}

View File

@@ -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,

View File

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

View File

@@ -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
View 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));
});

View File

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

View File

@@ -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" }`.
- В логах сервера: строка ошибки с контекстом маршрута. - В логах сервера: строка ошибки с контекстом маршрута.

View File

@@ -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
View 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

View File

@@ -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",

View File

@@ -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,

View File

@@ -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;
} }

View 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>
);
}

View File

@@ -1 +1 @@
export {}; export { PaceTrendChart } from "./PaceTrendChart";

View File

@@ -3,7 +3,9 @@ export {
formatRaceDate, formatRaceDate,
getPaceLabel, getPaceLabel,
getRaceCountdownLabel, getRaceCountdownLabel,
getRaceStatusClassName,
getRaceStatusLabel, getRaceStatusLabel,
isCloseDistance,
parseFinishTimeToSeconds, parseFinishTimeToSeconds,
sortByDateAsc, sortByDateAsc,
sortByDateDesc, sortByDateDesc,

View File

@@ -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 "планирую";
} }

View File

@@ -1,11 +1,13 @@
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, getPaceLabel,
isCloseDistance,
parseFinishTimeToSeconds, parseFinishTimeToSeconds,
splitRacesByDate, splitRacesByDate,
} from "../lib"; } from "../lib";
@@ -19,14 +21,11 @@ function getErrorMessage(error: unknown): string {
return "Не удалось загрузить данные dashboard."; return "Не удалось загрузить данные dashboard.";
} }
function isSameDistance(left: number, right: number): boolean {
return Math.abs(left - right) < 0.05;
}
export function DashboardPage(): JSX.Element { 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;
@@ -98,7 +97,7 @@ export function DashboardPage(): JSX.Element {
const candidates = races.filter((race) => { const candidates = races.filter((race) => {
return ( return (
race.status === "completed" && race.status === "completed" &&
isSameDistance(race.distanceKm, distanceKm) && isCloseDistance(race.distanceKm, distanceKm) &&
Boolean(parseFinishTimeToSeconds(race.finishTime)) Boolean(parseFinishTimeToSeconds(race.finishTime))
); );
}); });
@@ -136,7 +135,7 @@ export function DashboardPage(): JSX.Element {
distance: formatDistance(race.distanceKm), distance: formatDistance(race.distanceKm),
finishTime: race.finishTime ?? "время не указано", finishTime: race.finishTime ?? "время не указано",
pace: getPaceLabel(race.finishTime, race.distanceKm) ?? "не удалось вычислить", pace: getPaceLabel(race.finishTime, race.distanceKm) ?? "не удалось вычислить",
place: "нет данных", place: race.finishPlace?.trim() ? race.finishPlace : "нет данных",
})) }))
.sort((left, right) => right.year - left.year || left.title.localeCompare(right.title, "ru-RU")); .sort((left, right) => right.year - left.year || left.title.localeCompare(right.title, "ru-RU"));
}, [races]); }, [races]);
@@ -218,6 +217,30 @@ export function DashboardPage(): JSX.Element {
</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="Личные рекорды по дистанциям"> <section className="dashboard-section" aria-label="Личные рекорды по дистанциям">
<h2 className="dashboard-section__title">PR по дистанциям</h2> <h2 className="dashboard-section__title">PR по дистанциям</h2>
<div className="dashboard-grid dashboard-grid--pr"> <div className="dashboard-grid dashboard-grid--pr">

View File

@@ -1,7 +1,13 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { ApiError, getRaceById } from "../api"; import { ApiError, getRaceById } from "../api";
import { formatDistance, formatRaceDate, getPaceLabel, getRaceStatusLabel } from "../lib"; import {
formatDistance,
formatRaceDate,
getPaceLabel,
getRaceStatusClassName,
getRaceStatusLabel,
} from "../lib";
import type { Race } from "../api"; import type { Race } from "../api";
function getErrorMessage(error: unknown): string { function getErrorMessage(error: unknown): string {
@@ -91,15 +97,7 @@ export function RaceDetailsPage(): JSX.Element {
{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={
isCompleted
? "race-card__status race-card__status--completed"
: "race-card__status race-card__status--planned"
}
>
{getRaceStatusLabel(race.status)}
</span>
</div> </div>
<div className="race-details-grid"> <div className="race-details-grid">
@@ -133,6 +131,12 @@ export function RaceDetailsPage(): JSX.Element {
<dt className="race-details-meta__key">Темп</dt> <dt className="race-details-meta__key">Темп</dt>
<dd className="race-details-meta__value">{paceLabel ?? "не удалось вычислить"}</dd> <dd className="race-details-meta__value">{paceLabel ?? "не удалось вычислить"}</dd>
</div> </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"> <div className="race-details-meta__item">
<dt className="race-details-meta__key">Стартовый номер</dt> <dt className="race-details-meta__key">Стартовый номер</dt>
<dd className="race-details-meta__value">{race.bibNumber ?? "не указан"}</dd> <dd className="race-details-meta__value">{race.bibNumber ?? "не указан"}</dd>

View File

@@ -1,8 +1,41 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import type { Race } from "../api"; 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) {
@@ -31,15 +64,7 @@ function RaceList(props: { title: string; races: Race[] }): JSX.Element {
{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>
@@ -54,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;
} }
@@ -82,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>
@@ -109,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} />

View File

@@ -144,6 +144,14 @@ a {
font-size: var(--font-size-h2); 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 { .dashboard-grid--pr {
margin-top: 0; margin-top: 0;
} }
@@ -258,6 +266,74 @@ 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 { .page-link {
margin-top: var(--space-4); margin-top: var(--space-4);
display: inline-flex; display: inline-flex;