diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 10a354d..d7e6669 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "calendar-run-frontend", - "version": "0.4.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "calendar-run-frontend", - "version": "0.4.0", + "version": "0.4.1", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/frontend/package.json b/frontend/package.json index a2a58cd..e9a0434 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "calendar-run-frontend", "private": true, - "version": "0.4.0", + "version": "0.4.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 268fff1..3127d49 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react"; +import { Link } from "react-router-dom"; import type { Race } from "../api"; import { ApiError, getRaces } from "../api"; import { PaceTrendChart } from "../components"; @@ -61,26 +62,14 @@ export function DashboardPage(): JSX.Element { 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 lastPersonalRecord = + past.find( + (race) => race.status === "completed" && parseFinishTimeToSeconds(race.finishTime) !== null, + ) ?? null; const currentYear = new Date().getFullYear(); const seasonRaces = races.filter((race) => parseRaceDate(race.date).getFullYear() === currentYear); @@ -89,7 +78,7 @@ export function DashboardPage(): JSX.Element { return { nextRace, lastResult, - personalRecord, + lastPersonalRecord, seasonTotal: seasonRaces.length, seasonCompletedCount: seasonCompleted.length, }; @@ -167,48 +156,79 @@ export function DashboardPage(): JSX.Element {

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

-
-

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

+
{dashboardMetrics.nextRace ? ( - <> + +

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

{dashboardMetrics.nextRace.title}

- {formatRaceDate(dashboardMetrics.nextRace.date)} · {formatDistance(dashboardMetrics.nextRace.distanceKm)} + {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)} +

+ {dashboardMetrics.lastPersonalRecord ? ( + +

Последний личный рекорд

+

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

-

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

- +

+ {dashboardMetrics.lastPersonalRecord.title} ·{" "} + {formatDistance(dashboardMetrics.lastPersonalRecord.distanceKm)} +

+

{formatRaceDate(dashboardMetrics.lastPersonalRecord.date)}

+ ) : ( -

Недостаточно данных для личного рекорда.

+ <> +

Последний личный рекорд

+

Нет завершённых стартов с финишным временем.

+ )}
diff --git a/frontend/src/pages/RaceFormPage.tsx b/frontend/src/pages/RaceFormPage.tsx index 05c7bf4..72178da 100644 --- a/frontend/src/pages/RaceFormPage.tsx +++ b/frontend/src/pages/RaceFormPage.tsx @@ -3,7 +3,7 @@ import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom" import { ApiError, createRace, getRaceById, updateRace } from "../api"; import type { CreateRacePayload, Race, RaceStatus, UpdateRacePayload } from "../api"; import { StartTimeSelects } from "../components/StartTimeSelects"; -import { isRaceDateInPast } from "../lib"; +import { isRaceDateInPast, parseFinishTimeToSeconds } from "../lib"; function slugify(text: string): string { return text @@ -151,7 +151,16 @@ export function RaceFormPage(): JSX.Element { const handleChange = useCallback( (event: React.ChangeEvent) => { const { name, value } = event.target; - setForm((prev) => ({ ...prev, [name]: value })); + setForm((prev) => { + const next = { ...prev, [name]: value }; + if (name === "finishTime") { + const trimmed = value.trim(); + if (trimmed !== "" && parseFinishTimeToSeconds(trimmed) !== null) { + return { ...next, status: "completed" }; + } + } + return next; + }); }, [], ); @@ -170,10 +179,16 @@ export function RaceFormPage(): JSX.Element { setIsSaving(true); try { - const statusValue: RaceStatus | null = + const finishTrimmed = form.finishTime.trim(); + const hasParsedFinish = + finishTrimmed !== "" && parseFinishTimeToSeconds(finishTrimmed) !== null; + let statusValue: RaceStatus | null = form.status === "planned" || form.status === "registered" || form.status === "completed" ? form.status : null; + if (hasParsedFinish) { + statusValue = "completed"; + } if (isEditMode && raceId) { const payload: UpdateRacePayload = { diff --git a/frontend/src/pages/RacesPage.tsx b/frontend/src/pages/RacesPage.tsx index 623a716..ba774d7 100644 --- a/frontend/src/pages/RacesPage.tsx +++ b/frontend/src/pages/RacesPage.tsx @@ -8,7 +8,6 @@ import { formatRaceDate, getRaceStatusClassName, getRaceStatusLabel, - raceNeedsResultEntry, splitRacesByDate, } from "../lib"; @@ -67,38 +66,16 @@ function RaceList(props: { title: string; races: Race[] }): JSX.Element {

{title}

{races.length > 0 ? (
    - {races.map((race) => { - const needsResult = raceNeedsResultEntry(race); - if (needsResult) { - return ( -
  • - -
    -

    - {race.title} -

    -

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

    -
    - - {getRaceStatusLabel(race.status, race.date)} - - -
  • - ); - } - return ( -
  • + {races.map((race) => ( +
  • +

    - - {race.title} - + {race.title}

    {formatRaceDate(race.date)} · {formatDistance(race.distanceKm)} @@ -107,9 +84,9 @@ function RaceList(props: { title: string; races: Race[] }): JSX.Element { {getRaceStatusLabel(race.status, race.date)} -

  • - ); - })} + + + ))}
) : (

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

diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 19a2f9f..5a477cc 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -128,6 +128,35 @@ a { border-radius: var(--radius-md); padding: var(--space-5); background: #fcfdff; + transition: + transform 0.15s ease, + box-shadow 0.15s ease; +} + +.dashboard-card:hover, +.dashboard-card:focus-within { + transform: scale(1.02); + box-shadow: var(--shadow-card-lift); +} + +.dashboard-card--linked { + padding: 0; +} + +.dashboard-card__link-surface { + display: flex; + flex-direction: column; + height: 100%; + padding: var(--space-5); + border-radius: var(--radius-md); + color: inherit; + text-decoration: none; + outline: none; +} + +.dashboard-card__link-surface:hover, +.dashboard-card__link-surface:focus-visible { + outline: none; } .dashboard-card__title { @@ -254,12 +283,6 @@ a { font-weight: 600; } -.race-card__link:hover, -.race-card__link:focus-visible { - text-decoration: underline; - outline: none; -} - .race-card__meta { margin: var(--space-2) 0 0; color: var(--color-text-muted); @@ -729,7 +752,7 @@ a { .races-cal__year { display: grid; - grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-5); } @@ -967,6 +990,10 @@ a { grid-template-columns: 1fr; } + .races-cal__year { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .race-details-header { flex-direction: column; } @@ -976,3 +1003,9 @@ a { align-items: stretch; } } + +@media (max-width: 560px) { + .races-cal__year { + grid-template-columns: 1fr; + } +}