Files
runners-calendar/frontend/src/pages/DashboardPage.tsx
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

168 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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">Ключевые метрики по вашему календарю стартов.</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>
);
}