Compare commits
8 Commits
feature/pl
...
feat/phase
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b63af8da5 | ||
|
|
f74ce6ed88 | ||
|
|
3b8f41f905 | ||
| b422223c03 | |||
|
|
007d899721 | ||
| 2cf01186e9 | |||
|
|
2849b16e36 | ||
| d187bc776e |
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.git
|
||||||
|
.github
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
**/*.md
|
||||||
|
.cursor
|
||||||
|
*.log
|
||||||
32
.env.example
32
.env.example
@@ -1,23 +1,33 @@
|
|||||||
# ─── PostgreSQL ───────────────────────────────────────────────
|
# =============================================================================
|
||||||
# Не обязательны, если CALENDAR_RUN_MOCK_DB=1 (только HTTP API без БД).
|
# Корневой шаблон окружения: локальная разработка, docker-compose.yml (Postgres)
|
||||||
|
# и docker-compose.stack.yml (backend + frontend в общей сети с Postgres).
|
||||||
|
# Скопируйте в .env и подставьте значения. Файл .env не коммитьте.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# ─── Подключение бэкенда к PostgreSQL ──────────────────────────
|
||||||
|
# Локально + docker-compose из этого репо: DB_HOST=localhost, DB_PORT=5432
|
||||||
|
# Backend в Docker рядом с Postgres: DB_HOST = имя контейнера/сервиса в той же
|
||||||
|
# docker-сети (например postgres_budget), DB_PORT=5432 (внутренний порт Postgres).
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
DB_NAME=calendar_run
|
DB_NAME=calendar_run
|
||||||
DB_USER=calendar_user
|
DB_USER=calendar_user
|
||||||
DB_PASSWORD=calendar_pass
|
DB_PASSWORD=replace_with_strong_secret
|
||||||
|
|
||||||
# ─── Backend API ──────────────────────────────────────────────
|
# ─── Backend API ───────────────────────────────────────────────
|
||||||
# Порт: сначала читается PORT (если задан), иначе API_PORT, иначе 3001.
|
# Порт процесса: сначала PORT, иначе API_PORT, иначе 3001.
|
||||||
# PORT=3001
|
# В docker-compose.stack.yml для контейнера задаётся PORT=3000 (см. compose).
|
||||||
|
# PORT=3000
|
||||||
API_PORT=3001
|
API_PORT=3001
|
||||||
|
|
||||||
# ─── Dev/CI: без PostgreSQL для smoke API (не для migrate/seed) ─
|
# ─── Режим без БД (только тесты / smoke API, не для migrate/seed) ─
|
||||||
# CALENDAR_RUN_MOCK_DB=1
|
# CALENDAR_RUN_MOCK_DB=1
|
||||||
|
|
||||||
# ─── CORS ─────────────────────────────────────────────────────
|
# ─── CORS ────────────────────────────────────────────────────
|
||||||
# Allowed origin for the frontend (Vite dev server default)
|
# Локальный Vite: http://localhost:5173
|
||||||
|
# Стек с фронтом на 3033: http://localhost:3033
|
||||||
CORS_ORIGIN=http://localhost:5173
|
CORS_ORIGIN=http://localhost:5173
|
||||||
|
|
||||||
# ─── Frontend (Vite) ─────────────────────────────────────────
|
# ─── Frontend (Vite, локально из каталога frontend/) ─────────
|
||||||
# Public URL of the API, used in SPA code via import.meta.env
|
# В Docker-образе фронта базовый URL API задаётся при сборке (/api), не из .env.
|
||||||
VITE_API_BASE_URL=http://localhost:3001
|
VITE_API_BASE_URL=http://localhost:3001
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,5 +2,3 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
*plan*
|
|
||||||
*PLAN*
|
|
||||||
22
Dockerfile.backend
Normal file
22
Dockerfile.backend
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Сборка из корня монорепо: docker build -f Dockerfile.backend .
|
||||||
|
FROM node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY backend/package.json backend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM deps AS build
|
||||||
|
COPY backend/tsconfig.json ./
|
||||||
|
COPY backend/src ./src
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY backend/package.json backend/package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
COPY backend/migrations ./migrations
|
||||||
|
COPY import ./import
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
15
Dockerfile.frontend
Normal file
15
Dockerfile.frontend
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Сборка из корня монорепо: docker build -f Dockerfile.frontend .
|
||||||
|
# SPA дергает API по префиксу /api (nginx проксирует на сервис backend:3000).
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
|
ARG VITE_API_BASE_URL=/api
|
||||||
|
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY docker/nginx.frontend.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
100
FRONTEND_PLAN.md
Normal file
100
FRONTEND_PLAN.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
name: Frontend implementation plan
|
||||||
|
overview: Собрать минималистичный frontend для календаря забегов по UI-инструкции, строго в рамках текущего API-контракта.
|
||||||
|
todos:
|
||||||
|
|
||||||
|
- id: frontend-structure
|
||||||
|
content: Подготовить структуру frontend, роутинг, базовые layout-компоненты и дизайн-токены на CSS variables + BEM
|
||||||
|
status: completed
|
||||||
|
- id: api-contract-layer
|
||||||
|
content: Реализовать типизированный API-клиент и слой нормализации/обработки ошибок по контракту backend-api-for-frontend.md
|
||||||
|
status: completed
|
||||||
|
- id: dashboard-and-calendar
|
||||||
|
content: Собрать Dashboard, списки будущих/прошедших стартов и базовые карточки по минималистичному UI-гайду
|
||||||
|
status: completed
|
||||||
|
- id: race-details-and-metrics
|
||||||
|
content: Реализовать экран карточки старта, вычисление темпа на фронте и отображение completed-метрик
|
||||||
|
status: completed
|
||||||
|
- id: pr-and-comparison
|
||||||
|
content: Сделать блок PR и сравнение стартов с fallback для отсутствующего поля place
|
||||||
|
status: completed
|
||||||
|
- id: backend-dependency-task
|
||||||
|
content: "Расширение API полем finishPlace: миграция 002, маппер, DTO, фронтенд-интеграция — выполнено"
|
||||||
|
status: completed
|
||||||
|
isProject: false
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# План frontend части Calendar Run
|
||||||
|
|
||||||
|
## Исходные опоры
|
||||||
|
|
||||||
|
- UI и UX принципы: минимализм, воздух, акцент на данных, спокойная палитра, быстрые сценарии (дизайн-токены в `frontend/src/styles/tokens.css`).
|
||||||
|
- Продуктовые ограничения и структура экранов сверяем с [d:\vaka.pro\calendar_run\PLAN.md](d:/vaka.pro/calendar_run/PLAN.md).
|
||||||
|
- Интеграционный контракт берём из [d:\vaka.pro\calendar_run\docs\backend-api-for-frontend.md](d:/vaka.pro/calendar_run/docs/backend-api-for-frontend.md).
|
||||||
|
- Общий контекст запуска/окружения — [d:\vaka.pro\calendar_run\README.md](d:/vaka.pro/calendar_run/README.md) и [d:\vaka.pro\calendar_run\docs\backend.md](d:/vaka.pro/calendar_run/docs/backend.md).
|
||||||
|
|
||||||
|
## Границы версии (V1)
|
||||||
|
|
||||||
|
- Только frontend + интеграция с текущим API.
|
||||||
|
- Статус `зарегистрирован` трактуется как UI-вариант `planned` (без изменения backend-контракта).
|
||||||
|
- Для completed-забегов обязательно показываем `темп`; считаем на фронте из `finishTime` и `distanceKm`.
|
||||||
|
- Для completed-забегов поле `место` (`finishPlace`) доступно в API (миграция 002, маппер, DTO); фронтенд отображает его в карточке и таблице сравнения.
|
||||||
|
|
||||||
|
## Архитектура frontend
|
||||||
|
|
||||||
|
- Базовая структура: `frontend/src/app`, `frontend/src/pages`, `frontend/src/components`, `frontend/src/api`, `frontend/src/features`, `frontend/src/lib`, `frontend/src/styles`.
|
||||||
|
- Дизайн-система на CSS variables: токены цвета/типографики/отступов/радиусов, единые состояния (`success`, `warning`, `error`).
|
||||||
|
- БЭМ для всех UI-блоков и модификаторов (`block`, `block__element`, `block--modifier`).
|
||||||
|
- Единый слой API-клиента:
|
||||||
|
- `GET /races`, `GET /races/:id`, `POST /races`, `PATCH /races/:id`, `DELETE /races/:id` (если используется UI-сценарием).
|
||||||
|
- Типы `Race`, `RaceStatus`, DTO для POST/PATCH.
|
||||||
|
- Централизованный маппинг ошибок API (`validation_error`, `not_found`, `database_unavailable`, `conflict`) в UX-сообщения.
|
||||||
|
|
||||||
|
## Экранная модель и сценарии
|
||||||
|
|
||||||
|
- Dashboard:
|
||||||
|
- `Ближайший старт`, `Последний результат`, `Личный рекорд`, `Сезон`.
|
||||||
|
- CTA к календарю и добавлению старта.
|
||||||
|
- Календарь стартов:
|
||||||
|
- Переключение `Будущие` / `Прошедшие`.
|
||||||
|
- Карточка старта: `title`, `date`, `distanceKm`, статус-лейбл.
|
||||||
|
- Карточка старта:
|
||||||
|
- Базовые поля + `finishTime`, вычисляемый `pace`, `finishPlace`, `notes`.
|
||||||
|
- PR блок:
|
||||||
|
- Дистанции: 5K, 10K, 21.1, 42.2 (согласно UI-инструкции).
|
||||||
|
- Расчёт по completed-забегам с валидным `finishTime`.
|
||||||
|
- Сравнение стартов (ключевая фича):
|
||||||
|
- Таблица/карточки по годам с `time`, `pace`, `finishPlace`.
|
||||||
|
- Если `finishPlace` не заполнено — graceful-degradation: колонка в состоянии «нет данных».
|
||||||
|
|
||||||
|
## UX и визуальные требования
|
||||||
|
|
||||||
|
- Визуальная система: светлый фон, белые карточки, один акцентный цвет, без кислотных сочетаний.
|
||||||
|
- Иерархия типографики: H1/H2/body/caption, крупные числовые метрики.
|
||||||
|
- Минимум визуального шума, 2–3 клика на частые действия.
|
||||||
|
- Консистентные состояния загрузки/ошибок/пустых данных.
|
||||||
|
- A11y-базис: фокус-стили, клавиатурная навигация, контраст, корректная разметка интерактивных элементов.
|
||||||
|
|
||||||
|
## Зависимая задача (выполнена)
|
||||||
|
|
||||||
|
- Поле `finishPlace` добавлено в модель `Race`: миграция `002_finish_place_and_registered_status.sql`, маппер `race.ts`, DTO, API-документация.
|
||||||
|
- Frontend-типы, карточка и блок сравнения используют `finishPlace`.
|
||||||
|
|
||||||
|
## Порядок реализации
|
||||||
|
|
||||||
|
1. Подготовить каркас frontend и дизайн-токены (BEM + CSS variables).
|
||||||
|
2. Реализовать API-клиент и типы данных с обработкой ошибок.
|
||||||
|
3. Собрать Dashboard и календарные списки (будущие/прошедшие).
|
||||||
|
4. Реализовать карточку старта с вычислением `pace` на клиенте.
|
||||||
|
5. Реализовать PR и блок сравнения стартов с fallback для `place`.
|
||||||
|
6. Добавить состояния пустых данных/ошибок/загрузки и а11y-полировку.
|
||||||
|
|
||||||
|
## Definition of Done для frontend
|
||||||
|
|
||||||
|
- Все ключевые экраны из UI-инструкции доступны и консистентны визуально.
|
||||||
|
- API-интеграция работает по текущему контракту без локальных обходов хранилища.
|
||||||
|
- `pace` считается корректно для completed-забегов.
|
||||||
|
- `registered` не ломает модель: визуально интерпретируется в рамках `planned`.
|
||||||
|
- `finishPlace` интегрирован; при отсутствии значения — безопасный fallback в UI.
|
||||||
49
PLAN.md
49
PLAN.md
@@ -10,25 +10,27 @@
|
|||||||
|
|
||||||
## Модель данных `Race` (API — camelCase)
|
## Модель данных `Race` (API — camelCase)
|
||||||
|
|
||||||
| Поле | Тип | Описание |
|
|
||||||
|------|-----|----------|
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
PostgreSQL: `snake_case` столбцы, маппинг в [`backend/src/mappers/race.ts`](backend/src/mappers/race.ts).
|
| Поле | Тип | Описание |
|
||||||
|
| ----------------- | --------------------------------------------- | ----------------------------------------------- |
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
|
||||||
|
PostgreSQL: `snake_case` столбцы, маппинг в `[backend/src/mappers/race.ts](backend/src/mappers/race.ts)`.
|
||||||
|
|
||||||
## HTTP API (минимум)
|
## HTTP API (минимум)
|
||||||
|
|
||||||
@@ -37,25 +39,26 @@ PostgreSQL: `snake_case` столбцы, маппинг в [`backend/src/mappers
|
|||||||
- `GET /races` — список; query: `year`, `month` (целые; `month` 1–12).
|
- `GET /races` — список; query: `year`, `month` (целые; `month` 1–12).
|
||||||
- `GET /races/:id`, `POST /races`, `PATCH /races/:id`, `DELETE /races/:id`.
|
- `GET /races/:id`, `POST /races`, `PATCH /races/:id`, `DELETE /races/:id`.
|
||||||
|
|
||||||
Ошибки: JSON, единый стиль (`validation_error`, `not_found`, `conflict`, `database_unavailable`). Подробности — [`docs/backend-api-for-frontend.md`](docs/backend-api-for-frontend.md).
|
Ошибки: JSON, единый стиль (`validation_error`, `not_found`, `conflict`, `database_unavailable`). Подробности — `[docs/backend-api-for-frontend.md](docs/backend-api-for-frontend.md)`.
|
||||||
|
|
||||||
## Seed
|
## Seed
|
||||||
|
|
||||||
- Файл [`import/races_2026_calendar.csv`](import/races_2026_calendar.csv).
|
- Файл `[import/races_2026_calendar.csv](import/races_2026_calendar.csv)`.
|
||||||
- Стабильный `id`, upsert по `id`. Повторный запуск безопасен.
|
- Стабильный `id`, upsert по `id`. Повторный запуск безопасен.
|
||||||
|
|
||||||
## Режим без PostgreSQL (dev/CI)
|
## Режим без PostgreSQL (dev/CI)
|
||||||
|
|
||||||
Переменная `CALENDAR_RUN_MOCK_DB=1` (или `true`): HTTP-обработчики используют заглушку пула **без** реальной БД. **Не использовать** для `npm run db:migrate` и `npm run seed` — нужен настоящий Postgres и `DB_*`.
|
Переменная `CALENDAR_RUN_MOCK_DB=1` (или `true`): HTTP-обработчики используют заглушку пула **без** реальной БД. **Не использовать** для `npm run db:migrate` и `npm run seed` — нужен настоящий Postgres и `DB_`*.
|
||||||
|
|
||||||
## Frontend (SPA)
|
## Frontend (SPA)
|
||||||
|
|
||||||
- Маршруты: дашборд (`/`), список стартов (`/races`), карточка (`/races/:id`).
|
- Маршруты: дашборд (`/`), список стартов (`/races`), карточка (`/races/:id`).
|
||||||
- Дашборд: ближайший старт, последний результат, PR, сезон, PR по ключевым дистанциям, сравнение завершённых стартов, при необходимости — лёгкая визуализация прогресса.
|
- Дашборд: ближайший старт, последний результат, PR, сезон, PR по ключевым дистанциям, сравнение завершённых стартов, при необходимости — лёгкая визуализация прогресса.
|
||||||
- Список: будущие / прошедшие; фильтрация по году и месяцу через API.
|
- Список: будущие / прошедшие; фильтрация по году и месяцу через API.
|
||||||
- Стили: BEM и дизайн-токены; ориентир по духу — [`agent-frontend-ui-instructions.md`](agent-frontend-ui-instructions.md).
|
- Стили: BEM и дизайн-токены (см. `frontend/src/styles/tokens.css`).
|
||||||
|
|
||||||
## Критерии готовности текущей итерации
|
## Критерии готовности текущей итерации
|
||||||
|
|
||||||
- Документация согласована с кодом: [`README.md`](README.md), [`docs/backend.md`](docs/backend.md), [`docs/backend-api-for-frontend.md`](docs/backend-api-for-frontend.md).
|
- Документация согласована с кодом: `[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-БД.
|
- Миграции и seed воспроизводимы; контракт API покрыт smoke-тестами в CI при необходимости с mock-БД.
|
||||||
|
|
||||||
|
|||||||
70
README.md
70
README.md
@@ -1,56 +1,48 @@
|
|||||||
# Calendar Run
|
# Calendar Run
|
||||||
|
|
||||||
Calendar Run is a races calendar project: a **backend API** (Express + PostgreSQL) and a **React SPA** for viewing and analyzing your race schedule.
|
Монорепозиторий: **backend** (Express + PostgreSQL) и **frontend** (React + Vite) — календарь забегов.
|
||||||
|
|
||||||
Product scope and data model: [PLAN.md](PLAN.md).
|
## Переменные окружения
|
||||||
|
|
||||||
## Backend — quick start
|
Один шаблон для локальной разработки и для Docker-стека: **[`.env.example`](.env.example)** → скопируйте в **`.env`** в корне репозитория.
|
||||||
|
|
||||||
1. Install dependencies:
|
Там же перечислены **`DB_HOST`**, **`DB_PORT`**, **`DB_NAME`**, **`DB_USER`**, **`DB_PASSWORD`** (подключение бэкенда к БД), **`PORT`** / **`API_PORT`**, опционально **`CALENDAR_RUN_MOCK_DB`**, **`CORS_ORIGIN`**, а для локального Vite — **`VITE_API_BASE_URL`**.
|
||||||
- `cd backend`
|
|
||||||
- `npm install`
|
|
||||||
2. Configure environment variables. Copy the root template and edit:
|
|
||||||
|
|
||||||
```bash
|
## Backend — локально
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the **repository root** `.env` (the backend loads it via `backend/src/config.ts`).
|
1. `cd backend && npm install`
|
||||||
|
2. Корень: `cp .env.example .env`, задайте `DB_*` (и при необходимости `CORS_ORIGIN`).
|
||||||
|
3. Postgres: из корня `docker compose up -d` (см. [`docker-compose.yml`](docker-compose.yml)) — в compose используются те же `DB_NAME`, `DB_USER`, `DB_PASSWORD` из `.env`.
|
||||||
|
4. `cd backend && npm run db:migrate && npm run seed`
|
||||||
|
5. Dev-режим: `npm run dev`
|
||||||
|
6. Или production: `npm run build && npm start`
|
||||||
|
|
||||||
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` — required unless mock mode is on.
|
Без PostgreSQL (только smoke API): в `.env` задайте `CALENDAR_RUN_MOCK_DB=1`; **`db:migrate` и `seed` с mock не использовать**.
|
||||||
- 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
|
## Frontend — локально
|
||||||
npm run build
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Frontend — quick start
|
```bash
|
||||||
|
cd frontend
|
||||||
|
cp .env.example .env
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
1. Configure the API URL for Vite (file is read from `frontend/`):
|
Значение `VITE_API_BASE_URL` см. в **корневом** [`.env.example`](.env.example); для dev по умолчанию `http://localhost:3001`. У бэкенда `CORS_ORIGIN` должен совпадать с origin приложения (например `http://localhost:5173`).
|
||||||
|
|
||||||
```bash
|
## Docker: backend + frontend рядом с Postgres
|
||||||
cd frontend
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
Edit `VITE_API_BASE_URL` if the API is not on `http://localhost:3001`.
|
Используйте [`docker-compose.stack.yml`](docker-compose.stack.yml): общая **внешняя** сеть с контейнером Postgres (как в вашей инфраструктуре). В корне должен быть **`.env`** (из `.env.example`): `DB_HOST` — имя сервиса/контейнера Postgres в этой сети, `DB_PORT=5432`, плюс остальные `DB_*` и **`CORS_ORIGIN=http://localhost:3033`**, если заходите на фронт с хоста на порту 3033.
|
||||||
|
|
||||||
2. Install and run:
|
```bash
|
||||||
|
docker compose -f docker-compose.stack.yml up -d --build
|
||||||
|
docker compose -f docker-compose.stack.yml exec backend node dist/migrate.js
|
||||||
|
docker compose -f docker-compose.stack.yml exec backend node dist/seed.js
|
||||||
|
```
|
||||||
|
|
||||||
```bash
|
Фронт в браузере обращается к API по префиксу **`/api`** (nginx в образе фронта проксирует на backend).
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Default app URL: `http://localhost:5173`. The backend `CORS_ORIGIN` must match this origin (see root `.env.example`).
|
## Документация API и бэкенда
|
||||||
|
|
||||||
## Docs
|
- [Шпаргалка API для фронта](docs/backend-api-for-frontend.md)
|
||||||
|
- [Эксплуатация backend](docs/backend.md)
|
||||||
- [Backend API for Frontend](docs/backend-api-for-frontend.md)
|
|
||||||
- [Backend operations](docs/backend.md)
|
|
||||||
- [Backend agent instruction](docs/agent-backend-instruction.md)
|
|
||||||
- [Backend sync fix instruction](docs/agent-fix-backend-sync-instruction.md)
|
|
||||||
|
|||||||
@@ -1,336 +0,0 @@
|
|||||||
Ниже — инструкция для дизайн-агента, которая задаёт стиль, структуру и принципы интерфейса для твоего SPA. Она ориентирована на **минимализм, читаемость данных и удовольствие от использования**, а не на “перегруженный фитнес-дэшборд”.
|
|
||||||
|
|
||||||
# 🎯 Общая задача
|
|
||||||
|
|
||||||
Создать **чистый, современный, минималистичный интерфейс** для SPA-приложения бегуна, где пользователь:
|
|
||||||
|
|
||||||
- отслеживает свои старты (прошлые и будущие),
|
|
||||||
- видит прогресс и личные рекорды,
|
|
||||||
- сравнивает результаты,
|
|
||||||
- получает ощущение “контроля над своей беговой историей”.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 🧭 Общая философия дизайна
|
|
||||||
|
|
||||||
### 1. “Спокойная сила”, а не “спортивная агрессия”
|
|
||||||
|
|
||||||
Не:
|
|
||||||
|
|
||||||
- кислотные цвета,
|
|
||||||
- перегруженные графики,
|
|
||||||
- “фитнес-клуб стиль 2015”.
|
|
||||||
|
|
||||||
Да:
|
|
||||||
|
|
||||||
- чистый интерфейс,
|
|
||||||
- воздух,
|
|
||||||
- акцент на данных,
|
|
||||||
- ощущение премиальности.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Минимум визуального шума
|
|
||||||
|
|
||||||
Каждый экран должен отвечать на вопрос:
|
|
||||||
|
|
||||||
> “Что здесь главное?”
|
|
||||||
|
|
||||||
Если не ясно — убрать лишнее.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Данные — главный герой
|
|
||||||
|
|
||||||
Интерфейс не про “украшения”, а про:
|
|
||||||
|
|
||||||
- результаты,
|
|
||||||
- прогресс,
|
|
||||||
- сравнение,
|
|
||||||
- числа.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 🎨 Визуальный стиль
|
|
||||||
|
|
||||||
## Цвета
|
|
||||||
|
|
||||||
### Основа:
|
|
||||||
|
|
||||||
- фон: светлый (#F7F8FA или близко)
|
|
||||||
- карточки: белые
|
|
||||||
- текст:
|
|
||||||
- основной: почти чёрный (#111)
|
|
||||||
- вторичный: серый (#666)
|
|
||||||
|
|
||||||
### Акцент:
|
|
||||||
|
|
||||||
- 1 основной цвет:
|
|
||||||
- глубокий синий или изумрудный (например #2F6BFF или #1FA37A)
|
|
||||||
|
|
||||||
### Дополнительно:
|
|
||||||
|
|
||||||
- PR / успех: мягкий зелёный
|
|
||||||
- предупреждения: нейтральный жёлтый
|
|
||||||
- ошибки: мягкий красный
|
|
||||||
|
|
||||||
❗ Никаких “радуг”.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Типографика
|
|
||||||
|
|
||||||
- современный sans-serif (Inter / SF Pro / аналог)
|
|
||||||
- крупные числа:
|
|
||||||
- результаты (время) — большие, жирные
|
|
||||||
- иерархия:
|
|
||||||
- H1 — экран
|
|
||||||
- H2 — блок
|
|
||||||
- body — данные
|
|
||||||
- caption — мета
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Отступы и сетка
|
|
||||||
|
|
||||||
- много воздуха
|
|
||||||
- карточки с padding 16–24px
|
|
||||||
- равномерная вертикальная ритмика
|
|
||||||
- закругления: 12–16px
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 🧱 Основные экраны
|
|
||||||
|
|
||||||
## 1. Главный экран (Dashboard)
|
|
||||||
|
|
||||||
### Цель:
|
|
||||||
|
|
||||||
дать быстрый ответ:
|
|
||||||
|
|
||||||
- что впереди
|
|
||||||
- где я сейчас
|
|
||||||
- какой прогресс
|
|
||||||
|
|
||||||
### Блоки:
|
|
||||||
|
|
||||||
#### 🔹 Ближайший старт
|
|
||||||
|
|
||||||
- название
|
|
||||||
- дата
|
|
||||||
- дистанция
|
|
||||||
- countdown (“через 12 дней”)
|
|
||||||
|
|
||||||
#### 🔹 Последний результат
|
|
||||||
|
|
||||||
- время
|
|
||||||
- дистанция
|
|
||||||
- место
|
|
||||||
- дата
|
|
||||||
|
|
||||||
#### 🔹 Личный рекорд
|
|
||||||
|
|
||||||
- по ключевой дистанции
|
|
||||||
- выделен визуально
|
|
||||||
|
|
||||||
#### 🔹 Сезон
|
|
||||||
|
|
||||||
- количество стартов
|
|
||||||
- лучший результат
|
|
||||||
- краткая статистика
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Календарь стартов
|
|
||||||
|
|
||||||
### Два раздела:
|
|
||||||
|
|
||||||
- будущие
|
|
||||||
- прошедшие
|
|
||||||
|
|
||||||
### Карточка старта:
|
|
||||||
|
|
||||||
- название
|
|
||||||
- дата
|
|
||||||
- дистанция
|
|
||||||
- статус:
|
|
||||||
- планирую
|
|
||||||
- зарегистрирован
|
|
||||||
- пробежал
|
|
||||||
|
|
||||||
Минимум кликов.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Карточка старта
|
|
||||||
|
|
||||||
- название
|
|
||||||
- дата
|
|
||||||
- дистанция
|
|
||||||
- время
|
|
||||||
- темп
|
|
||||||
- место (если есть)
|
|
||||||
- заметки (опционально)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Личные рекорды (PR)
|
|
||||||
|
|
||||||
- список дистанций:
|
|
||||||
- 5K
|
|
||||||
- 10K
|
|
||||||
- 21.1
|
|
||||||
- 42.2
|
|
||||||
|
|
||||||
Для каждой:
|
|
||||||
|
|
||||||
- лучшее время
|
|
||||||
- дата
|
|
||||||
- старт
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Сравнение стартов (ВАЖНО)
|
|
||||||
|
|
||||||
Это ключевая фича.
|
|
||||||
|
|
||||||
### UI:
|
|
||||||
|
|
||||||
таблица или карточки:
|
|
||||||
|
|
||||||
|
|
||||||
| Год | Время | Темп | Место |
|
|
||||||
| --- | ----- | ---- | ----- |
|
|
||||||
|
|
||||||
|
|
||||||
Можно добавить:
|
|
||||||
|
|
||||||
- стрелки (лучше/хуже)
|
|
||||||
- визуальный прогресс
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 📊 Графики
|
|
||||||
|
|
||||||
Минимально и аккуратно:
|
|
||||||
|
|
||||||
- линия прогресса по дистанции
|
|
||||||
- без перегруза
|
|
||||||
- без 10 линий сразу
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 🧩 Компоненты
|
|
||||||
|
|
||||||
- карточки
|
|
||||||
- таблицы (очень аккуратно)
|
|
||||||
- фильтры
|
|
||||||
- переключатели (tabs)
|
|
||||||
- кнопки (primary / secondary)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 🧠 UX принципы
|
|
||||||
|
|
||||||
### 1. Минимум кликов
|
|
||||||
|
|
||||||
Любая частая задача:
|
|
||||||
→ максимум 2–3 клика
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Быстрое считывание
|
|
||||||
|
|
||||||
Пользователь должен за 2 секунды понять:
|
|
||||||
|
|
||||||
- что это
|
|
||||||
- где он
|
|
||||||
- что делать
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Консистентность
|
|
||||||
|
|
||||||
- одинаковые карточки
|
|
||||||
- одинаковые статусы
|
|
||||||
- одинаковые действия
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 🧪 Для аналитики (важно)
|
|
||||||
|
|
||||||
Заложить:
|
|
||||||
|
|
||||||
- зоны кликов
|
|
||||||
- понятные CTA
|
|
||||||
- отсутствие “пустых зон”
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 🖼️ Использование изображений
|
|
||||||
|
|
||||||
## Когда использовать:
|
|
||||||
|
|
||||||
- пустые состояния
|
|
||||||
- onboarding
|
|
||||||
- вдохновение
|
|
||||||
|
|
||||||
## Когда НЕ использовать:
|
|
||||||
|
|
||||||
- в данных
|
|
||||||
- в списках стартов
|
|
||||||
- в результатах
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Если генерировать изображения
|
|
||||||
|
|
||||||
Стиль:
|
|
||||||
|
|
||||||
- минимализм
|
|
||||||
- спорт без пафоса
|
|
||||||
- одиночный бегун
|
|
||||||
- город / парк
|
|
||||||
- мягкий свет
|
|
||||||
- утро / вечер
|
|
||||||
|
|
||||||
### Пример prompt:
|
|
||||||
|
|
||||||
> “minimalist photo of a runner jogging alone in a city park at sunrise, soft light, calm mood, no crowd, modern aesthetic”
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 🚫 Чего избегать
|
|
||||||
|
|
||||||
- перегруженных дашбордов
|
|
||||||
- 100 метрик сразу
|
|
||||||
- ярких кислотных цветов
|
|
||||||
- сложных графиков
|
|
||||||
- таблиц как в Excel
|
|
||||||
- лишних иконок
|
|
||||||
- “геймификации ради геймификации”
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 💡 Вдохновение (по духу)
|
|
||||||
|
|
||||||
Если бы нужно было описать стиль:
|
|
||||||
|
|
||||||
- как Notion, но для бегунов
|
|
||||||
- как Apple Fitness, но менее ярко
|
|
||||||
- как Strava, но более спокойно и чисто
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 🧭 Финальный ориентир
|
|
||||||
|
|
||||||
Интерфейс должен вызывать ощущение:
|
|
||||||
|
|
||||||
> “Я контролирую свою беговую историю и прогресс”
|
|
||||||
|
|
||||||
а не:
|
|
||||||
|
|
||||||
> “Я заполняю таблицу результатов”
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
100
backend/package-lock.json
generated
100
backend/package-lock.json
generated
@@ -20,6 +20,7 @@
|
|||||||
"@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",
|
"@types/supertest": "^6.0.2",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
@@ -39,6 +40,13 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@epic-web/invariant": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.7",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||||
@@ -643,6 +651,7 @@
|
|||||||
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
|
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -936,6 +945,39 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cross-env": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@epic-web/invariant": "^1.0.0",
|
||||||
|
"cross-spawn": "^7.0.6"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"cross-env": "dist/bin/cross-env.js",
|
||||||
|
"cross-env-shell": "dist/bin/cross-env-shell.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cross-spawn": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"path-key": "^3.1.0",
|
||||||
|
"shebang-command": "^2.0.0",
|
||||||
|
"which": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csv-parse": {
|
"node_modules/csv-parse": {
|
||||||
"version": "5.6.0",
|
"version": "5.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz",
|
||||||
@@ -1442,6 +1484,13 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/isexe": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/make-error": {
|
"node_modules/make-error": {
|
||||||
"version": "1.3.6",
|
"version": "1.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||||
@@ -1585,6 +1634,16 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-key": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "0.1.13",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||||
@@ -1596,6 +1655,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.12.0",
|
"pg-connection-string": "^2.12.0",
|
||||||
"pg-pool": "^3.13.0",
|
"pg-pool": "^3.13.0",
|
||||||
@@ -1858,6 +1918,29 @@
|
|||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/shebang-command": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"shebang-regex": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shebang-regex": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/side-channel": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
@@ -2124,6 +2207,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -2173,6 +2257,22 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"isexe": "^2.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-which": "bin/node-which"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wrappy": {
|
"node_modules/wrappy": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"db:migrate": "ts-node src/migrate.ts",
|
"db:migrate": "ts-node src/migrate.ts",
|
||||||
"seed": "ts-node src/seed.ts",
|
"seed": "ts-node src/seed.ts",
|
||||||
"test": "CALENDAR_RUN_MOCK_DB=1 tsx --test test/app.test.ts"
|
"test": "cross-env CALENDAR_RUN_MOCK_DB=1 tsx --test test/app.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
"@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",
|
"@types/supertest": "^6.0.2",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import express from "express";
|
import express, { Request, Response, NextFunction } from "express";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import { config } from "./config";
|
import { config } from "./config";
|
||||||
import healthRouter from "./routes/health";
|
import healthRouter from "./routes/health";
|
||||||
@@ -13,5 +13,14 @@ export function createApp(): express.Express {
|
|||||||
app.use(healthRouter);
|
app.use(healthRouter);
|
||||||
app.use(racesRouter);
|
app.use(racesRouter);
|
||||||
|
|
||||||
|
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||||
|
if (err instanceof SyntaxError && "body" in err) {
|
||||||
|
res.status(400).json({ error: "validation_error", details: ["Invalid JSON in request body"] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("[app] Unhandled error:", err);
|
||||||
|
res.status(500).json({ error: "unknown_error", details: ["Internal server error"] });
|
||||||
|
});
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ function createMockPool(): Pool {
|
|||||||
fields: [],
|
fields: [],
|
||||||
}) as QueryResult<T>;
|
}) as QueryResult<T>;
|
||||||
|
|
||||||
|
const store = new Map<string, RaceRow>();
|
||||||
|
|
||||||
const mockQuery = async <T extends QueryResultRow>(
|
const mockQuery = async <T extends QueryResultRow>(
|
||||||
text: string,
|
text: string,
|
||||||
params?: unknown[],
|
params?: unknown[],
|
||||||
@@ -76,11 +78,9 @@ function createMockPool(): Pool {
|
|||||||
const sql = text.replace(/\s+/g, " ").trim();
|
const sql = text.replace(/\s+/g, " ").trim();
|
||||||
const p = params ?? [];
|
const p = params ?? [];
|
||||||
|
|
||||||
if (sql.includes("DELETE FROM races")) {
|
|
||||||
return emptyResult();
|
|
||||||
}
|
|
||||||
if (sql.includes("INSERT INTO races") && sql.includes("RETURNING")) {
|
if (sql.includes("INSERT INTO races") && sql.includes("RETURNING")) {
|
||||||
const row = mockRowFromInsert(text, p);
|
const row = mockRowFromInsert(text, p);
|
||||||
|
store.set(row.id, row);
|
||||||
return {
|
return {
|
||||||
rows: [row as unknown as T],
|
rows: [row as unknown as T],
|
||||||
rowCount: 1,
|
rowCount: 1,
|
||||||
@@ -89,12 +89,49 @@ function createMockPool(): Pool {
|
|||||||
fields: [],
|
fields: [],
|
||||||
} as QueryResult<T>;
|
} as QueryResult<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sql.includes("DELETE FROM races")) {
|
||||||
|
const id = String(p[0] ?? "");
|
||||||
|
const existed = store.delete(id);
|
||||||
|
return {
|
||||||
|
rows: [],
|
||||||
|
rowCount: existed ? 1 : 0,
|
||||||
|
command: "DELETE",
|
||||||
|
oid: 0,
|
||||||
|
fields: [],
|
||||||
|
} as QueryResult<T>;
|
||||||
|
}
|
||||||
|
|
||||||
if (sql.includes("UPDATE races") && sql.includes("RETURNING")) {
|
if (sql.includes("UPDATE races") && sql.includes("RETURNING")) {
|
||||||
return emptyResult();
|
const id = String(p[p.length - 1] ?? "");
|
||||||
|
const existing = store.get(id);
|
||||||
|
if (!existing) {
|
||||||
|
return emptyResult();
|
||||||
|
}
|
||||||
|
const updated = { ...existing, updated_at: new Date().toISOString() };
|
||||||
|
store.set(id, updated);
|
||||||
|
return {
|
||||||
|
rows: [updated as unknown as T],
|
||||||
|
rowCount: 1,
|
||||||
|
command: "UPDATE",
|
||||||
|
oid: 0,
|
||||||
|
fields: [],
|
||||||
|
} as QueryResult<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sql.includes("SELECT * FROM races WHERE id =")) {
|
||||||
|
const id = String(p[0] ?? "");
|
||||||
|
const row = store.get(id);
|
||||||
|
return row
|
||||||
|
? { rows: [row as unknown as T], rowCount: 1, command: "SELECT", oid: 0, fields: [] } as QueryResult<T>
|
||||||
|
: emptyResult();
|
||||||
|
}
|
||||||
|
|
||||||
if (sql.includes("SELECT * FROM races")) {
|
if (sql.includes("SELECT * FROM races")) {
|
||||||
return emptyResult();
|
const rows = Array.from(store.values());
|
||||||
|
return { rows: rows as unknown as T[], rowCount: rows.length, command: "SELECT", oid: 0, fields: [] } as QueryResult<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return emptyResult();
|
return emptyResult();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
51
docker-compose.stack.yml
Normal file
51
docker-compose.stack.yml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Backend + frontend в сети с уже поднятым PostgreSQL (external network).
|
||||||
|
#
|
||||||
|
# Подготовка: из корня репозитория скопируйте .env.example → .env и задайте DB_*
|
||||||
|
# и CORS_ORIGIN (для фронта на :3033 — http://localhost:3033).
|
||||||
|
#
|
||||||
|
# Сеть (имя должно существовать, как у вашего Postgres):
|
||||||
|
# docker network ls
|
||||||
|
#
|
||||||
|
# Запуск:
|
||||||
|
# docker compose -f docker-compose.stack.yml up -d --build
|
||||||
|
#
|
||||||
|
# Миграции и seed (один раз после появления БД):
|
||||||
|
# docker compose -f docker-compose.stack.yml exec backend node dist/migrate.js
|
||||||
|
# docker compose -f docker-compose.stack.yml exec backend node dist/seed.js
|
||||||
|
#
|
||||||
|
# NPM: проброс на порт 3033. Браузер ходит на /api → nginx во фронте → backend:3000.
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.backend
|
||||||
|
container_name: runners-calendar-backend
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- PORT=3000
|
||||||
|
ports:
|
||||||
|
- "3001:3000"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- postgres_default
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.frontend
|
||||||
|
args:
|
||||||
|
VITE_API_BASE_URL: /api
|
||||||
|
container_name: runners-calendar-frontend
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
ports:
|
||||||
|
- "3033:80"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- postgres_default
|
||||||
|
|
||||||
|
networks:
|
||||||
|
postgres_default:
|
||||||
|
external: true
|
||||||
21
docker/nginx.frontend.conf
Normal file
21
docker/nginx.frontend.conf
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Браузер ходит на тот же origin: /api/* → бэкенд без префикса /api
|
||||||
|
location /api/ {
|
||||||
|
rewrite ^/api/(.*) /$1 break;
|
||||||
|
proxy_pass http://backend:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
# Инструкция агенту: реализация бэкенда по [PLAN.md](../PLAN.md)
|
|
||||||
|
|
||||||
Документ для ИИ-агента или разработчика, который создаёт **backend** монорепозитория «календарь забегов». Продуктовые цели и модель данных — в корневом **PLAN.md**; здесь — порядок работ, ограничения и **обязательные итоговые артефакты для фронтенда**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. Ограничение: нет возможности проверить подключение к БД
|
|
||||||
|
|
||||||
1. **Всё равно реализовать полноценный бэкенд «как для прод»**: миграции SQL, пул подключений `pg`, переменные `DB_*`, `docker-compose.yml` с PostgreSQL в корне репозитория.
|
|
||||||
2. **Не блокировать работу отсутствием живой БД у исполнителя:**
|
|
||||||
- код миграций и seed должен быть **валидным и согласованным** с PLAN;
|
|
||||||
- при старте API при невозможности подключиться к БД — **ясное сообщение в лог** и **корректный HTTP-ответ** на зависящих от БД маршрутах (например 503 с телом `{"error":"database_unavailable",...}`) **или** падение процесса на старте с понятной ошибкой (выбрать одну стратегию и описать её в `docs/backend.md`).
|
|
||||||
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 кода.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Ветка и расположение в репо
|
|
||||||
|
|
||||||
- Если репозиторий под git: создать ветку `feature/backend-api` (или аналог по соглашению команды).
|
|
||||||
- Каталог **`backend/`** в корне проекта рядом с будущим `frontend/`.
|
|
||||||
- Корневой **`docker-compose.yml`** — сервис `postgres` (порт, пользователь, БД, пароль — согласовать с `.env.example`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Порядок реализации (обязательный)
|
|
||||||
|
|
||||||
### Шаг A. Каркас
|
|
||||||
|
|
||||||
- Node **LTS**, **TypeScript**, **`backend/package.json`**.
|
|
||||||
- Фреймворк: **Fastify** или **Express** (выбрать один, не смешивать).
|
|
||||||
- Загрузка env: `dotenv` или встроенные средства; **валидация** наличия `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` при старте (или при первом запросе к БД — но тогда задокументировать).
|
|
||||||
- Сервер слушает порт из **`PORT`** или **`API_PORT`** (значение по умолчанию, напр. `3001`, указать в `.env.example`).
|
|
||||||
|
|
||||||
### Шаг B. CORS
|
|
||||||
|
|
||||||
- Читать **`CORS_ORIGIN`** (например `http://localhost:5173` для Vite в dev). В prod — origin фронта.
|
|
||||||
- Разрешить методы и заголовки, нужные для `GET/POST/PATCH` + `Content-Type: application/json`.
|
|
||||||
|
|
||||||
### Шаг C. Миграции
|
|
||||||
|
|
||||||
- Каталог миграций, напр. `backend/migrations/` с нумерованными SQL-файлами **или** один миграционный runner (node-pg-migrate, graphile-migrate, собственный скрипт — на выбор, зафиксировать в `docs/backend.md`).
|
|
||||||
- Первая миграция: таблица **`races`** со столбцами, соответствующими PLAN (см. раздел 3 ниже).
|
|
||||||
- Команда **`npm run db:migrate`** (или эквивалент в `backend/`) — идемпотентное накатывание на чистую БД.
|
|
||||||
|
|
||||||
### Шаг D. Доступ к данным
|
|
||||||
|
|
||||||
- Клиент **`pg`**: `Pool` с параметрами из `DB_*`.
|
|
||||||
- Слой репозитория или прямые запросы в обработчиках — на усмотрение, без лишних абстракций сверх задачи.
|
|
||||||
|
|
||||||
### Шаг E. HTTP API
|
|
||||||
|
|
||||||
Реализовать минимум:
|
|
||||||
|
|
||||||
| Метод | Путь | Назначение |
|
|
||||||
|--------|------|------------|
|
|
||||||
| `GET` | `/health` | Liveness: процесс жив; **не обязан** проверять БД (или опционально — задокументировать). |
|
|
||||||
| `GET` | `/ready` (опционально) | Readiness: проверка соединения с БД — полезно для оркестраторов. |
|
|
||||||
| `GET` | `/races` | Список забегов; **query**: `year`, `month` (1–12) — фильтрация для экранов календаря; без параметров — все строки или разумный лимит + документация пагинации если добавите позже. |
|
|
||||||
| `GET` | `/races/:id` | Одна запись по `id`. |
|
|
||||||
| `POST` | `/races` | Создание; тело JSON в **camelCase** как в PLAN. |
|
|
||||||
| `PATCH` | `/races/:id` | Частичное обновление; только переданные поля. |
|
|
||||||
| `DELETE` | `/races/:id` | Опционально по PLAN; если не нужен фронту — можно не делать, но тогда явно написать в документации «удаление не поддерживается». |
|
|
||||||
|
|
||||||
**Ошибки:**
|
|
||||||
|
|
||||||
- 400 — валидация тела/параметров.
|
|
||||||
- 404 — нет `id`.
|
|
||||||
- 409 — конфликт уникального `id` при POST (если клиент прислал уже существующий).
|
|
||||||
- 503 или 500 — недоступна БД (согласовать с разделом 0).
|
|
||||||
|
|
||||||
Формат ошибки: JSON, единообразно, напр. `{"error":"validation_error","details":[...]}`.
|
|
||||||
|
|
||||||
### Шаг F. Seed (разовый скрипт)
|
|
||||||
|
|
||||||
- Команда **`npm run seed`** в `backend/` (или корне монорепо с `workspace` — единообразно).
|
|
||||||
- Читает **`import/races_2026_calendar.csv`** (путь от корня репо); парсинг с учётом кавычек в поле названия.
|
|
||||||
- Генерирует **`id`** стабильно: например `{date}-{slug-from-title}`; при коллизии — суффикс или upsert по `id`.
|
|
||||||
- **`INSERT ... ON CONFLICT (id) DO UPDATE`** (upsert) — удобно для повторного запуска seed.
|
|
||||||
- Seed **не** вызывается из HTTP handlers.
|
|
||||||
|
|
||||||
### Шаг G. Корневой `.env.example`
|
|
||||||
|
|
||||||
- Все переменные: `DB_*`, `PORT`/`API_PORT`, `CORS_ORIGIN`.
|
|
||||||
- **Без** реальных паролей; комментарии к каждой переменной.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Соответствие полей PLAN ↔ SQL ↔ JSON API
|
|
||||||
|
|
||||||
**В JSON API (запрос/ответ)** — **camelCase**, как в PLAN:
|
|
||||||
|
|
||||||
`id`, `date`, `title`, `distanceKm`, `status` (`planned` \| `registered` \| `completed`), `officialUrl`, `startTime`, `clusterSchedule`, `bibPickup`, `bibNumber`, `finishTime`, `finishPlace`, `notes`.
|
|
||||||
|
|
||||||
**В PostgreSQL** — **snake_case**, например:
|
|
||||||
|
|
||||||
| SQL column | Тип (рекомендация) |
|
|
||||||
|------------|---------------------|
|
|
||||||
| `id` | `TEXT` PRIMARY KEY |
|
|
||||||
| `race_date` | `DATE` |
|
|
||||||
| `title` | `TEXT` |
|
|
||||||
| `distance_km` | `NUMERIC(6,3)` |
|
|
||||||
| `status` | `TEXT` CHECK (опционально) |
|
|
||||||
| `official_url` | `TEXT` |
|
|
||||||
| `start_time` | `TEXT` |
|
|
||||||
| `cluster_schedule` | `TEXT` |
|
|
||||||
| `bib_pickup` | `TEXT` |
|
|
||||||
| `bib_number` | `TEXT` |
|
|
||||||
| `finish_time` | `TEXT` |
|
|
||||||
| `finish_place` | `TEXT` |
|
|
||||||
| `notes` | `TEXT` |
|
|
||||||
| `created_at` | `TIMESTAMPTZ` DEFAULT now() |
|
|
||||||
| `updated_at` | `TIMESTAMPTZ` |
|
|
||||||
|
|
||||||
Маппинг **строго** в одном модуле (например `backend/src/mappers/race.ts`), чтобы фронт всегда видел camelCase.
|
|
||||||
|
|
||||||
**Типы `date`:** в API строка `YYYY-MM-DD`. **`distanceKm`:** число. **`finishTime`:** строка времени как в PLAN; бэкенд **не обязан** парсить для бизнес-логики (PR считает фронт), но может валидировать формат по желанию.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Обязательный итог для упрощения фронтенда
|
|
||||||
|
|
||||||
После того как код бэкенда готов, агент **обязан** добавить в репозиторий документ:
|
|
||||||
|
|
||||||
### Файл: `docs/backend-api-for-frontend.md`
|
|
||||||
|
|
||||||
В нём **кратко и без пробелов в фактах**:
|
|
||||||
|
|
||||||
1. **Base URL** — что подставлять во фронт (`VITE_API_BASE_URL`), пример для dev.
|
|
||||||
2. **CORS** — какой `CORS_ORIGIN` ожидается в dev.
|
|
||||||
3. **Таблица эндпоинтов** — метод, путь, query, тело запроса (пример JSON), пример успешного ответа, коды ошибок.
|
|
||||||
4. **Модель `Race`** — перечень полей в **camelCase** с типами и обязательностью для POST vs PATCH.
|
|
||||||
5. **Фильтр списка** — как именно работают `year` и `month` на `GET /races` (включая границы: пустой месяц, только год и т.д.).
|
|
||||||
6. **Идемпотентность seed** — одна фраза: upsert по `id`, откуда берётся CSV.
|
|
||||||
7. **Поведение при недоступной БД** — что возвращает API / что в логах (как в реализации).
|
|
||||||
|
|
||||||
Дополнительно можно дублировать суть в **`docs/backend.md`** (общая эксплуатация: docker, migrate, seed, запуск), но **`backend-api-for-frontend.md`** — главная «шпаргалка» для разработчика SPA.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Критерии готовности (чеклист агента)
|
|
||||||
|
|
||||||
- [ ] `docker-compose.yml` поднимает Postgres с параметрами, совместимыми с `.env.example`.
|
|
||||||
- [ ] Миграция создаёт `races`; есть команда миграции.
|
|
||||||
- [ ] Реализованы `GET /races`, `GET /races/:id`, `POST /races`, `PATCH /races/:id` согласно PLAN.
|
|
||||||
- [ ] Реализован seed из `import/races_2026_calendar.csv`.
|
|
||||||
- [ ] `GET /health` (и при наличии `/ready` — описано).
|
|
||||||
- [ ] Корневой `.env.example` обновлён.
|
|
||||||
- [ ] Написан **`docs/backend-api-for-frontend.md`** (раздел 4).
|
|
||||||
- [ ] `docs/backend.md` содержит команды: установка зависимостей, migrate, seed, `npm run dev` для API.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Не входит в объём бэкенда (напоминание)
|
|
||||||
|
|
||||||
- Авторизация, пользователи, сессии.
|
|
||||||
- Парсинг сайтов организаторов.
|
|
||||||
- Отдача статики фронта с того же процесса (фронт — отдельно Vite/build).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Конец инструкции. Источник требований к продукту — всегда [PLAN.md](../PLAN.md).*
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# Инструкция агенту: устранение рассинхронизации backend с планом/контрактом
|
|
||||||
|
|
||||||
Документ описывает, как выполнить план исправлений **только через изменения кода** (без правок существующей документации в `docs/`).
|
|
||||||
|
|
||||||
## 1) Ветка и границы задачи
|
|
||||||
|
|
||||||
- Создать отдельную ветку по best practice, например: `fix/backend-api-validation-and-runtime-sync`.
|
|
||||||
- Не менять существующие файлы документации в `docs/` как способ "починить" замечания.
|
|
||||||
- Исправления вносятся в runtime-код и обязательные артефакты репозитория.
|
|
||||||
|
|
||||||
## 2) Обязательные изменения в коде
|
|
||||||
|
|
||||||
### A. Строгая валидация `GET /races`
|
|
||||||
|
|
||||||
Файл: `backend/src/routes/races.ts`
|
|
||||||
|
|
||||||
- Добавить явную проверку `year`:
|
|
||||||
- целое число;
|
|
||||||
- при невалидном значении вернуть `400`:
|
|
||||||
- `{"error":"validation_error","details":[...]}`
|
|
||||||
- Добавить явную проверку `month`:
|
|
||||||
- целое число в диапазоне `1..12`;
|
|
||||||
- при невалидном значении вернуть `400` в том же формате.
|
|
||||||
- Исключить передачу `NaN`/некорректных значений в SQL-параметры.
|
|
||||||
|
|
||||||
### B. Разделение ошибок валидации и ошибок БД
|
|
||||||
|
|
||||||
Файл: `backend/src/routes/races.ts`
|
|
||||||
|
|
||||||
- `400` использовать только для ошибок входа (query/body/params).
|
|
||||||
- `503 {"error":"database_unavailable"}` использовать только для технической недоступности БД.
|
|
||||||
- Сохранить единый JSON-формат ошибок во всех CRUD-маршрутах.
|
|
||||||
|
|
||||||
### C. Выравнивание конфигурации порта
|
|
||||||
|
|
||||||
Файл: `backend/src/config.ts`
|
|
||||||
|
|
||||||
- Поддержать оба env-подхода:
|
|
||||||
- приоритет `PORT`,
|
|
||||||
- затем `API_PORT`,
|
|
||||||
- затем fallback `3001`.
|
|
||||||
|
|
||||||
### D. Обязательный root-артефакт
|
|
||||||
|
|
||||||
Файл: `README.md` (в корне)
|
|
||||||
|
|
||||||
- Создать базовый `README.md` с:
|
|
||||||
- кратким описанием проекта,
|
|
||||||
- минимальным quick start,
|
|
||||||
- ссылками на текущие документы backend/API.
|
|
||||||
|
|
||||||
## 3) Допустимая реорганизация кода
|
|
||||||
|
|
||||||
- Можно добавить небольшие локальные helper-функции в `backend/src/routes/races.ts`.
|
|
||||||
- При необходимости можно вынести общие mapper/validator-хелперы в `backend/src/mappers/race.ts`, если это уменьшает дублирование.
|
|
||||||
- Не усложнять архитектуру: только то, что нужно для контракта и устойчивого поведения API.
|
|
||||||
|
|
||||||
## 4) Проверка результата
|
|
||||||
|
|
||||||
Минимум выполнить:
|
|
||||||
|
|
||||||
1. `npm run build` в `backend/` (типизация/сборка).
|
|
||||||
2. Проверка диагностики/линтов по измененным backend-файлам.
|
|
||||||
3. Smoke-сценарии API:
|
|
||||||
- `GET /health` -> `200`,
|
|
||||||
- `GET /ready` -> `200` при доступной БД или `503` при недоступной,
|
|
||||||
- `GET /races?year=bad` -> `400`,
|
|
||||||
- `GET /races?month=13` -> `400`,
|
|
||||||
- `GET /races?year=2026&month=5` -> корректный `200` и данные/пустой массив.
|
|
||||||
|
|
||||||
## 5) Критерии готовности (Definition of Done)
|
|
||||||
|
|
||||||
- Контракт валидации `GET /races` соблюден в runtime.
|
|
||||||
- Валидационные ошибки не маскируются под `database_unavailable`.
|
|
||||||
- Конфиг порта поддерживает `PORT` и `API_PORT` с правильным приоритетом.
|
|
||||||
- В репозитории есть корневой `README.md`.
|
|
||||||
- Никакие существующие документы в `docs/` не менялись для "закрытия" замечаний.
|
|
||||||
@@ -8,6 +8,8 @@ VITE_API_BASE_URL=http://localhost:3001
|
|||||||
|
|
||||||
В коде SPA: `import.meta.env.VITE_API_BASE_URL`.
|
В коде SPA: `import.meta.env.VITE_API_BASE_URL`.
|
||||||
|
|
||||||
|
В Docker-стеке из репозитория образ фронта собирается с **`VITE_API_BASE_URL=/api`**: запросы идут на тот же origin, nginx проксирует `/api` на backend (см. `docker/nginx.frontend.conf`).
|
||||||
|
|
||||||
## 2. CORS
|
## 2. CORS
|
||||||
|
|
||||||
В dev-режиме бэкенд ожидает переменную:
|
В dev-режиме бэкенд ожидает переменную:
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ API слушает порт: **`PORT`** (если задан), иначе **`API
|
|||||||
| `API_PORT` | Порт API-сервера | `3001` |
|
| `API_PORT` | Порт API-сервера | `3001` |
|
||||||
| `CORS_ORIGIN` | Разрешённый origin для CORS | `http://localhost:5173` |
|
| `CORS_ORIGIN` | Разрешённый origin для CORS | `http://localhost:5173` |
|
||||||
|
|
||||||
|
Для локального Vite в корневом `.env.example` также указан **`VITE_API_BASE_URL`** (читает только фронт из `frontend/`). В Docker-стеке базовый URL API задаётся при **сборке** образа фронта (`/api`), не через этот файл.
|
||||||
|
|
||||||
**Без mock:** при отсутствии любой из `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_*`.
|
**С `CALENDAR_RUN_MOCK_DB=1`:** переменные `DB_*` не обязательны; реальный пул не поднимается. **Не использовать** mock для `npm run db:migrate` и `npm run seed` — нужен настоящий Postgres и корректные `DB_*`.
|
||||||
@@ -111,3 +113,9 @@ backend/
|
|||||||
├── package.json
|
├── package.json
|
||||||
└── tsconfig.json
|
└── tsconfig.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Docker: стек backend + frontend
|
||||||
|
|
||||||
|
Файл [`docker-compose.stack.yml`](../docker-compose.stack.yml) поднимает API и nginx со статикой SPA в **внешней** сети Docker (рядом с уже запущенным Postgres). Переменные — в **корневом** `.env` (шаблон [`.env.example`](../.env.example)): как минимум `DB_*`, `CORS_ORIGIN` (для выдачи фронта на порту 3033 задайте `http://localhost:3033`). Перед первым `up` файл `.env` должен существовать.
|
||||||
|
|
||||||
|
Порядок после старта контейнеров: `node dist/migrate.js` и `node dist/seed.js` внутри контейнера `backend` (см. комментарии в compose-файле).
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
# Base URL of the Calendar Run API (must match CORS_ORIGIN on the backend)
|
# Для локального npm run dev. Полный список переменных — в корневом .env.example репозитория.
|
||||||
VITE_API_BASE_URL=http://localhost:3001
|
VITE_API_BASE_URL=http://localhost:3001
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ export function AppLayout(): JSX.Element {
|
|||||||
>
|
>
|
||||||
Races
|
Races
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/races/new"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
isActive ? "app-shell__link app-shell__link--active" : "app-shell__link"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+ Добавить
|
||||||
|
</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main className="app-shell__main">
|
<main className="app-shell__main">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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";
|
import { RaceDetailsPage } from "../pages/RaceDetailsPage";
|
||||||
|
import { RaceFormPage } from "../pages/RaceFormPage";
|
||||||
|
|
||||||
export const appRouter = createBrowserRouter([
|
export const appRouter = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@@ -11,7 +12,9 @@ export const appRouter = createBrowserRouter([
|
|||||||
children: [
|
children: [
|
||||||
{ index: true, element: <DashboardPage /> },
|
{ index: true, element: <DashboardPage /> },
|
||||||
{ path: "races", element: <RacesPage /> },
|
{ path: "races", element: <RacesPage /> },
|
||||||
|
{ path: "races/new", element: <RaceFormPage /> },
|
||||||
{ path: "races/:raceId", element: <RaceDetailsPage /> },
|
{ path: "races/:raceId", element: <RaceDetailsPage /> },
|
||||||
|
{ path: "races/:raceId/edit", element: <RaceFormPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -70,6 +70,22 @@ export function splitRacesByDate(races: Race[], now: Date = new Date()): { upcom
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pluralizeDays(n: number): string {
|
||||||
|
const mod10 = n % 10;
|
||||||
|
const mod100 = n % 100;
|
||||||
|
|
||||||
|
if (mod100 >= 11 && mod100 <= 19) {
|
||||||
|
return "дней";
|
||||||
|
}
|
||||||
|
if (mod10 === 1) {
|
||||||
|
return "день";
|
||||||
|
}
|
||||||
|
if (mod10 >= 2 && mod10 <= 4) {
|
||||||
|
return "дня";
|
||||||
|
}
|
||||||
|
return "дней";
|
||||||
|
}
|
||||||
|
|
||||||
export function getRaceCountdownLabel(date: string, now: Date = new Date()): string {
|
export function getRaceCountdownLabel(date: string, now: Date = new Date()): string {
|
||||||
const today = new Date(now);
|
const today = new Date(now);
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
@@ -80,13 +96,7 @@ export function getRaceCountdownLabel(date: string, now: Date = new Date()): str
|
|||||||
if (days <= 0) {
|
if (days <= 0) {
|
||||||
return "сегодня";
|
return "сегодня";
|
||||||
}
|
}
|
||||||
if (days === 1) {
|
return `через ${days} ${pluralizeDays(days)}`;
|
||||||
return "через 1 день";
|
|
||||||
}
|
|
||||||
if (days < 5) {
|
|
||||||
return `через ${days} дня`;
|
|
||||||
}
|
|
||||||
return `через ${days} дней`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCloseDistance(left: number, right: number): boolean {
|
export function isCloseDistance(left: number, right: number): boolean {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
import { ApiError, getRaceById } from "../api";
|
import { ApiError, deleteRace, getRaceById } from "../api";
|
||||||
import {
|
import {
|
||||||
formatDistance,
|
formatDistance,
|
||||||
formatRaceDate,
|
formatRaceDate,
|
||||||
@@ -17,11 +17,44 @@ function getErrorMessage(error: unknown): string {
|
|||||||
return "Не удалось загрузить карточку старта.";
|
return "Не удалось загрузить карточку старта.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DetailItem(props: { label: string; value: string | null | undefined }): JSX.Element | null {
|
||||||
|
const text = props.value?.trim();
|
||||||
|
if (!text) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="race-details-meta__item">
|
||||||
|
<dt className="race-details-meta__key">{props.label}</dt>
|
||||||
|
<dd className="race-details-meta__value">{text}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailLink(props: { label: string; url: string | null | undefined }): JSX.Element | null {
|
||||||
|
const href = props.url?.trim();
|
||||||
|
if (!href) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="race-details-meta__item">
|
||||||
|
<dt className="race-details-meta__key">{props.label}</dt>
|
||||||
|
<dd className="race-details-meta__value">
|
||||||
|
<a href={href} target="_blank" rel="noopener noreferrer" className="race-details-meta__link">
|
||||||
|
{href}
|
||||||
|
</a>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function RaceDetailsPage(): JSX.Element {
|
export function RaceDetailsPage(): JSX.Element {
|
||||||
const { raceId } = useParams<{ raceId: string }>();
|
const { raceId } = useParams<{ raceId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [race, setRace] = useState<Race | null>(null);
|
const [race, setRace] = useState<Race | null>(null);
|
||||||
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 [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
@@ -65,6 +98,22 @@ export function RaceDetailsPage(): JSX.Element {
|
|||||||
return getPaceLabel(race.finishTime, race.distanceKm);
|
return getPaceLabel(race.finishTime, race.distanceKm);
|
||||||
}, [race]);
|
}, [race]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
if (!raceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteRace(raceId);
|
||||||
|
navigate("/races", { replace: true });
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error instanceof ApiError ? error.message : "Не удалось удалить старт.");
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
}, [raceId, navigate]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<section className="page page--race-details" aria-busy="true">
|
<section className="page page--race-details" aria-busy="true">
|
||||||
@@ -100,6 +149,43 @@ export function RaceDetailsPage(): JSX.Element {
|
|||||||
<span className={getRaceStatusClassName(race.status)}>{getRaceStatusLabel(race.status)}</span>
|
<span className={getRaceStatusClassName(race.status)}>{getRaceStatusLabel(race.status)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="race-details-actions">
|
||||||
|
<Link className="btn btn--primary" to={`/races/${race.id}/edit`}>
|
||||||
|
Редактировать
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="btn btn--danger"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showDeleteConfirm ? (
|
||||||
|
<div className="confirm-banner" role="alertdialog" aria-label="Подтверждение удаления">
|
||||||
|
<p className="confirm-banner__text">Удалить «{race.title}»? Это действие необратимо.</p>
|
||||||
|
<div className="confirm-banner__actions">
|
||||||
|
<button
|
||||||
|
className="btn btn--danger"
|
||||||
|
type="button"
|
||||||
|
disabled={isDeleting}
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
{isDeleting ? "Удаляем…" : "Да, удалить"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn--secondary"
|
||||||
|
type="button"
|
||||||
|
disabled={isDeleting}
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="race-details-grid">
|
<div className="race-details-grid">
|
||||||
<article className="race-details-card">
|
<article className="race-details-card">
|
||||||
<h2 className="race-details-card__title">Основная информация</h2>
|
<h2 className="race-details-card__title">Основная информация</h2>
|
||||||
@@ -116,11 +202,16 @@ 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">{getRaceStatusLabel(race.status)}</dd>
|
<dd className="race-details-meta__value">{getRaceStatusLabel(race.status)}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<DetailLink label="Сайт организатора" url={race.officialUrl} />
|
||||||
|
<DetailItem label="Время старта" value={race.startTime} />
|
||||||
|
<DetailItem label="Расписание кластеров" value={race.clusterSchedule} />
|
||||||
|
<DetailItem label="Выдача номеров" value={race.bibPickup} />
|
||||||
|
<DetailItem label="Стартовый номер" value={race.bibNumber} />
|
||||||
</dl>
|
</dl>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article className="race-details-card">
|
<article className="race-details-card">
|
||||||
<h2 className="race-details-card__title">Completed-метрики</h2>
|
<h2 className="race-details-card__title">Результаты</h2>
|
||||||
{isCompleted ? (
|
{isCompleted ? (
|
||||||
<dl className="race-details-meta">
|
<dl className="race-details-meta">
|
||||||
<div className="race-details-meta__item">
|
<div className="race-details-meta__item">
|
||||||
@@ -131,16 +222,7 @@ 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">
|
<DetailItem label="Место" value={race.finishPlace} />
|
||||||
<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>
|
</dl>
|
||||||
) : (
|
) : (
|
||||||
<p className="race-details-card__empty">
|
<p className="race-details-card__empty">
|
||||||
|
|||||||
421
frontend/src/pages/RaceFormPage.tsx
Normal file
421
frontend/src/pages/RaceFormPage.tsx
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { ApiError, createRace, getRaceById, updateRace } from "../api";
|
||||||
|
import type { CreateRacePayload, Race, RaceStatus, UpdateRacePayload } from "../api";
|
||||||
|
|
||||||
|
function slugify(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[«»"]/g, "")
|
||||||
|
.replace(/[^a-zа-яё0-9]+/gi, "-")
|
||||||
|
.replace(/(^-|-$)/g, "")
|
||||||
|
.substring(0, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateId(date: string, title: string): string {
|
||||||
|
return `${date}-${slugify(title)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_OPTIONS: { value: string; label: string }[] = [
|
||||||
|
{ value: "", label: "Не указан" },
|
||||||
|
{ value: "planned", label: "Планирую" },
|
||||||
|
{ value: "registered", label: "Зарегистрирован" },
|
||||||
|
{ value: "completed", label: "Пробежал" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
date: string;
|
||||||
|
title: string;
|
||||||
|
distanceKm: string;
|
||||||
|
status: string;
|
||||||
|
officialUrl: string;
|
||||||
|
startTime: string;
|
||||||
|
clusterSchedule: string;
|
||||||
|
bibPickup: string;
|
||||||
|
bibNumber: string;
|
||||||
|
finishTime: string;
|
||||||
|
finishPlace: string;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FORM: FormData = {
|
||||||
|
date: "",
|
||||||
|
title: "",
|
||||||
|
distanceKm: "",
|
||||||
|
status: "planned",
|
||||||
|
officialUrl: "",
|
||||||
|
startTime: "",
|
||||||
|
clusterSchedule: "",
|
||||||
|
bibPickup: "",
|
||||||
|
bibNumber: "",
|
||||||
|
finishTime: "",
|
||||||
|
finishPlace: "",
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
function raceToFormData(race: Race): FormData {
|
||||||
|
return {
|
||||||
|
date: race.date,
|
||||||
|
title: race.title,
|
||||||
|
distanceKm: String(race.distanceKm),
|
||||||
|
status: race.status ?? "",
|
||||||
|
officialUrl: race.officialUrl ?? "",
|
||||||
|
startTime: race.startTime ?? "",
|
||||||
|
clusterSchedule: race.clusterSchedule ?? "",
|
||||||
|
bibPickup: race.bibPickup ?? "",
|
||||||
|
bibNumber: race.bibNumber ?? "",
|
||||||
|
finishTime: race.finishTime ?? "",
|
||||||
|
finishPlace: race.finishPlace ?? "",
|
||||||
|
notes: race.notes ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyToNull(value: string): string | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed === "" ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm(form: FormData): string[] {
|
||||||
|
const errors: string[] = [];
|
||||||
|
if (!form.date.trim()) {
|
||||||
|
errors.push("Дата обязательна.");
|
||||||
|
}
|
||||||
|
if (!form.title.trim()) {
|
||||||
|
errors.push("Название обязательно.");
|
||||||
|
}
|
||||||
|
const km = parseFloat(form.distanceKm);
|
||||||
|
if (Number.isNaN(km) || km <= 0) {
|
||||||
|
errors.push("Дистанция должна быть положительным числом.");
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RaceFormPage(): JSX.Element {
|
||||||
|
const { raceId } = useParams<{ raceId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isEditMode = Boolean(raceId);
|
||||||
|
|
||||||
|
const [form, setForm] = useState<FormData>(EMPTY_FORM);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(isEditMode);
|
||||||
|
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!raceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
async function loadRace(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const race = await getRaceById(raceId!);
|
||||||
|
if (!isMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setForm(raceToFormData(race));
|
||||||
|
setErrorMessage(null);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setErrorMessage(error instanceof ApiError ? error.message : "Не удалось загрузить данные старта.");
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadRace();
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [raceId]);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||||
|
const { name, value } = event.target;
|
||||||
|
setForm((prev) => ({ ...prev, [name]: value }));
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setErrorMessage(null);
|
||||||
|
|
||||||
|
const errors = validateForm(form);
|
||||||
|
setValidationErrors(errors);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const statusValue: RaceStatus | null =
|
||||||
|
form.status === "planned" || form.status === "registered" || form.status === "completed"
|
||||||
|
? form.status
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (isEditMode && raceId) {
|
||||||
|
const payload: UpdateRacePayload = {
|
||||||
|
date: form.date.trim(),
|
||||||
|
title: form.title.trim(),
|
||||||
|
distanceKm: parseFloat(form.distanceKm),
|
||||||
|
status: statusValue,
|
||||||
|
officialUrl: emptyToNull(form.officialUrl),
|
||||||
|
startTime: emptyToNull(form.startTime),
|
||||||
|
clusterSchedule: emptyToNull(form.clusterSchedule),
|
||||||
|
bibPickup: emptyToNull(form.bibPickup),
|
||||||
|
bibNumber: emptyToNull(form.bibNumber),
|
||||||
|
finishTime: emptyToNull(form.finishTime),
|
||||||
|
finishPlace: emptyToNull(form.finishPlace),
|
||||||
|
notes: emptyToNull(form.notes),
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateRace(raceId, payload);
|
||||||
|
navigate(`/races/${raceId}`);
|
||||||
|
} else {
|
||||||
|
const id = generateId(form.date.trim(), form.title.trim());
|
||||||
|
const payload: CreateRacePayload = {
|
||||||
|
id,
|
||||||
|
date: form.date.trim(),
|
||||||
|
title: form.title.trim(),
|
||||||
|
distanceKm: parseFloat(form.distanceKm),
|
||||||
|
status: statusValue,
|
||||||
|
officialUrl: emptyToNull(form.officialUrl),
|
||||||
|
startTime: emptyToNull(form.startTime),
|
||||||
|
clusterSchedule: emptyToNull(form.clusterSchedule),
|
||||||
|
bibPickup: emptyToNull(form.bibPickup),
|
||||||
|
bibNumber: emptyToNull(form.bibNumber),
|
||||||
|
finishTime: emptyToNull(form.finishTime),
|
||||||
|
finishPlace: emptyToNull(form.finishPlace),
|
||||||
|
notes: emptyToNull(form.notes),
|
||||||
|
};
|
||||||
|
|
||||||
|
const created = await createRace(payload);
|
||||||
|
navigate(`/races/${created.id}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
setErrorMessage(error.details.length > 0 ? error.details.join("; ") : error.message);
|
||||||
|
} else {
|
||||||
|
setErrorMessage("Произошла ошибка при сохранении.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[form, isEditMode, raceId, navigate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const pageTitle = isEditMode ? "Редактирование старта" : "Новый старт";
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<section className="page page--race-form" aria-busy="true">
|
||||||
|
<h1 className="page__title">{pageTitle}</h1>
|
||||||
|
<p className="page__subtitle">Загружаем данные...</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page page--race-form">
|
||||||
|
<h1 className="page__title">{pageTitle}</h1>
|
||||||
|
|
||||||
|
{errorMessage ? (
|
||||||
|
<p className="page__subtitle page__subtitle--error" role="alert">{errorMessage}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{validationErrors.length > 0 ? (
|
||||||
|
<ul className="form-errors" role="alert">
|
||||||
|
{validationErrors.map((msg) => (
|
||||||
|
<li key={msg} className="form-errors__item">{msg}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<form className="race-form" onSubmit={handleSubmit} noValidate>
|
||||||
|
<fieldset className="race-form__group">
|
||||||
|
<legend className="race-form__legend">Основная информация</legend>
|
||||||
|
|
||||||
|
<label className="race-form__field">
|
||||||
|
<span className="race-form__label">Дата *</span>
|
||||||
|
<input
|
||||||
|
className="race-form__input"
|
||||||
|
type="date"
|
||||||
|
name="date"
|
||||||
|
value={form.date}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="race-form__field">
|
||||||
|
<span className="race-form__label">Название *</span>
|
||||||
|
<input
|
||||||
|
className="race-form__input"
|
||||||
|
type="text"
|
||||||
|
name="title"
|
||||||
|
value={form.title}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
placeholder="Казанский марафон"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="race-form__field">
|
||||||
|
<span className="race-form__label">Дистанция, км *</span>
|
||||||
|
<input
|
||||||
|
className="race-form__input"
|
||||||
|
type="number"
|
||||||
|
name="distanceKm"
|
||||||
|
value={form.distanceKm}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
min="0.1"
|
||||||
|
step="0.001"
|
||||||
|
placeholder="21.1"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="race-form__field">
|
||||||
|
<span className="race-form__label">Статус</span>
|
||||||
|
<select
|
||||||
|
className="race-form__input"
|
||||||
|
name="status"
|
||||||
|
value={form.status}
|
||||||
|
onChange={handleChange}
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="race-form__group">
|
||||||
|
<legend className="race-form__legend">Организация</legend>
|
||||||
|
|
||||||
|
<label className="race-form__field">
|
||||||
|
<span className="race-form__label">Сайт организатора</span>
|
||||||
|
<input
|
||||||
|
className="race-form__input"
|
||||||
|
type="url"
|
||||||
|
name="officialUrl"
|
||||||
|
value={form.officialUrl}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="race-form__field">
|
||||||
|
<span className="race-form__label">Время старта</span>
|
||||||
|
<input
|
||||||
|
className="race-form__input"
|
||||||
|
type="text"
|
||||||
|
name="startTime"
|
||||||
|
value={form.startTime}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="09:30"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="race-form__field">
|
||||||
|
<span className="race-form__label">Расписание кластеров</span>
|
||||||
|
<input
|
||||||
|
className="race-form__input"
|
||||||
|
type="text"
|
||||||
|
name="clusterSchedule"
|
||||||
|
value={form.clusterSchedule}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="race-form__field">
|
||||||
|
<span className="race-form__label">Выдача номеров</span>
|
||||||
|
<input
|
||||||
|
className="race-form__input"
|
||||||
|
type="text"
|
||||||
|
name="bibPickup"
|
||||||
|
value={form.bibPickup}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="race-form__field">
|
||||||
|
<span className="race-form__label">Стартовый номер</span>
|
||||||
|
<input
|
||||||
|
className="race-form__input"
|
||||||
|
type="text"
|
||||||
|
name="bibNumber"
|
||||||
|
value={form.bibNumber}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="1234"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="race-form__group">
|
||||||
|
<legend className="race-form__legend">Результаты</legend>
|
||||||
|
|
||||||
|
<label className="race-form__field">
|
||||||
|
<span className="race-form__label">Финишное время</span>
|
||||||
|
<input
|
||||||
|
className="race-form__input"
|
||||||
|
type="text"
|
||||||
|
name="finishTime"
|
||||||
|
value={form.finishTime}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="1:45:30"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="race-form__field">
|
||||||
|
<span className="race-form__label">Место на финише</span>
|
||||||
|
<input
|
||||||
|
className="race-form__input"
|
||||||
|
type="text"
|
||||||
|
name="finishPlace"
|
||||||
|
value={form.finishPlace}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="12/340"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="race-form__group">
|
||||||
|
<legend className="race-form__legend">Дополнительно</legend>
|
||||||
|
|
||||||
|
<label className="race-form__field">
|
||||||
|
<span className="race-form__label">Заметки</span>
|
||||||
|
<textarea
|
||||||
|
className="race-form__input race-form__input--textarea"
|
||||||
|
name="notes"
|
||||||
|
value={form.notes}
|
||||||
|
onChange={handleChange}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div className="race-form__actions">
|
||||||
|
<button className="btn btn--primary" type="submit" disabled={isSaving}>
|
||||||
|
{isSaving ? "Сохраняем…" : isEditMode ? "Сохранить" : "Создать"}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
className="btn btn--secondary"
|
||||||
|
to={isEditMode && raceId ? `/races/${raceId}` : "/races"}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -145,6 +145,12 @@ 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>
|
||||||
|
|
||||||
|
{errorMessage && !isLoading ? (
|
||||||
|
<p className="page__subtitle page__subtitle--error" role="alert" style={{ marginTop: "var(--space-4)" }}>
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="races-filter" role="search" aria-label="Фильтр по дате">
|
<div className="races-filter" role="search" aria-label="Фильтр по дате">
|
||||||
<label className="races-filter__field">
|
<label className="races-filter__field">
|
||||||
<span className="races-filter__label">Год</span>
|
<span className="races-filter__label">Год</span>
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ a {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-shell__link--active {
|
.app-shell__link--active,
|
||||||
|
.app-shell__link--active:hover,
|
||||||
|
.app-shell__link--active:focus-visible {
|
||||||
color: var(--color-surface);
|
color: var(--color-surface);
|
||||||
background: var(--color-accent);
|
background: var(--color-accent);
|
||||||
}
|
}
|
||||||
@@ -406,6 +408,175 @@ a {
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Buttons ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary:hover:not(:disabled) {
|
||||||
|
background: #1766be;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--secondary {
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--secondary:hover:not(:disabled) {
|
||||||
|
background: #eef2f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--danger {
|
||||||
|
background: var(--color-error);
|
||||||
|
color: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--danger:hover:not(:disabled) {
|
||||||
|
background: #a82e2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Confirm banner ───────────────────────────────────── */
|
||||||
|
|
||||||
|
.confirm-banner {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: 1px solid var(--color-error);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-banner__text {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-banner__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Race details actions ─────────────────────────────── */
|
||||||
|
|
||||||
|
.race-details-actions {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-details-meta__link {
|
||||||
|
color: var(--color-accent);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-details-meta__link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Race form ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.race-form {
|
||||||
|
margin-top: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-form__group {
|
||||||
|
margin: 0 0 var(--space-6);
|
||||||
|
padding: var(--space-5);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: #fcfdff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-form__legend {
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-form__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-form__field:first-of-type {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-form__label {
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-form__input {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-form__input:focus {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: -1px;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-form__input--textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-form__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-errors {
|
||||||
|
margin: var(--space-3) 0 0;
|
||||||
|
padding: var(--space-3) var(--space-5);
|
||||||
|
border: 1px solid var(--color-error);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: #fef2f2;
|
||||||
|
color: var(--color-error);
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-errors__item {
|
||||||
|
margin: var(--space-1) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Responsive ───────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.dashboard-grid,
|
.dashboard-grid,
|
||||||
.race-lists,
|
.race-lists,
|
||||||
@@ -416,4 +587,9 @@ a {
|
|||||||
.race-details-header {
|
.race-details-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.race-form__actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
--font-size-caption: 0.875rem;
|
--font-size-caption: 0.875rem;
|
||||||
--line-height-base: 1.5;
|
--line-height-base: 1.5;
|
||||||
|
|
||||||
|
--space-1: 0.25rem;
|
||||||
--space-2: 0.5rem;
|
--space-2: 0.5rem;
|
||||||
--space-3: 0.75rem;
|
--space-3: 0.75rem;
|
||||||
--space-4: 1rem;
|
--space-4: 1rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user