Compare commits

...

2 Commits

Author SHA1 Message Date
Vaka.pro
42ee36d0a2 feat: русский UI, версии в футере, даты и устойчивость загрузки API
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
- 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, без английских фраз в интерфейсе
2026-04-08 00:40:03 +03:00
fc995ed07d Merge pull request 'fix(seed): resolve CSV path for Docker and mount import in stack compose; deleted plans; adds extra files to gitignore' (#12) from fix/seed-csv-path-docker-import-mount into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #12
2026-04-07 21:22:53 +00:00
22 changed files with 251 additions and 77 deletions

View File

@@ -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,

View File

@@ -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) => {

14
backend/src/version.ts Normal file
View File

@@ -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;
}

View File

@@ -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 () => {

View File

@@ -1,9 +1,9 @@
<!doctype html>
<html lang="en">
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Calendar Run</title>
<title>Календарь стартов</title>
</head>
<body>
<div id="root"></div>

View File

@@ -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)

View File

@@ -0,0 +1,10 @@
import { requestJson } from "./http";
export type HealthResponse = {
status: string;
version: string;
};
export async function getHealth(init?: RequestInit): Promise<HealthResponse> {
return requestJson<HealthResponse>("/health", init);
}

View File

@@ -20,7 +20,20 @@ async function parseResponseBody(response: Response): Promise<unknown> {
}
}
const GATEWAY_RETRY_STATUSES = new Set([502, 503, 504]);
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
export async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
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,
@@ -37,6 +50,11 @@ export async function requestJson<T>(path: string, init?: RequestInit): Promise<
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);
}
@@ -45,7 +63,14 @@ export async function requestJson<T>(path: string, init?: RequestInit): Promise<
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,
@@ -53,3 +78,10 @@ export async function requestJson<T>(path: string, init?: RequestInit): Promise<
});
}
}
throw new ApiError({
code: "network_error",
status: null,
message: "Не удалось связаться с сервером.",
});
}

View File

@@ -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";

View File

