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:
110
frontend/src/lib/raceMetrics.ts
Normal file
110
frontend/src/lib/raceMetrics.ts
Normal 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 "планирую";
|
||||
}
|
||||
Reference in New Issue
Block a user