void;
scheduleClose: () => void;
cancelClose: () => void;
+ onMonthSelect?: (monthIndex: number) => void;
+ todayYmd: string;
}): JSX.Element {
const {
year,
@@ -88,6 +97,8 @@ function CalendarMonthBlock(props: {
setOpenYmd,
scheduleClose,
cancelClose,
+ onMonthSelect,
+ todayYmd,
} = props;
const cells = useMemo(() => buildMonthCells(year, monthIndex), [year, monthIndex]);
const title = `${MONTH_NAMES_RU_SHORT[monthIndex]} ${year}`;
@@ -96,7 +107,21 @@ function CalendarMonthBlock(props: {
return (
-
{title}
+
+ {onMonthSelect ? (
+
+ ) : (
+ title
+ )}
+
{WEEKDAY_LABELS_SHORT_RU.map((d) => (
@@ -113,11 +138,22 @@ function CalendarMonthBlock(props: {
const dayRaces = racesByYmd.get(ymd) ?? [];
const hasRaces = dayRaces.length > 0;
const isOpen = openYmd === ymd;
+ const isPast = isRaceDateInPast(ymd);
+ const isToday = ymd === todayYmd;
+ const cellClassName = [
+ "races-cal__cell",
+ hasRaces ? "races-cal__cell--has-race" : "",
+ isOpen ? "races-cal__cell--open" : "",
+ isPast ? "races-cal__cell--past" : "",
+ isToday ? "races-cal__cell--today" : "",
+ ]
+ .filter(Boolean)
+ .join(" ");
return (
{
cancelClose();
setOpenYmd(hasRaces ? ymd : null);
@@ -182,6 +218,7 @@ export function RacesCalendar(props: RacesCalendarProps): JSX.Element {
}, [cancelClose]);
const racesByYmd = useMemo(() => groupRacesByYmd(races), [races]);
+ const todayYmd = useMemo(() => toLocalYmd(new Date()), []);
const focusedMonthIndex = monthFilter === "" ? null : parseInt(monthFilter, 10) - 1;
@@ -202,6 +239,11 @@ export function RacesCalendar(props: RacesCalendarProps): JSX.Element {
setOpenYmd={setOpenYmd}
scheduleClose={scheduleClose}
cancelClose={cancelClose}
+ onMonthSelect={(mi) => {
+ onMonthFilterChange(String(mi + 1));
+ setOpenYmd(null);
+ }}
+ todayYmd={todayYmd}
/>
))}
@@ -240,6 +282,7 @@ export function RacesCalendar(props: RacesCalendarProps): JSX.Element {
setOpenYmd={setOpenYmd}
scheduleClose={scheduleClose}
cancelClose={cancelClose}
+ todayYmd={todayYmd}
/>
)}
diff --git a/frontend/src/pages/RaceFormPage.tsx b/frontend/src/pages/RaceFormPage.tsx
index d994632..5df1c33 100644
--- a/frontend/src/pages/RaceFormPage.tsx
+++ b/frontend/src/pages/RaceFormPage.tsx
@@ -96,6 +96,17 @@ function validateForm(form: FormData): string[] {
return errors;
}
+function isRaceDateTodayOrPast(date: string): boolean {
+ if (!date.trim()) {
+ return false;
+ }
+ const today = new Date();
+ const y = today.getFullYear();
+ const m = String(today.getMonth() + 1).padStart(2, "0");
+ const d = String(today.getDate()).padStart(2, "0");
+ return isRaceDateInPast(date) || date.slice(0, 10) === `${y}-${m}-${d}`;
+}
+
export function RaceFormPage(): JSX.Element {
const { raceId } = useParams<{ raceId: string }>();
const navigate = useNavigate();
@@ -248,6 +259,7 @@ export function RaceFormPage(): JSX.Element {
);
const hideOrgScheduleFields = isEditMode && isRaceDateInPast(form.date);
+ const showResultFields = isRaceDateTodayOrPast(form.date);
const pageTitle = isEditMode ? "Редактирование старта" : "Новый старт";
if (isLoading) {
@@ -414,33 +426,35 @@ export function RaceFormPage(): JSX.Element {
-
+ ) : null}
diff --git a/frontend/src/pages/RacesPage.tsx b/frontend/src/pages/RacesPage.tsx
index 572c405..34e12fc 100644
--- a/frontend/src/pages/RacesPage.tsx
+++ b/frontend/src/pages/RacesPage.tsx
@@ -10,7 +10,8 @@ import {
getRaceStatusClassName,
getRaceStatusLabel,
parseRaceDate,
- splitRacesByDate,
+ sortByDateAsc,
+ sortByDateDesc,
} from "../lib";
const MONTH_OPTIONS: { value: string; label: string }[] = [
@@ -220,7 +221,32 @@ export function RacesPage(): JSX.Element {
};
}, [listQuery]);
- const { upcoming, past } = useMemo(() => splitRacesByDate(races), [races]);
+ const { upcoming, completed } = useMemo(
+ () => ({
+ upcoming: sortByDateAsc(races.filter((race) => race.status !== "completed")),
+ completed: sortByDateDesc(races.filter((race) => race.status === "completed")),
+ }),
+ [races],
+ );
+ const statusMessage = useMemo(() => {
+ if (errorMessage && !isLoading) {
+ return errorMessage;
+ }
+ if (isLoading) {
+ return "Загружаем данные...";
+ }
+ if (viewMode === "calendar" && monthFilter === "") {
+ return "Выберите месяц, чтобы увидеть его крупным планом.";
+ }
+ return "";
+ }, [errorMessage, isLoading, monthFilter, viewMode]);
+ const statusClassName = [
+ "races-status__message",
+ !statusMessage ? "races-status__message--empty" : "",
+ errorMessage && !isLoading ? "races-status__message--error" : "",
+ ]
+ .filter(Boolean)
+ .join(" ");
if (errorMessage && races.length === 0 && !isLoading) {
return (
@@ -294,26 +320,21 @@ export function RacesPage(): JSX.Element {
- {errorMessage && !isLoading ? (
-
- {errorMessage}
+
+
+ {statusMessage || "\u00a0"}
- ) : null}
-
- {viewMode === "calendar" && monthFilter === "" ? (
-
Выберите месяц, чтобы увидеть его крупным планом.
- ) : null}
-
- {isLoading ? (
-
- Загружаем данные...
-
- ) : null}
+
{viewMode === "list" ? (
-
+
) : (
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index a0b004e..28d5698 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -45,6 +45,12 @@ a {
font-weight: 700;
}
+.app-shell__brand:hover,
+.app-shell__brand:focus-visible {
+ color: var(--color-text);
+ outline: none;
+}
+
.app-shell__nav {
display: flex;
align-items: center;
@@ -961,6 +967,25 @@ a {
margin-top: var(--space-6);
}
+.races-status {
+ min-height: calc(var(--line-height-base) * var(--font-size-caption));
+ margin-top: var(--space-4);
+}
+
+.races-status__message {
+ margin: 0;
+ color: var(--color-text-muted);
+ font-size: var(--font-size-caption);
+}
+
+.races-status__message--empty {
+ visibility: hidden;
+}
+
+.races-status__message--error {
+ color: var(--color-danger);
+}
+
.races-cal__filter-hint {
margin-top: var(--space-3);
color: var(--color-text-muted);
@@ -1006,6 +1031,26 @@ a {
color: var(--color-text);
}
+.races-cal__month-title-button {
+ display: inline-flex;
+ align-items: center;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ font-weight: inherit;
+ cursor: pointer;
+}
+
+.races-cal__month-title-button:hover,
+.races-cal__month-title-button:focus-visible {
+ color: var(--color-accent);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ outline: none;
+}
+
.races-cal__weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
@@ -1040,6 +1085,12 @@ a {
color: var(--color-accent);
}
+.races-cal__cell--past .races-cal__day-btn {
+ color: var(--color-text-muted);
+ background: #f0f2f4;
+ border-color: #d8dde2;
+}
+
.races-cal__cell--open {
z-index: 2;
}
@@ -1070,6 +1121,12 @@ a {
border-color: var(--color-accent);
}
+.races-cal__cell--today .races-cal__day-btn,
+.races-cal__cell--today .races-cal__day-btn:hover,
+.races-cal__cell--today .races-cal__day-btn:focus-visible {
+ border-color: var(--color-text);
+}
+
.races-cal__popover {
position: absolute;
top: calc(100% + var(--space-1));
@@ -1226,6 +1283,11 @@ body {
color: var(--color-bg-deep);
}
+.app-shell__brand:hover,
+.app-shell__brand:focus-visible {
+ color: var(--color-accent);
+}
+
.app-shell__link {
font-size: var(--font-size-caption);
font-weight: 700;
@@ -1747,18 +1809,61 @@ body {
font-weight: 800;
}
+.races-cal__month-title-button {
+ border-radius: var(--radius-sm);
+ transition:
+ color 0.15s ease,
+ text-decoration-color 0.15s ease;
+}
+
+.races-cal__month-title-button:hover,
+.races-cal__month-title-button:focus-visible {
+ color: var(--color-accent);
+}
+
.races-cal__cell--has-race .races-cal__day-btn {
color: #071927;
background: linear-gradient(135deg, rgba(185, 242, 74, 0.7), rgba(17, 104, 216, 0.14));
border-color: rgba(17, 104, 216, 0.18);
}
+.races-cal__cell--past .races-cal__day-btn,
+.races-cal__cell--past.races-cal__cell--has-race .races-cal__day-btn {
+ color: #6f7c87;
+ background: #eef1f3;
+ border-color: #d4dbe1;
+}
+
.races-cal__cell--has-race .races-cal__day-btn:hover,
.races-cal__cell--has-race .races-cal__day-btn:focus-visible {
background: var(--color-lime);
border-color: var(--color-bg-deep);
}
+.races-cal__cell--past .races-cal__day-btn:hover,
+.races-cal__cell--past .races-cal__day-btn:focus-visible,
+.races-cal__cell--past.races-cal__cell--has-race .races-cal__day-btn:hover,
+.races-cal__cell--past.races-cal__cell--has-race .races-cal__day-btn:focus-visible {
+ color: #52616d;
+ background: #e3e8ec;
+ border-color: #aeb9c3;
+}
+
+.races-cal__cell--today .races-cal__day-btn,
+.races-cal__cell--today.races-cal__cell--has-race .races-cal__day-btn,
+.races-cal__cell--today.races-cal__cell--past .races-cal__day-btn {
+ border-color: #071927;
+ box-shadow: inset 0 0 0 1px #071927;
+}
+
+.races-cal__cell--today .races-cal__day-btn:hover,
+.races-cal__cell--today .races-cal__day-btn:focus-visible,
+.races-cal__cell--today.races-cal__cell--has-race .races-cal__day-btn:hover,
+.races-cal__cell--today.races-cal__cell--has-race .races-cal__day-btn:focus-visible {
+ border-color: #071927;
+ box-shadow: inset 0 0 0 1px #071927;
+}
+
.races-cal__popover {
border-color: rgba(17, 104, 216, 0.2);
border-radius: var(--radius-md);