From a0dd0a5596280f374360ff4d6c3f7006162d1555 Mon Sep 17 00:00:00 2001 From: gnezim Date: Sat, 25 Apr 2026 02:07:35 +0300 Subject: [PATCH] baseline: carry WIP schedule/UI changes from main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls in 13 modified + 4 new source files that were uncommitted on main when this branch forked. Without them, ScheduleStartPage.test.tsx fails 4 tests against the committed main state, which would mask real regressions during the CI/CD pipeline rollout. Source files only — no test infra or pipeline code. The user's main checkout still owns these changes; this commit will dedupe naturally once the branches reconcile. --- .../components/OnlineBoardFilter.scss | 142 +++++++++--------- .../components/DayGroupedFlightList.scss | 97 ++++++++++-- .../components/ScheduleColumnHeaders.scss | 118 +++++++++++++++ .../components/ScheduleColumnHeaders.tsx | 110 ++++++++++++++ .../components/ScheduleFlightBody.scss | 70 ++++++++- .../components/ScheduleStartPage.test.tsx | 65 ++++---- .../schedule/components/WeekTabs.scss | 85 ++++++++--- src/features/schedule/components/WeekTabs.tsx | 8 +- src/features/schedule/dateLabels.test.ts | 18 ++- src/features/schedule/dateLabels.ts | 20 +-- src/features/schedule/extractSimpleFlights.ts | 54 +++++++ src/shared/airportUrls.ts | 16 ++ .../city-autocomplete/CityAutocomplete.scss | 17 ++- src/ui/flights/FlightCard.scss | 91 +++++++++++ src/ui/flights/FlightCard.tsx | 64 +++++++- src/ui/flights/StationDisplay.scss | 13 ++ src/ui/flights/StationDisplay.tsx | 50 +++++- 17 files changed, 866 insertions(+), 172 deletions(-) create mode 100644 src/features/schedule/components/ScheduleColumnHeaders.scss create mode 100644 src/features/schedule/components/ScheduleColumnHeaders.tsx create mode 100644 src/features/schedule/extractSimpleFlights.ts create mode 100644 src/shared/airportUrls.ts diff --git a/src/features/online-board/components/OnlineBoardFilter.scss b/src/features/online-board/components/OnlineBoardFilter.scss index 6f15de5f..8e92065c 100644 --- a/src/features/online-board/components/OnlineBoardFilter.scss +++ b/src/features/online-board/components/OnlineBoardFilter.scss @@ -3,11 +3,22 @@ @use "../../../styles/colors" as colors; @use "../../../styles/shadows" as shadows; +// Schedule-parity sidebar: the OnlineBoard filter's city/airport selector +// now uses the same visual language as `ScheduleFilter`: +// - plain white `section.frame` (no $blue-extra-light tint), +// - flat accordion headers without PrimeNG chrome (no shadows / borders / +// pill radii), slightly muted label color, chevron on the right, +// - shared `.filter-content`, `.label--filter`, `.input--filter`, +// `.calendar-input-wrapper`, `.search-button` rules matching Schedule's. + .online-board-filter { section.frame { - background-color: colors.$blue-extra-light; + background-color: colors.$white; } + // Accordion tab list — kept so the user can toggle between the + // "Flight number" and "Route" search modes. Visually it's now just + // a clickable row, not a pill, so it reads like a subtle divider. .p-accordion { .p-accordion-tab { .p-accordion-header { @@ -15,15 +26,17 @@ margin: 0; a { - background-color: transparent; + background: transparent; border: none; color: colors.$blue; border-radius: 0; - padding: 0 vars.$space-l 0 vars.$space-xl; - height: vars.$button-height; + padding: vars.$space-m vars.$space-xl; + height: auto; + min-height: 0; display: flex; align-items: center; font-weight: fonts.$font-bold; + font-size: fonts.$font-size-m; cursor: pointer; text-decoration: none; @@ -35,46 +48,46 @@ } } + // The currently-active tab header reads slightly muted (matches + // Schedule's plain form label) and drops any pill/shadow chrome. &.p-highlight { a { - background-color: colors.$white; + background: transparent; border: none; - color: colors.$text-color; + color: colors.$gray; + font-weight: fonts.$font-medium; } - border-bottom: none; } } .p-accordion-content { + // Schedule uses a flat white panel — no shadow / bottom border. + box-shadow: none; + border-bottom: none; + padding: 0 vars.$space-xl vars.$space-xl; + background: colors.$white; + } + + &:first-child .p-accordion-header a { + border-radius: vars.$border-radius vars.$border-radius 0 0; + } + + // Thin hairline between tabs, matching Schedule's subtle section + // divider above `Популярные разделы`. + &:not(:last-child) .p-accordion-header { border-bottom: 1px solid colors.$border; - @include shadows.box-shadow-small; - padding: vars.$space-s vars.$space-xl vars.$space-xl vars.$space-xl; } - &:first-child { - .p-accordion-header { - a { - border-radius: vars.$border-radius vars.$border-radius 0 0; - } - } - } - - &:not(:last-child) { - .p-accordion-header { - border-bottom: 1px solid colors.$border; - } - } - - &:last-child { - .p-accordion-content { - border-radius: 0 0 vars.$border-radius vars.$border-radius; - border: none; - } + &:last-child .p-accordion-content { + border-radius: 0 0 vars.$border-radius vars.$border-radius; + border: none; } } } + // Mirrors Angular `.label--filter` — 12px regular $gray with + // $label-margin-bottom under the label. Matches ScheduleFilter. .label--filter { display: block; margin-right: vars.$space-xl; @@ -155,49 +168,29 @@ // `styles/_icons.scss`. .wrapper--time-selector { - margin-top: vars.$space-xl; + // Schedule uses the compact (inline label + value) layout everywhere, + // so drop the legacy top margin that OnlineBoard inherited. + display: flex; + flex-direction: column; + gap: vars.$space-s2; + + .time-selector__label-value { + display: flex; + justify-content: space-between; + align-items: baseline; + } .time-selector__label { font-size: fonts.$font-size-s; - color: colors.$gray; - margin-bottom: vars.$space-s; - } - - .time-selector { - padding: 0 vars.$space-s; + color: colors.$light-gray; + margin: 0; } .time-selector__value { font-size: fonts.$font-size-s; - color: colors.$gray; - margin-top: vars.$space-s; - text-align: right; - } - - &.compact-view { - display: flex; - flex-direction: column; - gap: 6px; - - .time-selector__label-value { - display: flex; - justify-content: space-between; - align-items: baseline; - } - - .time-selector__label { - color: colors.$text-color; - font-size: fonts.$font-size-s; - font-weight: fonts.$font-bold; - margin-bottom: 0; - } - - .time-selector__value { - color: colors.$light-gray; - font-size: fonts.$font-size-s; - margin-top: 0; - text-align: left; - } + color: colors.$text-color; + font-weight: fonts.$font-medium; + margin: 0; } } @@ -245,10 +238,6 @@ } } - .calendar { - // margin-top removed: vertical rhythm now driven by .filter-content gap. - } - .calendar-input-wrapper { position: relative; display: flex; @@ -284,27 +273,29 @@ } .filter-content { - // Vertical rhythm between filter rows. Angular's accordion content - // separates fields by ~$space-l (15px); the previous default - // packed inputs about ~6 px tighter and surfaced as a measurable - // pixel-diff against Angular on the start page. + // Vertical rhythm between filter rows — same as Schedule + // ($space-l / 15px between fields). display: flex; flex-direction: column; gap: vars.$space-l; } .filter-button { - margin-top: 0; + margin-top: vars.$space-l; } + // Mirrors Angular `.search-button.color.blue-light` and Schedule's + // submit button: 48px tall pill with $blue-light background. .search-button { - margin-top: vars.$space-xl; width: 100%; height: vars.$standard-button-height; background-color: colors.$blue-light; color: colors.$white; border: none; border-radius: vars.$border-radius; + padding: 0 vars.$space-l; + font-size: fonts.$font-size-m; + font-weight: fonts.$font-bold; cursor: pointer; transition-duration: 0.2s; @@ -337,7 +328,8 @@ } } - // PrimeReact AutoComplete dropdown button — match Angular's subtle chevron + // PrimeReact AutoComplete dropdown button — subtle chevron, matches + // Schedule. .p-autocomplete-dropdown { background: transparent !important; border: none !important; diff --git a/src/features/schedule/components/DayGroupedFlightList.scss b/src/features/schedule/components/DayGroupedFlightList.scss index d4a205cf..9cafda8d 100644 --- a/src/features/schedule/components/DayGroupedFlightList.scss +++ b/src/features/schedule/components/DayGroupedFlightList.scss @@ -5,7 +5,22 @@ .day-grouped-flight-list { display: flex; flex-direction: column; - gap: 18px; + // Angular's `schedule-days .frame` lays day blocks flush — no gap; a + // single 1.3px hairline divider between siblings is drawn from the + // group's `::before` (see &__group + &__group below). + gap: 0; + + // When the column-headers row immediately follows the week-tabs inside + // the sticky card (the Angular-parity layout), cancel the WeekTabs + // bottom margin so the two sit flush together. + .week-tabs + &__column-headers, + .week-tabs + * + &__column-headers { + margin-top: 0; + } + .week-tabs:has(+ &__column-headers), + .week-tabs:has(+ * + &__column-headers) { + margin-bottom: 0; + } &__column-headers { display: grid; @@ -14,13 +29,19 @@ grid-template-columns: 80px 120px 100px minmax(45px, 240px) 100px 100px minmax(45px, 240px) 10px; gap: 16px; - padding: 14px 24px; + padding: 10px 24px; color: colors.$light-gray; font-size: 11px; font-weight: fonts.$font-medium; text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid colors.$border; + // Put every column label on the same top baseline so "ВЫЛЕТ *" / + // "ПРИЛЕТ *" with their sort arrows don't push the row taller + // than "РЕЙС" / "ВРЕМЯ В ПУТИ". Each cell is top-aligned; the sort + // stack is absolute-positioned relative to the cell so it doesn't + // expand the row. + align-items: start; // The first two header labels span the first two grid columns. > span:nth-child(1) { grid-column: 1; } @@ -37,17 +58,30 @@ white-space: nowrap; } + &__col-asterisk { + margin-left: 2px; + font-size: 0.85em; + } + &__sort-group { display: inline-flex; flex-direction: column; gap: 0; line-height: 0; + // Shrink the two 6px triangles so they fit within one text line + // height without inflating the header row. + font-size: 0; } &__sort { background: transparent; border: 0; padding: 0; + // Global `button { min-height: 35px }` in styles/_buttons.scss would + // otherwise inflate each 6px triangle to 35px and double the column + // header row height. + min-height: 0; + height: 6px; cursor: pointer; color: colors.$border-blue; line-height: 0; @@ -60,23 +94,36 @@ &--active { color: colors.$blue; } } + // Angular's `schedule-days .frame` renders each day flat — no per-group + // border or rounded corners. A 1.3px top hairline divides siblings, + // inset 20px on both sides (see `flight-border-top` mixin in + // schedule-search-result.scss). The divider is drawn via a `::before` + // on every group except the first. &__group { - border: 1px solid colors.$border; - border-radius: vars.$border-radius; - overflow: hidden; background: colors.$white; + position: relative; } - // Angular's `schedule-search-result-day` wraps the whole row in - // `padding: $space-xl` (20px). Match it so the day group header has - // the same visual weight as the Angular reference. + &__group + &__group::before { + content: ""; + position: absolute; + top: 0; + left: vars.$space-xl; + right: vars.$space-xl; + height: 1.3px; + background: colors.$border; + z-index: 1; + } + + // Angular's `schedule-search-result-day` stacks the weekday above the + // date (small-gray "Вторник" on top, bold "21 Апреля" below). The + // chevron stays vertically centered against the stacked title. &__header { display: flex; align-items: center; gap: vars.$space-m2; padding: vars.$space-xl; - background: colors.$blue-extra-light; - border-bottom: 1px solid colors.$border; + background: colors.$white; cursor: pointer; user-select: none; @@ -90,6 +137,20 @@ } } + // Empty-day header should not change background on hover (mirrors + // Angular's disabled-looking row with cursor:default + opacity 0.5). + &__group--empty &__header:hover { + background: colors.$white; + } + + &__header-title { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + line-height: 1.15; + } + &__weekday { color: colors.$light-gray; font-size: 13px; @@ -101,19 +162,29 @@ color: colors.$blue-dark; font-size: fonts.$font-size-xl; font-weight: fonts.$font-medium; + line-height: 1.1; } + // The SVG path is an UP-pointing chevron (apex at top). Angular's + // `arrow-down-icon` uses the same path and applies `rotate(180deg)` + // by default (down, "click to expand") and `rotate(0deg)` when + // `[rotated]=true` i.e. expanded (up, "click to collapse"). &__chevron { margin-left: auto; color: colors.$blue; transition: transform 0.2s ease; + transform: rotate(0deg); &--collapsed { - transform: rotate(-90deg); + transform: rotate(180deg); } } - &__group--collapsed &__header { - border-bottom: none; + // Empty days (no flights for that date) render faded + no chevron, + // mirroring Angular's `[style.opacity]="scheduleItem.flights.length ? '1' : '0.5'"`. + &__group--empty &__header { + cursor: default; + opacity: 0.5; } + } diff --git a/src/features/schedule/components/ScheduleColumnHeaders.scss b/src/features/schedule/components/ScheduleColumnHeaders.scss new file mode 100644 index 00000000..5b5de2e9 --- /dev/null +++ b/src/features/schedule/components/ScheduleColumnHeaders.scss @@ -0,0 +1,118 @@ +/** + * Styles mirror Angular's `schedule-search-result-header.scss`: + * - Flex row, padding 0 20px, h-spacing 10px between cells. + * - Each cell is 56px tall (big-button-height), 12px uppercase gray text. + * - Sort buttons 12×12 px, 30% opacity faded, border on active. + * - Asterisk note (`*`) absolutely positioned top-right of the label. + */ + +@use "../../../styles/colors" as colors; +@use "../../../styles/variables" as vars; +@use "../../../styles/fonts" as fonts; + +.schedule-col-header { + display: flex; + align-items: center; + padding: 0 vars.$space-xl; + background: colors.$white; + + // 10px horizontal gap between cells (h-spacing $space-m in Angular). + > div + div { + margin-left: 10px; + } + + // When placed inside the sticky card directly after the week-tabs, + // the week-tabs bottom margin / card padding shouldn't pad the cell. + // Kill any margin-top so it sits flush. + margin-top: 0; + + &__flight { + width: 80px; + } + + &__company { + width: 120px; + } + + &__departure, + &__arrival { + flex: 1; + display: flex; + align-items: center; + } + + &__time { + width: 80px; + display: flex; + align-items: center; + justify-content: center; + } + + &__label { + font-size: 12px; + font-family: fonts.$font-family; + font-weight: fonts.$font-regular; + color: colors.$gray; + text-transform: uppercase; + line-height: normal; + display: flex; + align-items: center; + height: 56px; + + &--note { + position: relative; + padding-right: 10px; + } + } + + &__note { + position: absolute; + top: 8px; + right: 0; + font-size: 10px; + color: colors.$gray; + } + + &__sort-container { + margin-left: 5px; + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + } + + &__sort { + width: 12px; + height: 12px; + min-height: 0; // override global `button { min-height: 35px }` + min-width: 0; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + background: colors.$white; + border: 1px solid transparent; + border-radius: 0; + cursor: pointer; + color: colors.$gray; + opacity: 0.3; + line-height: 0; + transition: opacity 0.15s, border-color 0.15s; + + svg { + display: block; + width: 10px; + height: 10px; + } + + &:hover { + opacity: 0.8; + border-color: colors.$border; + } + + &--active { + opacity: 0.7; + border-color: #002776; + } + } +} diff --git a/src/features/schedule/components/ScheduleColumnHeaders.tsx b/src/features/schedule/components/ScheduleColumnHeaders.tsx new file mode 100644 index 00000000..dd6e8b4a --- /dev/null +++ b/src/features/schedule/components/ScheduleColumnHeaders.tsx @@ -0,0 +1,110 @@ +/** + * Sortable column header row for the schedule route-results page. + * + * Structure mirrors Angular's `schedule-search-result-header` — a flex + * row where each column cell contains a `.sort-label` (optionally with + * an absolutely-positioned asterisk note) and a `.sort-container` with + * stacked up/down sort buttons. Widths: РЕЙС 80px, АВИАКОМПАНИЯ 120px, + * ВЫЛЕТ flex:1, ВРЕМЯ 80px, ПРИЛЕТ flex:1. + * + * Sort state is owned by the parent page (ScheduleSearchPage), which + * also passes it to `DayGroupedFlightList` so the two stay in sync. + * + * @module + */ + +import type { FC } from "react"; +import { useTranslation } from "@/i18n/provider.js"; +import "./ScheduleColumnHeaders.scss"; + +export type ScheduleSortMode = + | "none" + | "departureUp" + | "departureDown" + | "timeUp" + | "timeDown" + | "arrivalUp" + | "arrivalDown"; + +export interface ScheduleColumnHeadersProps { + sortMode: ScheduleSortMode; + onSortChange: (mode: ScheduleSortMode) => void; +} + +export const ScheduleColumnHeaders: FC = ({ + sortMode, + onSortChange, +}) => { + const { t } = useTranslation(); + + const toggle = (mode: ScheduleSortMode): void => { + onSortChange(sortMode === mode ? "none" : mode); + }; + + const sortBtn = (mode: ScheduleSortMode, dir: "up" | "down") => ( + + ); + + return ( +
+
+
+ {t("SCHEDULE.COL-FLIGHT") || "РЕЙС"} +
+
+
+
+ {t("SCHEDULE.COL-AIRLINE") || "АВИАКОМПАНИЯ, БОРТ"} +
+
+
+
+ {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")} +
+
+
+ ); +}; diff --git a/src/features/schedule/components/ScheduleFlightBody.scss b/src/features/schedule/components/ScheduleFlightBody.scss index db8f8911..e6536984 100644 --- a/src/features/schedule/components/ScheduleFlightBody.scss +++ b/src/features/schedule/components/ScheduleFlightBody.scss @@ -269,8 +269,35 @@ &__timeline-time { flex-shrink: 0; + + // Shrink the TimeGroup time labels inside the route timeline and + // each leg row. The default (30px / light) is reserved for the + // collapsed summary row; inside the expanded body times read about + // half that — roughly matching Angular's `time-group size="small"` + // (16px). Apply to the sub-leg time columns as well. + .time-group__scheduled, + .time-group__actual { + font-size: 15px; + font-weight: fonts.$font-medium; + line-height: 1.2; + } } + &__leg-time { + .time-group__scheduled, + .time-group__actual { + font-size: 15px; + font-weight: fonts.$font-medium; + line-height: 1.2; + } + } + + // The `section` is the space between two timestamps in the route + // timeline. A single continuous 1px line runs horizontally across its + // center (via `::before`). The segment label ("1ч. 25мин.") sits + // ABOVE the line, and the section-number badge ("[1]") sits ON the + // line — its white background covers the line so the connector looks + // continuous (matching Angular's `connecting-flight-body` route bar). &__timeline-section { display: flex; flex-direction: column; @@ -280,16 +307,34 @@ color: colors.$light-gray; font-size: 13px; position: relative; + padding: 0 vars.$space-s; + + &::before { + content: ""; + position: absolute; + top: 50%; + left: 0; + right: 0; + border-top: 1px solid colors.$border; + z-index: 0; + } } + // The old structural bars are replaced by the section `::before`; keep + // the elements in the DOM (so TSX doesn't need to change) but hide them. &__timeline-bar { - flex: 1; - height: 1px; - width: 100%; - border-top: 1px solid colors.$border; + display: none; } + // [1]/[2] sits centered on the line. Absolute-position it at 50% so + // the number box vertically aligns with the connector, with its white + // background hiding the line behind the box. &__timeline-section-num { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 1; display: inline-flex; align-items: center; justify-content: center; @@ -301,11 +346,16 @@ color: colors.$text-color; font-size: fonts.$font-size-s; font-weight: fonts.$font-medium; - margin-bottom: 2px; } + // "1ч. 25мин." label sits BELOW the line — reserve top space equal to + // the number-badge height (~22px) + a gap so the label clears the line. &__timeline-section-dur { - margin-top: 2px; + position: relative; + z-index: 1; + margin-top: 22px; + padding: 0 4px; + background: colors.$white; color: colors.$light-gray; font-size: fonts.$font-size-s; white-space: nowrap; @@ -338,9 +388,15 @@ &:not(:first-child):not(:last-child) { text-align: center; } } + // Angular renders the route-timeline city names at 22px / 300 (light), + // measured on the live `connecting-flight-body`. Bumps them well above + // the surrounding 14px body copy so the three-stop diagram reads like + // a headline. &__timeline-station-city { color: colors.$text-color; - font-weight: fonts.$font-medium; + font-size: fonts.$font-size-xl2; + font-weight: fonts.$font-light; + line-height: 1.2; } &__timeline-station-terminal { diff --git a/src/features/schedule/components/ScheduleStartPage.test.tsx b/src/features/schedule/components/ScheduleStartPage.test.tsx index 753c0301..9a02e6cb 100644 --- a/src/features/schedule/components/ScheduleStartPage.test.tsx +++ b/src/features/schedule/components/ScheduleStartPage.test.tsx @@ -76,10 +76,13 @@ vi.mock("@/shared/hooks/useCitySearch.js", () => ({ })); vi.mock("@/ui/city-autocomplete/index.js", () => ({ + // Controlled mock — reflects `value` prop changes so tests can assert + // post-update form state, not just mount-time prefill. CityAutocomplete: (props: Record) => ( ), SwapCityButton: (props: { onClick: () => void; testId?: string }) => ( @@ -188,35 +191,38 @@ describe("ScheduleStartPage", () => { }); }); - it("4.1.5-S1: one-way Route click prefills current ISO week dates (from clamped to today-1) + no return", () => { + it("4.1.5-S1: one-way Route click populates form with current ISO week dates (from clamped to today-1) + no return", () => { // 2026-05-15 (Fri) → raw Mon 2026-05-11, raw Sun 2026-05-17 // `from` is clamped to today−1 = 2026-05-14 so the route guard does // not redirect the search back to the start page. + // Same-page Schedule click updates form state directly (navigate to + // the same route would no-op), so we assert visible form state and + // submit the form to verify the dates landed in component state. render(); fireEvent.click(screen.getByTestId("popular-click-route")); - const stored = JSON.parse(sessionStore.getRaw("afl-prefill:schedule")!); - expect(stored.departure).toBe("SVO"); - expect(stored.arrival).toBe("LED"); - expect(stored.withReturn).toBe(false); - expect(stored.dateFrom).toBe("20260514"); // clamped to today−1 (raw Mon was 2026-05-11) - expect(stored.dateTo).toBe("20260517"); // Sun - expect(stored.returnDateFrom).toBeUndefined(); - expect(stored.returnDateTo).toBeUndefined(); - expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule"); + expect(mockNavigate).not.toHaveBeenCalled(); + expect((screen.getByTestId("schedule-departure-input") as HTMLInputElement).value).toBe("SVO"); + expect((screen.getByTestId("schedule-arrival-input") as HTMLInputElement).value).toBe("LED"); + expect((screen.getByTestId("round-trip-toggle") as HTMLInputElement).checked).toBe(false); + expect(screen.queryByTestId("return-date-range-input")).toBeNull(); + + // Submit drives the dates from state into the URL — proves they were set. + fireEvent.submit(screen.getByTestId("schedule-search-form")); + expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule/route/SVO-LED-20260514-20260517"); }); - it("4.1.5-S2: round-trip RouteWithBack click prefills current + next week dates (outbound from clamped)", () => { + it("4.1.5-S2: round-trip RouteWithBack click populates form with current + next week dates (outbound from clamped)", () => { // current week raw: 20260511-20260517 (clamped from: 20260514-20260517) // next week: 20260518-20260524 (unclamped — future) render(); fireEvent.click(screen.getByTestId("popular-click-roundtrip")); - const stored = JSON.parse(sessionStore.getRaw("afl-prefill:schedule")!); - expect(stored.withReturn).toBe(true); - expect(stored.dateFrom).toBe("20260514"); // clamped - expect(stored.dateTo).toBe("20260517"); - expect(stored.returnDateFrom).toBe("20260518"); // next Mon - expect(stored.returnDateTo).toBe("20260524"); // next Sun - expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule"); + expect(mockNavigate).not.toHaveBeenCalled(); + expect((screen.getByTestId("round-trip-toggle") as HTMLInputElement).checked).toBe(true); + + fireEvent.submit(screen.getByTestId("schedule-search-form")); + expect(mockNavigate).toHaveBeenCalledWith( + "/ru-ru/schedule/route/SVO-LED-20260514-20260517/LED-SVO-20260518-20260524", + ); }); it("4.1.5-S3: prefill dates hydrate into form calendar state (no search on mount)", () => { @@ -250,13 +256,17 @@ describe("ScheduleStartPage", () => { expect((roundTripCheckbox as HTMLInputElement).checked).toBe(true); }); - it("writes prefill + navigates to onlineboard on Onlineboard-type popular click", () => { + it("Onlineboard-type Departure popular click stays on Schedule and sets departure only", () => { + // Deviation from Angular: Angular always navigates Arrival/Departure + // popular clicks to /onlineboard. We instead populate the relevant + // Schedule field in-place so users planning a route don't lose context. render(); fireEvent.click(screen.getByTestId("popular-click-onlineboard")); - expect(sessionStore.getRaw("afl-prefill:online-board")).toBe( - JSON.stringify({ tab: "route", departure: "LED" }), - ); - expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/onlineboard"); + expect(mockNavigate).not.toHaveBeenCalled(); + expect((screen.getByTestId("schedule-departure-input") as HTMLInputElement).value).toBe("LED"); + expect((screen.getByTestId("schedule-arrival-input") as HTMLInputElement).value).toBe(""); + // Onlineboard-type clicks must not write to either prefill slot. + expect(sessionStore.getRaw("afl-prefill:online-board")).toBeNull(); }); it("initializes form from sessionStorage prefill (legacy shape — withReturn only)", () => { @@ -288,14 +298,13 @@ describe("4.1.9-R: Current-Week label substitution", () => { vi.useRealTimers(); }); - it("4.1.9-R: start page renders with current-week dates pre-populated in session store on Route click", () => { + it("4.1.9-R: start page populates date range with current week on Route click", () => { render(); fireEvent.click(screen.getByTestId("popular-click-route")); - const stored = JSON.parse(sessionStore.getRaw("afl-prefill:schedule")!); // Current week Sun for 2026-05-15 is 2026-05-17; `from` is clamped to // today−1 = 2026-05-14 so the range is inside Schedule's −1/+330 window. - expect(stored.dateFrom).toBe("20260514"); - expect(stored.dateTo).toBe("20260517"); + fireEvent.submit(screen.getByTestId("schedule-search-form")); + expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule/route/SVO-LED-20260514-20260517"); }); }); diff --git a/src/features/schedule/components/WeekTabs.scss b/src/features/schedule/components/WeekTabs.scss index a69e4c68..e3f23dd0 100644 --- a/src/features/schedule/components/WeekTabs.scss +++ b/src/features/schedule/components/WeekTabs.scss @@ -2,61 +2,104 @@ @use "../../../styles/variables" as vars; @use "../../../styles/fonts" as fonts; +// Mirrors Angular's `date-tabs` + `tab-button` (see ClientApp/src/app/ +// toolkit/date-tabs/*). Each tab is a flat rectangle on $blue-extra-light +// with a 1px border-right between siblings and a 1px border-bottom along +// the row; the active tab is white with no bottom border so it visually +// "merges" into the content below. Carousel-style chevron arrows sit at +// each end (top-rounded outer corner, $blue-extra-light fill). .week-tabs { display: flex; align-items: stretch; - gap: 4px; - background: rgba(255, 255, 255, 0.92); - border-radius: vars.$border-radius; - padding: 4px; - margin-bottom: vars.$space-m2; + + // When week-tabs sits directly above the column-header row inside the + // sticky card (Angular parity layout), cancel the bottom margin so the + // two rows sit flush together. + &:has(+ .schedule-col-header), + &:has(+ .schedule-direction-switch + .schedule-col-header) { + margin-bottom: 0; + } &__nav { - background: transparent; + flex: 0 0 50px; + width: 50px; + max-height: 48px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + background: colors.$blue-extra-light; border: none; - color: colors.$light-gray; - font-size: fonts.$font-size-xl; - width: 28px; + border-bottom: 1px solid colors.$border; + color: colors.$blue; cursor: pointer; - border-radius: vars.$border-radius; + // Override the global `button { min-height: 35px }` so we can hit + // the Angular 48px row height precisely. + min-height: 0; + height: 48px; + line-height: 0; + transition: opacity 0.15s; + + svg { display: block; } &:hover:not(:disabled) { - background: rgba(0, 0, 0, 0.04); - color: colors.$blue-dark; + background: colors.$blue-icon; } + &:disabled { - opacity: 0.3; + opacity: 0.5; cursor: not-allowed; } + + &--prev { + border-top-left-radius: vars.$border-radius; + } + + &--next { + border-top-right-radius: vars.$border-radius; + } } &__list { display: flex; - gap: 2px; flex: 1; - overflow-x: auto; + min-width: 0; + overflow: hidden; } &__tab { flex: 1; min-width: 0; - padding: vars.$space-s2 vars.$space-m2; - background: transparent; + padding: 0 vars.$space-m2; + height: 48px; + max-height: 48px; + // Override the global `button { min-height: 35px }`. + min-height: 0; + background: colors.$blue-extra-light; border: none; - font-size: 13px; + border-right: 1px solid colors.$border; + border-bottom: 1px solid colors.$border; + border-radius: 0; + font-size: 12px; + font-weight: fonts.$font-medium; color: colors.$blue; cursor: pointer; - border-radius: vars.$border-radius; white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; transition: background 0.2s; - &:hover { background: colors.$blue-extra-light; } + &:hover:not(:disabled):not(&--active) { + background: colors.$blue-icon; + } &--active { background: colors.$white; + // Hide the bottom border so the active tab visually merges into + // the column-header / table below it (Angular parity). + border-bottom-color: colors.$white; color: colors.$blue-dark; font-weight: fonts.$font-bold; - box-shadow: inset 0 -2px 0 colors.$blue; cursor: default; } diff --git a/src/features/schedule/components/WeekTabs.tsx b/src/features/schedule/components/WeekTabs.tsx index 4301e9ac..cbe6035f 100644 --- a/src/features/schedule/components/WeekTabs.tsx +++ b/src/features/schedule/components/WeekTabs.tsx @@ -133,7 +133,9 @@ export const WeekTabs: FC = ({ selectedMonday, onNavigate }) => { onClick={() => setPage((p) => Math.max(0, p - 1))} aria-label={t("SHARED.A11Y-PREV-PAGE")} > - ‹ +
{activeSlice.map((w) => { @@ -174,7 +176,9 @@ export const WeekTabs: FC = ({ selectedMonday, onNavigate }) => { onClick={() => setPage((p) => Math.min(activeTotalPages - 1, p + 1))} aria-label={t("SHARED.A11Y-NEXT-PAGE")} > - › + ); diff --git a/src/features/schedule/dateLabels.test.ts b/src/features/schedule/dateLabels.test.ts index 4af2b30c..f5f00b4a 100644 --- a/src/features/schedule/dateLabels.test.ts +++ b/src/features/schedule/dateLabels.test.ts @@ -15,18 +15,28 @@ describe("4.1.9-R: formatScheduleDateRangeWithCurrentWeek", () => { ).toBe("Текущая неделя"); }); - it("returns dd.MM.yyyy-dd.MM.yyyy for other ranges", () => { + it("returns dd.MM.yyyy-dd.MM.yyyy for ranges that don't contain today", () => { const t = (k: string) => k; expect( formatScheduleDateRangeWithCurrentWeek(new Date(2026, 4, 18), new Date(2026, 4, 24), t), ).toBe("18.05.2026-24.05.2026"); }); - it("returns dd.MM.yyyy-dd.MM.yyyy for partial current week", () => { - const t = (k: string) => k; + it("returns 'Текущая неделя' for partial current week containing today (matches Angular)", () => { + // today = 2026-05-15 (Fri); range 2026-05-13 .. 2026-05-17 contains today. + // Angular's CalendarInputWeekComponent uses `from <= today <= to`. + const t = (k: string) => (k === "SCHEDULE.CURRENT-WEEK" ? "Текущая неделя" : k); expect( formatScheduleDateRangeWithCurrentWeek(new Date(2026, 4, 13), new Date(2026, 4, 17), t), - ).toBe("13.05.2026-17.05.2026"); + ).toBe("Текущая неделя"); + }); + + it("returns 'Текущая неделя' for clamped popular-click range (today-1 .. Sun)", () => { + // Popular-click on Schedule clamps `from` to today−1 = 2026-05-14. + const t = (k: string) => (k === "SCHEDULE.CURRENT-WEEK" ? "Текущая неделя" : k); + expect( + formatScheduleDateRangeWithCurrentWeek(new Date(2026, 4, 14), new Date(2026, 4, 17), t), + ).toBe("Текущая неделя"); }); it("returns empty string for null inputs", () => { diff --git a/src/features/schedule/dateLabels.ts b/src/features/schedule/dateLabels.ts index 1cc33ca8..cbb8e605 100644 --- a/src/features/schedule/dateLabels.ts +++ b/src/features/schedule/dateLabels.ts @@ -1,6 +1,11 @@ /** * Schedule range-calendar label substitution per TZ §4.1.9 Table 14. - * Current week Mon-Sun → "Текущая неделя", otherwise dd.MM.yyyy-dd.MM.yyyy. + * Any range containing today → "Текущая неделя", otherwise dd.MM.yyyy-dd.MM.yyyy. + * + * Matches Angular `CalendarInputWeekComponent.getDateString()`: substitutes + * the label whenever `from <= today <= to`, not only on an exact Mon-Sun + * match. This covers the popular-click case where the start page clamps + * the outbound `from` to today−1 to stay inside Schedule's [-1, +330] window. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -12,14 +17,6 @@ function toYmd(d: Date): string { return `${day}.${month}.${d.getFullYear()}`; } -function mondayOfWeek(base: Date): Date { - const d = new Date(base); - d.setHours(0, 0, 0, 0); - const offset = (d.getDay() + 6) % 7; - d.setDate(d.getDate() - offset); - return d; -} - export function formatScheduleDateRangeWithCurrentWeek( dateFrom: Date | null | undefined, dateTo: Date | null | undefined, @@ -28,14 +25,11 @@ export function formatScheduleDateRangeWithCurrentWeek( if (!dateFrom || !dateTo) return ""; const today = new Date(); today.setHours(0, 0, 0, 0); - const thisMon = mondayOfWeek(today); - const thisSun = new Date(thisMon); - thisSun.setDate(thisSun.getDate() + 6); const from = new Date(dateFrom); from.setHours(0, 0, 0, 0); const to = new Date(dateTo); to.setHours(0, 0, 0, 0); - if (from.getTime() === thisMon.getTime() && to.getTime() === thisSun.getTime()) { + if (from.getTime() <= today.getTime() && today.getTime() <= to.getTime()) { return t("SCHEDULE.CURRENT-WEEK"); } return `${toYmd(from)}-${toYmd(to)}`; diff --git a/src/features/schedule/extractSimpleFlights.ts b/src/features/schedule/extractSimpleFlights.ts new file mode 100644 index 00000000..a549319d --- /dev/null +++ b/src/features/schedule/extractSimpleFlights.ts @@ -0,0 +1,54 @@ +/** + * Convert the mixed `IFlight[]` schedule-search response into a flat + * `ISimpleFlight[]` for rendering. + * + * Connecting flights are folded into a synthetic MultiLeg shape so the + * existing FlightCard can render them with combined leg numbers, both + * airline logos, and the total flying time — matching Angular's + * `schedule-list-flight-header` for connecting flights. + * + * @module + */ + +import type { FlightStatus, IFlightLeg } from "@/features/online-board/types.js"; +import type { ISimpleFlight } from "./types.js"; + +export function extractSimpleFlights( + flights: Array<{ routeType: string }>, +): ISimpleFlight[] { + const out: ISimpleFlight[] = []; + for (const f of flights) { + if (f.routeType === "Direct" || f.routeType === "MultiLeg") { + out.push(f as unknown as ISimpleFlight); + continue; + } + if (f.routeType === "Connecting") { + const conn = f as unknown as { + flights: ISimpleFlight[]; + flyingTime: string; + status: FlightStatus; + }; + const first = conn.flights[0]; + if (!first) continue; + const allLegs: IFlightLeg[] = []; + for (const child of conn.flights) { + if (child.routeType === "Direct") allLegs.push(child.leg); + else allLegs.push(...child.legs); + } + const synthetic = { + routeType: "MultiLeg", + flightId: first.flightId, + flyingTime: conn.flyingTime, + operatingBy: first.operatingBy, + id: conn.flights.map((c) => c.id).join("+"), + status: conn.status, + legs: allLegs, + // Carry through the original child flight numbers so the header + // can display 'SU 6188, SU 6233'. + _childFlightIds: conn.flights.map((c) => c.flightId), + } as unknown as ISimpleFlight; + out.push(synthetic); + } + } + return out; +} diff --git a/src/shared/airportUrls.ts b/src/shared/airportUrls.ts new file mode 100644 index 00000000..11ba260c --- /dev/null +++ b/src/shared/airportUrls.ts @@ -0,0 +1,16 @@ +/** + * Airport IATA → official site URL map. Mirrors Angular's + * `ClientApp/src/app/shared/services/airports-data.service.ts`. + * Used by the station-display terminal link. + */ +export const airportUrls: Readonly> = { + SVO: "https://www.svo.aero/ru/main", + VKO: "http://www.vnukovo.ru/", + DME: "https://www.dme.ru/", + ZIA: "http://www.zia.aero/", +}; + +export function airportUrl(airportCode: string | undefined | null): string | undefined { + if (!airportCode) return undefined; + return airportUrls[airportCode]; +} diff --git a/src/ui/city-autocomplete/CityAutocomplete.scss b/src/ui/city-autocomplete/CityAutocomplete.scss index 6008e109..dfd0d046 100644 --- a/src/ui/city-autocomplete/CityAutocomplete.scss +++ b/src/ui/city-autocomplete/CityAutocomplete.scss @@ -30,17 +30,27 @@ text-overflow: ellipsis; } + // Angular `city-autocomplete__input` measured height = 46px with + // 16px font-size (`Расписание рейсов` sidebar on the live site). + // Previously this was 38px / default font-size which looked noticeably + // shorter than Angular's pill. + $city-input-h: 46px; + &__input { display: flex; flex-direction: row; position: relative; align-items: center; width: 100%; + height: $city-input-h; box-shadow: 0 0 0 1px colors.$border-input; border-radius: vars.$border-radius; .p-autocomplete { flex: 1; + display: flex; + align-items: center; + height: 100%; } // Reset the inner PrimeReact input's native border — the outer @@ -49,6 +59,9 @@ input.p-inputtext { border: none !important; box-shadow: none !important; + height: 100%; + font-size: fonts.$font-size-l; // 16px, matches Angular + padding: 0 vars.$space-l; } // Also drop PrimeReact's blue focus shadow on the inner input @@ -61,7 +74,7 @@ .button-clear { display: none; width: 32px; - height: 38px; + height: $city-input-h; border: none; background: transparent; cursor: pointer; @@ -91,7 +104,7 @@ &__search-button { width: 38px !important; min-width: 38px; - height: 38px; + height: $city-input-h; border-radius: 0 vars.$border-radius vars.$border-radius 0 !important; border: none !important; border-left: 1px solid white !important; diff --git a/src/ui/flights/FlightCard.scss b/src/ui/flights/FlightCard.scss index 8dbcefa7..4478d8f3 100644 --- a/src/ui/flights/FlightCard.scss +++ b/src/ui/flights/FlightCard.scss @@ -62,12 +62,48 @@ grid-template-columns: 80px 120px 100px minmax(45px, 240px) 100px 100px minmax(45px, 240px) 10px; gap: 0 vars.$space-l; + padding: vars.$space-xl; + } + + // Schedule row typography — values taken from the live Angular page + // (computed styles on `list-scheduled-flight schedule-list-flight-header`, + // measured 2026-04-23): + // flight number 18px / 400 (regular) + // time 30px / 300 (light) + // station city 14px / 400 (regular) + // station term 12px / 400 (regular, underlined) + // duration text 12px / 400 (regular) + &--schedule .flight-card__number { + font-size: fonts.$font-size-xl; + font-weight: fonts.$font-regular; + line-height: 1.25; + } + + &--schedule .flight-card__time .time-group__scheduled, + &--schedule .flight-card__time .time-group__actual { + font-size: fonts.$font-size-xxl; + font-weight: fonts.$font-light; + line-height: 1.15; + } + + &--schedule .flight-card__station .station__city--bold { + font-size: fonts.$font-size-m; + font-weight: fonts.$font-regular; + } + + &--schedule .flight-card__station .station__terminal { + font-size: fonts.$font-size-s; + } + + &--schedule .flight-card__duration { + font-size: fonts.$font-size-s; } &__number { font-weight: fonts.$font-medium; color: colors.$text-color; font-size: fonts.$font-size-m; + line-height: 1.2; } &__aircraft { @@ -120,6 +156,61 @@ } } + // Angular hides the row-expand chevron unless the row is hovered or + // already expanded (see schedule-list-flight-header.scss + // `.arrow-icon { display: none } :host:hover .arrow-icon { display: initial }`). + &--schedule .flight-card__chevron { + visibility: hidden; + } + &--schedule .flight-card__row:hover .flight-card__chevron, + &--schedule.flight-card--expanded .flight-card__chevron { + visibility: visible; + } + + // Angular renders a compact `transfer-inline` bar below the collapsed + // schedule row for connecting flights. The bar is offset left by the + // number + operator columns (`margin-left: 80px + 120px + 2 * $space-xl` + // per schedule-list-flight-header.scss) and sits in a thin pill with + // the dumbbell transfer icon tinted orange. + &__transfer { + display: flex; + align-items: center; + gap: vars.$space-s; + // 80 (number) + 120 (logos) + 20 (left pad) + 20 (gap) = 240px + margin: 0 vars.$space-xl vars.$space-m 240px; + padding: 6px vars.$space-m; + background: colors.$white; + border: 1px solid colors.$border; + border-radius: vars.$border-radius; + font-size: fonts.$font-size-s; + color: colors.$text-color; + width: fit-content; + max-width: calc(100% - 240px - #{vars.$space-xl}); + } + + &__transfer-icon { + display: inline-flex; + align-items: center; + color: #f78c2f; // Aeroflot orange transfer-dot tint + } + + &__transfer-label { + font-weight: fonts.$font-regular; + color: colors.$text-color; + } + + &__transfer-dash { + color: colors.$light-gray; + } + + &__transfer-stations { + color: colors.$text-color; + } + + &__transfer-airport { + color: colors.$blue; + } + &__inline-actions { display: flex; align-items: center; diff --git a/src/ui/flights/FlightCard.tsx b/src/ui/flights/FlightCard.tsx index 1af1f95f..25e9c154 100644 --- a/src/ui/flights/FlightCard.tsx +++ b/src/ui/flights/FlightCard.tsx @@ -355,7 +355,19 @@ export const FlightCard: FC = ({ : {})} >
-
{flightNumber}
+ {/* Angular's `schedule-list-flight-header` stacks each leg's + flight number on its own line (e.g. "SU 6951," / "SU 6345") + in the schedule row. Outside schedule mode we keep the + existing single-line presentation. */} + {direction === "schedule" && childFlightIds && childFlightIds.length > 1 ? ( + childFlightIds.map((id, i) => ( +
+ {id.carrier} {id.flightNumber}{id.suffix ?? ""}{i < childFlightIds.length - 1 ? "," : ""} +
+ )) + ) : ( +
{flightNumber}
+ )} {expanded && flight.routeType === "Direct" && aircraftName && (
{aircraftName}
)} @@ -480,6 +492,56 @@ export const FlightCard: FC = ({ )}
+ {/* Angular `schedule-list-flight-header` renders a compact transfer + bar below the row when the flight is collapsed and connecting + (`*ngIf="!flight.expanded && flight.boardings >= 1"`). */} + {direction === "schedule" && !expanded && flight.routeType !== "Direct" && + flight.legs.length > 1 && ( +
+ + + {t( + flight.legs.length > 2 + ? "SHARED.INTERMEDIATE-LANDING-PLURAL-OTHER" + : "SHARED.FLIGHT-TRANSFER-PLURAL-ONE", + )} + +  —  + + {flight.legs.slice(0, -1).map((l, i) => { + const s = l.arrival.scheduled; + const terminal = l.arrival.terminal; + const airportWithTerminal = terminal + ? `${s.airport} - ${terminal}` + : s.airport; + return ( + + {i > 0 ? ", " : ""} + {s.city} + {s.airport ? ( + <> + {", "} + + {airportWithTerminal} + + + ) : null} + + ); + })} + +
+ )} + {expandable && expanded && renderExpandedBody && (
to the airport's site (SVO, + // VKO, …). Match Angular's terminal-link blue hover state; the + // dotted underline keeps it visually distinct from a full-blue + // CTA without losing its "clickable" affordance. + &--link { + color: colors.$blue; + cursor: pointer; + + &:hover { + color: colors.$blue--hover; + } + } } &--city-first { diff --git a/src/ui/flights/StationDisplay.tsx b/src/ui/flights/StationDisplay.tsx index c7afe58f..f54c81eb 100644 --- a/src/ui/flights/StationDisplay.tsx +++ b/src/ui/flights/StationDisplay.tsx @@ -1,5 +1,6 @@ -import type { FC } from "react"; +import type { FC, MouseEvent } from "react"; import { useCityName } from "@/shared/hooks/useDictionaries.js"; +import { airportUrl } from "@/shared/airportUrls.js"; import "./StationDisplay.scss"; export interface StationDisplayProps { @@ -32,14 +33,49 @@ export const StationDisplay: FC = ({ }) => { const resolvedCity = cityName ?? useCityName(airportCode); const terminalLine = [airportName, terminal].filter(Boolean).join(" — "); + const url = airportUrl(airportCode); + + // Clicking the airport link should NOT toggle the parent flight row. + const stopBubble = (e: MouseEvent): void => { + e.stopPropagation(); + }; + + // Airport tooltip mirrors Angular's `terminal-link` pTooltip — the + // full "Airport name — Terminal N" (e.g. "Шереметьево — B"). The + // city tooltip just shows the city name itself, matching station's + // `[tooltip]="city"` ellipsis helper. + const airportTooltip = terminalLine || airportName || undefined; + const cityTooltip = resolvedCity; + + const terminalEl = terminalLine ? ( + url ? ( + + {terminalLine} + + ) : ( + + {terminalLine} + + ) + ) : null; if (cityFirst) { return (
- {resolvedCity} - {terminalLine ? ( - {terminalLine} - ) : null} + + {resolvedCity} + + {terminalEl}
); } @@ -50,7 +86,9 @@ export const StationDisplay: FC = ({ {airportName ? ( {airportName} ) : null} - {resolvedCity} + + {resolvedCity} +
); };