Compare commits

..

4 Commits

Author SHA1 Message Date
Anton
a332703e2f feat(frontend): add dashboard and race calendar views
Implement dashboard metrics and split race lists with BEM-styled cards using the typed races API.

Made-with: Cursor
2026-04-06 17:17:08 +03:00
1d89e2bce2 Merge pull request 'feat(frontend): add typed API contract layer' (#1) from feat/backend-postgres-migrations into main
Reviewed-on: #1
2026-04-06 13:47:17 +00:00
Anton
800fbfa560 docs: fix plan 2026-04-06 16:14:14 +03:00
Anton
d318828f73 feat(frontend): add typed API contract layer
Made-with: Cursor
2026-04-06 16:09:28 +03:00
11 changed files with 800 additions and 8 deletions

View File

@@ -177,7 +177,7 @@ flowchart LR
## 10. Чеклист задач (implementation todos)
1. Монорепо: `frontend/` + `backend/`, BEM, токены, роутер.
2. Postgres в docker-compose, миграции таблицы `races`, бэкенд читает `DB_`*.
2. Postgres в docker-compose, миграции таблицы `races`, бэкенд читает `DB`_*.
3. REST CRUD + разовый seed (CSV и/или JSON) → БД.
4. Клиент API на фронте, типы, загрузка данных для экранов и PR.
5. Экраны месяц и год, модалка по дате.

View File

@@ -0,0 +1,75 @@
export type ApiErrorCode =
| "validation_error"
| "not_found"
| "database_unavailable"
| "conflict"
| "network_error"
| "unknown_error";
export interface ApiErrorPayload {
error?: string;
details?: string[];
}
export class ApiError extends Error {
public readonly code: ApiErrorCode;
public readonly status: number | null;
public readonly details: string[];
constructor(params: {
code: ApiErrorCode;
message: string;
status?: number | null;
details?: string[];
}) {
super(params.message);
this.name = "ApiError";
this.code = params.code;
this.status = params.status ?? null;
this.details = params.details ?? [];
}
}
function normalizeApiCode(value: string | undefined): ApiErrorCode {
if (
value === "validation_error" ||
value === "not_found" ||
value === "database_unavailable" ||
value === "conflict"
) {
return value;
}
return "unknown_error";
}
export function toApiError(status: number, payload: unknown): ApiError {
const maybePayload = payload as ApiErrorPayload;
const code = normalizeApiCode(maybePayload?.error);
const details = Array.isArray(maybePayload?.details)
? maybePayload.details.filter((item): item is string => typeof item === "string")
: [];
return new ApiError({
code,
status,
message: getApiErrorMessage(code),
details,
});
}
export function getApiErrorMessage(code: ApiErrorCode): string {
switch (code) {
case "validation_error":
return "Проверьте введённые данные и попробуйте снова.";
case "not_found":
return "Запись не найдена.";
case "database_unavailable":
return "Сервис временно недоступен. Попробуйте позже.";
case "conflict":
return "Запись с таким идентификатором уже существует.";
case "network_error":
return "Не удалось связаться с сервером.";
default:
return "Произошла неизвестная ошибка.";
}
}

55
frontend/src/api/http.ts Normal file
View File

@@ -0,0 +1,55 @@
import { ApiError, toApiError } from "./errors";
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.trim() || "http://localhost:3001";
function buildUrl(path: string): string {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
return `${API_BASE_URL}${normalizedPath}`;
}
async function parseResponseBody(response: Response): Promise<unknown> {
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
return null;
}
try {
return await response.json();
} catch {
return null;
}
}
export async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
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) {
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: "Не удалось связаться с сервером.",
});
}
}

View File

