Compare commits

..

9 Commits

Author SHA1 Message Date
Vaka.pro
00985732ec Merge remote-tracking branch 'origin/main' into feature/race-cover-images
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
# Conflicts:
#	frontend/package-lock.json
#	frontend/package.json
2026-04-27 23:01:19 +03:00
Vaka.pro
0153f223f2 feat: add race cover image extraction
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-04-27 22:56:41 +03:00
b1b363a7e8 Merge pull request 'feat(frontend): add service favicon' (#31) from feat/add-service-favicon into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #31
2026-04-27 11:29:02 +00:00
Anton
f5e16c44b3 feat(frontend): add service favicon
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-04-27 14:26:42 +03:00
c5ca511ea7 Merge pull request 'chore: fix versioning' (#30) from chore/frontend-version-0.5.1 into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #30
2026-04-27 11:20:31 +00:00
Anton
42057ddb1c chore: fix versioning
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-04-27 14:20:05 +03:00
1a37afd16f Merge pull request 'fix(frontend): prevent calendar loading layout shift' (#29) from fix/calendar-loading-layout-shift into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #29
2026-04-27 11:03:46 +00:00
Anton
f7b611bbbe fix(frontend): prevent calendar loading layout shift
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-04-27 14:02:20 +03:00
55fc23ec64 Merge pull request 'fix frontend calendar race states' (#28) from codex/calendar-race-ui-fixes into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #28
2026-04-27 09:32:39 +00:00
19 changed files with 367 additions and 22 deletions

View File

@@ -0,0 +1,2 @@
ALTER TABLE races
ADD COLUMN IF NOT EXISTS cover_image_url TEXT;

View File

@@ -1,12 +1,12 @@
{
"name": "calendar-run-backend",
"version": "1.2.2",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "calendar-run-backend",
"version": "1.2.2",
"version": "1.3.0",
"dependencies": {
"cors": "^2.8.5",
"csv-parse": "^5.6.0",

View File

@@ -1,6 +1,6 @@
{
"name": "calendar-run-backend",
"version": "1.2.2",
"version": "1.3.0",
"private": true,
"scripts": {
"build": "tsc",

View File

@@ -24,6 +24,7 @@ function mockRowFromInsert(sql: string, params: unknown[]): RaceRow {
distance_km: "0",
status: null,
official_url: null,
cover_image_url: null,
start_time: null,
cluster_schedule: null,
bib_pickup: null,
@@ -47,6 +48,7 @@ function mockRowFromInsert(sql: string, params: unknown[]): RaceRow {
distance_km: String(row.distance_km ?? "0"),
status: row.status != null ? String(row.status) : null,
official_url: row.official_url != null ? String(row.official_url) : null,
cover_image_url: row.cover_image_url != null ? String(row.cover_image_url) : null,
start_time: row.start_time != null ? String(row.start_time) : null,
cluster_schedule: row.cluster_schedule != null ? String(row.cluster_schedule) : null,
bib_pickup: row.bib_pickup != null ? String(row.bib_pickup) : null,
@@ -108,7 +110,19 @@ function createMockPool(): Pool {
if (!existing) {
return emptyResult();
}
const setMatch = sql.match(/UPDATE races SET (.+) WHERE id =/);
const updated = { ...existing, updated_at: new Date() };
const setColumns =
setMatch?.[1]
.split(",")
.map((part) => part.trim())
.filter((part) => !part.startsWith("updated_at"))
.map((part) => part.split("=")[0]?.trim())
.filter((col): col is string => Boolean(col)) ?? [];
setColumns.forEach((col, index) => {
(updated as unknown as Record<string, unknown>)[col] = p[index] ?? null;
});
store.set(id, updated);
return {
rows: [updated as unknown as T],

View File

@@ -9,6 +9,7 @@ export interface RaceRow {
distance_km: string;
status: string | null;
official_url: string | null;
cover_image_url: string | null;
start_time: string | null;
cluster_schedule: string | null;
bib_pickup: string | null;
@@ -28,6 +29,7 @@ export interface RaceDto {
distanceKm: number;
status: string | null;
officialUrl: string | null;
coverImageUrl: string | null;
startTime: string | null;
clusterSchedule: string | null;
bibPickup: string | null;
@@ -64,6 +66,7 @@ export function rowToDto(row: RaceRow): RaceDto {
distanceKm: parseFloat(row.distance_km),
status: row.status,
officialUrl: row.official_url,
coverImageUrl: row.cover_image_url,
startTime: row.start_time,
clusterSchedule: row.cluster_schedule,
bibPickup: row.bib_pickup,
@@ -83,6 +86,7 @@ const FIELD_MAP: Record<string, string> = {
distanceKm: "distance_km",
status: "status",
officialUrl: "official_url",
coverImageUrl: "cover_image_url",
startTime: "start_time",
clusterSchedule: "cluster_schedule",
bibPickup: "bib_pickup",

View File

@@ -0,0 +1,103 @@
const IMAGE_META_KEYS = new Set([
"og:image",
"og:image:url",
"twitter:image",
"twitter:image:src",
]);
const FETCH_TIMEOUT_MS = 5_000;
function getAttribute(tag: string, name: string): string | null {
const pattern = new RegExp(`${name}\\s*=\\s*["']([^"']+)["']`, "i");
return tag.match(pattern)?.[1] ?? null;
}
function toHttpUrl(value: string, baseUrl: string): string | null {
try {
const url = new URL(value, baseUrl);
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
} catch {
return null;
}
}
function isRuncRunUrl(value: string): boolean {
try {
const hostname = new URL(value).hostname.toLowerCase();
return hostname === "runc.run" || hostname.endsWith(".runc.run");
} catch {
return false;
}
}
function findRuncIntroImage(html: string, baseUrl: string): string | null {
const introMatch = html.match(/<div\b[^>]*class=["'][^"']*\brun-intro__image\b[^"']*["'][^>]*>[\s\S]*?<img\b[^>]*>/i);
if (!introMatch) {
return null;
}
const src = getAttribute(introMatch[0], "src");
return src ? toHttpUrl(src, baseUrl) : null;
}
function findMetaImage(html: string, baseUrl: string): string | null {
const tags = html.match(/<meta\b[^>]*>/gi) ?? [];
for (const tag of tags) {
const key = (getAttribute(tag, "property") || getAttribute(tag, "name") || "").toLowerCase();
if (!IMAGE_META_KEYS.has(key)) {
continue;
}
const content = getAttribute(tag, "content");
if (!content) {
continue;
}
const imageUrl = toHttpUrl(content, baseUrl);
if (imageUrl) {
return imageUrl;
}
}
return null;
}
export function extractRaceCoverImageFromHtml(html: string, pageUrl: string): string | null {
if (isRuncRunUrl(pageUrl)) {
const runcImage = findRuncIntroImage(html, pageUrl);
if (runcImage) {
return runcImage;
}
}
return findMetaImage(html, pageUrl);
}
export async function extractRaceCoverImage(officialUrl: string): Promise<string | null> {
const normalizedUrl = toHttpUrl(officialUrl, officialUrl);
if (!normalizedUrl) {
return null;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(normalizedUrl, {
redirect: "follow",
signal: controller.signal,
});
if (!response.ok) {
return null;
}
const html = await response.text();
return extractRaceCoverImageFromHtml(html, response.url || normalizedUrl);
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}

View File

@@ -1,6 +1,7 @@
import { Router, Request, Response } from "express";
import { pool } from "../db";
import { rowToDto, bodyToColumns, RaceRow } from "../mappers/race";
import { extractRaceCoverImage } from "../raceCoverImage";
const router = Router();
@@ -117,7 +118,15 @@ router.post("/races", async (req: Request, res: Response) => {
return;
}
const { columns, values } = bodyToColumns(body);
const payload = { ...body };
const hasManualCover = typeof payload.coverImageUrl === "string" && payload.coverImageUrl.trim() !== "";
const hasOfficialUrl = typeof payload.officialUrl === "string" && payload.officialUrl.trim() !== "";
if (!hasManualCover && hasOfficialUrl) {
payload.coverImageUrl = await extractRaceCoverImage(payload.officialUrl);
}
const { columns, values } = bodyToColumns(payload);
columns.unshift("id");
values.unshift(body.id);

View File

@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import request from "supertest";
import { createApp } from "../src/app";
import { extractRaceCoverImageFromHtml } from "../src/raceCoverImage";
const app = createApp();
@@ -45,3 +46,124 @@ test("GET /api/races/:id returns not_found", async () => {
assert.equal(res.body.error, "not_found");
assert.ok(Array.isArray(res.body.details));
});
test("extractRaceCoverImageFromHtml prefers runc.run intro image", () => {
const html = `
<meta property="og:image" content="https://example.com/og.jpg">
<div class="run-intro__image">
<div class="run-intro__image-left-shadow"></div>
<img src="/uploads/race_landing_header_backgrounds/header.jpg" alt="">
<div class="run-intro__image-right-shadow"></div>
</div>
`;
assert.equal(
extractRaceCoverImageFromHtml(html, "https://aprilrun5km.runc.run/"),
"https://aprilrun5km.runc.run/uploads/race_landing_header_backgrounds/header.jpg",
);
});
test("extractRaceCoverImageFromHtml reads Open Graph and Twitter images", () => {
assert.equal(
extractRaceCoverImageFromHtml(
'<meta property="og:image" content="/cover.png">',
"https://example.com/race",
),
"https://example.com/cover.png",
);
assert.equal(
extractRaceCoverImageFromHtml(
'<meta name="twitter:image" content="https://cdn.example.com/twitter.jpg">',
"https://example.com/race",
),
"https://cdn.example.com/twitter.jpg",
);
});
test("POST /api/races stores manual coverImageUrl", async () => {
const coverImageUrl = "https://example.com/manual.jpg";
const res = await request(app)
.post("/api/races")
.send({
id: "2026-06-01-manual-cover",
date: "2026-06-01",
title: "Manual Cover",
distanceKm: 10,
officialUrl: "https://example.com/race",
coverImageUrl,
})
.expect(201);
assert.equal(res.body.coverImageUrl, coverImageUrl);
});
test("POST /api/races auto extracts coverImageUrl from officialUrl", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response('<meta property="og:image" content="/auto.jpg">', {
status: 200,
headers: { "content-type": "text/html" },
});
try {
const res = await request(app)
.post("/api/races")
.send({
id: "2026-06-02-auto-cover",
date: "2026-06-02",
title: "Auto Cover",
distanceKm: 21.1,
officialUrl: "https://example.com/race",
})
.expect(201);
assert.equal(res.body.coverImageUrl, "https://example.com/auto.jpg");
} finally {
globalThis.fetch = originalFetch;
}
});
test("POST /api/races succeeds when cover extraction fails", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => {
throw new Error("network down");
};
try {
const res = await request(app)
.post("/api/races")
.send({
id: "2026-06-03-cover-fail",
date: "2026-06-03",
title: "Cover Fail",
distanceKm: 5,
officialUrl: "https://example.com/race",
})
.expect(201);
assert.equal(res.body.coverImageUrl, null);
} finally {
globalThis.fetch = originalFetch;
}
});
test("PATCH /api/races/:id updates coverImageUrl explicitly", async () => {
const id = "2026-06-04-patch-cover";
await request(app)
.post("/api/races")
.send({
id,
date: "2026-06-04",
title: "Patch Cover",
distanceKm: 10,
})
.expect(201);
const coverImageUrl = "https://example.com/patched.jpg";
const res = await request(app)
.patch(`/api/races/${id}`)
.send({ coverImageUrl })
.expect(200);
assert.equal(res.body.coverImageUrl, coverImageUrl);
});

View File

@@ -81,6 +81,7 @@ GET /api/races?year=2026&month=5
"distanceKm": 42.195,
"status": "planned",
"officialUrl": null,
"coverImageUrl": null,
"startTime": null,
"clusterSchedule": null,
"bibPickup": null,
@@ -124,6 +125,7 @@ GET /api/races?year=2026&month=5
"distanceKm": 10,
"status": "planned",
"officialUrl": "https://example.com",
"coverImageUrl": "https://example.com/cover.jpg",
"startTime": "09:30",
"clusterSchedule": null,
"bibPickup": null,
@@ -167,7 +169,7 @@ GET /api/races?year=2026&month=5
}
```
**Допустимые поля:** `date`, `title`, `distanceKm`, `status`, `officialUrl`, `startTime`, `clusterSchedule`, `bibPickup`, `bibNumber`, `finishTime`, `finishPlace`, `notes`.
**Допустимые поля:** `date`, `title`, `distanceKm`, `status`, `officialUrl`, `coverImageUrl`, `startTime`, `clusterSchedule`, `bibPickup`, `bibNumber`, `finishTime`, `finishPlace`, `notes`.
**Ответ 200:** обновлённый объект `Race`.
@@ -207,6 +209,7 @@ GET /api/races?year=2026&month=5
| `distanceKm` | number | да | да | Дистанция в км |
| `status` | string \| null | нет | да | `"planned"` / `"registered"` / `"completed"` |
| `officialUrl` | string \| null | нет | да | URL организатора |
| `coverImageUrl` | string \| null | нет | да | URL обложки забега. При `POST` может быть найден автоматически по `officialUrl`, если не передан вручную |
| `startTime` | string \| null | нет | да | Время старта, напр. `"09:30"` или `"09:30:00"` (часы:минуты:секунды) |
| `clusterSchedule` | string \| null | нет | да | Расписание кластеров |
| `bibPickup` | string \| null | нет | да | Выдача номеров |

View File

@@ -2,6 +2,7 @@
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Календарь стартов</title>
</head>

View File

@@ -1,12 +1,12 @@
{
"name": "calendar-run-frontend",
"version": "0.5.0",
"version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "calendar-run-frontend",
"version": "0.5.0",
"version": "0.6.0",
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",

View File

@@ -1,7 +1,7 @@
{
"name": "calendar-run-frontend",
"private": true,
"version": "0.5.0",
"version": "0.6.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Календарь стартов">
<defs>
<linearGradient id="bg" x1="12" y1="4" x2="52" y2="60" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#1168d8" />
<stop offset="1" stop-color="#071927" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="16" fill="url(#bg)" />
<path
d="M18 20h28a5 5 0 0 1 5 5v21a5 5 0 0 1-5 5H18a5 5 0 0 1-5-5V25a5 5 0 0 1 5-5Z"
fill="#ffffff"
/>
<path d="M13 29h38" stroke="#d6e1ea" stroke-width="4" />
<path d="M23 14v11M41 14v11" stroke="#b9f24a" stroke-width="5" stroke-linecap="round" />
<path
d="M22 41c5-8 13-8 18 0M22 41h18"
fill="none"
stroke="#1168d8"
stroke-width="5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="44" cy="44" r="5" fill="#ff6f5e" />
</svg>

After

Width:  |  Height:  |  Size: 890 B

View File

@@ -23,6 +23,7 @@ function normalizeRace(input: unknown): Race {
race?.status === "registered" ||
race?.status === "completed") &&
isNullableString(race?.officialUrl) &&
isNullableString(race?.coverImageUrl) &&
isNullableString(race?.startTime) &&
isNullableString(race?.clusterSchedule) &&
isNullableString(race?.bibPickup) &&
@@ -48,6 +49,7 @@ function normalizeRace(input: unknown): Race {
distanceKm: race.distanceKm,
status: race.status,
officialUrl: race.officialUrl,
coverImageUrl: race.coverImageUrl,
startTime: race.startTime,
clusterSchedule: race.clusterSchedule,
bibPickup: race.bibPickup,

View File

@@ -7,6 +7,7 @@ export interface Race {
distanceKm: number;
status: RaceStatus | null;
officialUrl: string | null;
coverImageUrl: string | null;
startTime: string | null;
clusterSchedule: string | null;
bibPickup: string | null;
@@ -30,6 +31,7 @@ export interface CreateRacePayload {
distanceKm: number;
status?: RaceStatus | null;
officialUrl?: string | null;
coverImageUrl?: string | null;
startTime?: string | null;
clusterSchedule?: string | null;
bibPickup?: string | null;

View File

@@ -187,6 +187,15 @@ function getFallbackRaceVisual(race: Race): RaceVisual {
export function getRaceVisual(race: Race): RaceVisual {
const fallback = getFallbackRaceVisual(race);
if (race.coverImageUrl) {
return {
...fallback,
imageSrc: race.coverImageUrl,
fallbackSrc: fallback.imageSrc,
};
}
const title = normalizeTitle(race.title);
const official = OFFICIAL_VISUALS.find((visual) =>
visual.keywords.some((keyword) => title.includes(normalizeTitle(keyword))),

View File

@@ -31,6 +31,7 @@ interface FormData {
distanceKm: string;
status: string;
officialUrl: string;
coverImageUrl: string;
startTime: string;
clusterSchedule: string;
bibPickup: string;
@@ -46,6 +47,7 @@ const EMPTY_FORM: FormData = {
distanceKm: "",
status: "planned",
officialUrl: "",
coverImageUrl: "",
startTime: "",
clusterSchedule: "",
bibPickup: "",
@@ -63,6 +65,7 @@ function raceToFormData(race: Race): FormData {
distanceKm: String(race.distanceKm),
status: race.status ?? "",
officialUrl: race.officialUrl ?? "",
coverImageUrl: race.coverImageUrl ?? "",
startTime: race.startTime ?? "",
clusterSchedule: race.clusterSchedule ?? "",
bibPickup: race.bibPickup ?? "",
@@ -208,6 +211,7 @@ export function RaceFormPage(): JSX.Element {
distanceKm: parseFloat(form.distanceKm),
status: statusValue,
officialUrl: emptyToNull(form.officialUrl),
coverImageUrl: emptyToNull(form.coverImageUrl),
startTime: emptyToNull(form.startTime),
clusterSchedule: emptyToNull(form.clusterSchedule),
bibPickup: emptyToNull(form.bibPickup),
@@ -228,6 +232,7 @@ export function RaceFormPage(): JSX.Element {
distanceKm: parseFloat(form.distanceKm),
status: statusValue,
officialUrl: emptyToNull(form.officialUrl),
coverImageUrl: emptyToNull(form.coverImageUrl),
startTime: emptyToNull(form.startTime),
clusterSchedule: emptyToNull(form.clusterSchedule),
bibPickup: emptyToNull(form.bibPickup),
@@ -358,6 +363,18 @@ export function RaceFormPage(): JSX.Element {
</label>
)}
<label className="race-form__field">
<span className="race-form__label">URL обложки</span>
<input
className="race-form__input"
type="url"
name="coverImageUrl"
value={form.coverImageUrl}
onChange={handleChange}
placeholder="https://…"
/>
</label>
{hideOrgScheduleFields ? null : (
<div className="race-form__field">
<span className="race-form__label">Время старта</span>

View File

@@ -228,6 +228,25 @@ export function RacesPage(): JSX.Element {
}),
[races],
);
const statusMessage = useMemo(() => {
if (errorMessage && !isLoading) {
return errorMessage;
}
if (isLoading) {
return "Загружаем данные...";
}
if (viewMode === "calendar" && monthFilter === "") {
return "Выберите месяц, чтобы увидеть его крупным планом.";
}
return "";
}, [errorMessage, isLoading, monthFilter, viewMode]);
const statusClassName = [
"races-status__message",
!statusMessage ? "races-status__message--empty" : "",
errorMessage && !isLoading ? "races-status__message--error" : "",
]
.filter(Boolean)
.join(" ");
if (errorMessage && races.length === 0 && !isLoading) {
return (
@@ -301,21 +320,16 @@ export function RacesPage(): JSX.Element {
</div>
</section>
{errorMessage && !isLoading ? (
<p className="page__subtitle page__subtitle--error" role="alert" style={{ marginTop: "var(--space-4)" }}>
{errorMessage}
<div className="races-status" aria-live="polite">
<p
className={statusClassName}
role={errorMessage && !isLoading ? "alert" : undefined}
aria-busy={isLoading || undefined}
aria-hidden={!statusMessage || undefined}
>
{statusMessage || "\u00a0"}
</p>
) : null}
{viewMode === "calendar" && monthFilter === "" ? (
<p className="page__subtitle races-cal__filter-hint">Выберите месяц, чтобы увидеть его крупным планом.</p>
) : null}
{isLoading ? (
<p className="page__subtitle" aria-busy="true">
Загружаем данные...
</p>
) : null}
</div>
{viewMode === "list" ? (
<div className="race-lists">

View File

@@ -967,6 +967,25 @@ a {
margin-top: var(--space-6);
}
.races-status {
min-height: calc(var(--line-height-base) * var(--font-size-caption));
margin-top: var(--space-4);
}
.races-status__message {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-caption);
}
.races-status__message--empty {
visibility: hidden;
}
.races-status__message--error {
color: var(--color-danger);
}
.races-cal__filter-hint {
margin-top: var(--space-3);
color: var(--color-text-muted);