From 42ee36d0a2289f2012d7347af114995c51ff8c96 Mon Sep 17 00:00:00 2001 From: "Vaka.pro" Date: Wed, 8 Apr 2026 00:40:03 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=80=D1=83=D1=81=D1=81=D0=BA=D0=B8?= =?UTF-8?q?=D0=B9=20UI,=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B8=20=D0=B2=20?= =?UTF-8?q?=D1=84=D1=83=D1=82=D0=B5=D1=80=D0=B5,=20=D0=B4=D0=B0=D1=82?= =?UTF-8?q?=D1=8B=20=D0=B8=20=D1=83=D1=81=D1=82=D0=BE=D0=B9=D1=87=D0=B8?= =?UTF-8?q?=D0=B2=D0=BE=D1=81=D1=82=D1=8C=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=B7=D0=BA=D0=B8=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API: дата старта всегда YYYY-MM-DD; фронт: parseRaceDate без двойного T00:00:00 - GET /health с version из package.json; Vite define __FRONTEND_VERSION__ - Футер с версиями клиента/сервера (BEM), сетка app-shell на три ряда - AbortController для карточки старта; ретраи GET при 502–504 и понятные ошибки шлюза - Русские подписи навигации/страниц, lang=ru, без английских фраз в интерфейсе --- backend/src/mappers/race.ts | 16 +++- backend/src/routes/health.ts | 3 +- backend/src/version.ts | 14 +++ backend/test/app.test.ts | 2 + frontend/index.html | 4 +- frontend/src/api/errors.ts | 19 +++++ frontend/src/api/health.ts | 10 +++ frontend/src/api/http.ts | 94 ++++++++++++++------- frontend/src/api/index.ts | 2 + frontend/src/api/races.ts | 8 +- frontend/src/app/layouts/AppLayout.tsx | 10 ++- frontend/src/app/layouts/AppShellFooter.tsx | 38 +++++++++ frontend/src/components/PaceTrendChart.tsx | 9 +- frontend/src/lib/index.ts | 1 + frontend/src/lib/raceMetrics.ts | 10 ++- frontend/src/pages/DashboardPage.tsx | 29 ++++--- frontend/src/pages/RaceDetailsPage.tsx | 10 ++- frontend/src/pages/RaceFormPage.tsx | 5 +- frontend/src/pages/RacesPage.tsx | 10 ++- frontend/src/styles/global.css | 20 ++++- frontend/src/vite-env.d.ts | 3 + frontend/vite.config.ts | 11 ++- 22 files changed, 251 insertions(+), 77 deletions(-) create mode 100644 backend/src/version.ts create mode 100644 frontend/src/api/health.ts create mode 100644 frontend/src/app/layouts/AppShellFooter.tsx create mode 100644 frontend/src/vite-env.d.ts diff --git a/backend/src/mappers/race.ts b/backend/src/mappers/race.ts index cf83194..382de88 100644 --- a/backend/src/mappers/race.ts +++ b/backend/src/mappers/race.ts @@ -4,7 +4,7 @@ */ export interface RaceRow { id: string; - race_date: string; + race_date: string | Date; title: string; distance_km: string; status: string | null; @@ -43,11 +43,23 @@ function toISOString(value: Date | string): string { return value instanceof Date ? value.toISOString() : String(value); } +/** DATE column may arrive as string or Date; API always exposes YYYY-MM-DD for the calendar day. */ +function raceDateToApiValue(value: string | Date): string { + if (typeof value === "string") { + const m = value.match(/^(\d{4}-\d{2}-\d{2})/); + return m ? m[1]! : value; + } + const y = value.getFullYear(); + const mo = String(value.getMonth() + 1).padStart(2, "0"); + const day = String(value.getDate()).padStart(2, "0"); + return `${y}-${mo}-${day}`; +} + /** Convert a DB row to the API DTO (camelCase). */ export function rowToDto(row: RaceRow): RaceDto { return { id: row.id, - date: row.race_date, + date: raceDateToApiValue(row.race_date), title: row.title, distanceKm: parseFloat(row.distance_km), status: row.status, diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts index d3416d7..9e6edb1 100644 --- a/backend/src/routes/health.ts +++ b/backend/src/routes/health.ts @@ -1,10 +1,11 @@ import { Router, Request, Response } from "express"; import { checkDbConnection } from "../db"; +import { getBackendVersion } from "../version"; const router = Router(); router.get("/health", (_req: Request, res: Response) => { - res.json({ status: "ok" }); + res.json({ status: "ok", version: getBackendVersion() }); }); router.get("/ready", async (_req: Request, res: Response) => { diff --git a/backend/src/version.ts b/backend/src/version.ts new file mode 100644 index 0000000..734b6a7 --- /dev/null +++ b/backend/src/version.ts @@ -0,0 +1,14 @@ +import fs from "fs"; +import path from "path"; + +let cached: string | null = null; + +export function getBackendVersion(): string { + if (cached) { + return cached; + } + const pkgPath = path.join(__dirname, "..", "package.json"); + const raw = fs.readFileSync(pkgPath, "utf-8"); + cached = (JSON.parse(raw) as { version: string }).version; + return cached; +} diff --git a/backend/test/app.test.ts b/backend/test/app.test.ts index 46b74ee..ff852e4 100644 --- a/backend/test/app.test.ts +++ b/backend/test/app.test.ts @@ -8,6 +8,8 @@ const app = createApp(); test("GET /health returns ok", async () => { const res = await request(app).get("/health").expect(200); assert.equal(res.body.status, "ok"); + assert.equal(typeof res.body.version, "string"); + assert.ok(res.body.version.length > 0); }); test("GET /ready succeeds with mock database", async () => { diff --git a/frontend/index.html b/frontend/index.html index e2633a9..6e31b3d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,9 +1,9 @@ - + - Calendar Run + Календарь стартов
diff --git a/frontend/src/api/errors.ts b/frontend/src/api/errors.ts index e1eeca5..302814a 100644 --- a/frontend/src/api/errors.ts +++ b/frontend/src/api/errors.ts @@ -42,7 +42,26 @@ function normalizeApiCode(value: string | undefined): ApiErrorCode { return "unknown_error"; } +function isGatewayStatus(status: number): boolean { + return status === 502 || status === 503 || status === 504; +} + +function hasStructuredApiError(payload: unknown): payload is ApiErrorPayload { + if (payload === null || typeof payload !== "object" || Array.isArray(payload)) { + return false; + } + return typeof (payload as ApiErrorPayload).error === "string"; +} + export function toApiError(status: number, payload: unknown): ApiError { + if (isGatewayStatus(status) && !hasStructuredApiError(payload)) { + return new ApiError({ + code: "network_error", + status, + message: "Сервер временно недоступен. Попробуйте обновить страницу.", + }); + } + const maybePayload = payload as ApiErrorPayload; const code = normalizeApiCode(maybePayload?.error); const details = Array.isArray(maybePayload?.details) diff --git a/frontend/src/api/health.ts b/frontend/src/api/health.ts new file mode 100644 index 0000000..a055ef7 --- /dev/null +++ b/frontend/src/api/health.ts @@ -0,0 +1,10 @@ +import { requestJson } from "./http"; + +export type HealthResponse = { + status: string; + version: string; +}; + +export async function getHealth(init?: RequestInit): Promise { + return requestJson("/health", init); +} diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts index af9932f..b179f07 100644 --- a/frontend/src/api/http.ts +++ b/frontend/src/api/http.ts @@ -20,36 +20,68 @@ async function parseResponseBody(response: Response): Promise { } } -export async function requestJson(path: string, init?: RequestInit): Promise { - try { - const response = await fetch(buildUrl(path), { - ...init, - headers: { - "Content-Type": "application/json", - ...(init?.headers ?? {}), - }, - }); +const GATEWAY_RETRY_STATUSES = new Set([502, 503, 504]); - if (response.status === 204) { - return undefined as T; - } - - const payload = await parseResponseBody(response); - - if (!response.ok) { - throw toApiError(response.status, payload); - } - - return payload as T; - } catch (error) { - if (error instanceof ApiError) { - throw error; - } - - throw new ApiError({ - code: "network_error", - status: null, - message: "Не удалось связаться с сервером.", - }); - } +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +export async function requestJson(path: string, init?: RequestInit): Promise { + const method = (init?.method ?? "GET").toUpperCase(); + const idempotent = method === "GET" || method === "HEAD"; + const maxAttempts = idempotent ? 3 : 1; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const response = await fetch(buildUrl(path), { + ...init, + headers: { + "Content-Type": "application/json", + ...(init?.headers ?? {}), + }, + }); + + if (response.status === 204) { + return undefined as T; + } + + const payload = await parseResponseBody(response); + + if (!response.ok) { + const retryable = idempotent && GATEWAY_RETRY_STATUSES.has(response.status) && attempt < maxAttempts; + if (retryable) { + await delay(80 * attempt); + continue; + } + throw toApiError(response.status, payload); + } + + return payload as T; + } catch (error) { + if (error instanceof ApiError) { + throw error; + } + if (error instanceof DOMException && error.name === "AbortError") { + throw error; + } + const retryable = idempotent && attempt < maxAttempts; + if (retryable) { + await delay(80 * attempt); + continue; + } + throw new ApiError({ + code: "network_error", + status: null, + message: "Не удалось связаться с сервером.", + }); + } + } + + throw new ApiError({ + code: "network_error", + status: null, + message: "Не удалось связаться с сервером.", + }); } diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index fac7b8f..5f73a56 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,3 +1,5 @@ export type { CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types"; export { ApiError, getApiErrorMessage } from "./errors"; +export type { HealthResponse } from "./health"; +export { getHealth } from "./health"; export { getRaceById, getRaces, createRace, updateRace, deleteRace } from "./races"; diff --git a/frontend/src/api/races.ts b/frontend/src/api/races.ts index 7143b8f..e2e6bcd 100644 --- a/frontend/src/api/races.ts +++ b/frontend/src/api/races.ts @@ -77,8 +77,8 @@ function buildRacesQuery(query?: RacesQuery): string { return serialized ? `?${serialized}` : ""; } -export async function getRaces(query?: RacesQuery): Promise { - const response = await requestJson(`/races${buildRacesQuery(query)}`); +export async function getRaces(query?: RacesQuery, init?: RequestInit): Promise { + const response = await requestJson(`/races${buildRacesQuery(query)}`, init); if (!Array.isArray(response)) { throw new ApiError({ code: "unknown_error", @@ -90,8 +90,8 @@ export async function getRaces(query?: RacesQuery): Promise { return response.map(normalizeRace); } -export async function getRaceById(id: string): Promise { - return normalizeRace(await requestJson(`/races/${id}`)); +export async function getRaceById(id: string, init?: RequestInit): Promise { + return normalizeRace(await requestJson(`/races/${id}`, init)); } export async function createRace(payload: CreateRacePayload): Promise { diff --git a/frontend/src/app/layouts/AppLayout.tsx b/frontend/src/app/layouts/AppLayout.tsx index 9f85d3b..e441942 100644 --- a/frontend/src/app/layouts/AppLayout.tsx +++ b/frontend/src/app/layouts/AppLayout.tsx @@ -1,11 +1,12 @@ import { NavLink, Outlet } from "react-router-dom"; +import { AppShellFooter } from "./AppShellFooter"; export function AppLayout(): JSX.Element { return (
-
Calendar Run
-
); } diff --git a/frontend/src/app/layouts/AppShellFooter.tsx b/frontend/src/app/layouts/AppShellFooter.tsx new file mode 100644 index 0000000..7b77faa --- /dev/null +++ b/frontend/src/app/layouts/AppShellFooter.tsx @@ -0,0 +1,38 @@ +import { useEffect, useState } from "react"; +import { getHealth } from "../../api"; + +export function AppShellFooter(): JSX.Element { + const [backendVersion, setBackendVersion] = useState(null); + + useEffect(() => { + const ac = new AbortController(); + void getHealth({ signal: ac.signal }) + .then((h) => { + if (ac.signal.aborted) { + return; + } + setBackendVersion(h.version); + }) + .catch(() => { + if (ac.signal.aborted) { + return; + } + setBackendVersion("недоступна"); + }); + return () => ac.abort(); + }, []); + + const backendLabel = backendVersion === null ? "…" : backendVersion; + + return ( +
+

+ Версия клиента: {__FRONTEND_VERSION__} + + Версия сервера: {backendLabel} +

+
+ ); +} diff --git a/frontend/src/components/PaceTrendChart.tsx b/frontend/src/components/PaceTrendChart.tsx index 8040297..1efd95b 100644 --- a/frontend/src/components/PaceTrendChart.tsx +++ b/frontend/src/components/PaceTrendChart.tsx @@ -1,15 +1,12 @@ import type { Race } from "../api"; -import { formatRaceDate, isCloseDistance, parseFinishTimeToSeconds } from "../lib"; +import { formatRaceDate, isCloseDistance, parseFinishTimeToSeconds, parseRaceDate } from "../lib"; type PaceTrendChartProps = { races: Race[]; distanceKm: number; }; -/** - * Minimal SVG sparkline: finish time (minutes) over chronological completed races - * at the selected distance. Lower time = higher point (better). - */ +/** Линейный график: время финиша (минуты) по завершённым стартам выбранной дистанции. */ export function PaceTrendChart(props: PaceTrendChartProps): JSX.Element { const { races, distanceKm } = props; @@ -21,7 +18,7 @@ export function PaceTrendChart(props: PaceTrendChartProps): JSX.Element { parseFinishTimeToSeconds(race.finishTime) != null, ) .sort( - (a, b) => new Date(`${a.date}T00:00:00`).getTime() - new Date(`${b.date}T00:00:00`).getTime(), + (a, b) => parseRaceDate(a.date).getTime() - parseRaceDate(b.date).getTime(), ) .map((race) => { const seconds = parseFinishTimeToSeconds(race.finishTime)!; diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts index 0e5deba..adba963 100644 --- a/frontend/src/lib/index.ts +++ b/frontend/src/lib/index.ts @@ -7,6 +7,7 @@ export { getRaceStatusLabel, isCloseDistance, parseFinishTimeToSeconds, + parseRaceDate, sortByDateAsc, sortByDateDesc, splitRacesByDate, diff --git a/frontend/src/lib/raceMetrics.ts b/frontend/src/lib/raceMetrics.ts index 476b152..333308c 100644 --- a/frontend/src/lib/raceMetrics.ts +++ b/frontend/src/lib/raceMetrics.ts @@ -2,8 +2,14 @@ import type { Race } from "../api"; const MS_IN_DAY = 24 * 60 * 60 * 1000; -function parseRaceDate(date: string): Date { - return new Date(`${date}T00:00:00`); +/** API date: YYYY-MM-DD или ISO-строка от сериализации (не склеивать с «T00:00:00» повторно). */ +export function parseRaceDate(date: string): Date { + const ymd = date.slice(0, 10); + if (/^\d{4}-\d{2}-\d{2}$/.test(ymd)) { + return new Date(`${ymd}T00:00:00`); + } + const parsed = new Date(date); + return parsed; } export function parseFinishTimeToSeconds(value: string | null): number | null { diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 4b18c54..268fff1 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -9,6 +9,7 @@ import { getPaceLabel, isCloseDistance, parseFinishTimeToSeconds, + parseRaceDate, splitRacesByDate, } from "../lib"; @@ -18,7 +19,7 @@ function getErrorMessage(error: unknown): string { if (error instanceof ApiError) { return error.message; } - return "Не удалось загрузить данные dashboard."; + return "Не удалось загрузить данные обзора."; } export function DashboardPage(): JSX.Element { @@ -28,23 +29,24 @@ export function DashboardPage(): JSX.Element { const [chartDistanceKm, setChartDistanceKm] = useState(10); useEffect(() => { + const ac = new AbortController(); let isMounted = true; async function loadDashboardData(): Promise { try { - const items = await getRaces(); - if (!isMounted) { + const items = await getRaces(undefined, { signal: ac.signal }); + if (!isMounted || ac.signal.aborted) { return; } setRaces(items); setErrorMessage(null); } catch (error) { - if (!isMounted) { + if (ac.signal.aborted || !isMounted) { return; } setErrorMessage(getErrorMessage(error)); } finally { - if (isMounted) { + if (isMounted && !ac.signal.aborted) { setIsLoading(false); } } @@ -53,6 +55,7 @@ export function DashboardPage(): JSX.Element { void loadDashboardData(); return () => { isMounted = false; + ac.abort(); }; }, []); @@ -80,7 +83,7 @@ export function DashboardPage(): JSX.Element { } const currentYear = new Date().getFullYear(); - const seasonRaces = races.filter((race) => new Date(`${race.date}T00:00:00`).getFullYear() === currentYear); + const seasonRaces = races.filter((race) => parseRaceDate(race.date).getFullYear() === currentYear); const seasonCompleted = seasonRaces.filter((race) => race.status === "completed"); return { @@ -130,7 +133,7 @@ export function DashboardPage(): JSX.Element { .filter((race) => race.status === "completed") .map((race) => ({ id: race.id, - year: new Date(`${race.date}T00:00:00`).getFullYear(), + year: parseRaceDate(race.date).getFullYear(), title: race.title, distance: formatDistance(race.distanceKm), finishTime: race.finishTime ?? "время не указано", @@ -143,7 +146,7 @@ export function DashboardPage(): JSX.Element { if (isLoading) { return (
-

Dashboard

+

Обзор

Загружаем ваши старты...

); @@ -152,7 +155,7 @@ export function DashboardPage(): JSX.Element { if (errorMessage) { return (
-

Dashboard

+

Обзор

{errorMessage}

); @@ -160,7 +163,7 @@ export function DashboardPage(): JSX.Element { return (
-

Dashboard

+

Обзор

Ключевые метрики по вашему календарю стартов.

@@ -205,7 +208,7 @@ export function DashboardPage(): JSX.Element {

Лучший темп среди завершённых стартов.

) : ( -

Недостаточно данных для PR.

+

Недостаточно данных для личного рекорда.

)} @@ -242,7 +245,7 @@ export function DashboardPage(): JSX.Element {
-

PR по дистанциям

+

Рекорды по дистанциям

{personalRecordsByDistance.map((item) => (
@@ -291,7 +294,7 @@ export function DashboardPage(): JSX.Element {
) : ( -

Нет completed-стартов для сравнения.

+

Нет завершённых стартов для сравнения.

)}
diff --git a/frontend/src/pages/RaceDetailsPage.tsx b/frontend/src/pages/RaceDetailsPage.tsx index 0c0492e..7f70834 100644 --- a/frontend/src/pages/RaceDetailsPage.tsx +++ b/frontend/src/pages/RaceDetailsPage.tsx @@ -57,6 +57,7 @@ export function RaceDetailsPage(): JSX.Element { const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); useEffect(() => { + const ac = new AbortController(); let isMounted = true; async function loadRace(): Promise { @@ -67,19 +68,19 @@ export function RaceDetailsPage(): JSX.Element { } try { - const item = await getRaceById(raceId); - if (!isMounted) { + const item = await getRaceById(raceId, { signal: ac.signal }); + if (!isMounted || ac.signal.aborted) { return; } setRace(item); setErrorMessage(null); } catch (error) { - if (!isMounted) { + if (ac.signal.aborted || !isMounted) { return; } setErrorMessage(getErrorMessage(error)); } finally { - if (isMounted) { + if (isMounted && !ac.signal.aborted) { setIsLoading(false); } } @@ -88,6 +89,7 @@ export function RaceDetailsPage(): JSX.Element { void loadRace(); return () => { isMounted = false; + ac.abort(); }; }, [raceId]); diff --git a/frontend/src/pages/RaceFormPage.tsx b/frontend/src/pages/RaceFormPage.tsx index 02d91bd..56e3482 100644 --- a/frontend/src/pages/RaceFormPage.tsx +++ b/frontend/src/pages/RaceFormPage.tsx @@ -54,8 +54,9 @@ const EMPTY_FORM: FormData = { }; function raceToFormData(race: Race): FormData { + const dateValue = race.date.length >= 10 ? race.date.slice(0, 10) : race.date; return { - date: race.date, + date: dateValue, title: race.title, distanceKm: String(race.distanceKm), status: race.status ?? "", @@ -310,7 +311,7 @@ export function RaceFormPage(): JSX.Element { name="officialUrl" value={form.officialUrl} onChange={handleChange} - placeholder="https://example.com" + placeholder="https://…" /> diff --git a/frontend/src/pages/RacesPage.tsx b/frontend/src/pages/RacesPage.tsx index ae9d133..d7370fc 100644 --- a/frontend/src/pages/RacesPage.tsx +++ b/frontend/src/pages/RacesPage.tsx @@ -100,24 +100,25 @@ export function RacesPage(): JSX.Element { }, [yearFilter, monthFilter]); useEffect(() => { + const ac = new AbortController(); let isMounted = true; async function loadRaces(): Promise { setIsLoading(true); try { - const items = await getRaces(listQuery); - if (!isMounted) { + const items = await getRaces(listQuery, { signal: ac.signal }); + if (!isMounted || ac.signal.aborted) { return; } setRaces(items); setErrorMessage(null); } catch (error) { - if (!isMounted) { + if (ac.signal.aborted || !isMounted) { return; } setErrorMessage(getErrorMessage(error)); } finally { - if (isMounted) { + if (isMounted && !ac.signal.aborted) { setIsLoading(false); } } @@ -126,6 +127,7 @@ export function RacesPage(): JSX.Element { void loadRaces(); return () => { isMounted = false; + ac.abort(); }; }, [listQuery]); diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index ed9bd1f..cb12269 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -27,7 +27,7 @@ a { .app-shell { min-height: 100vh; display: grid; - grid-template-rows: auto 1fr; + grid-template-rows: auto 1fr auto; } .app-shell__header { @@ -77,6 +77,24 @@ a { padding: var(--space-6); } +.app-shell__footer { + margin-top: auto; + padding: var(--space-3) var(--space-6); + border-top: 1px solid var(--color-border); + background: var(--color-surface); +} + +.app-shell__footer-versions { + margin: 0; + text-align: center; + font-size: var(--font-size-caption); + color: var(--color-text-muted); +} + +.app-shell__footer-sep { + user-select: none; +} + .page { background: var(--color-surface); border: 1px solid var(--color-border); diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..d9800ae --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,3 @@ +/// + +declare const __FRONTEND_VERSION__: string; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index db1b19e..6e57d29 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,6 +1,15 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const pkg = JSON.parse(readFileSync(path.join(__dirname, "package.json"), "utf-8")) as { version: string }; + export default defineConfig({ - plugins: [react()] + plugins: [react()], + define: { + __FRONTEND_VERSION__: JSON.stringify(pkg.version), + }, });