diff --git a/src/features/schedule/components/DayGroupedFlightList.scss b/src/features/schedule/components/DayGroupedFlightList.scss index 798cf3f1..c9d13430 100644 --- a/src/features/schedule/components/DayGroupedFlightList.scss +++ b/src/features/schedule/components/DayGroupedFlightList.scss @@ -1,3 +1,5 @@ +@use "../../../styles/colors" as colors; + .day-grouped-flight-list { display: flex; flex-direction: column; @@ -5,15 +7,49 @@ &__column-headers { display: grid; - grid-template-columns: 80px 1fr 100px 120px 1fr; - gap: 12px; - padding: 8px 16px; + // Match FlightCard schedule grid column widths so labels align + // with their data columns. + grid-template-columns: 80px 120px 100px minmax(80px, 1fr) 100px 100px minmax(80px, 1fr) 16px; + gap: 16px; + padding: 14px 24px; color: #6b7280; font-size: 11px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid #e8edf3; + + // The first two header labels span the first two grid columns. + > span:nth-child(1) { grid-column: 1; } + > span:nth-child(2) { grid-column: 2 / span 2; } + > span:nth-child(3) { grid-column: 4 / span 1; } // ВЫЛЕТ over dep-station + > span:nth-child(4) { grid-column: 5 / span 1; } // ВРЕМЯ В ПУТИ over duration + > span:nth-child(5) { grid-column: 7 / span 1; } // ПРИЛЕТ over arr-station + } + + &__col-sortable { + display: inline-flex; + align-items: center; + gap: 6px; + } + + &__sort-group { + display: inline-flex; + flex-direction: column; + gap: 1px; + } + + &__sort { + background: transparent; + border: 0; + padding: 1px 2px; + cursor: pointer; + color: #b3becd; + line-height: 0; + border-radius: 2px; + + &:hover { color: colors.$blue; } + &--active { color: colors.$blue; } } &__group { @@ -25,11 +61,22 @@ &__header { display: flex; - align-items: baseline; + align-items: center; gap: 12px; padding: 12px 18px; background: #f6f9fd; border-bottom: 1px solid #e8edf3; + cursor: pointer; + user-select: none; + + &:hover { + background: #eef3fa; + } + + &:focus-visible { + outline: 2px solid colors.$blue; + outline-offset: -2px; + } } &__weekday { @@ -44,4 +91,18 @@ font-size: 18px; font-weight: 500; } + + &__chevron { + margin-left: auto; + color: colors.$blue; + transition: transform 150ms ease; + + &--collapsed { + transform: rotate(-90deg); + } + } + + &__group--collapsed &__header { + border-bottom: none; + } } diff --git a/src/features/schedule/components/DayGroupedFlightList.tsx b/src/features/schedule/components/DayGroupedFlightList.tsx index 08806595..79c8034a 100644 --- a/src/features/schedule/components/DayGroupedFlightList.tsx +++ b/src/features/schedule/components/DayGroupedFlightList.tsx @@ -10,7 +10,7 @@ * (e.g. single-day search) — no header noise. */ -import { type FC, useMemo } from "react"; +import { type FC, useMemo, useState } from "react"; import { useTranslation } from "@/i18n/provider.js"; import { FlightList } from "@/ui/flights/FlightList.js"; import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js"; @@ -18,6 +18,15 @@ import { useLocale } from "@/i18n/useLocale.js"; import type { ISimpleFlight, IFlightLeg } from "@/features/online-board/types.js"; import "./DayGroupedFlightList.scss"; +type SortMode = + | "none" + | "departureUp" + | "departureDown" + | "timeUp" + | "timeDown" + | "arrivalUp" + | "arrivalDown"; + export interface DayGroupedFlightListProps { flights: ISimpleFlight[]; loading?: boolean; @@ -66,6 +75,42 @@ function groupFlightsByDay(flights: ISimpleFlight[]): DayGroup[] { }); } +/** Departure-time sort key from the primary leg (used by `departureUp/Down`). */ +function depMinutes(f: ISimpleFlight): number { + const iso = getPrimaryLeg(f).departure.times.scheduledDeparture.local ?? ""; + const t = iso.slice(11, 16); // 'HH:mm' + if (!t) return 0; + const [h, m] = t.split(":").map((s) => parseInt(s, 10)); + return (h ?? 0) * 60 + (m ?? 0); +} +function arrMinutes(f: ISimpleFlight): number { + const last = f.routeType === "Direct" ? f.leg : f.legs[f.legs.length - 1]!; + const iso = last.arrival.times.scheduledArrival.local ?? ""; + const t = iso.slice(11, 16); + if (!t) return 0; + const [h, m] = t.split(":").map((s) => parseInt(s, 10)); + return (h ?? 0) * 60 + (m ?? 0); +} +function flyingMinutes(f: ISimpleFlight): number { + const ft = f.flyingTime ?? ""; + const m = /^(\d+):(\d+)/.exec(ft); + if (!m) return 0; + return parseInt(m[1]!, 10) * 60 + parseInt(m[2]!, 10); +} + +function sortFlights(flights: ISimpleFlight[], mode: SortMode): ISimpleFlight[] { + if (mode === "none") return flights; + const arr = flights.slice(); + switch (mode) { + case "departureUp": return arr.sort((a, b) => depMinutes(a) - depMinutes(b)); + case "departureDown": return arr.sort((a, b) => depMinutes(b) - depMinutes(a)); + case "timeUp": return arr.sort((a, b) => flyingMinutes(a) - flyingMinutes(b)); + case "timeDown": return arr.sort((a, b) => flyingMinutes(b) - flyingMinutes(a)); + case "arrivalUp": return arr.sort((a, b) => arrMinutes(a) - arrMinutes(b)); + case "arrivalDown": return arr.sort((a, b) => arrMinutes(b) - arrMinutes(a)); + } +} + export const DayGroupedFlightList: FC = ({ flights, loading, @@ -74,11 +119,43 @@ export const DayGroupedFlightList: FC = ({ }) => { const { language } = useLocale(); const { t } = useTranslation(); - const groups = useMemo(() => groupFlightsByDay(flights), [flights]); + const [sortMode, setSortMode] = useState("none"); + // Track collapsed days. Default: every day group expanded — matches + // Angular which auto-opens days on initial render. User can collapse + // individual days by clicking the header chevron. + const [collapsedDays, setCollapsedDays] = useState>(() => new Set()); + const groups = useMemo(() => { + const g = groupFlightsByDay(flights); + return g.map((day) => ({ ...day, flights: sortFlights(day.flights, sortMode) })); + }, [flights, sortMode]); + + const setSort = (mode: SortMode) => { + setSortMode((cur) => (cur === mode ? "none" : mode)); + }; /** Angular's schedule renders a sortable column header row above the - * list. We render the same labels as a non-sortable bar — sortable - * headers are tracked separately. Skipped while loading or empty. */ + * list. Click an arrow to toggle ascending/descending; clicking the + * same arrow again clears the sort. */ + const sortBtn = (mode: SortMode, dir: "up" | "down") => ( + + ); + const headerRow = (
= ({ > {t("SCHEDULE.COL-FLIGHT") || "РЕЙС"} {t("SCHEDULE.COL-AIRLINE") || "АВИАКОМПАНИЯ, БОРТ"} - {t("SCHEDULE.COL-DEPARTURE") || "ВЫЛЕТ"} - {t("SCHEDULE.COL-DURATION") || "ВРЕМЯ В ПУТИ"} - {t("SCHEDULE.COL-ARRIVAL") || "ПРИЛЕТ"} + + {t("SCHEDULE.COL-DEPARTURE") || "ВЫЛЕТ"} + + {sortBtn("departureUp", "up")} + {sortBtn("departureDown", "down")} + + + + {t("SCHEDULE.COL-DURATION") || "ВРЕМЯ В ПУТИ"} + + {sortBtn("timeUp", "up")} + {sortBtn("timeDown", "down")} + + + + {t("SCHEDULE.COL-ARRIVAL") || "ПРИЛЕТ"} + + {sortBtn("arrivalUp", "up")} + {sortBtn("arrivalDown", "down")} + +
); @@ -122,33 +217,69 @@ export const DayGroupedFlightList: FC = ({ month: "long", }); + const toggleDay = (date: string): void => { + setCollapsedDays((prev) => { + const next = new Set(prev); + if (next.has(date)) next.delete(date); + else next.add(date); + return next; + }); + }; + return (
{headerRow} {groups.map((g) => { const weekday = weekdayFmt.format(g.parsed); const dayMonth = dayMonthFmt.format(g.parsed); + const collapsed = collapsedDays.has(g.date); return (
-
+
toggleDay(g.date)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleDay(g.date); + } + }} + > {weekday.charAt(0).toUpperCase() + weekday.slice(1)}

{dayMonth}

+
- + {!collapsed && ( + + )}
); })}