diff --git a/CHANGELOG.md b/CHANGELOG.md index fc0d972..4385504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,18 +7,20 @@ ## В работе -### Backend 1.5.0 +### Backend 1.6.0 #### Added - Управление паролем и сессиями: проверка текущего пароля, список активных сессий, отзыв выбранной или всех остальных сессий. +- Фильтрация стартов по статусу, периоду и дистанции; backfill и обновление автоматически получаемых обложек. -### Frontend 0.8.0 +### Frontend 0.9.0 #### Added - Экран «Аккаунт»: подтверждённый email, смена пароля и управление другими активными сессиями. - На дашборде появились фильтруемые «Последние старты» с пагинацией и переходом в старт, ссылки из карточек рекордов и интерактивный график прогресса. +- Поиск, фильтры, сортировка и экспорт истории; график прогресса переведён на AG Charts. ## Backend diff --git a/backend/migrations/007_cover_image_source.sql b/backend/migrations/007_cover_image_source.sql new file mode 100644 index 0000000..7b3e392 --- /dev/null +++ b/backend/migrations/007_cover_image_source.sql @@ -0,0 +1,2 @@ +ALTER TABLE races + ADD COLUMN IF NOT EXISTS cover_image_manual BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/backend/package-lock.json b/backend/package-lock.json index d8d849e..dd2fdc3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "calendar-run-backend", - "version": "1.5.3", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "calendar-run-backend", - "version": "1.5.3", + "version": "1.6.0", "dependencies": { "argon2": "^0.44.0", "cookie-parser": "^1.4.7", diff --git a/backend/package.json b/backend/package.json index 609a4fa..cdfc713 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "calendar-run-backend", - "version": "1.5.3", + "version": "1.6.0", "private": true, "scripts": { "build": "tsc", @@ -8,6 +8,7 @@ "start": "node dist/index.js", "db:migrate": "ts-node src/migrate.ts", "seed": "ts-node src/seed.ts", + "backfill:covers": "ts-node src/backfillRaceCovers.ts", "test": "tsx --import ./test/setup.ts --test test/app.test.ts" }, "dependencies": { diff --git a/backend/src/backfillRaceCovers.ts b/backend/src/backfillRaceCovers.ts new file mode 100644 index 0000000..56b9dba --- /dev/null +++ b/backend/src/backfillRaceCovers.ts @@ -0,0 +1,55 @@ +import { pool } from "./db"; +import { extractRaceCoverImage } from "./raceCoverImage"; + +type Candidate = { id: string; official_url: string }; + +export type CoverBackfillStats = { + candidates: number; + restored: number; + skipped: number; +}; + +export async function backfillRaceCovers(): Promise { + const { rows } = await pool.query( + `SELECT id, official_url + FROM races + WHERE cover_image_url IS NULL + AND official_url IS NOT NULL + AND BTRIM(official_url) <> '' + ORDER BY race_date ASC, id ASC`, + ); + const stats: CoverBackfillStats = { candidates: rows.length, restored: 0, skipped: 0 }; + + // ponytail: sequential network calls keep memory and outbound load bounded; parallelize per batch if volume requires it. + for (const row of rows) { + const coverImageUrl = await extractRaceCoverImage(row.official_url); + if (!coverImageUrl) { + stats.skipped += 1; + continue; + } + + const result = await pool.query( + `UPDATE races + SET cover_image_url = $1, cover_image_manual = FALSE, updated_at = NOW() + WHERE id = $2 AND cover_image_url IS NULL`, + [coverImageUrl, row.id], + ); + if (result.rowCount === 1) { + stats.restored += 1; + } else { + stats.skipped += 1; + } + } + + return stats; +} + +if (require.main === module) { + backfillRaceCovers() + .then((stats) => console.log(JSON.stringify(stats))) + .catch((error: unknown) => { + console.error("[backfill:covers] FAILED:", error instanceof Error ? error.message : error); + process.exitCode = 1; + }) + .finally(() => pool.end()); +} diff --git a/backend/src/mappers/race.ts b/backend/src/mappers/race.ts index 068eb7b..64f0a35 100644 --- a/backend/src/mappers/race.ts +++ b/backend/src/mappers/race.ts @@ -12,6 +12,7 @@ export interface RaceRow { status: string | null; official_url: string | null; cover_image_url: string | null; + cover_image_manual: boolean; start_time: string | null; cluster_schedule: string | null; bib_pickup: string | null; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 47925ef..1698a0b 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -20,7 +20,7 @@ import { clearSessionCookie, requireAuth, setSessionCookie } from "../authMiddle import { isValidPassword, normalizeEmail } from "../security"; import { verifyTurnstileToken } from "../turnstile"; -const router = Router(); +const router: Router = Router(); const genericOk = { ok: true }; const genericAuthError = { error: "invalid_credentials", details: ["Invalid email or password"] }; diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts index 2087d09..2e5c0fc 100644 --- a/backend/src/routes/health.ts +++ b/backend/src/routes/health.ts @@ -2,7 +2,7 @@ import { Router, Request, Response } from "express"; import { checkDbConnection } from "../db"; import { getBackendVersion } from "../version"; -const router = Router(); +const router: Router = Router(); router.get("/health", (_req: Request, res: Response) => { res.json({ status: "ok", version: getBackendVersion() }); diff --git a/backend/src/routes/races.ts b/backend/src/routes/races.ts index 3c2bae1..dded669 100644 --- a/backend/src/routes/races.ts +++ b/backend/src/routes/races.ts @@ -5,7 +5,7 @@ import { rowToDto, bodyToColumns, RaceRow } from "../mappers/race"; import { extractRaceCoverImage } from "../raceCoverImage"; import { requireAuth } from "../authMiddleware"; -const router = Router(); +const router: Router = Router(); router.use(requireAuth); type ValidationErrorBody = { @@ -136,12 +136,77 @@ function parseOptionalIntegerQuery( return { value: parsed }; } +function parseOptionalNumberQuery( + value: unknown, + fieldName: string, + min?: number, + max?: number, +): { value?: number; error?: string } { + if (value == null) { + return {}; + } + if (typeof value !== "string" || value.trim() === "") { + return { error: `${fieldName} must be a number` }; + } + + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return { error: `${fieldName} must be a number` }; + } + if (min != null && parsed < min) { + return { error: `${fieldName} must be between ${min} and ${max}` }; + } + if (max != null && parsed > max) { + return { error: `${fieldName} must be between ${min} and ${max}` }; + } + + return { value: parsed }; +} + +function parseOptionalDateQuery(value: unknown, fieldName: string): { value?: string; error?: string } { + if (value == null) { + return {}; + } + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return { error: `${fieldName} must be YYYY-MM-DD` }; + } + const parsed = new Date(`${value}T00:00:00.000Z`); + if (Number.isNaN(parsed.valueOf()) || parsed.toISOString().slice(0, 10) !== value) { + return { error: `${fieldName} must be a valid calendar date` }; + } + return { value }; +} + /* ─── GET /races ──────────────────────────────────────────── */ router.get("/races", async (req: Request, res: Response) => { const yearResult = parseOptionalIntegerQuery(req.query.year, "year"); const monthResult = parseOptionalIntegerQuery(req.query.month, "month", 1, 12); - const details = [yearResult.error, monthResult.error].filter(Boolean) as string[]; + const statusResult = req.query.status == null + ? {} + : typeof req.query.status === "string" && ["planned", "registered", "completed"].includes(req.query.status) + ? { value: req.query.status } + : { error: "status must be planned, registered or completed" }; + const dateFromResult = parseOptionalDateQuery(req.query.dateFrom, "dateFrom"); + const dateToResult = parseOptionalDateQuery(req.query.dateTo, "dateTo"); + const distanceMinResult = parseOptionalNumberQuery(req.query.distanceMin, "distanceMin", 0.001, 999.999); + const distanceMaxResult = parseOptionalNumberQuery(req.query.distanceMax, "distanceMax", 0.001, 999.999); + const details = [ + yearResult.error, + monthResult.error, + statusResult.error, + dateFromResult.error, + dateToResult.error, + distanceMinResult.error, + distanceMaxResult.error, + ].filter(Boolean) as string[]; + + if (dateFromResult.value && dateToResult.value && dateFromResult.value > dateToResult.value) { + details.push("dateFrom must not be after dateTo"); + } + if (distanceMinResult.value != null && distanceMaxResult.value != null && distanceMinResult.value > distanceMaxResult.value) { + details.push("distanceMin must not be greater than distanceMax"); + } if (details.length > 0) { validationError(res, details); @@ -164,6 +229,26 @@ router.get("/races", async (req: Request, res: Response) => { conditions.push(`EXTRACT(MONTH FROM race_date) = $${idx++}`); params.push(monthResult.value); } + if (statusResult.value != null) { + conditions.push(`status = $${idx++}`); + params.push(statusResult.value); + } + if (dateFromResult.value != null) { + conditions.push(`race_date >= $${idx++}`); + params.push(dateFromResult.value); + } + if (dateToResult.value != null) { + conditions.push(`race_date <= $${idx++}`); + params.push(dateToResult.value); + } + if (distanceMinResult.value != null) { + conditions.push(`distance_km >= $${idx++}`); + params.push(distanceMinResult.value); + } + if (distanceMaxResult.value != null) { + conditions.push(`distance_km <= $${idx++}`); + params.push(distanceMaxResult.value); + } const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; const sql = `SELECT * FROM races ${where} ORDER BY race_date ASC`; @@ -220,6 +305,8 @@ router.post("/races", async (req: Request, res: Response) => { const { columns, values } = bodyToColumns(payload as Record); columns.unshift("owner_user_id"); values.unshift(req.auth!.user.id); + columns.push("cover_image_manual"); + values.push(hasManualCover); const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); const sql = `INSERT INTO races (${columns.join(", ")}) VALUES (${placeholders}) RETURNING *`; @@ -251,15 +338,43 @@ router.patch("/races/:id", async (req: Request, res: Response) => { validationError(res, schemaDetails(parsed.error)); return; } - const { columns, values } = bodyToColumns(parsed.data as Record); - - const sets = columns.map((col, i) => `${col} = $${i + 1}`); - sets.push(`updated_at = NOW()`); - values.push(id); - values.push(req.auth!.user.id); - const sql = `UPDATE races SET ${sets.join(", ")} WHERE id = $${values.length - 1} AND owner_user_id = $${values.length} RETURNING *`; - try { + const currentResult = await pool.query>( + "SELECT official_url, cover_image_url, cover_image_manual FROM races WHERE id = $1 AND owner_user_id = $2", + [id, req.auth!.user.id], + ); + if (currentResult.rows.length === 0) { + res.status(404).json({ error: "not_found", details: ["Race not found"] }); + return; + } + + const payload = { ...parsed.data }; + const officialUrlChanged = Object.prototype.hasOwnProperty.call(payload, "officialUrl"); + const current = currentResult.rows[0]!; + const hasCoverField = Object.prototype.hasOwnProperty.call(payload, "coverImageUrl"); + const sameAsExistingCover = typeof payload.coverImageUrl === "string" && payload.coverImageUrl === current.cover_image_url; + const manualCoverChanged = typeof payload.coverImageUrl === "string" && payload.coverImageUrl.trim() !== "" && !sameAsExistingCover; + let coverImageManual = current.cover_image_manual; + if (hasCoverField && !sameAsExistingCover) { + coverImageManual = manualCoverChanged; + } + const officialUrl = payload.officialUrl === undefined ? current.official_url : payload.officialUrl; + if (officialUrlChanged && !current.cover_image_manual && !manualCoverChanged && typeof officialUrl === "string" && officialUrl.trim() !== "") { + payload.coverImageUrl = await extractRaceCoverImage(officialUrl); + coverImageManual = false; + } + + const { columns, values } = bodyToColumns(payload as Record); + if (hasCoverField || officialUrlChanged) { + columns.push("cover_image_manual"); + values.push(coverImageManual); + } + const sets = columns.map((col, i) => `${col} = $${i + 1}`); + sets.push(`updated_at = NOW()`); + values.push(id); + values.push(req.auth!.user.id); + const sql = `UPDATE races SET ${sets.join(", ")} WHERE id = $${values.length - 1} AND owner_user_id = $${values.length} RETURNING *`; + const { rows } = await pool.query(sql, values); if (rows.length === 0) { res.status(404).json({ error: "not_found", details: ["Race not found"] }); diff --git a/backend/test/app.test.ts b/backend/test/app.test.ts index efaa9c0..23bcca5 100644 --- a/backend/test/app.test.ts +++ b/backend/test/app.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { after, test } from "node:test"; import request from "supertest"; import { buildHelmetOptions, createApp } from "../src/app"; +import { backfillRaceCovers } from "../src/backfillRaceCovers"; import { cleanupExpiredAuthRows, createResetToken, @@ -161,6 +162,29 @@ test("GET /api/races accepts year and month", async () => { assert.ok(Array.isArray(res.body)); }); +test("GET /api/races filters by status, date and distance", async () => { + const { agent, csrfToken } = await authAgent(); + await agent.post("/api/races").set("X-CSRF-Token", csrfToken).send({ + slug: "2026-05-01-filter-planned", date: "2026-05-01", title: "Planned 5", distanceKm: 5, status: "planned", + }).expect(201); + await agent.post("/api/races").set("X-CSRF-Token", csrfToken).send({ + slug: "2026-06-01-filter-completed", date: "2026-06-01", title: "Completed 10", distanceKm: 10, status: "completed", + }).expect(201); + await agent.post("/api/races").set("X-CSRF-Token", csrfToken).send({ + slug: "2026-07-01-filter-completed", date: "2026-07-01", title: "Completed 21", distanceKm: 21.1, status: "completed", + }).expect(201); + + const res = await agent.get("/api/races?status=completed&dateFrom=2026-06-01&dateTo=2026-06-30&distanceMax=10").expect(200); + assert.deepEqual(res.body.map((race: { title: string }) => race.title), ["Completed 10"]); +}); + +test("GET /api/races rejects inverted filter ranges", async () => { + const { agent } = await authAgent(); + const res = await agent.get("/api/races?dateFrom=2026-07-01&dateTo=2026-06-01").expect(400); + assert.equal(res.body.error, "validation_error"); + assert.ok(res.body.details.some((detail: string) => detail.includes("dateFrom"))); +}); + test("GET /api/races/:id rejects a non-UUID id", async () => { const { agent } = await authAgent(); const res = await agent.get("/api/races/does-not-exist").expect(400); @@ -460,6 +484,39 @@ test("POST /api/races stores manual coverImageUrl", async () => { assert.equal(res.body.coverImageUrl, coverImageUrl); }); +test("PATCH /api/races refreshes an automatic cover but preserves a manual cover", async () => { + const originalFetch = globalThis.fetch; + let imageName = "first.jpg"; + globalThis.fetch = async () => new Response(``, { + status: 200, + headers: { "content-type": "text/html" }, + }); + + try { + const { agent, csrfToken } = await authAgent(); + const created = await agent.post("/api/races").set("X-CSRF-Token", csrfToken).send({ + slug: "2026-06-06-auto-refresh", date: "2026-06-06", title: "Auto Refresh", distanceKm: 10, officialUrl: "https://example.com/first", + }).expect(201); + imageName = "second.jpg"; + const refreshed = await agent.patch(`/api/races/${created.body.id}`).set("X-CSRF-Token", csrfToken).send({ + officialUrl: "https://example.com/second", + }).expect(200); + assert.equal(refreshed.body.coverImageUrl, "https://example.com/second.jpg"); + + const manual = await agent.patch(`/api/races/${created.body.id}`).set("X-CSRF-Token", csrfToken).send({ + officialUrl: "https://example.com/third", coverImageUrl: "https://example.com/manual.jpg", + }).expect(200); + imageName = "fourth.jpg"; + const preserved = await agent.patch(`/api/races/${created.body.id}`).set("X-CSRF-Token", csrfToken).send({ + officialUrl: "https://example.com/fourth", + }).expect(200); + assert.equal(manual.body.coverImageUrl, "https://example.com/manual.jpg"); + assert.equal(preserved.body.coverImageUrl, "https://example.com/manual.jpg"); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("POST /api/races rejects unknown and malformed fields", async () => { const { agent, csrfToken } = await authAgent(); const res = await agent @@ -526,6 +583,58 @@ test("POST /api/races auto extracts coverImageUrl from officialUrl", async () => } }); +test("PATCH /api/races auto extracts a missing cover when officialUrl changes", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response("", { status: 404 }); + + try { + const { agent, csrfToken } = await authAgent(); + const created = await agent + .post("/api/races") + .set("X-CSRF-Token", csrfToken) + .send({ slug: "2026-06-04-patch-cover", date: "2026-06-04", title: "Patch Cover", distanceKm: 10 }) + .expect(201); + + globalThis.fetch = async () => new Response('', { + status: 200, + headers: { "content-type": "text/html" }, + }); + const updated = await agent + .patch(`/api/races/${created.body.id}`) + .set("X-CSRF-Token", csrfToken) + .send({ officialUrl: "https://example.com/patched" }) + .expect(200); + + assert.equal(updated.body.coverImageUrl, "https://example.com/patched.jpg"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("cover backfill restores only races without a cover", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response('', { + status: 200, + headers: { "content-type": "text/html" }, + }); + + try { + const { agent, csrfToken } = await authAgent(); + await agent.post("/api/races").set("X-CSRF-Token", csrfToken).send({ + slug: "2026-06-05-backfill", date: "2026-06-05", title: "Backfill", distanceKm: 10, + }).expect(201); + await pool.query("UPDATE races SET official_url = $1 WHERE slug = $2", ["https://example.com/backfill", "2026-06-05-backfill"]); + + const stats = await backfillRaceCovers(); + assert.ok(stats.candidates >= 1); + assert.ok(stats.restored >= 1); + const result = await pool.query<{ cover_image_url: string }>("SELECT cover_image_url FROM races WHERE slug = $1", ["2026-06-05-backfill"]); + assert.equal(result.rows[0]?.cover_image_url, "https://example.com/backfill.jpg"); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("POST /api/races succeeds when cover extraction fails", async () => { const originalFetch = globalThis.fetch; globalThis.fetch = async () => { diff --git a/docs/backend-api-for-frontend.md b/docs/backend-api-for-frontend.md index 2114b37..3add33c 100644 --- a/docs/backend-api-for-frontend.md +++ b/docs/backend-api-for-frontend.md @@ -85,8 +85,10 @@ Liveness без проверки БД: ### `GET /api/races` Возвращает только забеги текущего пользователя, отсортированные по дате. Опциональные -query-параметры: `year` (целое число) и `month` (1–12); без результатов возвращается -`[]`. +query-параметры: `year` (целое число), `month` (1–12), `status` (`planned`, +`registered` или `completed`), `dateFrom`/`dateTo` (`YYYY-MM-DD`) и +`distanceMin`/`distanceMax` (километры, от `0.001` до `999.999`). Диапазоны не +могут быть перевёрнуты; без результатов возвращается `[]`. ### `GET /api/races/:id` @@ -124,6 +126,13 @@ query-параметры: `year` (целое число) и `month` (1–12); б Удаляет забег. Успех: `204` без тела. +При изменении `officialUrl` backend best-effort получает обложку, если у старта +ещё нет обложки. Ручная `coverImageUrl` не перезаписывается. + +Одноразовое восстановление обложек существующих стартов запускается командой +`npm run backfill:covers`. Команда идемпотентна и печатает только агрегированную +статистику. + ## Модель `Race` | Поле | Тип | POST | PATCH | Ограничение | diff --git a/docs/product-backlog.md b/docs/product-backlog.md index 7cc7994..6e84ea7 100644 --- a/docs/product-backlog.md +++ b/docs/product-backlog.md @@ -6,8 +6,8 @@ | Контур | Версия | Уже работает | |---|---:|---| -| Backend | `1.5.0` | Express + PostgreSQL, миграции и seed, строгий CRUD личных стартов, cookie-сессии, CSRF, подтверждение email, Turnstile, SMTP, смена пароля и управление сессиями, SSRF-защита обложек, `/health`, `/ready`, `/meta`, CI с PostgreSQL. | -| Frontend | `0.8.0` | Защищённые маршруты и auth-экраны, экран аккаунта, нативные поля даты/старта, удобный ввод результатов, дашборд с последними стартами и интерактивным графиком, версия FE/BE в футере. | +| Backend | `1.6.0` | Express + PostgreSQL, миграции и seed, строгий CRUD личных стартов, фильтры истории, cookie-сессии, CSRF, подтверждение email, Turnstile, SMTP, смена пароля и управление сессиями, SSRF-защита и восстановление обложек, `/health`, `/ready`, `/meta`, CI с PostgreSQL. | +| Frontend | `0.9.0` | Защищённые маршруты и auth-экраны, экран аккаунта, поиск/фильтры/экспорт истории, нативные поля даты/старта, удобный ввод результатов, дашборд с последними стартами и графиком AG Charts, версия FE/BE в футере. | ## Правила версионирования и выпуска @@ -21,9 +21,9 @@ | Очередь | BE | FE | Цель | Условие выпуска | |---|---:|---:|---|---| | P0 | `1.4.2` | `0.7.1` | Исправить риски текущего релиза и привести контракт в соответствие коду. | Нет невалидных записей, SSRF-защита, критические сценарии проверяются на PostgreSQL. | -| P1 | `1.5.0` | `0.8.0` | Дать пользователю контроль над аккаунтом и данными, улучшить работу с историей стартов. | Управление сессиями и экспорт доступны, фильтры не ломают существующий список. | -| P2 | `1.6.0` | `0.9.0` | Подготовка к старту: чек-листы, напоминания и календарная интеграция. | Напоминание отправляется не более одного раза, подписка/токен отзываемы. | -| P3 | `1.7.0` | `1.0.0` | Сделать персональный календарь эксплуатационно готовым. | Восстановление из backup проверено, есть E2E критического пути и аудит доступности. | +| P1 | `1.6.0` | `0.9.0` | Дать пользователю контроль над аккаунтом и данными, улучшить работу с историей стартов. | Управление сессиями и экспорт доступны, фильтры не ломают существующий список. | +| P2 | `1.7.0` | `1.0.0` | Подготовка к старту: чек-листы, напоминания и календарная интеграция. | Напоминание отправляется не более одного раза, подписка/токен отзываемы. | +| P3 | `1.8.0` | `1.1.0` | Сделать персональный календарь эксплуатационно готовым. | Восстановление из backup проверено, есть E2E критического пути и аудит доступности. | `2.0.0` не планируется заранее: его поводом может быть только подтверждённая потребность в совместном доступе, командах или публичных стартах — это изменит модель владельцев и доступа. @@ -51,32 +51,33 @@ | FE-071-05 | P0 | Завершать подтверждение email понятным экраном успеха | После успешной ссылки `/verify-email?token=…` заголовок явно сообщает, что email подтверждён; ввод email, инструкция и кнопка повторной отправки не показываются. Форма повторной отправки остаётся только для недействительной/отсутствующей ссылки. | | FE-071-06 | P0 | Упростить ввод результата забега | Время вводится отдельными цифровыми полями «часы», «минуты», «секунды» без ручного ввода `:`. Часы — целое число ≥ 0, минуты и секунды — от 0 до 59; пустые минуты/секунды автоматически нормализуются в `0`. Итоговое время не показывает незаполненные старшие части: без часов — `MM:SS`, без часов и минут — `SS`. Место вводится числами «место» и «всего участников» (второе необязательно), проверяется, что место ≥ 1 и не больше общего числа; в текущий `finishPlace` без изменения API сохраняется `место/всего` либо только место. Старые значения `finishPlace` не теряются до редактирования результата. | -## P1 — BE 1.5.0 / FE 0.8.0 +## P1 — BE 1.6.0 / FE 0.9.0 -### Backend 1.5.0 +### Backend 1.6.0 | ID | Приоритет | Задача | Результат / критерий приёмки | |---|---|---|---| | BE-150-01 | Готово | Управление паролем и сессиями | Выполнено в `1.5.0`: авторизованный пользователь меняет пароль после проверки текущего; все другие сессии отзываются. Есть список активных сессий с отзывом выбранной и «выйти везде». Токены и пароли в ответах/логах не появляются. | | BE-150-02 | P1 | Удаление аккаунта и данных | Явно подтверждённый запрос удаляет аккаунт, его сессии и личные старты транзакционно; повторный запрос идемпотентен. Перед реализацией согласовать необходимость периода восстановления. | -| BE-150-03 | P1 | Обратносуместимая фильтрация стартов | Добавить документированные фильтры `status`, диапазон дат и диапазон дистанции, не меняя текущий ответ-массив `GET /races`. Валидация параметров и покрытие индексами — по фактическому query plan. | -| BE-150-04 | P0 | Восстановить обложки существующих стартов | Одноразовый идемпотентный backfill безопасно извлекает `cover_image_url` из сохранённого `official_url` только для стартов без обложки. Та же существующая SSRF-защищённая логика применяется при создании старта и при изменении официальной страницы, если ручная обложка не задана; ручные URL не перезаписываются. Повторный запуск ничего не меняет, недоступные сайты пропускаются, результат содержит только агрегированную статистику. Каталог соответствий по названиям не возвращается. | +| BE-150-03 | Готово | Обратносуместимая фильтрация стартов | Добавлены документированные фильтры `status`, диапазон дат и диапазон дистанции без изменения ответа-массива `GET /races`, с валидацией параметров. | +| BE-150-04 | Готово | Восстановить обложки существующих стартов | Добавлен идемпотентный backfill и обновление обложки при изменении официальной страницы. SSRF-защищённая логика переиспользуется; ручные URL не перезаписываются, результат содержит агрегированную статистику. | -### Frontend 0.8.0 +### Frontend 0.9.0 | ID | Приоритет | Задача | Результат / критерий приёмки | |---|---|---|---| | FE-080-01 | Готово | Экран «Аккаунт» | Выполнено в `0.8.0`: пользователь видит подтверждённый email, меняет пароль и видит/отзывает другие сессии. Удаление аккаунта остаётся отдельной задачей BE-150-02. | -| FE-080-02 | P1 | Поиск, фильтры и сортировка истории | Фильтры по статусу, периоду и дистанции синхронизированы с URL; поиск по названию работает без новой зависимости. Годы в селекторе строятся из данных, а не ограничены окном «-2/+4». | -| FE-080-03 | P1 | Выгрузка личных данных | Из текущей отфильтрованной выборки пользователь скачивает CSV и одноразовый `.ics` в браузере. Сервер не нужен: данные уже принадлежат пользователю и загружены в UI. | +| FE-080-02 | Готово | Поиск, фильтры и сортировка истории | Фильтры по статусу, периоду и дистанции, поиск по названию и сортировка синхронизированы с URL. Годы в селекторе строятся из данных. | +| FE-080-03 | Готово | Выгрузка личных данных | Из текущей отфильтрованной выборки пользователь скачивает CSV и одноразовый `.ics` в браузере; сервер не нужен. | | FE-080-04 | Готово | Сделать на дашборде список последних стартов | Выполнено в `0.8.0`: «Последние старты» показывают до 5 завершённых стартов с клиентской пагинацией, фильтрами по дистанции и году и доступным переходом в старт. | | FE-080-05 | Готово | Связать рекорды по дистанциям со стартами | Выполнено в `0.8.0`: карточка рекорда с найденным стартом ведёт на `/races/:id` и использует существующую анимацию связанных карточек. | | FE-080-06 | Готово | Сделать график прогресса информативным и интерактивным | Выполнено в `0.8.0`: график имеет подписанные оси, компактные подписи, tooltip по наведению/фокусу и доступный переход в старт. | -| FE-080-07 | P0 | Вернуть управление официальной страницей старта | Поле «Официальная страница старта» отображается при создании и редактировании любого старта, включая прошедший. Для прошедших стартов скрываются только неактуальные поля расписания. После добавления или изменения официальной страницы и сохранения backend автоматически заполняет отсутствующую обложку; локальная картинка остаётся fallback только при отсутствии или ошибке внешней обложки. | +| FE-080-07 | Готово | Вернуть управление официальной страницей старта | Поле «Официальная страница старта» доступно для любого старта, включая прошедший; для прошедших скрываются только неактуальные поля расписания. | +| FE-080-08 | Готово | Перевести график прогресса на AG Charts | Самописный SVG заменён на AG Charts; сохранены данные, оси, tooltip, клавиатурная доступность, переход по точке, адаптивность и пустое состояние. | -## P2 — BE 1.6.0 / FE 0.9.0 +## P2 — BE 1.7.0 / FE 1.0.0 -### Backend 1.6.0 +### Backend 1.7.0 | ID | Приоритет | Задача | Результат / критерий приёмки | |---|---|---|---| @@ -84,7 +85,7 @@ | BE-160-02 | P1 | Надёжные email-напоминания | Настройки пользователя и очередь напоминаний хранятся в БД; отправка идемпотентна, ошибки ретраятся ограниченно и логируются без PII. Расписание запуска — внешний cron/worker, а не `setInterval` в каждом web-инстансе. | | BE-160-03 | P2 | Подписка на персональный iCalendar | Выдать отдельный отзывной секретный URL только для read-only календаря пользователя. Нужны лимитирование, отсутствие email/заметок в ICS и отзыв/ротация токена. | -### Frontend 0.9.0 +### Frontend 1.0.0 | ID | Приоритет | Задача | Результат / критерий приёмки | |---|---|---|---| @@ -92,9 +93,9 @@ | FE-090-02 | P1 | Настройки напоминаний | Опт-ин по email и выбор сроков до старта; понятно отображаются часовой пояс и факт отключения рассылки. | | FE-090-03 | P2 | Управление iCalendar-подпиской | Экран показывает URL только по явному действию, даёт скопировать, отозвать и выпустить новый. Одноразовый экспорт из `0.8.0` остаётся доступным без подписки. | -## P3 — BE 1.7.0 / FE 1.0.0 +## P3 — BE 1.8.0 / FE 1.1.0 -### Backend 1.7.0 +### Backend 1.8.0 | ID | Приоритет | Задача | Результат / критерий приёмки | |---|---|---|---| @@ -102,7 +103,7 @@ | BE-170-02 | P0 | Наблюдаемость без утечки данных | Структурированные логи с request ID, готовность БД, счётчики ошибок и alert на недоступность. Email, пароли, токены, cookie и текст заметок не логируются. | | BE-170-03 | P1 | Машиночитаемый API-контракт | OpenAPI-спецификация синхронизирована с маршрутам и используется в CI для проверки ключевых ответов. Это предотвращает повторное расхождение документации и реализации. | -### Frontend 1.0.0 +### Frontend 1.1.0 | ID | Приоритет | Задача | Результат / критерий приёмки | |---|---|---|---| @@ -132,3 +133,14 @@ 2. `BE-142-01` и `BE-142-02`: зафиксировать настоящий контракт и закрыть trust boundary. 3. `BE-142-03`: убрать SSRF-риск до любого расширения автозагрузки обложек. 4. `FE-071-01`–`FE-071-06`: устранить неверную аналитику и ошибки критических форм, включая понятное завершение подтверждения email и ввод результата. + +## Выполненный спринт + +**Цель:** пользователь быстро находит старты, восстанавливает данные истории, выгружает результат и видит прогресс в поддерживаемой chart-визуализации. + +1. `BE-150-04` + `FE-080-07`: восстановление обложек существующих стартов и управление официальной страницей, включая прошедшие старты. Ручные обложки не перезаписываются; используется существующая SSRF-защищённая логика. +2. `BE-150-03` + `FE-080-02`: фильтры по статусу, периоду и дистанции, поиск по названию, сортировка и синхронизация с URL. Обновить API-документацию и контрактные тесты. +3. `FE-080-03`: экспорт текущей отфильтрованной выборки в CSV и одноразовый `.ics` без нового backend и зависимостей. +4. `FE-080-08`: заменить самописный SVG-график прогресса на AG Charts. Сохранить текущие данные и метрики, подписи осей, tooltip, клавиатурную доступность, переход по точке в старт, адаптивность и пустое состояние. Не менять API и не добавлять новую обёртку поверх `PaceTrendChart`. + +`BE-150-02` в спринт не брать до решения: удаление аккаунта сразу или с 30-дневным восстановлением. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8ec30d6..2b5ea77 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,13 +1,15 @@ { "name": "calendar-run-frontend", - "version": "0.8.2", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "calendar-run-frontend", - "version": "0.8.2", + "version": "0.9.0", "dependencies": { + "ag-charts-community": "^14.0.0", + "ag-charts-react": "^14.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.30.1" @@ -1221,6 +1223,50 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/ag-charts-community": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/ag-charts-community/-/ag-charts-community-14.0.0.tgz", + "integrity": "sha512-u/O7T/EGxj8s6cZjbYU1CaRObnjMzdfXZVido9uXbeAwBkT4rxXF6OWSq1SkkSrZD1+pwusHn3cbul4Ly3yzPA==", + "license": "MIT", + "dependencies": { + "ag-charts-core": "14.0.0", + "ag-charts-locale": "14.0.0", + "ag-charts-types": "14.0.0" + } + }, + "node_modules/ag-charts-core": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/ag-charts-core/-/ag-charts-core-14.0.0.tgz", + "integrity": "sha512-xcFSQ0Ces8JvH5RpKoZIjrFzdvju91gIXODj33Byg6ShwkixrHyLaCf17addfSsqerSSdMkw0iiceD9afU83UQ==", + "license": "MIT", + "dependencies": { + "ag-charts-types": "14.0.0" + } + }, + "node_modules/ag-charts-locale": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/ag-charts-locale/-/ag-charts-locale-14.0.0.tgz", + "integrity": "sha512-14mGnsQgG7VzdQwt7Jq2MFPGPtO8y6jAECN4nae7t3fJAPq162mmrgDaqpwHdAZxKPkMNte98DUQ8ULrtSbUpQ==", + "license": "MIT" + }, + "node_modules/ag-charts-react": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/ag-charts-react/-/ag-charts-react-14.0.0.tgz", + "integrity": "sha512-PTwfdmyFbePzaKHuGWfB2o8KNXO5wR5fOCyUCRqdUNnCEK8cBPwlDsfe28CRsevQSLPZqTNpW2y5qJhHC4q6EQ==", + "license": "MIT", + "dependencies": { + "ag-charts-community": "14.0.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/ag-charts-types": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-14.0.0.tgz", + "integrity": "sha512-89X4J/OeOTzTp505126JayGEn3k6QUKz3UHSR0/ggoVZYzVqtMN13k/B136S1+YBwgnzyJ/NARnnIRTPWsWG9g==", + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.15", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.15.tgz", diff --git a/frontend/package.json b/frontend/package.json index b0bad8c..da8a584 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "calendar-run-frontend", "private": true, - "version": "0.8.2", + "version": "0.9.0", "type": "module", "scripts": { "dev": "vite", @@ -9,6 +9,8 @@ "preview": "vite preview" }, "dependencies": { + "ag-charts-community": "^14.0.0", + "ag-charts-react": "^14.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.30.1" diff --git a/frontend/src/api/races.ts b/frontend/src/api/races.ts index 1486146..63549ff 100644 --- a/frontend/src/api/races.ts +++ b/frontend/src/api/races.ts @@ -47,24 +47,25 @@ function normalizeRace(input: unknown): Race { }); } + const validRace = race as Race; return { - id: race.id, - slug: race.slug, - date: race.date, - title: race.title, - distanceKm: race.distanceKm, - status: race.status, - officialUrl: race.officialUrl, - coverImageUrl: race.coverImageUrl ?? null, - startTime: race.startTime, - clusterSchedule: race.clusterSchedule, - bibPickup: race.bibPickup, - bibNumber: race.bibNumber, - finishTime: race.finishTime, - finishPlace: race.finishPlace, - notes: race.notes, - createdAt: race.createdAt, - updatedAt: race.updatedAt, + id: validRace.id, + slug: validRace.slug, + date: validRace.date, + title: validRace.title, + distanceKm: validRace.distanceKm, + status: validRace.status, + officialUrl: validRace.officialUrl, + coverImageUrl: validRace.coverImageUrl ?? null, + startTime: validRace.startTime, + clusterSchedule: validRace.clusterSchedule, + bibPickup: validRace.bibPickup, + bibNumber: validRace.bibNumber, + finishTime: validRace.finishTime, + finishPlace: validRace.finishPlace, + notes: validRace.notes, + createdAt: validRace.createdAt, + updatedAt: validRace.updatedAt, }; } @@ -80,6 +81,21 @@ function buildRacesQuery(query?: RacesQuery): string { if (typeof query.month === "number") { params.set("month", String(query.month)); } + if (query.status) { + params.set("status", query.status); + } + if (query.dateFrom) { + params.set("dateFrom", query.dateFrom); + } + if (query.dateTo) { + params.set("dateTo", query.dateTo); + } + if (typeof query.distanceMin === "number") { + params.set("distanceMin", String(query.distanceMin)); + } + if (typeof query.distanceMax === "number") { + params.set("distanceMax", String(query.distanceMax)); + } const serialized = params.toString(); return serialized ? `?${serialized}` : ""; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 1b0a23a..c7007c5 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -23,6 +23,11 @@ export interface Race { export interface RacesQuery { year?: number; month?: number; + status?: RaceStatus; + dateFrom?: string; + dateTo?: string; + distanceMin?: number; + distanceMax?: number; } export interface CreateRacePayload { diff --git a/frontend/src/components/PaceTrendChart.tsx b/frontend/src/components/PaceTrendChart.tsx index 3d8a508..0f9e532 100644 --- a/frontend/src/components/PaceTrendChart.tsx +++ b/frontend/src/components/PaceTrendChart.tsx @@ -1,4 +1,6 @@ -import { useState } from "react"; +import { AgCharts } from "ag-charts-react"; +import type { AgChartOptions } from "ag-charts-community"; +import { useMemo } from "react"; import { useNavigate } from "react-router-dom"; import type { Race } from "../api"; import { formatRaceDate, getPaceLabel, isCloseDistance, parseFinishTimeToSeconds, parseRaceDate } from "../lib"; @@ -8,6 +10,14 @@ type PaceTrendChartProps = { distanceKm: number; }; +type PaceDatum = { + id: string; + date: string; + dateLabel: string; + minutes: number; + race: Race; +}; + function formatAxisTime(minutes: number): string { const totalSeconds = Math.round(minutes * 60); const hours = Math.floor(totalSeconds / 3600); @@ -19,89 +29,86 @@ function formatShortDate(date: string): string { return parseRaceDate(date).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" }); } +function cssToken(name: string, fallback: string): string { + if (typeof window === "undefined") { + return fallback; + } + return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; +} + /** Линейный график: время финиша по завершённым стартам выбранной дистанции. */ export function PaceTrendChart({ races, distanceKm }: PaceTrendChartProps): JSX.Element { const navigate = useNavigate(); - const [activeId, setActiveId] = useState(null); - const series = races - .filter((race) => race.status === "completed" && isCloseDistance(race.distanceKm, distanceKm) && parseFinishTimeToSeconds(race.finishTime) != null) - .sort((a, b) => parseRaceDate(a.date).getTime() - parseRaceDate(b.date).getTime()) - .map((race) => ({ race, minutes: parseFinishTimeToSeconds(race.finishTime)! / 60 })); + const series = useMemo( + () => races + .filter((race) => race.status === "completed" && isCloseDistance(race.distanceKm, distanceKm) && parseFinishTimeToSeconds(race.finishTime) != null) + .sort((a, b) => parseRaceDate(a.date).getTime() - parseRaceDate(b.date).getTime()) + .map((race) => ({ + id: race.id, + date: race.date, + dateLabel: formatShortDate(race.date), + minutes: parseFinishTimeToSeconds(race.finishTime)! / 60, + race, + })), + [distanceKm, races], + ); + + const options = useMemo>(() => ({ + data: series, + autoSize: true, + minHeight: 280, + padding: { top: 16, right: 20, bottom: 16, left: 16 }, + background: { fill: "transparent" }, + animation: { enabled: false }, + keyboard: { enabled: true, tabIndex: 0, initialFocus: "data-start" }, + axes: { + x: { type: "category", position: "bottom", title: { text: "Дата старта" } }, + y: { + type: "number", + position: "left", + title: { text: "Время" }, + label: { formatter: ({ value }) => formatAxisTime(Number(value)) }, + }, + }, + series: [{ + type: "line", + xKey: "dateLabel", + xName: "Дата старта", + yKey: "minutes", + yName: "Время", + stroke: cssToken("--color-accent", "#1168d8"), + strokeWidth: 3, + marker: { + enabled: true, + size: 8, + fill: cssToken("--color-surface", "#ffffff"), + stroke: cssToken("--color-accent", "#1168d8"), + strokeWidth: 2, + }, + tooltip: { + renderer: ({ datum }) => ({ + title: datum.race.title, + data: [ + { label: "Дата", value: formatRaceDate(datum.date) }, + { label: "Время", value: datum.race.finishTime ?? "—" }, + { label: "Темп", value: getPaceLabel(datum.race.finishTime, datum.race.distanceKm) ?? "—" }, + ], + }), + }, + }], + listeners: { + seriesNodeClick: ({ datum }) => navigate(`/races/${datum.race.id}`), + }, + }), [navigate, series]); if (series.length < 2) { return

