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 (
+
+ );
+}
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),
+ },
});