Compare commits
2 Commits
feat/race-
...
feat/front
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7845d8d961 | ||
| 0ddf37683a |
@@ -5,10 +5,13 @@ import {
|
||||
formatDistance,
|
||||
formatRaceDate,
|
||||
getRaceCountdownLabel,
|
||||
getPaceLabel,
|
||||
parseFinishTimeToSeconds,
|
||||
splitRacesByDate,
|
||||
} from "../lib";
|
||||
|
||||
const PR_DISTANCES = [5, 10, 21.1, 42.2] as const;
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.message;
|
||||
@@ -16,6 +19,10 @@ function getErrorMessage(error: unknown): string {
|
||||
return "Не удалось загрузить данные dashboard.";
|
||||
}
|
||||
|
||||
function isSameDistance(left: number, right: number): boolean {
|
||||
return Math.abs(left - right) < 0.05;
|
||||
}
|
||||
|
||||
export function DashboardPage(): JSX.Element {
|
||||
const [races, setRaces] = useState<Race[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
@@ -86,6 +93,54 @@ export function DashboardPage(): JSX.Element {
|
||||
};
|
||||
}, [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) {
|
||||
return (
|
||||
<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>
|
||||
</article>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,6 +135,49 @@ a {
|
||||
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 {
|
||||
margin-top: var(--space-6);
|
||||
display: grid;
|
||||
|
||||
Reference in New Issue
Block a user