Нужно минимум два завершённых старта с временем на выбранной дистанции.

; } - const values = series.map((item) => item.minutes); - const min = Math.min(...values); - const max = Math.max(...values); - const range = max - min || 1; - const w = 100; - const h = 58; - const margin = { top: 6, right: 4, bottom: 14, left: 17 }; - const innerW = w - margin.left - margin.right; - const innerH = h - margin.top - margin.bottom; - const pointAt = (item: (typeof series)[number], index: number) => ({ - x: margin.left + (index / (series.length - 1)) * innerW, - y: margin.top + ((max - item.minutes) / range) * innerH, - }); - const points = series.map(pointAt); - const line = points.map(({ x, y }) => `${x},${y}`).join(" "); - const yTicks = [max, (max + min) / 2, min]; - const labelStep = Math.max(1, Math.ceil(series.length / 6)); - const active = series.find((item) => item.race.id === activeId) ?? null; - return ( -
- - Время - {yTicks.map((tick) => { - const y = margin.top + ((max - tick) / range) * innerH; - return ( - - - {formatAxisTime(tick)} - - ); - })} - - - - {series.map((item, index) => { - const point = points[index]!; - const isLabeled = index % labelStep === 0 || index === series.length - 1; - return ( - setActiveId(item.race.id)} - onMouseLeave={() => setActiveId(null)} - onFocus={() => setActiveId(item.race.id)} - onBlur={() => setActiveId(null)} - onClick={() => navigate(`/races/${item.race.id}`)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - navigate(`/races/${item.race.id}`); - } - }} - > - - {isLabeled ? {formatShortDate(item.race.date)} : null} - - ); - })} - Дата старта - - {active ? ( -
- {active.race.title} - {formatRaceDate(active.race.date)} · {active.race.finishTime} · {getPaceLabel(active.race.finishTime, active.race.distanceKm)} -
- ) :

Наведите на точку или выберите её клавиатурой.

} +
+ +

