Some checks failed
CI / build-and-test (pull_request) Has been cancelled
- 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
170 lines
4.9 KiB
TypeScript
170 lines
4.9 KiB
TypeScript
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 "планирую";
|
||
}
|