From e0ed0b6435f577260ec1934d414caa7dc93e4a77 Mon Sep 17 00:00:00 2001
From: "Vaka.pro"
Date: Wed, 8 Apr 2026 01:21:11 +0300
Subject: [PATCH] =?UTF-8?q?fix:=20=D0=BF=D1=80=D0=BE=D0=B4=20=E2=80=94=20C?=
=?UTF-8?q?ORS,=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F=20API,=20=D0=BE?=
=?UTF-8?q?=D1=88=D0=B8=D0=B1=D0=BA=D0=B8=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD?=
=?UTF-8?q?=D1=82=D0=B0=20=D0=B8=20=D0=BF=D0=BE=D0=B4=D1=81=D0=BA=D0=B0?=
=?UTF-8?q?=D0=B7=D0=BA=D0=B0=20=D0=BF=D0=BE=20=D0=BF=D1=80=D0=BE=D1=88?=
=?UTF-8?q?=D0=B5=D0=B4=D1=88=D0=B8=D0=BC=20=D1=81=D1=82=D0=B0=D1=80=D1=82?=
=?UTF-8?q?=D0=B0=D0=BC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- CORS_ORIGIN: несколько origin через запятую; комментарии в .env.example
- Версия бэкенда: APP_VERSION, безопасное чтение package.json, футер при пустой версии
- Сообщения API: unknown_error и ответы 401/403/404 без JSON; отладочный лог при !ok
- Статус «внесите результат» для прошедшей даты + блок на карточке старта и стили
---
.env.example | 6 ++++
backend/src/config.ts | 18 ++++++++++-
backend/src/version.ts | 18 ++++++++---
frontend/src/api/errors.ts | 16 ++++++++-
frontend/src/api/http.ts | 19 +++++++++++
frontend/src/app/layouts/AppShellFooter.tsx | 3 +-
frontend/src/lib/index.ts | 1 +
frontend/src/lib/raceMetrics.ts | 36 +++++++++++++++------
frontend/src/pages/RaceDetailsPage.tsx | 15 +++++++--
frontend/src/pages/RacesPage.tsx | 2 +-
frontend/src/styles/global.css | 25 ++++++++++++++
11 files changed, 140 insertions(+), 19 deletions(-)
diff --git a/.env.example b/.env.example
index cc42396..ec216bd 100644
--- a/.env.example
+++ b/.env.example
@@ -24,10 +24,16 @@ API_PORT=3001
# CALENDAR_RUN_MOCK_DB=1
# ─── CORS ────────────────────────────────────────────────────
+# Должен совпадать с origin в браузере (схема + хост + порт, без пути), иначе API «молчит».
# Локальный Vite: http://localhost:5173
# Стек с фронтом на 3033: http://localhost:3033
+# Прод: https://ваш-домен — несколько origin через запятую: https://a.ru,https://www.a.ru
CORS_ORIGIN=http://localhost:5173
+# ─── Версия API (опционально) ─────────────────────────────────
+# Если в образе не удаётся прочитать package.json, подставьте вручную (видно в GET /health).
+# APP_VERSION=1.0.0
+
# ─── Frontend (Vite, локально из каталога frontend/) ─────────
# В Docker-образе фронта базовый URL API задаётся при сборке (/api), не из .env.
VITE_API_BASE_URL=http://localhost:3001
diff --git a/backend/src/config.ts b/backend/src/config.ts
index f5fc252..5319951 100644
--- a/backend/src/config.ts
+++ b/backend/src/config.ts
@@ -33,5 +33,21 @@ export const config = {
password: requireEnv("DB_PASSWORD"),
},
apiPort: parseInt(process.env.PORT || process.env.API_PORT || "3001", 10),
- corsOrigin: process.env.CORS_ORIGIN || "http://localhost:5173",
+ /** Одно значение или несколько через запятую (прод: https://домен) */
+ corsOrigin: parseCorsOrigins(),
};
+
+function parseCorsOrigins(): string | string[] {
+ const raw = process.env.CORS_ORIGIN?.trim();
+ if (!raw) {
+ return "http://localhost:5173";
+ }
+ const parts = raw.split(",").map((s) => s.trim()).filter(Boolean);
+ if (parts.length === 0) {
+ return "http://localhost:5173";
+ }
+ if (parts.length === 1) {
+ return parts[0]!;
+ }
+ return parts;
+}
diff --git a/backend/src/version.ts b/backend/src/version.ts
index 734b6a7..1f09a44 100644
--- a/backend/src/version.ts
+++ b/backend/src/version.ts
@@ -7,8 +7,18 @@ 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;
+ const fromEnv = process.env.APP_VERSION?.trim();
+ if (fromEnv) {
+ cached = fromEnv;
+ return cached;
+ }
+ try {
+ const pkgPath = path.join(__dirname, "..", "package.json");
+ const raw = fs.readFileSync(pkgPath, "utf-8");
+ cached = (JSON.parse(raw) as { version: string }).version;
+ return cached;
+ } catch {
+ cached = "0.0.0";
+ return cached;
+ }
}
diff --git a/frontend/src/api/errors.ts b/frontend/src/api/errors.ts
index 302814a..69e3b85 100644
--- a/frontend/src/api/errors.ts
+++ b/frontend/src/api/errors.ts
@@ -35,7 +35,8 @@ function normalizeApiCode(value: string | undefined): ApiErrorCode {
value === "validation_error" ||
value === "not_found" ||
value === "database_unavailable" ||
- value === "conflict"
+ value === "conflict" ||
+ value === "unknown_error"
) {
return value;
}
@@ -62,6 +63,17 @@ export function toApiError(status: number, payload: unknown): ApiError {
});
}
+ if (!hasStructuredApiError(payload) && (status === 401 || status === 403 || status === 404)) {
+ return new ApiError({
+ code: "network_error",
+ status,
+ message:
+ status === 404
+ ? "API не найден по этому адресу. Проверьте прокси и префикс /api."
+ : "Запрос отклонён сервером. Проверьте переменную CORS_ORIGIN на бэкенде.",
+ });
+ }
+
const maybePayload = payload as ApiErrorPayload;
const code = normalizeApiCode(maybePayload?.error);
const details = Array.isArray(maybePayload?.details)
@@ -88,6 +100,8 @@ export function getApiErrorMessage(code: ApiErrorCode): string {
return "Запись с таким идентификатором уже существует.";
case "network_error":
return "Не удалось связаться с сервером.";
+ case "unknown_error":
+ return "Сервер не смог обработать запрос. Попробуйте позже или обновите страницу.";
default:
return "Произошла неизвестная ошибка.";
}
diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts
index b179f07..9aaac54 100644
--- a/frontend/src/api/http.ts
+++ b/frontend/src/api/http.ts
@@ -55,6 +55,25 @@ export async function requestJson(path: string, init?: RequestInit): Promise<
await delay(80 * attempt);
continue;
}
+ // #region agent log
+ fetch("http://127.0.0.1:7488/ingest/a18f912f-72c6-4a58-866b-17810a6b89d2", {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "587ee5" },
+ body: JSON.stringify({
+ sessionId: "587ee5",
+ hypothesisId: "H-http-not-ok",
+ location: "http.ts:requestJson",
+ message: "HTTP error response",
+ data: {
+ path,
+ status: response.status,
+ contentType: response.headers.get("content-type"),
+ payloadIsObject: payload !== null && typeof payload === "object",
+ },
+ timestamp: Date.now(),
+ }),
+ }).catch(() => {});
+ // #endregion
throw toApiError(response.status, payload);
}
diff --git a/frontend/src/app/layouts/AppShellFooter.tsx b/frontend/src/app/layouts/AppShellFooter.tsx
index 1966638..d047480 100644
--- a/frontend/src/app/layouts/AppShellFooter.tsx
+++ b/frontend/src/app/layouts/AppShellFooter.tsx
@@ -12,7 +12,8 @@ export function AppShellFooter(): JSX.Element {
if (ac.signal.aborted) {
return;
}
- setBackendVersion(h.version);
+ const v = h.version;
+ setBackendVersion(typeof v === "string" && v.length > 0 ? v : "не указана");
})
.catch(() => {
if (ac.signal.aborted) {
diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts
index adba963..9ed3e2e 100644
--- a/frontend/src/lib/index.ts
+++ b/frontend/src/lib/index.ts
@@ -8,6 +8,7 @@ export {
isCloseDistance,
parseFinishTimeToSeconds,
parseRaceDate,
+ raceNeedsResultEntry,
sortByDateAsc,
sortByDateDesc,
splitRacesByDate,
diff --git a/frontend/src/lib/raceMetrics.ts b/frontend/src/lib/raceMetrics.ts
index 333308c..1bdba62 100644
--- a/frontend/src/lib/raceMetrics.ts
+++ b/frontend/src/lib/raceMetrics.ts
@@ -122,18 +122,36 @@ export function getPaceLabel(finishTime: string | null, distanceKm: number): str
return `${String(paceMinutes).padStart(2, "0")}:${String(paceRemainder).padStart(2, "0")} /км`;
}
-export function getRaceStatusClassName(status: Race["status"]): string {
- const base = "race-card__status";
- if (status === "completed") {
- return `${base} ${base}--completed`;
+function isPastDateNeedingResult(status: Race["status"], raceDate: string): boolean {
+ if (status !== "planned" && status !== "registered") {
+ return false;
}
- if (status === "registered") {
- return `${base} ${base}--registered`;
- }
- return `${base} ${base}--planned`;
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+ return parseRaceDate(raceDate).getTime() < today.getTime();
}
-export function getRaceStatusLabel(status: Race["status"]): string {
+export function raceNeedsResultEntry(race: Race): boolean {
+ return isPastDateNeedingResult(race.status, race.date);
+}
+
+export function getRaceStatusClassName(status: Race["status"], raceDate?: string): string {
+ const base = "race-card__status";
+ let tier = `${base}--planned`;
+ if (status === "completed") {
+ tier = `${base}--completed`;
+ } else if (status === "registered") {
+ tier = `${base}--registered`;
+ }
+ const needs =
+ raceDate && isPastDateNeedingResult(status, raceDate) ? ` ${base}--needs-result` : "";
+ return `${base} ${tier}${needs}`;
+}
+
+export function getRaceStatusLabel(status: Race["status"], raceDate?: string): string {
+ if (raceDate && isPastDateNeedingResult(status, raceDate)) {
+ return "внесите результат";
+ }
if (status === "completed") {
return "пробежал";
}
diff --git a/frontend/src/pages/RaceDetailsPage.tsx b/frontend/src/pages/RaceDetailsPage.tsx
index 7f70834..83d3d29 100644
--- a/frontend/src/pages/RaceDetailsPage.tsx
+++ b/frontend/src/pages/RaceDetailsPage.tsx
@@ -7,6 +7,7 @@ import {
getPaceLabel,
getRaceStatusClassName,
getRaceStatusLabel,
+ raceNeedsResultEntry,
} from "../lib";
import type { Race } from "../api";
@@ -148,9 +149,19 @@ export function RaceDetailsPage(): JSX.Element {
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}
- {getRaceStatusLabel(race.status)}
+ {getRaceStatusLabel(race.status, race.date)}
+ {raceNeedsResultEntry(race) ? (
+
+ Дата старта уже прошла —{" "}
+
+ внесите результат или обновите статус
+
+ .
+
+ ) : null}
+
Редактировать
@@ -202,7 +213,7 @@ export function RaceDetailsPage(): JSX.Element {
Статус
- {getRaceStatusLabel(race.status)}
+ {getRaceStatusLabel(race.status, race.date)}
diff --git a/frontend/src/pages/RacesPage.tsx b/frontend/src/pages/RacesPage.tsx
index d7370fc..8637aca 100644
--- a/frontend/src/pages/RacesPage.tsx
+++ b/frontend/src/pages/RacesPage.tsx
@@ -64,7 +64,7 @@ function RaceList(props: { title: string; races: Race[] }): JSX.Element {
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}
- {getRaceStatusLabel(race.status)}
+ {getRaceStatusLabel(race.status, race.date)}
))}
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index cb12269..066d28b 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -291,6 +291,31 @@ a {
color: #8a5a00;
}
+.race-card__status--needs-result {
+ outline: 1px solid var(--color-warning);
+}
+
+.race-details-past-hint {
+ margin: 0 0 var(--space-4);
+ padding: var(--space-3) var(--space-4);
+ border-radius: var(--radius-md);
+ border: 1px solid var(--color-warning);
+ background: #fffaf0;
+ color: var(--color-text);
+ font-size: var(--font-size-caption);
+}
+
+.race-details-past-hint__link {
+ color: var(--color-accent);
+ font-weight: 600;
+ text-decoration: underline;
+}
+
+.race-details-past-hint__link:hover,
+.race-details-past-hint__link:focus-visible {
+ outline: none;
+}
+
.races-filter {
margin-top: var(--space-5);
display: flex;
--
2.49.1