Наведите на точку или выберите её клавиатурой.

); } diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts index babc74c..9d8b6e3 100644 --- a/frontend/src/lib/index.ts +++ b/frontend/src/lib/index.ts @@ -19,3 +19,4 @@ export { export { buildMonthCells, groupRacesByYmd, toYmd, WEEKDAY_LABELS_SHORT_RU } from "./calendarUtils"; export { getRaceVisual } from "./raceVisuals"; export type { RaceVisualVariant } from "./raceVisuals"; +export { downloadTextFile, racesToCsv, racesToIcs } from "./raceExport"; diff --git a/frontend/src/lib/raceExport.ts b/frontend/src/lib/raceExport.ts new file mode 100644 index 0000000..684bf97 --- /dev/null +++ b/frontend/src/lib/raceExport.ts @@ -0,0 +1,60 @@ +import type { Race } from "../api"; + +function escapeCsvCell(value: string | number | null): string { + const text = value == null ? "" : String(value); + return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +function escapeIcsText(value: string): string { + return value.replace(/[\\;,\n]/g, (character) => ({ "\\": "\\\\", ";": "\\;", ",": "\\,", "\n": "\\n" })[character]!); +} + +function toIcsDate(date: string): string { + return date.slice(0, 10).replace(/-/g, ""); +} + +function addOneDay(date: string): string { + const next = new Date(`${date.slice(0, 10)}T00:00:00Z`); + next.setUTCDate(next.getUTCDate() + 1); + return next.toISOString().slice(0, 10); +} + +export function racesToCsv(races: Race[]): string { + const headers = ["Дата", "Название", "Дистанция (км)", "Статус", "Время", "Место", "Официальная страница"]; + const rows = races.map((race) => [ + race.date, + race.title, + race.distanceKm, + race.status, + race.finishTime, + race.finishPlace, + race.officialUrl, + ]); + return `\uFEFF${[headers, ...rows].map((row) => row.map(escapeCsvCell).join(",")).join("\r\n")}\r\n`; +} + +export function racesToIcs(races: Race[]): string { + const events = races.map((race) => { + const start = race.date.slice(0, 10); + const end = addOneDay(start); + return [ + "BEGIN:VEVENT", + `UID:${race.id}@calendar-run`, + `DTSTART;VALUE=DATE:${toIcsDate(start)}`, + `DTEND;VALUE=DATE:${toIcsDate(end)}`, + `SUMMARY:${escapeIcsText(race.title)}`, + `DESCRIPTION:${escapeIcsText(`${race.distanceKm} км${race.officialUrl ? `\n${race.officialUrl}` : ""}`)}`, + "END:VEVENT", + ].join("\r\n"); + }); + return ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Calendar Run//Races//RU", ...events, "END:VCALENDAR", ""].join("\r\n"); +} + +export function downloadTextFile(filename: string, content: string, type: string): void { + const url = URL.createObjectURL(new Blob([content], { type })); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); +} diff --git a/frontend/src/pages/RaceFormPage.tsx b/frontend/src/pages/RaceFormPage.tsx index 7414c79..c92df6e 100644 --- a/frontend/src/pages/RaceFormPage.tsx +++ b/frontend/src/pages/RaceFormPage.tsx @@ -412,19 +412,17 @@ export function RaceFormPage(): JSX.Element {
Организация - {hideOrgScheduleFields ? null : ( - - )} + + + + + + + + +
+
+ +
@@ -367,7 +457,7 @@ export function RacesPage(): JSX.Element { diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index abf1b28..dd6de11 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -542,7 +542,8 @@ a { color: var(--color-text-muted); } -.races-filter__select { +.races-filter__select, +.races-filter__input { font: inherit; padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm); @@ -551,6 +552,13 @@ a { color: var(--color-text); } +.races-filter__actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-top: var(--space-3); +} + .pace-chart { margin-top: var(--space-4); border: 1px solid var(--color-border); @@ -559,73 +567,9 @@ a { background: #fcfdff; } -.pace-chart__svg { - display: block; - width: 100%; - max-width: 560px; - height: auto; -} - -.pace-chart__line { - stroke: var(--color-accent); - stroke-width: 1.5; - vector-effect: non-scaling-stroke; -} - -.pace-chart__axis { - stroke: rgba(14, 31, 45, 0.42); - stroke-width: 0.75; - vector-effect: non-scaling-stroke; -} - -.pace-chart__axis-label, -.pace-chart__axis-title, -.pace-chart__point-label { - fill: var(--color-text-muted); - font-size: 2.5px; -} - -.pace-chart__axis-title { - font-size: 2.8px; - font-weight: 700; -} - -.pace-chart__point-label { - font-size: 2.25px; -} - -.pace-chart__point { - cursor: pointer; - outline: none; -} - -.pace-chart__point:focus-visible .pace-chart__dot, -.pace-chart__point:hover .pace-chart__dot { - stroke-width: 2; -} - -.pace-chart__tooltip, .pace-chart__hint { margin: var(--space-3) 0 0; font-size: var(--font-size-caption); -} - -.pace-chart__tooltip { - display: grid; - gap: var(--space-1); - padding: var(--space-3); - border-radius: var(--radius-sm); - background: rgba(255, 255, 255, 0.78); - color: var(--color-text); -} - -.pace-chart__hint { - color: var(--color-text-muted); -} - -.pace-chart__caption { - margin: var(--space-3) 0 0; - font-size: var(--font-size-caption); color: var(--color-text-muted); } @@ -1590,49 +1534,6 @@ body { #ffffff; } -.pace-chart__svg { - max-width: 100%; -} - -.pace-chart__grid-line { - stroke: rgba(14, 31, 45, 0.12); - stroke-width: 0.8; - vector-effect: non-scaling-stroke; -} - -.pace-chart__line { - stroke-width: 2.2; -} - -.pace-chart__dot { - fill: #ffffff; - stroke: var(--color-accent); - stroke-width: 1; - vector-effect: non-scaling-stroke; -} - -.pace-chart__dot--last { - fill: var(--color-lime); - stroke: var(--color-bg-deep); -} - -.pace-chart__stats { - display: flex; - flex-wrap: wrap; - gap: var(--space-3); -} - -.pace-chart__caption { - padding: var(--space-2) var(--space-3); - border-radius: var(--radius-sm); - background: rgba(255, 255, 255, 0.72); - color: var(--color-text); -} - -.pace-chart__caption--best { - color: #0e5d3d; -} - .races-hero { display: grid; grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.58fr); @@ -1676,6 +1577,11 @@ body { background: rgba(255, 255, 255, 0.94); } +.races-hero__filters .races-filter__input { + border-color: rgba(255, 255, 255, 0.24); + background: rgba(255, 255, 255, 0.94); +} + .races-view-toggle { border-color: rgba(255, 255, 255, 0.24); background: rgba(7, 25, 39, 0.28);