Files
runners-calendar/frontend/src/lib/raceMetrics.ts
Vaka.pro 3c6baa66a1
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
feat(frontend): race form, start time selects, calendar views, day page
- Hide org schedule fields when editing a past race; isRaceDateInPast helper
- StartTimeSelects (HH:mm:ss) and optional ?date= prefill on new race
- Full-card link to edit for races needing result entry; shadow token
- List/calendar toggle (sessionStorage); year grid and month focus views
- Date hover popover and /races/day/:ymd page with Add button
- Docs plan-korrektirovok-starty.md and startTime API note; client 0.4.0

Made-with: Cursor
2026-04-13 22:07:37 +03:00

170 lines
4.9 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";
const MS_IN_DAY = 24 * 60 * 60 * 1000;
/** API date: YYYY-MM-DD или ISO-строка от сериализации (не склеивать с «T00:00:00» повторно). */
export function parseRaceDate(date: string): Date {
const ymd = date.slice(0, 10);
if (/^\d{4}-\d{2}-\d{2}$/.test(ymd)) {
return new Date(`${ymd}T00:00:00`);
}
const parsed = new Date(date);
return parsed;
}
/** Дата старта (календарный день) строго раньше сегодняшней полуночи по локали. */
export function isRaceDateInPast(raceDate: string, now: Date = new Date()): boolean {
const today = new Date(now);
today.setHours(0, 0, 0, 0);
return parseRaceDate(raceDate).getTime() < today.getTime();
}
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),
};
}
function pluralizeDays(n: number): string {
const mod10 = n % 10;
const mod100 = n % 100;
if (mod100 >= 11 && mod100 <= 19) {
return "дней";
}
if (mod10 === 1) {
return "день";
}
if (mod10 >= 2 && mod10 <= 4) {
return "дня";
}
return "дней";
}
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 "сегодня";
}
return `через ${days} ${pluralizeDays(days)}`;
}
export function isCloseDistance(left: number, right: number): boolean {
return Math.abs(left - right) < 0.05;
}
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")} /км`;
}
function isPastDateNeedingResult(status: Race["status"], raceDate: string): boolean {
if (status !== "planned" && status !== "registered") {
return false;
}
const today = new Date();
today.setHours(0, 0, 0, 0);
return parseRaceDate(raceDate).getTime() < today.getTime();
}
export function raceNeedsResultEntry(race: Race): boolean {
return isPastDateNeedingResult(race.status, race.date);
}
export function getRaceStatusClassName(status: Race["status"], raceDate?: string): string {
const base = "race-card__status";
let tier = `${base}--planned`;
if (status === "completed") {
tier = `${base}--completed`;
} else if (status === "registered") {
tier = `${base}--registered`;
}
const needs =
raceDate && isPastDateNeedingResult(status, raceDate) ? ` ${base}--needs-result` : "";
return `${base} ${tier}${needs}`;
}
export function getRaceStatusLabel(status: Race["status"], raceDate?: string): string {
if (raceDate && isPastDateNeedingResult(status, raceDate)) {
return "внесите результат";
}
if (status === "completed") {
return "пробежал";
}
if (status === "registered") {
return "зарегистрирован";
}
return "планирую";
}