diff --git a/FRONTEND_PLAN.md b/FRONTEND_PLAN.md new file mode 100644 index 0000000..c5298e1 --- /dev/null +++ b/FRONTEND_PLAN.md @@ -0,0 +1,102 @@ +--- + +name: Frontend implementation plan +overview: Собрать минималистичный frontend для календаря забегов по UI-инструкции, строго в рамках текущего API-контракта, с отдельной внешней задачей на недостающие backend-поля для completed-забегов. +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 полем place и последующую интеграцию во frontend +status: pending +isProject: false + +--- + +# План frontend части Calendar Run + +## Исходные опоры + +- UI и UX принципы берём строго из [d:\vaka.pro\calendar_run\agent-frontend-ui-instructions.md](d:/vaka.pro/calendar_run/agent-frontend-ui-instructions.md): минимализм, воздух, акцент на данных, спокойная палитра, быстрые сценарии. +- Продуктовые ограничения и структура экранов сверяем с [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-забегов поле `место` обязательно по UX, но в API отсутствует: выделяем отдельную задачу другому агенту на расширение backend (`place`) и считаем это внешней зависимостью. + +## Архитектура 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`, `notes`. + - `place` — вывод включается после backend-расширения. +- PR блок: + - Дистанции: 5K, 10K, 21.1, 42.2 (согласно UI-инструкции). + - Расчёт по completed-забегам с валидным `finishTime`. +- Сравнение стартов (ключевая фича): + - Таблица/карточки по годам с `time`, `pace`, `place`. + - До появления `place` в API — graceful-degradation: колонка в состоянии «нет данных». + +## UX и визуальные требования + +- Визуальная система: светлый фон, белые карточки, один акцентный цвет, без кислотных сочетаний. +- Иерархия типографики: H1/H2/body/caption, крупные числовые метрики. +- Минимум визуального шума, 2–3 клика на частые действия. +- Консистентные состояния загрузки/ошибок/пустых данных. +- A11y-базис: фокус-стили, клавиатурная навигация, контраст, корректная разметка интерактивных элементов. + +## Отдельная зависимая задача (другой агент) + +- Создать отдельную backend-задачу: добавить поле `place` в модель `Race` (миграция + API + документация контракта). +- После доставки backend-изменения: обновить frontend-типы, формы и блок сравнения, заменить placeholder на реальное значение. + +## Порядок реализации + +1. Подготовить каркас frontend и дизайн-токены (BEM + CSS variables). +2. Реализовать API-клиент и типы данных с обработкой ошибок. +3. Собрать Dashboard и календарные списки (будущие/прошедшие). +4. Реализовать карточку старта с вычислением `pace` на клиенте. +5. Реализовать PR и блок сравнения стартов с fallback для `place`. +6. Добавить состояния пустых данных/ошибок/загрузки и а11y-полировку. +7. Подготовить handoff-note с зависимостью на backend-задачу (`place`) и интеграционным чеклистом. + +## Definition of Done для frontend + +- Все ключевые экраны из UI-инструкции доступны и консистентны визуально. +- API-интеграция работает по текущему контракту без локальных обходов хранилища. +- `pace` считается корректно для completed-забегов. +- `registered` не ломает модель: визуально интерпретируется в рамках `planned`. +- Для `place` есть явная внешняя задача и безопасный fallback в UI. \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 0d389ba..5849a18 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -20,6 +20,7 @@ "@types/node": "^22.12.0", "@types/pg": "^8.11.10", "@types/supertest": "^6.0.2", + "cross-env": "^10.1.0", "supertest": "^7.0.0", "ts-node": "^10.9.2", "tsx": "^4.19.2", @@ -39,6 +40,13 @@ "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": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -643,6 +651,7 @@ "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -936,6 +945,39 @@ "dev": true, "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": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz", @@ -1442,6 +1484,13 @@ "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": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -1585,6 +1634,16 @@ "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": { "version": "0.1.13", "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", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -1858,6 +1918,29 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -2124,6 +2207,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2173,6 +2257,22 @@ "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": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index f01bbba..39246cb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,7 +8,7 @@ "start": "node dist/index.js", "db:migrate": "ts-node src/migrate.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": { "cors": "^2.8.5", @@ -23,6 +23,7 @@ "@types/node": "^22.12.0", "@types/pg": "^8.11.10", "@types/supertest": "^6.0.2", + "cross-env": "^10.1.0", "supertest": "^7.0.0", "ts-node": "^10.9.2", "tsx": "^4.19.2", diff --git a/backend/src/app.ts b/backend/src/app.ts index faa94db..76ae6c0 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,4 +1,4 @@ -import express from "express"; +import express, { Request, Response, NextFunction } from "express"; import cors from "cors"; import { config } from "./config"; import healthRouter from "./routes/health"; @@ -13,5 +13,14 @@ export function createApp(): express.Express { app.use(healthRouter); 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; } diff --git a/backend/src/db.ts b/backend/src/db.ts index 6d8b4b2..a236eb0 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -69,6 +69,8 @@ function createMockPool(): Pool { fields: [], }) as QueryResult; + const store = new Map(); + const mockQuery = async ( text: string, params?: unknown[], @@ -76,11 +78,9 @@ function createMockPool(): Pool { const sql = text.replace(/\s+/g, " ").trim(); const p = params ?? []; - if (sql.includes("DELETE FROM races")) { - return emptyResult(); - } if (sql.includes("INSERT INTO races") && sql.includes("RETURNING")) { const row = mockRowFromInsert(text, p); + store.set(row.id, row); return { rows: [row as unknown as T], rowCount: 1, @@ -89,12 +89,49 @@ function createMockPool(): Pool { fields: [], } as QueryResult; } + + 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; + } + 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; } + + 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 + : emptyResult(); + } + 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; } + return emptyResult(); }; diff --git a/frontend/src/lib/raceMetrics.ts b/frontend/src/lib/raceMetrics.ts index 76d9228..476b152 100644 --- a/frontend/src/lib/raceMetrics.ts +++ b/frontend/src/lib/raceMetrics.ts @@ -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 { const today = new Date(now); today.setHours(0, 0, 0, 0); @@ -80,13 +96,7 @@ export function getRaceCountdownLabel(date: string, now: Date = new Date()): str if (days <= 0) { return "сегодня"; } - if (days === 1) { - return "через 1 день"; - } - if (days < 5) { - return `через ${days} дня`; - } - return `через ${days} дней`; + return `через ${days} ${pluralizeDays(days)}`; } export function isCloseDistance(left: number, right: number): boolean { diff --git a/frontend/src/pages/RacesPage.tsx b/frontend/src/pages/RacesPage.tsx index 0dc06a2..ae9d133 100644 --- a/frontend/src/pages/RacesPage.tsx +++ b/frontend/src/pages/RacesPage.tsx @@ -145,6 +145,12 @@ export function RacesPage(): JSX.Element {

Календарь стартов

Будущие и прошедшие старты в одном месте.

+ {errorMessage && !isLoading ? ( +

+ {errorMessage} +

+ ) : null} +