@@ -1 +1,3 @@
export {};
export type { CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types";
export { ApiError, getApiErrorMessage } from "./errors";
export { getRaceById, getRaces, createRace, updateRace, deleteRace } from "./races";

112
frontend/src/api/races.ts Normal file
View File

@@ -0,0 +1,112 @@
import { ApiError } from "./errors";
import { requestJson } from "./http";
import type { CreateRacePayload, Race, RacesQuery, UpdateRacePayload } from "./types";
function isString(value: unknown): value is string {
return typeof value === "string";
}
function isNullableString(value: unknown): value is string | null {
return value === null || typeof value === "string";
}
function normalizeRace(input: unknown): Race {
const race = input as Partial<Race>;
const isValid =
isString(race?.id) &&
isString(race?.date) &&
isString(race?.title) &&
typeof race?.distanceKm === "number" &&
(race?.status === null || race?.status === "planned" || race?.status === "completed") &&
isNullableString(race?.officialUrl) &&
isNullableString(race?.startTime) &&
isNullableString(race?.clusterSchedule) &&
isNullableString(race?.bibPickup) &&
isNullableString(race?.bibNumber) &&
isNullableString(race?.finishTime) &&
isNullableString(race?.notes) &&
isString(race?.createdAt) &&
(race?.updatedAt === null || isString(race?.updatedAt));
if (!isValid) {
throw new ApiError({
code: "unknown_error",
status: null,
message: "Некорректный формат данных от API.",
});
}
return {
id: race.id,
date: race.date,
title: race.title,
distanceKm: race.distanceKm,
status: race.status,
officialUrl: race.officialUrl,
startTime: race.startTime,
clusterSchedule: race.clusterSchedule,
bibPickup: race.bibPickup,
bibNumber: race.bibNumber,
finishTime: race.finishTime,
notes: race.notes,
createdAt: race.createdAt,
updatedAt: race.updatedAt,
};
}
function buildRacesQuery(query?: RacesQuery): string {
if (!query) {
return "";
}
const params = new URLSearchParams();
if (typeof query.year === "number") {
params.set("year", String(query.year));
}
if (typeof query.month === "number") {
params.set("month", String(query.month));
}
const serialized = params.toString();
return serialized ? `?${serialized}` : "";
}
export async function getRaces(query?: RacesQuery): Promise<Race[]> {
const response = await requestJson<unknown[]>(`/races${buildRacesQuery(query)}`);
if (!Array.isArray(response)) {
throw new ApiError({
code: "unknown_error",
status: null,
message: "Некорректный формат списка забегов от API.",
});
}
return response.map(normalizeRace);
}
export async function getRaceById(id: string): Promise<Race> {
return normalizeRace(await requestJson<unknown>(`/races/${id}`));
}
export async function createRace(payload: CreateRacePayload): Promise<Race> {
return normalizeRace(
await requestJson<unknown>("/races", {
method: "POST",
body: JSON.stringify(payload),
}),
);
}
export async function updateRace(id: string, payload: UpdateRacePayload): Promise<Race> {
return normalizeRace(
await requestJson<unknown>(`/races/${id}`, {
method: "PATCH",
body: JSON.stringify(payload),
}),
);
}
export async function deleteRace(id: string): Promise<void> {
await requestJson<void>(`/races/${id}`, { method: "DELETE" });
}

40
frontend/src/api/types.ts Normal file
View File

@@ -0,0 +1,40 @@
export type RaceStatus = "planned" | "completed";
export interface Race {
id: string;
date: string;
title: string;
distanceKm: number;
status: RaceStatus | null;
officialUrl: string | null;
startTime: string | null;
clusterSchedule: string | null;
bibPickup: string | null;
bibNumber: string | null;
finishTime: string | null;
notes: string | null;
createdAt: string;
updatedAt: string | null;
}
export interface RacesQuery {
year?: number;
month?: number;
}
export interface CreateRacePayload {
id: string;
date: string;
title: string;
distanceKm: number;
status?: RaceStatus | null;
officialUrl?: string | null;
startTime?: string | null;
clusterSchedule?: string | null;
bibPickup?: string | null;
bibNumber?: string | null;
finishTime?: string | null;
notes?: string | null;
}
export type UpdateRacePayload = Partial<Omit<CreateRacePayload, "id">>;

View File

@@ -1 +1,11 @@
export {};
export {
formatDistance,
formatRaceDate,
getPaceLabel,
getRaceCountdownLabel,
getRaceStatusLabel,
parseFinishTimeToSeconds,
sortByDateAsc,
sortByDateDesc,
splitRacesByDate,
} from "./raceMetrics";

View File

@@ -0,0 +1,110 @@
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`);
}
export function parseFinishTimeToSeconds(value: string | null): number | null {
if (!value) {
return null;
}
const parts = value.split(":").map((part) => Number(part));
if (parts.some((part) => Number.isNaN(part) || part < 0)) {
return null;
}
if (parts.length === 2) {
const [minutes, seconds] = parts;
return minutes * 60 + seconds;
}
if (parts.length === 3) {
const [hours, minutes, seconds] = parts;
return hours * 3600 + minutes * 60 + seconds;
}
return null;
}
export function formatDistance(distanceKm: number): string {
return `${distanceKm.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} км`;
}
export function formatRaceDate(date: string): string {
return parseRaceDate(date).toLocaleDateString("ru-RU", {
day: "2-digit",
month: "long",
year: "numeric",
});
}
export function sortByDateAsc(races: Race[]): Race[] {
return [...races].sort((left, right) => parseRaceDate(left.date).getTime() - parseRaceDate(right.date).getTime());
}
export function sortByDateDesc(races: Race[]): Race[] {
return [...races].sort((left, right) => parseRaceDate(right.date).getTime() - parseRaceDate(left.date).getTime());
}
export function splitRacesByDate(races: Race[], now: Date = new Date()): { upcoming: Race[]; past: Race[] } {
const today = new Date(now);
today.setHours(0, 0, 0, 0);
const upcoming: Race[] = [];
const past: Race[] = [];
for (const race of races) {
if (parseRaceDate(race.date).getTime() >= today.getTime()) {
upcoming.push(race);
} else {
past.push(race);
}
}
return {
upcoming: sortByDateAsc(upcoming),
past: sortByDateDesc(past),
};
}
export function getRaceCountdownLabel(date: string, now: Date = new Date()): string {
const today = new Date(now);
today.setHours(0, 0, 0, 0);
const target = parseRaceDate(date);
const days = Math.ceil((target.getTime() - today.getTime()) / MS_IN_DAY);
if (days <= 0) {
return "сегодня";
}
if (days === 1) {
return "через 1 день";
}
if (days < 5) {
return `через ${days} дня`;
}
return `через ${days} дней`;
}
export function getPaceLabel(finishTime: string | null, distanceKm: number): string | null {
const totalSeconds = parseFinishTimeToSeconds(finishTime);
if (!totalSeconds || distanceKm <= 0) {
return null;
}
const paceSeconds = Math.round(totalSeconds / distanceKm);
const paceMinutes = Math.floor(paceSeconds / 60);
const paceRemainder = paceSeconds % 60;
return `${String(paceMinutes).padStart(2, "0")}:${String(paceRemainder).padStart(2, "0")} /км`;
}
export function getRaceStatusLabel(status: Race["status"]): string {
if (status === "completed") {
return "пробежал";
}
return "планирую";
}

View File

@@ -1,8 +1,167 @@
import { useEffect, useMemo, useState } from "react";
import type { Race } from "../api";
import { ApiError, getRaces } from "../api";
import {
formatDistance,
formatRaceDate,
getRaceCountdownLabel,
parseFinishTimeToSeconds,
splitRacesByDate,
} from "../lib";
function getErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
return error.message;
}
return "Не удалось загрузить данные dashboard.";
}
export function DashboardPage(): JSX.Element {
const [races, setRaces] = useState<Race[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
async function loadDashboardData(): Promise<void> {
try {
const items = await getRaces();
if (!isMounted) {
return;
}
setRaces(items);
setErrorMessage(null);
} catch (error) {
if (!isMounted) {
return;
}
setErrorMessage(getErrorMessage(error));
} finally {
if (isMounted) {
setIsLoading(false);
}
}
}
void loadDashboardData();
return () => {
isMounted = false;
};
}, []);
const dashboardMetrics = useMemo(() => {
const { upcoming, past } = splitRacesByDate(races);
const completed = races.filter((race) => race.status === "completed");
const nextRace = upcoming[0] ?? null;
const lastResult = past.find((race) => race.status === "completed") ?? null;
let personalRecord: Race | null = null;
let personalRecordSeconds = Number.POSITIVE_INFINITY;
for (const race of completed) {
const finishSeconds = parseFinishTimeToSeconds(race.finishTime);
if (!finishSeconds) {
continue;
}
const candidate = finishSeconds / race.distanceKm;
if (candidate < personalRecordSeconds) {
personalRecordSeconds = candidate;
personalRecord = race;
}
}
const currentYear = new Date().getFullYear();
const seasonRaces = races.filter((race) => new Date(`${race.date}T00:00:00`).getFullYear() === currentYear);
const seasonCompleted = seasonRaces.filter((race) => race.status === "completed");
return {
nextRace,
lastResult,
personalRecord,
seasonTotal: seasonRaces.length,
seasonCompletedCount: seasonCompleted.length,
};
}, [races]);
if (isLoading) {
return (
<section className="page page--dashboard" aria-busy="true">
<h1 className="page__title">Dashboard</h1>
<p className="page__subtitle">Загружаем ваши старты...</p>
</section>
);
}
if (errorMessage) {
return (
<section className="page page--dashboard" role="alert">
<h1 className="page__title">Dashboard</h1>
<p className="page__subtitle page__subtitle--error">{errorMessage}</p>
</section>
);
}
return (
<section className="page page--dashboard">
<h1 className="page__title">Dashboard</h1>
<p className="page__subtitle">Overview cards and quick actions will be added in the next task.</p>
<p className="page__subtitle">Ключевые метрики по вашему календарю стартов.</p>
<div className="dashboard-grid" aria-label="Ключевые метрики">
<article className="dashboard-card">
<h2 className="dashboard-card__title">Ближайший старт</h2>
{dashboardMetrics.nextRace ? (
<>
<p className="dashboard-card__value">{dashboardMetrics.nextRace.title}</p>
<p className="dashboard-card__meta">
{formatRaceDate(dashboardMetrics.nextRace.date)} · {formatDistance(dashboardMetrics.nextRace.distanceKm)}
</p>
<p className="dashboard-card__hint">{getRaceCountdownLabel(dashboardMetrics.nextRace.date)}</p>
</>
) : (
<p className="dashboard-card__empty">Нет запланированных стартов.</p>
)}
</article>
<article className="dashboard-card">
<h2 className="dashboard-card__title">Последний результат</h2>
{dashboardMetrics.lastResult ? (
<>
<p className="dashboard-card__value">{dashboardMetrics.lastResult.finishTime ?? "время не указано"}</p>
<p className="dashboard-card__meta">
{dashboardMetrics.lastResult.title} · {formatDistance(dashboardMetrics.lastResult.distanceKm)}
</p>
<p className="dashboard-card__hint">{formatRaceDate(dashboardMetrics.lastResult.date)}</p>
</>
) : (
<p className="dashboard-card__empty">Пока нет завершённых стартов.</p>
)}
</article>
<article className="dashboard-card">
<h2 className="dashboard-card__title">Личный рекорд</h2>
{dashboardMetrics.personalRecord ? (
<>
<p className="dashboard-card__value">{dashboardMetrics.personalRecord.finishTime ?? "время не указано"}</p>
<p className="dashboard-card__meta">
{dashboardMetrics.personalRecord.title} · {formatDistance(dashboardMetrics.personalRecord.distanceKm)}
</p>
<p className="dashboard-card__hint">Лучший темп среди завершённых стартов.</p>
</>
) : (
<p className="dashboard-card__empty">Недостаточно данных для PR.</p>
)}
</article>
<article className="dashboard-card">
<h2 className="dashboard-card__title">Сезон</h2>
<p className="dashboard-card__value">{dashboardMetrics.seasonTotal}</p>
<p className="dashboard-card__meta">стартов в этом году</p>
<p className="dashboard-card__hint">Завершено: {dashboardMetrics.seasonCompletedCount}</p>
</article>
</div>
</section>
);
}

View File

@@ -1,8 +1,113 @@
export function RacesPage(): JSX.Element {
import { useEffect, useMemo, useState } from "react";
import type { Race } from "../api";
import { ApiError, getRaces } from "../api";
import { formatDistance, formatRaceDate, getRaceStatusLabel, splitRacesByDate } from "../lib";
function getErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
return error.message;
}
return "Не удалось загрузить календарь стартов.";
}
function RaceList(props: { title: string; races: Race[] }): JSX.Element {
const { title, races } = props;
return (
<section className="page page--races">
<h1 className="page__title">Races</h1>
<p className="page__subtitle">Upcoming and completed race lists will be added in the next task.</p>
<section className="race-list" aria-label={title}>
<h2 className="race-list__title">{title}</h2>
{races.length > 0 ? (
<ul className="race-list__items">
{races.map((race) => (
<li key={race.id} className="race-card">
<div className="race-card__main">
<p className="race-card__title">{race.title}</p>
<p className="race-card__meta">
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}
</p>
</div>
<span
className={
race.status === "completed"
? "race-card__status race-card__status--completed"
: "race-card__status race-card__status--planned"
}
>
{getRaceStatusLabel(race.status)}
</span>
</li>
))}
</ul>
) : (
<p className="race-list__empty">Пока нет данных в этом разделе.</p>
)}
</section>
);
}
export function RacesPage(): JSX.Element {
const [races, setRaces] = useState<Race[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
async function loadRaces(): Promise<void> {
try {
const items = await getRaces();
if (!isMounted) {
return;
}
setRaces(items);
setErrorMessage(null);
} catch (error) {
if (!isMounted) {
return;
}
setErrorMessage(getErrorMessage(error));
} finally {
if (isMounted) {
setIsLoading(false);
}
}
}
void loadRaces();
return () => {
isMounted = false;
};
}, []);
const { upcoming, past } = useMemo(() => splitRacesByDate(races), [races]);
if (isLoading) {
return (
<section className="page page--races" aria-busy="true">
<h1 className="page__title">Календарь стартов</h1>
<p className="page__subtitle">Загружаем данные...</p>
</section>
);
}
if (errorMessage) {
return (
<section className="page page--races" role="alert">
<h1 className="page__title">Календарь стартов</h1>
<p className="page__subtitle page__subtitle--error">{errorMessage}</p>
</section>
);
}
return (
<section className="page page--races">
<h1 className="page__title">Календарь стартов</h1>
<p className="page__subtitle">Будущие и прошедшие старты в одном месте.</p>
<div className="race-lists">
<RaceList title="Будущие" races={upcoming} />
<RaceList title="Прошедшие" races={past} />
</div>
</section>
);
}

View File

@@ -91,3 +91,127 @@ a {
margin: 0;
color: var(--color-text-muted);
}
.page__subtitle--error {
color: var(--color-error);
}
.dashboard-grid {
margin-top: var(--space-6);
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
}
.dashboard-card {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-5);
background: #fcfdff;
}
.dashboard-card__title {
margin: 0 0 var(--space-3);
font-size: var(--font-size-body);
color: var(--color-text-muted);
}
.dashboard-card__value {
margin: 0;
font-size: var(--font-size-h2);
font-weight: 700;
color: var(--color-text);
}
.dashboard-card__meta {
margin: var(--space-2) 0 0;
color: var(--color-text);
}
.dashboard-card__hint,
.dashboard-card__empty {
margin: var(--space-2) 0 0;
color: var(--color-text-muted);
font-size: var(--font-size-caption);
}
.race-lists {
margin-top: var(--space-6);
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
}
.race-list {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-5);
background: #fcfdff;
}
.race-list__title {
margin: 0 0 var(--space-4);
font-size: var(--font-size-h2);
}
.race-list__items {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: var(--space-3);
}
.race-list__empty {
margin: 0;
color: var(--color-text-muted);
}
.race-card {
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--space-3);
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
background: var(--color-surface);
}
.race-card__title {
margin: 0;
font-weight: 600;
}
.race-card__meta {
margin: var(--space-2) 0 0;
color: var(--color-text-muted);
font-size: var(--font-size-caption);
}
.race-card__status {
display: inline-flex;
align-items: center;
white-space: nowrap;
border-radius: 999px;
padding: 0.2rem 0.5rem;
font-size: var(--font-size-caption);
font-weight: 600;
}
.race-card__status--planned {
background: #edf3ff;
color: var(--color-accent);
}
.race-card__status--completed {
background: #ecf8f1;
color: var(--color-success);
}
@media (max-width: 900px) {
.dashboard-grid,
.race-lists {
grid-template-columns: 1fr;
}
}