baseline: carry WIP schedule/UI changes from main

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.
This commit is contained in:
2026-04-25 02:07:35 +03:00
parent 21a2acdb89
commit a0dd0a5596
17 changed files with 866 additions and 172 deletions
@@ -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;
@@ -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;
}
}
@@ -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;
}
}
}
@@ -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<ScheduleColumnHeadersProps> = ({
sortMode,
onSortChange,
}) => {
const { t } = useTranslation();
const toggle = (mode: ScheduleSortMode): void => {
onSortChange(sortMode === mode ? "none" : mode);
};
const sortBtn = (mode: ScheduleSortMode, dir: "up" | "down") => (
<button
type="button"
onClick={() => toggle(mode)}
className={`schedule-col-header__sort schedule-col-header__sort--${dir}${
sortMode === mode ? " schedule-col-header__sort--active" : ""
}`}
aria-label={`${dir === "up" ? "↑" : "↓"} ${mode}`}
data-testid={`schedule-sort-${mode}`}
>
<svg viewBox="0 0 10 10" width="10" height="10" aria-hidden="true">
{dir === "up" ? (
<path d="M5 2L9 8H1Z" fill="currentColor" />
) : (
<path d="M5 8L1 2H9Z" fill="currentColor" />
)}
</svg>
</button>
);
return (
<div
className="schedule-col-header"
data-testid="schedule-column-headers"
>
<div className="schedule-col-header__flight">
<div className="schedule-col-header__label">
{t("SCHEDULE.COL-FLIGHT") || "РЕЙС"}
</div>
</div>
<div className="schedule-col-header__company">
<div className="schedule-col-header__label">
{t("SCHEDULE.COL-AIRLINE") || "АВИАКОМПАНИЯ, БОРТ"}
</div>
</div>
<div className="schedule-col-header__departure">
<div className="schedule-col-header__label schedule-col-header__label--note">
{t("SCHEDULE.COL-DEPARTURE") || "ВЫЛЕТ"}
<span className="schedule-col-header__note" aria-hidden="true">*</span>
</div>
<div className="schedule-col-header__sort-container">
{sortBtn("departureUp", "up")}
{sortBtn("departureDown", "down")}
</div>
</div>
<div className="schedule-col-header__time">
<div className="schedule-col-header__label">
{t("SCHEDULE.COL-DURATION") || "ВРЕМЯ В ПУТИ"}
</div>
<div className="schedule-col-header__sort-container">
{sortBtn("timeUp", "up")}
{sortBtn("timeDown", "down")}
</div>
</div>
<div className="schedule-col-header__arrival">
<div className="schedule-col-header__label schedule-col-header__label--note">
{t("SCHEDULE.COL-ARRIVAL") || "ПРИЛЕТ"}
<span className="schedule-col-header__note" aria-hidden="true">*</span>
</div>
<div className="schedule-col-header__sort-container">
{sortBtn("arrivalUp", "up")}
{sortBtn("arrivalDown", "down")}
</div>
</div>
</div>
);
};
@@ -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 {
@@ -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<string, unknown>) => (
<input
data-testid={`${(props["testIdPrefix"] as string) ?? "city-autocomplete"}-input`}
defaultValue={(props["value"] as string) ?? ""}
value={(props["value"] as string) ?? ""}
readOnly
/>
),
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 today1 = 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(<ScheduleStartPage />);
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 today1 (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(<ScheduleStartPage />);
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(<ScheduleStartPage />);
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(<ScheduleStartPage />);
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
// today1 = 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");
});
});
+64 -21
View File
@@ -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;
}
@@ -133,7 +133,9 @@ export const WeekTabs: FC<WeekTabsProps> = ({ selectedMonday, onNavigate }) => {
onClick={() => setPage((p) => Math.max(0, p - 1))}
aria-label={t("SHARED.A11Y-PREV-PAGE")}
>
<svg viewBox="0 0 8 12" width="8" height="12" aria-hidden="true">
<path d="M7 1L2 6L7 11" stroke="currentColor" strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<div className="week-tabs__list">
{activeSlice.map((w) => {
@@ -174,7 +176,9 @@ export const WeekTabs: FC<WeekTabsProps> = ({ selectedMonday, onNavigate }) => {
onClick={() => setPage((p) => Math.min(activeTotalPages - 1, p + 1))}
aria-label={t("SHARED.A11Y-NEXT-PAGE")}
>
<svg viewBox="0 0 8 12" width="8" height="12" aria-hidden="true">
<path d="M1 1L6 6L1 11" stroke="currentColor" strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</nav>
);
+14 -4
View File
@@ -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 today1 = 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", () => {
+7 -13
View File
@@ -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 today1 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)}`;
@@ -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;
}
+16
View File
@@ -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<Record<string, string>> = {
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];
}
+15 -2
View File
@@ -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;
+91
View File
@@ -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;
+63 -1
View File
@@ -355,7 +355,19 @@ export const FlightCard: FC<FlightCardProps> = ({
: {})}
>
<div className="flight-card__number" data-testid="flight-carrier-number">
<div>{flightNumber}</div>
{/* 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) => (
<div key={`${id.carrier}-${id.flightNumber}-${i}`}>
{id.carrier} {id.flightNumber}{id.suffix ?? ""}{i < childFlightIds.length - 1 ? "," : ""}
</div>
))
) : (
<div>{flightNumber}</div>
)}
{expanded && flight.routeType === "Direct" && aircraftName && (
<div className="flight-card__aircraft">{aircraftName}</div>
)}
@@ -480,6 +492,56 @@ export const FlightCard: FC<FlightCardProps> = ({
)}
</div>
{/* 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 && (
<div
className="flight-card__transfer"
data-testid="flight-card-transfer"
>
<span className="flight-card__transfer-icon" aria-hidden="true">
<svg viewBox="0 0 20 8" width="20" height="8">
<circle cx="3" cy="4" r="3" fill="currentColor" />
<path d="M6 4h8" stroke="currentColor" strokeWidth="1.5" />
<circle cx="17" cy="4" r="3" fill="currentColor" />
</svg>
</span>
<span className="flight-card__transfer-label">
{t(
flight.legs.length > 2
? "SHARED.INTERMEDIATE-LANDING-PLURAL-OTHER"
: "SHARED.FLIGHT-TRANSFER-PLURAL-ONE",
)}
</span>
<span className="flight-card__transfer-dash">&nbsp;&mdash;&nbsp;</span>
<span className="flight-card__transfer-stations">
{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 (
<span key={`tr-${i}`}>
{i > 0 ? ", " : ""}
{s.city}
{s.airport ? (
<>
{", "}
<span className="flight-card__transfer-airport">
{airportWithTerminal}
</span>
</>
) : null}
</span>
);
})}
</span>
</div>
)}
{expandable && expanded && renderExpandedBody && (
<div
className="flight-card__expanded flight-card__expanded--custom"
+13
View File
@@ -26,6 +26,19 @@
color: colors.$light-gray;
text-decoration: underline;
line-height: 16px;
// The `--link` variant renders an <a> 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 {
+44 -6
View File
@@ -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<StationDisplayProps> = ({
}) => {
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 ? (
<a
className="station__terminal station__terminal--link"
href={url}
target="_blank"
rel="noopener noreferrer"
onClick={stopBubble}
title={airportTooltip}
>
{terminalLine}
</a>
) : (
<span className="station__terminal" title={airportTooltip}>
{terminalLine}
</span>
)
) : null;
if (cityFirst) {
return (
<div className="station station--city-first">
<span className="station__city station__city--bold">{resolvedCity}</span>
{terminalLine ? (
<span className="station__terminal">{terminalLine}</span>
) : null}
<span
className="station__city station__city--bold"
title={cityTooltip}
>
{resolvedCity}
</span>
{terminalEl}
</div>
);
}
@@ -50,7 +86,9 @@ export const StationDisplay: FC<StationDisplayProps> = ({
{airportName ? (
<span className="station__name">{airportName}</span>
) : null}
<span className="station__city">{resolvedCity}</span>
<span className="station__city" title={cityTooltip}>
{resolvedCity}
</span>
</div>
);
};