Compare commits

...

4 Commits

Author SHA1 Message Date
Vaka.pro
afb0f7ef31 fix(frontend): animate full race list row on hover (li, not inner link)
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
Move scale/shadow transition to .race-card--action; keyboard focus ring on link.
Version 0.4.2.

Made-with: Cursor
2026-04-13 22:47:06 +03:00
92c2360feb Merge pull request 'fix(frontend): auto-completed on finish time, dashboard links, list/calendar UX' (#23) from fix/dashboard-form-races-ux into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #23
2026-04-13 19:36:10 +00:00
Vaka.pro
4ea8faf16f fix(frontend): auto-completed on finish time, dashboard links, list/calendar UX
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
- Set status to completed when finish time parses (input + submit)
- Dashboard: last personal record by recent date+time; links on top 3 cards
- Hover scale+shadow on all dashboard-card; linked card padding via BEM
- Race list: full row links to race detail; same hover as before
- Calendar year grid: 3 columns, 2 on tablet, 1 on narrow
- Version 0.4.1

Made-with: Cursor
2026-04-13 22:34:39 +03:00
74f059593e Merge pull request 'feat(frontend): race form, start time selects, calendar views, day page' (#22) from feat/race-ui-plan-implementation into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #22
2026-04-13 19:09:11 +00:00
6 changed files with 143 additions and 93 deletions

View File

@@ -1,12 +1,12 @@
{ {
"name": "calendar-run-frontend", "name": "calendar-run-frontend",
"version": "0.4.0", "version": "0.4.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "calendar-run-frontend", "name": "calendar-run-frontend",
"version": "0.4.0", "version": "0.4.2",
"dependencies": { "dependencies": {
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",

View File

@@ -1,7 +1,7 @@
{ {
"name": "calendar-run-frontend", "name": "calendar-run-frontend",
"private": true, "private": true,
"version": "0.4.0", "version": "0.4.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import type { Race } from "../api"; import type { Race } from "../api";
import { ApiError, getRaces } from "../api"; import { ApiError, getRaces } from "../api";
import { PaceTrendChart } from "../components"; import { PaceTrendChart } from "../components";
@@ -61,26 +62,14 @@ export function DashboardPage(): JSX.Element {
const dashboardMetrics = useMemo(() => { const dashboardMetrics = useMemo(() => {
const { upcoming, past } = splitRacesByDate(races); const { upcoming, past } = splitRacesByDate(races);
const completed = races.filter((race) => race.status === "completed");
const nextRace = upcoming[0] ?? null; const nextRace = upcoming[0] ?? null;
const lastResult = past.find((race) => race.status === "completed") ?? null; const lastResult = past.find((race) => race.status === "completed") ?? null;
let personalRecord: Race | null = null; const lastPersonalRecord =
let personalRecordSeconds = Number.POSITIVE_INFINITY; past.find(
(race) => race.status === "completed" && parseFinishTimeToSeconds(race.finishTime) !== null,
for (const race of completed) { ) ?? null;
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 currentYear = new Date().getFullYear();
const seasonRaces = races.filter((race) => parseRaceDate(race.date).getFullYear() === currentYear); const seasonRaces = races.filter((race) => parseRaceDate(race.date).getFullYear() === currentYear);
@@ -89,7 +78,7 @@ export function DashboardPage(): JSX.Element {
return { return {
nextRace, nextRace,
lastResult, lastResult,
personalRecord, lastPersonalRecord,
seasonTotal: seasonRaces.length, seasonTotal: seasonRaces.length,
seasonCompletedCount: seasonCompleted.length, seasonCompletedCount: seasonCompleted.length,
}; };
@@ -167,48 +156,79 @@ export function DashboardPage(): JSX.Element {
<p className="page__subtitle">Ключевые метрики по вашему календарю стартов.</p> <p className="page__subtitle">Ключевые метрики по вашему календарю стартов.</p>
<div className="dashboard-grid" aria-label="Ключевые метрики"> <div className="dashboard-grid" aria-label="Ключевые метрики">
<article className="dashboard-card"> <article
<h2 className="dashboard-card__title">Ближайший старт</h2> className={`dashboard-card${dashboardMetrics.nextRace ? " dashboard-card--linked" : ""}`}
>
{dashboardMetrics.nextRace ? ( {dashboardMetrics.nextRace ? (
<> <Link
className="dashboard-card__link-surface"
to={`/races/${dashboardMetrics.nextRace.id}`}
aria-label={`Ближайший старт: ${dashboardMetrics.nextRace.title}`}
>
<h2 className="dashboard-card__title">Ближайший старт</h2>
<p className="dashboard-card__value">{dashboardMetrics.nextRace.title}</p> <p className="dashboard-card__value">{dashboardMetrics.nextRace.title}</p>
<p className="dashboard-card__meta"> <p className="dashboard-card__meta">
{formatRaceDate(dashboardMetrics.nextRace.date)} · {formatDistance(dashboardMetrics.nextRace.distanceKm)} {formatRaceDate(dashboardMetrics.nextRace.date)} ·{" "}
{formatDistance(dashboardMetrics.nextRace.distanceKm)}
</p> </p>
<p className="dashboard-card__hint">{getRaceCountdownLabel(dashboardMetrics.nextRace.date)}</p> <p className="dashboard-card__hint">{getRaceCountdownLabel(dashboardMetrics.nextRace.date)}</p>
</> </Link>
) : ( ) : (
<>
<h2 className="dashboard-card__title">Ближайший старт</h2>
<p className="dashboard-card__empty">Нет запланированных стартов.</p> <p className="dashboard-card__empty">Нет запланированных стартов.</p>
</>
)} )}
</article> </article>
<article className="dashboard-card"> <article
<h2 className="dashboard-card__title">Последний результат</h2> className={`dashboard-card${dashboardMetrics.lastResult ? " dashboard-card--linked" : ""}`}
>
{dashboardMetrics.lastResult ? ( {dashboardMetrics.lastResult ? (
<> <Link
className="dashboard-card__link-surface"
to={`/races/${dashboardMetrics.lastResult.id}`}
aria-label={`Последний результат: ${dashboardMetrics.lastResult.title}`}
>
<h2 className="dashboard-card__title">Последний результат</h2>
<p className="dashboard-card__value">{dashboardMetrics.lastResult.finishTime ?? "время не указано"}</p> <p className="dashboard-card__value">{dashboardMetrics.lastResult.finishTime ?? "время не указано"}</p>
<p className="dashboard-card__meta"> <p className="dashboard-card__meta">
{dashboardMetrics.lastResult.title} · {formatDistance(dashboardMetrics.lastResult.distanceKm)} {dashboardMetrics.lastResult.title} · {formatDistance(dashboardMetrics.lastResult.distanceKm)}
</p> </p>
<p className="dashboard-card__hint">{formatRaceDate(dashboardMetrics.lastResult.date)}</p> <p className="dashboard-card__hint">{formatRaceDate(dashboardMetrics.lastResult.date)}</p>
</> </Link>
) : ( ) : (
<>
<h2 className="dashboard-card__title">Последний результат</h2>
<p className="dashboard-card__empty">Пока нет завершённых стартов.</p> <p className="dashboard-card__empty">Пока нет завершённых стартов.</p>
</>
)} )}
</article> </article>
<article className="dashboard-card"> <article
<h2 className="dashboard-card__title">Личный рекорд</h2> className={`dashboard-card${dashboardMetrics.lastPersonalRecord ? " dashboard-card--linked" : ""}`}
{dashboardMetrics.personalRecord ? ( >
<> {dashboardMetrics.lastPersonalRecord ? (
<p className="dashboard-card__value">{dashboardMetrics.personalRecord.finishTime ?? "время не указано"}</p> <Link
<p className="dashboard-card__meta"> className="dashboard-card__link-surface"
{dashboardMetrics.personalRecord.title} · {formatDistance(dashboardMetrics.personalRecord.distanceKm)} to={`/races/${dashboardMetrics.lastPersonalRecord.id}`}
aria-label={`Последний личный рекорд: ${dashboardMetrics.lastPersonalRecord.title}`}
>
<h2 className="dashboard-card__title">Последний личный рекорд</h2>
<p className="dashboard-card__value">
{dashboardMetrics.lastPersonalRecord.finishTime ?? "время не указано"}
</p> </p>
<p className="dashboard-card__hint">Лучший темп среди завершённых стартов.</p> <p className="dashboard-card__meta">
</> {dashboardMetrics.lastPersonalRecord.title} ·{" "}
{formatDistance(dashboardMetrics.lastPersonalRecord.distanceKm)}
</p>
<p className="dashboard-card__hint">{formatRaceDate(dashboardMetrics.lastPersonalRecord.date)}</p>
</Link>
) : ( ) : (
<p className="dashboard-card__empty">Недостаточно данных для личного рекорда.</p> <>
<h2 className="dashboard-card__title">Последний личный рекорд</h2>
<p className="dashboard-card__empty">Нет завершённых стартов с финишным временем.</p>
</>
)} )}
</article> </article>

View File

@@ -3,7 +3,7 @@ import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"
import { ApiError, createRace, getRaceById, updateRace } from "../api"; import { ApiError, createRace, getRaceById, updateRace } from "../api";
import type { CreateRacePayload, Race, RaceStatus, UpdateRacePayload } from "../api"; import type { CreateRacePayload, Race, RaceStatus, UpdateRacePayload } from "../api";
import { StartTimeSelects } from "../components/StartTimeSelects"; import { StartTimeSelects } from "../components/StartTimeSelects";
import { isRaceDateInPast } from "../lib"; import { isRaceDateInPast, parseFinishTimeToSeconds } from "../lib";
function slugify(text: string): string { function slugify(text: string): string {
return text return text
@@ -151,7 +151,16 @@ export function RaceFormPage(): JSX.Element {
const handleChange = useCallback( const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => { (event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = event.target; 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); setIsSaving(true);
try { 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 === "planned" || form.status === "registered" || form.status === "completed"
? form.status ? form.status
: null; : null;
if (hasParsedFinish) {
statusValue = "completed";
}
if (isEditMode && raceId) { if (isEditMode && raceId) {
const payload: UpdateRacePayload = { const payload: UpdateRacePayload = {

View File

@@ -8,7 +8,6 @@ import {
formatRaceDate, formatRaceDate,
getRaceStatusClassName, getRaceStatusClassName,
getRaceStatusLabel, getRaceStatusLabel,
raceNeedsResultEntry,
splitRacesByDate, splitRacesByDate,
} from "../lib"; } from "../lib";
@@ -67,15 +66,12 @@ function RaceList(props: { title: string; races: Race[] }): JSX.Element {
<h2 className="race-list__title">{title}</h2> <h2 className="race-list__title">{title}</h2>
{races.length > 0 ? ( {races.length > 0 ? (
<ul className="race-list__items"> <ul className="race-list__items">
{races.map((race) => { {races.map((race) => (
const needsResult = raceNeedsResultEntry(race);
if (needsResult) {
return (
<li key={race.id} className="race-card race-card--action"> <li key={race.id} className="race-card race-card--action">
<Link <Link
className="race-card__link-surface" className="race-card__link-surface"
to={`/races/${race.id}/edit`} to={`/races/${race.id}`}
aria-label={`${race.title}, внести результат`} aria-label={`Старт: ${race.title}`}
> >
<div className="race-card__main"> <div className="race-card__main">
<p className="race-card__title"> <p className="race-card__title">
@@ -90,26 +86,7 @@ function RaceList(props: { title: string; races: Race[] }): JSX.Element {
</span> </span>
</Link> </Link>
</li> </li>
); ))}
}
return (
<li key={race.id} className="race-card">
<div className="race-card__main">
<p className="race-card__title">
<Link className="race-card__link" to={`/races/${race.id}`}>
{race.title}
</Link>
</p>
<p className="race-card__meta">
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}
</p>
</div>
<span className={getRaceStatusClassName(race.status, race.date)}>
{getRaceStatusLabel(race.status, race.date)}
</span>
</li>
);
})}
</ul> </ul>
) : ( ) : (
<p className="race-list__empty">Пока нет данных в этом разделе.</p> <p className="race-list__empty">Пока нет данных в этом разделе.</p>

View File

@@ -128,6 +128,35 @@ a {
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: var(--space-5); padding: var(--space-5);
background: #fcfdff; 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 { .dashboard-card__title {
@@ -254,12 +283,6 @@ a {
font-weight: 600; font-weight: 600;
} }
.race-card__link:hover,
.race-card__link:focus-visible {
text-decoration: underline;
outline: none;
}
.race-card__meta { .race-card__meta {
margin: var(--space-2) 0 0; margin: var(--space-2) 0 0;
color: var(--color-text-muted); color: var(--color-text-muted);
@@ -298,6 +321,15 @@ a {
.race-card--action { .race-card--action {
padding: 0; padding: 0;
overflow: visible; overflow: visible;
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
}
.race-card--action:hover,
.race-card--action:focus-within {
transform: scale(1.02);
box-shadow: var(--shadow-card-lift);
} }
.race-card__link-surface { .race-card__link-surface {
@@ -310,16 +342,12 @@ a {
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
transition: outline: none;
transform 0.15s ease,
box-shadow 0.15s ease;
} }
.race-card--action:hover .race-card__link-surface, .race-card__link-surface:focus-visible {
.race-card--action:focus-within .race-card__link-surface { outline: 2px solid var(--color-accent);
transform: scale(1.02); outline-offset: 2px;
box-shadow: var(--shadow-card-lift);
outline: none;
} }
.race-card__title-text { .race-card__title-text {
@@ -729,7 +757,7 @@ a {
.races-cal__year { .races-cal__year {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-5); gap: var(--space-5);
} }
@@ -967,6 +995,10 @@ a {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.races-cal__year {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.race-details-header { .race-details-header {
flex-direction: column; flex-direction: column;
} }
@@ -976,3 +1008,9 @@ a {
align-items: stretch; align-items: stretch;
} }
} }
@media (max-width: 560px) {
.races-cal__year {
grid-template-columns: 1fr;
}
}