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
This commit is contained in:
Anton
2026-04-06 17:17:08 +03:00
parent 1d89e2bce2
commit a332703e2f
5 changed files with 514 additions and 6 deletions

View File

@@ -0,0 +1,110 @@
import type { Race } from "../api";
const MS_IN_DAY = 24 * 60 * 60 * 1000;
function parseRaceDate(date: string): Date {
return new Date(`${date}T00:00:00`);
}
export function parseFinishTimeToSeconds(value: string | null): number | null {
if (!value) {
return null;
}
const parts = value.split(":").map((part) => Number(part));
if (parts.some((part) => Number.isNaN(part) || part < 0)) {
return null;
}
if (parts.length === 2) {
const [minutes, seconds] = parts;
return minutes * 60 + seconds;
}
if (parts.length === 3) {
const [hours, minutes, seconds] = parts;
return hours * 3600 + minutes * 60 + seconds;
}
return null;
}
export function formatDistance(distanceKm: number): string {
return `${distanceKm.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} км`;
}
export function formatRaceDate(date: string): string {
return parseRaceDate(date).toLocaleDateString("ru-RU", {
day: "2-digit",
month: "long",
year: "numeric",
});
}
export function sortByDateAsc(races: Race[]): Race[] {
return [...races].sort((left, right) => parseRaceDate(left.date).getTime() - parseRaceDate(right.date).getTime());
}
export function sortByDateDesc(races: Race[]): Race[] {
return [...races].sort((left, right) => parseRaceDate(right.date).getTime() - parseRaceDate(left.date).getTime());
}
export function splitRacesByDate(races: Race[], now: Date = new Date()): { upcoming: Race[]; past: Race[] } {
const today = new Date(now);
today.setHours(0, 0, 0, 0);
const upcoming: Race[] = [];
const past: Race[] = [];
for (const race of races) {
if (parseRaceDate(race.date).getTime() >= today.getTime()) {
upcoming.push(race);
} else {
past.push(race);
}
}
return {
upcoming: sortByDateAsc(upcoming),
past: sortByDateDesc(past),
};
}
export function getRaceCountdownLabel(date: string, now: Date = new Date()): string {
const today = new Date(now);
today.setHours(0, 0, 0, 0);
const target = parseRaceDate(date);
const days = Math.ceil((target.getTime() - today.getTime()) / MS_IN_DAY);
if (days <= 0) {
return "сегодня";
}
if (days === 1) {
return "через 1 день";
}
if (days < 5) {
return `через ${days} дня`;
}
return `через ${days} дней`;
}
export function getPaceLabel(finishTime: string | null, distanceKm: number): string | null {
const totalSeconds = parseFinishTimeToSeconds(finishTime);
if (!totalSeconds || distanceKm <= 0) {
return null;
}
const paceSeconds = Math.round(totalSeconds / distanceKm);
const paceMinutes = Math.floor(paceSeconds / 60);
const paceRemainder = paceSeconds % 60;
return `${String(paceMinutes).padStart(2, "0")}:${String(paceRemainder).padStart(2, "0")} /км`;
}
export function getRaceStatusLabel(status: Race["status"]): string {
if (status === "completed") {
return "пробежал";
}
return "планирую";
}