@@ -77,8 +77,8 @@ function buildRacesQuery(query?: RacesQuery): string {
return serialized ? `?${serialized}` : "";
}
export async function getRaces(query?: RacesQuery): Promise<Race[]> {
const response = await requestJson<unknown[]>(`/races${buildRacesQuery(query)}`);
export async function getRaces(query?: RacesQuery, init?: RequestInit): Promise<Race[]> {
const response = await requestJson<unknown[]>(`/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<Race[]> {
return response.map(normalizeRace);
}
export async function getRaceById(id: string): Promise<Race> {
return normalizeRace(await requestJson<unknown>(`/races/${id}`));
export async function getRaceById(id: string, init?: RequestInit): Promise<Race> {
return normalizeRace(await requestJson<unknown>(`/races/${id}`, init));
}
export async function createRace(payload: CreateRacePayload): Promise<Race> {

View File

@@ -1,11 +1,12 @@
import { NavLink, Outlet } from "react-router-dom";
import { AppShellFooter } from "./AppShellFooter";
export function AppLayout(): JSX.Element {
return (
<div className="app-shell">
<header className="app-shell__header">
<div className="app-shell__brand">Calendar Run</div>
<nav className="app-shell__nav" aria-label="Primary navigation">
<div className="app-shell__brand">Календарь стартов</div>
<nav className="app-shell__nav" aria-label="Основная навигация">
<NavLink
to="/"
end
@@ -13,7 +14,7 @@ export function AppLayout(): JSX.Element {
isActive ? "app-shell__link app-shell__link--active" : "app-shell__link"
}
>
Dashboard
Обзор
</NavLink>
<NavLink
to="/races"
@@ -21,7 +22,7 @@ export function AppLayout(): JSX.Element {
isActive ? "app-shell__link app-shell__link--active" : "app-shell__link"
}
>
Races
Старты
</NavLink>
<NavLink
to="/races/new"
@@ -36,6 +37,7 @@ export function AppLayout(): JSX.Element {
<main className="app-shell__main">
<Outlet />
</main>
<AppShellFooter />
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { useEffect, useState } from "react";
import { getHealth } from "../../api";
export function AppShellFooter(): JSX.Element {
const [backendVersion, setBackendVersion] = useState<string | null>(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 (
<footer className="app-shell__footer">
<p className="app-shell__footer-versions">
Версия клиента: {__FRONTEND_VERSION__}
<span className="app-shell__footer-sep" aria-hidden="true">
{" · "}
</span>
Версия сервера: {backendLabel}
</p>
</footer>
);
}

View File

@@ -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)!;

View File

@@ -7,6 +7,7 @@ export {
getRaceStatusLabel,
isCloseDistance,
parseFinishTimeToSeconds,
parseRaceDate,
sortByDateAsc,
sortByDateDesc,
splitRacesByDate,

View File

@@ -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 {

View File

@@ -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<number>(10);
useEffect(() => {
const ac = new AbortController();
let isMounted = true;
async function loadDashboardData(): Promise<void> {
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 (
<section className="page page--dashboard" aria-busy="true">
<h1 className="page__title">Dashboard</h1>
<h1 className="page__title">Обзор</h1>
<p className="page__subtitle">Загружаем ваши старты...</p>
</section>
);
@@ -152,7 +155,7 @@ export function DashboardPage(): JSX.Element {
if (errorMessage) {
return (
<section className="page page--dashboard" role="alert">
<h1 className="page__title">Dashboard</h1>
<h1 className="page__title">Обзор</h1>
<p className="page__subtitle page__subtitle--error">{errorMessage}</p>
</section>
);
@@ -160,7 +163,7 @@ export function DashboardPage(): JSX.Element {
return (
<section className="page page--dashboard">
<h1 className="page__title">Dashboard</h1>
<h1 className="page__title">Обзор</h1>
<p className="page__subtitle">Ключевые метрики по вашему календарю стартов.</p>
<div className="dashboard-grid" aria-label="Ключевые метрики">
@@ -205,7 +208,7 @@ export function DashboardPage(): JSX.Element {
<p className="dashboard-card__hint">Лучший темп среди завершённых стартов.</p>
</>
) : (
<p className="dashboard-card__empty">Недостаточно данных для PR.</p>
<p className="dashboard-card__empty">Недостаточно данных для личного рекорда.</p>
)}
</article>
@@ -242,7 +245,7 @@ export function DashboardPage(): JSX.Element {
</section>
<section className="dashboard-section" aria-label="Личные рекорды по дистанциям">
<h2 className="dashboard-section__title">PR по дистанциям</h2>
<h2 className="dashboard-section__title">Рекорды по дистанциям</h2>
<div className="dashboard-grid dashboard-grid--pr">
{personalRecordsByDistance.map((item) => (
<article key={item.distanceKm} className="dashboard-card">
@@ -291,7 +294,7 @@ export function DashboardPage(): JSX.Element {
</table>
</div>
) : (
<p className="dashboard-card__empty">Нет completed-стартов для сравнения.</p>
<p className="dashboard-card__empty">Нет завершённых стартов для сравнения.</p>
)}
</section>
</section>

View File

@@ -57,6 +57,7 @@ export function RaceDetailsPage(): JSX.Element {
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
useEffect(() => {
const ac = new AbortController();
let isMounted = true;
async function loadRace(): Promise<void> {
@@ -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]);

View File

@@ -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://"
/>
</label>

View File

@@ -100,24 +100,25 @@ export function RacesPage(): JSX.Element {
}, [yearFilter, monthFilter]);
useEffect(() => {
const ac = new AbortController();
let isMounted = true;
async function loadRaces(): Promise<void> {
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]);

View File

@@ -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);

3
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare const __FRONTEND_VERSION__: string;

View File

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