diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts index cb0ff5c..cc8ea5d 100644 --- a/frontend/src/lib/index.ts +++ b/frontend/src/lib/index.ts @@ -1 +1,11 @@ -export {}; +export { + formatDistance, + formatRaceDate, + getPaceLabel, + getRaceCountdownLabel, + getRaceStatusLabel, + parseFinishTimeToSeconds, + sortByDateAsc, + sortByDateDesc, + splitRacesByDate, +} from "./raceMetrics"; diff --git a/frontend/src/lib/raceMetrics.ts b/frontend/src/lib/raceMetrics.ts new file mode 100644 index 0000000..d51793e --- /dev/null +++ b/frontend/src/lib/raceMetrics.ts @@ -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 "планирую"; +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index a4dc683..7c9e56c 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,8 +1,167 @@ +import { useEffect, useMemo, useState } from "react"; +import type { Race } from "../api"; +import { ApiError, getRaces } from "../api"; +import { + formatDistance, + formatRaceDate, + getRaceCountdownLabel, + parseFinishTimeToSeconds, + splitRacesByDate, +} from "../lib"; + +function getErrorMessage(error: unknown): string { + if (error instanceof ApiError) { + return error.message; + } + return "Не удалось загрузить данные dashboard."; +} + export function DashboardPage(): JSX.Element { + const [races, setRaces] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(null); + + useEffect(() => { + let isMounted = true; + + async function loadDashboardData(): Promise { + try { + const items = await getRaces(); + if (!isMounted) { + return; + } + setRaces(items); + setErrorMessage(null); + } catch (error) { + if (!isMounted) { + return; + } + setErrorMessage(getErrorMessage(error)); + } finally { + if (isMounted) { + setIsLoading(false); + } + } + } + + void loadDashboardData(); + return () => { + isMounted = false; + }; + }, []); + + const dashboardMetrics = useMemo(() => { + const { upcoming, past } = splitRacesByDate(races); + const completed = races.filter((race) => race.status === "completed"); + + const nextRace = upcoming[0] ?? null; + const lastResult = past.find((race) => race.status === "completed") ?? null; + + let personalRecord: Race | null = null; + let personalRecordSeconds = Number.POSITIVE_INFINITY; + + for (const race of completed) { + const finishSeconds = parseFinishTimeToSeconds(race.finishTime); + if (!finishSeconds) { + continue; + } + + const candidate = finishSeconds / race.distanceKm; + if (candidate < personalRecordSeconds) { + personalRecordSeconds = candidate; + personalRecord = race; + } + } + + const currentYear = new Date().getFullYear(); + const seasonRaces = races.filter((race) => new Date(`${race.date}T00:00:00`).getFullYear() === currentYear); + const seasonCompleted = seasonRaces.filter((race) => race.status === "completed"); + + return { + nextRace, + lastResult, + personalRecord, + seasonTotal: seasonRaces.length, + seasonCompletedCount: seasonCompleted.length, + }; + }, [races]); + + if (isLoading) { + return ( +
+

Dashboard

+

Загружаем ваши старты...

+
+ ); + } + + if (errorMessage) { + return ( +
+

Dashboard

+

{errorMessage}

+
+ ); + } + return (

Dashboard

-

Overview cards and quick actions will be added in the next task.

+

Ключевые метрики по вашему календарю стартов.

+ +
+
+

Ближайший старт

+ {dashboardMetrics.nextRace ? ( + <> +

{dashboardMetrics.nextRace.title}

+

+ {formatRaceDate(dashboardMetrics.nextRace.date)} · {formatDistance(dashboardMetrics.nextRace.distanceKm)} +

+

{getRaceCountdownLabel(dashboardMetrics.nextRace.date)}

+ + ) : ( +

Нет запланированных стартов.

+ )} +
+ +
+

Последний результат

+ {dashboardMetrics.lastResult ? ( + <> +

{dashboardMetrics.lastResult.finishTime ?? "время не указано"}

+

+ {dashboardMetrics.lastResult.title} · {formatDistance(dashboardMetrics.lastResult.distanceKm)} +

+

{formatRaceDate(dashboardMetrics.lastResult.date)}

+ + ) : ( +

Пока нет завершённых стартов.

+ )} +
+ +
+

Личный рекорд

+ {dashboardMetrics.personalRecord ? ( + <> +

{dashboardMetrics.personalRecord.finishTime ?? "время не указано"}

+

+ {dashboardMetrics.personalRecord.title} · {formatDistance(dashboardMetrics.personalRecord.distanceKm)} +

+

Лучший темп среди завершённых стартов.

+ + ) : ( +

Недостаточно данных для PR.

+ )} +
+ +
+

Сезон

+

{dashboardMetrics.seasonTotal}

+

стартов в этом году

+

Завершено: {dashboardMetrics.seasonCompletedCount}

+
+
); } diff --git a/frontend/src/pages/RacesPage.tsx b/frontend/src/pages/RacesPage.tsx index 1bb284e..7e3fca3 100644 --- a/frontend/src/pages/RacesPage.tsx +++ b/frontend/src/pages/RacesPage.tsx @@ -1,8 +1,113 @@ -export function RacesPage(): JSX.Element { +import { useEffect, useMemo, useState } from "react"; +import type { Race } from "../api"; +import { ApiError, getRaces } from "../api"; +import { formatDistance, formatRaceDate, getRaceStatusLabel, splitRacesByDate } from "../lib"; + +function getErrorMessage(error: unknown): string { + if (error instanceof ApiError) { + return error.message; + } + return "Не удалось загрузить календарь стартов."; +} + +function RaceList(props: { title: string; races: Race[] }): JSX.Element { + const { title, races } = props; + return ( -
-

Races

-

Upcoming and completed race lists will be added in the next task.

+
+

{title}

+ {races.length > 0 ? ( +
    + {races.map((race) => ( +
  • +
    +

    {race.title}

    +

    + {formatRaceDate(race.date)} · {formatDistance(race.distanceKm)} +

    +
    + + {getRaceStatusLabel(race.status)} + +
  • + ))} +
+ ) : ( +

Пока нет данных в этом разделе.

+ )} +
+ ); +} + +export function RacesPage(): JSX.Element { + const [races, setRaces] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(null); + + useEffect(() => { + let isMounted = true; + + async function loadRaces(): Promise { + try { + const items = await getRaces(); + if (!isMounted) { + return; + } + setRaces(items); + setErrorMessage(null); + } catch (error) { + if (!isMounted) { + return; + } + setErrorMessage(getErrorMessage(error)); + } finally { + if (isMounted) { + setIsLoading(false); + } + } + } + + void loadRaces(); + return () => { + isMounted = false; + }; + }, []); + + const { upcoming, past } = useMemo(() => splitRacesByDate(races), [races]); + + if (isLoading) { + return ( +
+

Календарь стартов

+

Загружаем данные...

+
+ ); + } + + if (errorMessage) { + return ( +
+

Календарь стартов

+

{errorMessage}

+
+ ); + } + + return ( +
+

Календарь стартов

+

Будущие и прошедшие старты в одном месте.

+ +
+ + +
); } diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index ac78917..eee2a88 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -91,3 +91,127 @@ a { margin: 0; color: var(--color-text-muted); } + +.page__subtitle--error { + color: var(--color-error); +} + +.dashboard-grid { + margin-top: var(--space-6); + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-4); +} + +.dashboard-card { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-5); + background: #fcfdff; +} + +.dashboard-card__title { + margin: 0 0 var(--space-3); + font-size: var(--font-size-body); + color: var(--color-text-muted); +} + +.dashboard-card__value { + margin: 0; + font-size: var(--font-size-h2); + font-weight: 700; + color: var(--color-text); +} + +.dashboard-card__meta { + margin: var(--space-2) 0 0; + color: var(--color-text); +} + +.dashboard-card__hint, +.dashboard-card__empty { + margin: var(--space-2) 0 0; + color: var(--color-text-muted); + font-size: var(--font-size-caption); +} + +.race-lists { + margin-top: var(--space-6); + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-4); +} + +.race-list { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-5); + background: #fcfdff; +} + +.race-list__title { + margin: 0 0 var(--space-4); + font-size: var(--font-size-h2); +} + +.race-list__items { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: var(--space-3); +} + +.race-list__empty { + margin: 0; + color: var(--color-text-muted); +} + +.race-card { + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: var(--space-3); + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-3); + background: var(--color-surface); +} + +.race-card__title { + margin: 0; + font-weight: 600; +} + +.race-card__meta { + margin: var(--space-2) 0 0; + color: var(--color-text-muted); + font-size: var(--font-size-caption); +} + +.race-card__status { + display: inline-flex; + align-items: center; + white-space: nowrap; + border-radius: 999px; + padding: 0.2rem 0.5rem; + font-size: var(--font-size-caption); + font-weight: 600; +} + +.race-card__status--planned { + background: #edf3ff; + color: var(--color-accent); +} + +.race-card__status--completed { + background: #ecf8f1; + color: var(--color-success); +} + +@media (max-width: 900px) { + .dashboard-grid, + .race-lists { + grid-template-columns: 1fr; + } +}