Files
runners-calendar/frontend/src/components/PaceTrendChart.tsx
Vaka.pro a2dcf67396
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
feat: align docs with code, finish_place, registered status, UI filters, tests, CI
- Add PLAN.md and sync backend docs, .env.example, API doc (404 details)
- Document mock DB and PORT/API_PORT in docs/backend.md; README monorepo + frontend/.env.example
- Migration 002: finish_place column, status registered; mapper and mock DB updated
- Frontend: registered status, finishPlace, calendar year/month filters, pace sparkline
- Extract createApp for tests; supertest + tsx; GitHub Actions CI

Made-with: Cursor
2026-04-06 22:20:31 +03:00

74 lines
2.2 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 type { Race } from "../api";
import { formatRaceDate, isCloseDistance, parseFinishTimeToSeconds } from "../lib";
type PaceTrendChartProps = {
races: Race[];
distanceKm: number;
};
/**
* Minimal SVG sparkline: finish time (minutes) over chronological completed races
* at the selected distance. Lower time = higher point (better).
*/
export function PaceTrendChart(props: PaceTrendChartProps): JSX.Element {
const { races, distanceKm } = props;
const series = races
.filter(
(race) =>
race.status === "completed" &&
isCloseDistance(race.distanceKm, distanceKm) &&
parseFinishTimeToSeconds(race.finishTime) != null,
)
.sort(
(a, b) => new Date(`${a.date}T00:00:00`).getTime() - new Date(`${b.date}T00:00:00`).getTime(),
)
.map((race) => {
const seconds = parseFinishTimeToSeconds(race.finishTime)!;
return { race, minutes: seconds / 60 };
});
if (series.length < 2) {
return (
<p className="pace-chart__empty">
Нужно минимум два завершённых старта с временем на выбранной дистанции.
</p>
);
}
const minutes = series.map((s) => s.minutes);
const minM = Math.min(...minutes);
const maxM = Math.max(...minutes);
const range = maxM - minM || 1;
const n = series.length;
const pad = 4;
const w = 100;
const h = 36;
const innerW = w - pad * 2;
const innerH = h - pad * 2;
const points = series
.map((s, i) => {
const x = pad + (n === 1 ? innerW / 2 : (i / (n - 1)) * innerW);
const norm = (maxM - s.minutes) / range;
const y = pad + (1 - norm) * innerH;
return `${x},${y}`;
})
.join(" ");
const last = series[series.length - 1]!;
return (
<div className="pace-chart">
<svg className="pace-chart__svg" viewBox={`0 0 ${w} ${h}`} role="img" aria-label="Динамика времени на дистанции">
<polyline className="pace-chart__line" fill="none" points={points} />
</svg>
<p className="pace-chart__caption">
Последний пункт: {formatRaceDate(last.race.date)} {last.race.finishTime} (
{last.minutes.toFixed(1)} мин)
</p>
</div>
);
}