feat(dashboard): add PR by distance and race comparison table #4

Merged
admin merged 1 commits from feat/frontend-task-5-pr-comparison into main 2026-04-06 16:10:27 +00:00
2 changed files with 152 additions and 0 deletions

View File

@@ -5,10 +5,13 @@ import {
formatDistance, formatDistance,
formatRaceDate, formatRaceDate,
getRaceCountdownLabel, getRaceCountdownLabel,
getPaceLabel,
parseFinishTimeToSeconds, parseFinishTimeToSeconds,
splitRacesByDate, splitRacesByDate,
} from "../lib"; } from "../lib";
const PR_DISTANCES = [5, 10, 21.1, 42.2] as const;
function getErrorMessage(error: unknown): string { function getErrorMessage(error: unknown): string {
if (error instanceof ApiError) { if (error instanceof ApiError) {
return error.message; return error.message;
@@ -16,6 +19,10 @@ function getErrorMessage(error: unknown): string {
return "Не удалось загрузить данные dashboard."; return "Не удалось загрузить данные dashboard.";
} }
function isSameDistance(left: number, right: number): boolean {
return Math.abs(left - right) < 0.05;
}
export function DashboardPage(): JSX.Element { export function DashboardPage(): JSX.Element {
const [races, setRaces] = useState<Race[]>([]); const [races, setRaces] = useState<Race[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
@@ -86,6 +93,54 @@ export function DashboardPage(): JSX.Element {
}; };
}, [races]); }, [races]);
const personalRecordsByDistance = useMemo(() => {
return PR_DISTANCES.map((distanceKm) => {
const candidates = races.filter((race) => {
return (
race.status === "completed" &&
isSameDistance(race.distanceKm, distanceKm) &&
Boolean(parseFinishTimeToSeconds(race.finishTime))
);
});
let bestRace: Race | null = null;
let bestPace = Number.POSITIVE_INFINITY;
for (const race of candidates) {
const totalSeconds = parseFinishTimeToSeconds(race.finishTime);
if (!totalSeconds) {
continue;
}
const paceSeconds = totalSeconds / race.distanceKm;
if (paceSeconds < bestPace) {
bestPace = paceSeconds;
bestRace = race;
}
}
return {
distanceKm,
bestRace,
};
});
}, [races]);
const comparisonRows = useMemo(() => {
return races
.filter((race) => race.status === "completed")
.map((race) => ({
id: race.id,
year: new Date(`${race.date}T00:00:00`).getFullYear(),
title: race.title,
distance: formatDistance(race.distanceKm),
finishTime: race.finishTime ?? "время не указано",
pace: getPaceLabel(race.finishTime, race.distanceKm) ?? "не удалось вычислить",
place: "нет данных",
}))
.sort((left, right) => right.year - left.year || left.title.localeCompare(right.title, "ru-RU"));
}, [races]);
if (isLoading) { if (isLoading) {
return ( return (
<section className="page page--dashboard" aria-busy="true"> <section className="page page--dashboard" aria-busy="true">
@@ -162,6 +217,60 @@ export function DashboardPage(): JSX.Element {
<p className="dashboard-card__hint">Завершено: {dashboardMetrics.seasonCompletedCount}</p> <p className="dashboard-card__hint">Завершено: {dashboardMetrics.seasonCompletedCount}</p>
</article> </article>
</div> </div>
<section className="dashboard-section" aria-label="Личные рекорды по дистанциям">
<h2 className="dashboard-section__title">PR по дистанциям</h2>
<div className="dashboard-grid dashboard-grid--pr">
{personalRecordsByDistance.map((item) => (
<article key={item.distanceKm} className="dashboard-card">
<h3 className="dashboard-card__title">{formatDistance(item.distanceKm)}</h3>
{item.bestRace ? (
<>
<p className="dashboard-card__value">{item.bestRace.finishTime ?? "время не указано"}</p>
<p className="dashboard-card__meta">{item.bestRace.title}</p>
<p className="dashboard-card__hint">{formatRaceDate(item.bestRace.date)}</p>
</>
) : (
<p className="dashboard-card__empty">Нет завершённых стартов для этой дистанции.</p>
)}
</article>
))}
</div>
</section>
<section className="dashboard-section" aria-label="Сравнение завершённых стартов">
<h2 className="dashboard-section__title">Сравнение стартов</h2>
{comparisonRows.length > 0 ? (
<div className="comparison-table-wrapper">
<table className="comparison-table">
<thead>
<tr>
<th>Год</th>
<th>Старт</th>
<th>Дистанция</th>
<th>Время</th>
<th>Темп</th>
<th>Место</th>
</tr>
</thead>
<tbody>
{comparisonRows.map((row) => (
<tr key={row.id}>
<td>{row.year}</td>
<td>{row.title}</td>
<td>{row.distance}</td>
<td>{row.finishTime}</td>
<td>{row.pace}</td>
<td>{row.place}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="dashboard-card__empty">Нет completed-стартов для сравнения.</p>
)}
</section>
</section> </section>
); );
} }

View File

@@ -135,6 +135,49 @@ a {
font-size: var(--font-size-caption); font-size: var(--font-size-caption);
} }
.dashboard-section {
margin-top: var(--space-6);
}
.dashboard-section__title {
margin: 0 0 var(--space-4);
font-size: var(--font-size-h2);
}
.dashboard-grid--pr {
margin-top: 0;
}
.comparison-table-wrapper {
overflow-x: auto;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: #fcfdff;
}
.comparison-table {
width: 100%;
border-collapse: collapse;
min-width: 720px;
}
.comparison-table th,
.comparison-table td {
padding: var(--space-3);
text-align: left;
border-bottom: 1px solid var(--color-border);
}
.comparison-table th {
color: var(--color-text-muted);
font-size: var(--font-size-caption);
font-weight: 600;
}
.comparison-table tbody tr:last-child td {
border-bottom: none;
}
.race-lists { .race-lists {
margin-top: var(--space-6); margin-top: var(--space-6);
display: grid; display: grid;