feat: align docs with code, finish_place, registered status, UI filters, tests, CI
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
- 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
This commit is contained in:
73
frontend/src/components/PaceTrendChart.tsx
Normal file
73
frontend/src/components/PaceTrendChart.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user