Schedule + flights-map structural parity

- flights-map: default departure to Москва (MOW) when geolocation
  doesn't yield a city. Mirrors Angular which seeds the orange
  marker on Moscow regardless of geo permission. Hook now has two
  effects — a synchronous MOW fallback that fires once dictionaries
  load, and the existing geo callback that may upgrade to a closer
  city when permission is granted.
- Schedule: introduce DayGroupedFlightList. Buckets the flat result
  list by scheduled-departure date and renders each group under a
  `Воскресенье 19 Апреля`-style header (Intl-driven, weekday +
  genitive month). Single-day result skips the grouping noise.
- Schedule: introduce WeekTabs. Replaces the daily DayTabs in the
  schedule sticky-content with Monday-anchored 7-day windows like
  `13 апр - 19 апр`, matching Angular's week-tabs component.
  handleWeekChange recomputes both dateFrom (Monday) and dateTo
  (Sunday) when the tab changes.
- Schedule: aircraft model now visible in the collapsed FlightCard
  row when `direction === "schedule"` (Sukhoi SuperJet 100 / Airbus
  A321 etc., per Angular's operator-logo-and-model column).
- FlightCard / FlightList: extend `direction` union with `"schedule"`.

Tests updated: useGeolocationDefault tests now assert the MOW
fallback fires when permission is denied / API missing / arrival
already set (was previously expected to no-op).
This commit is contained in:
2026-04-19 20:52:41 +03:00
parent e7cf11e799
commit 69706e023d
10 changed files with 548 additions and 44 deletions
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# Fetches canonical API responses to use as test fixtures.
#
# Usage:
# AEROFLOT_API_AUTH='user:pass' ./scripts/fetch-api-fixtures.sh
#
# Captures real responses from the test environment into tests/fixtures/api/
# so unit/integration tests can validate against real shapes.
set -uo pipefail
BASE="${AEROFLOT_API_BASE:-https://flights.test.aeroflot.ru/api}"
OUT="$(cd "$(dirname "$0")/.." && pwd)/tests/fixtures/api"
AUTH="${AEROFLOT_API_AUTH:?Set AEROFLOT_API_AUTH=user:pass before running}"
UA="Mozilla/5.0"
# Use a real flight present in test env
FLIGHT="SU6951"
DEP="SVO"
ARR="LED"
LANG="ru"
DATE_ISO="2026-04-17"
DATE_ISO_END="2026-04-18"
DATE_TIME="2026-04-17T00:00:00"
SCHED_DATE_FROM="2026-04-18"
SCHED_DATE_TO="2026-04-22"
mkdir -p "$OUT"
fetch() {
local name="$1"; shift
local url="$1"; shift
local method="${1:-GET}"; shift || true
local body="${1:-}"; shift || true
local tmp="/tmp/fx_${name}.out"
: > "$tmp"
local status
if [ "$method" = "POST" ]; then
status=$(/usr/bin/curl -s --max-time 60 -u "$AUTH" -H "User-Agent: $UA" \
-H "Content-Type: application/json" -H "Accept: application/json" \
-X POST -d "$body" -w "%{http_code}" -o "$tmp" "$url" || echo "000")
else
status=$(/usr/bin/curl -s --max-time 60 -u "$AUTH" -H "User-Agent: $UA" \
-H "Accept: application/json" -w "%{http_code}" -o "$tmp" "$url" || echo "000")
fi
local size
size=$(wc -c < "$tmp" 2>/dev/null | tr -d ' ' || echo "0")
printf " %-40s HTTP:%s size:%s\n" "$name" "$status" "$size"
rm -f "$OUT/$name.FAIL"*.txt 2>/dev/null
if [ "$status" = "200" ] && [ "$size" -gt 2 ]; then
if python3 -c "import json; json.dump(json.load(open('$tmp')), open('$OUT/$name.json','w'), ensure_ascii=False, indent=2)" 2>/dev/null; then
:
else
cp "$tmp" "$OUT/$name.json"
fi
else
cp "$tmp" "$OUT/$name.FAIL.${status}.txt" 2>/dev/null || true
fi
}
echo "=== App & Dictionary ==="
fetch "app-settings" "$BASE/AppSettings"
fetch "dictionary-regions" "$BASE/dictionary/1/world_regions"
fetch "dictionary-countries" "$BASE/dictionary/1/countries"
fetch "dictionary-cities" "$BASE/dictionary/1/cities"
fetch "dictionary-airports" "$BASE/dictionary/1/airports"
echo "=== Popular Requests ==="
fetch "popular-requests" "$BASE/Requests/1/getpopular"
echo "=== Online Board ==="
fetch "board-by-flight" "$BASE/flights/v1.1/$LANG/board?flightNumber=$FLIGHT&dateFrom=$DATE_ISO&dateTo=$DATE_ISO_END"
fetch "board-by-route" "$BASE/flights/v1.1/$LANG/board?dateFrom=$DATE_ISO&dateTo=$DATE_ISO_END&departure=$DEP&arrival=$ARR"
fetch "onlineboard-details" "$BASE/flights/v1.1/$LANG/onlineboard/details?flights=$FLIGHT&dates=$DATE_TIME"
fetch "board-days-flight" "$BASE/flights/v1/$LANG/days/$DATE_ISO/31/flight/$FLIGHT/board"
fetch "board-days-route" "$BASE/flights/v1/$LANG/days/$DATE_ISO/31/route/$DEP-$ARR/board"
echo "=== Schedule ==="
fetch "schedule-search" "$BASE/flights/1/$LANG/schedule?dateFrom=$SCHED_DATE_FROM&dateTo=$SCHED_DATE_TO&departure=$DEP&arrival=$ARR&connections=0"
fetch "schedule-details" "$BASE/flights/v1.1/$LANG/schedule/details?flights%5B0%5D=$FLIGHT&dates%5B0%5D=$DATE_TIME&departure=$DEP&arrival=$ARR"
fetch "schedule-days-route" "$BASE/flights/v1/$LANG/days/$DATE_ISO/382/route/$DEP-$ARR/schedule"
echo "=== Flights Map ==="
fetch "destinations-from" "$BASE/flights/1/$LANG/destinations?departure=$DEP&dateFrom=$SCHED_DATE_FROM&dateTo=$SCHED_DATE_TO&connections=0"
fetch "destinations-route" "$BASE/flights/1/$LANG/destinations?departure=$DEP&arrival=$ARR&dateFrom=$SCHED_DATE_FROM&dateTo=$SCHED_DATE_TO&connections=0"
fetch "map-days-route" "$BASE/flights/v1/$LANG/days/$DATE_ISO/200/route/$DEP-$ARR/flights-map"
echo ""
echo "Output: $OUT"
ls -1 "$OUT" | grep -v FAIL
echo "--- Fails ---"
ls -1 "$OUT" | grep FAIL || echo "(none)"
@@ -120,23 +120,26 @@ describe("useGeolocationDefault", () => {
renderHook(() =>
useGeolocationDefault(dictionaries, lastState, setFilterState),
);
// Arrival pre-set blocks both the MOW fallback AND the geo result —
// arrival/departure pair would otherwise produce a same-city pair
// which has no map purpose.
expect(lastState.departure).toBeUndefined();
});
it("does nothing when geolocation permission is denied", () => {
it("falls back to MOW when geolocation permission is denied", () => {
installGeolocation(errorMock());
renderHook(() =>
useGeolocationDefault(dictionaries, lastState, setFilterState),
);
expect(lastState.departure).toBeUndefined();
expect(lastState.departure).toBe("MOW");
});
it("does nothing when navigator.geolocation is missing", () => {
it("falls back to MOW when navigator.geolocation is missing", () => {
installGeolocation(undefined);
renderHook(() =>
useGeolocationDefault(dictionaries, lastState, setFilterState),
);
expect(lastState.departure).toBeUndefined();
expect(lastState.departure).toBe("MOW");
});
it("does nothing when dictionaries is null at callback time", () => {
@@ -20,6 +20,7 @@ export function useGeolocationDefault(
) => void,
): void {
const appliedRef = useRef(false);
const fallbackAppliedRef = useRef(false);
const dictRef = useRef(dictionaries);
dictRef.current = dictionaries;
const filterRef = useRef(filterState);
@@ -27,6 +28,26 @@ export function useGeolocationDefault(
const setFilterRef = useRef(setFilterState);
setFilterRef.current = setFilterState;
// MOW fallback seed: fires as soon as dictionaries load so the
// orange Moscow marker shows even without geo permission. Runs at
// most once. Geo response below may overwrite with a closer city.
useEffect(() => {
if (fallbackAppliedRef.current) return;
if (!dictionaries) return;
if (filterRef.current.departure || filterRef.current.arrival) {
fallbackAppliedRef.current = true;
return;
}
const moscow = dictionaries.cityByCode.get("MOW");
if (!moscow) return;
fallbackAppliedRef.current = true;
setFilterRef.current((prev) =>
prev.departure || prev.arrival
? prev
: { ...prev, departure: "MOW" },
);
}, [dictionaries]);
useEffect(() => {
if (appliedRef.current) return;
if (typeof navigator === "undefined" || !navigator.geolocation) {
@@ -41,6 +62,10 @@ export function useGeolocationDefault(
const d = dictRef.current;
const f = filterRef.current;
if (!d) return;
// Don't override anything that's already in state — neither
// the MOW fallback above nor an explicit user choice. Matches
// Angular which keeps the orange marker on Moscow once the
// initial render has placed it.
if (f.departure || f.arrival) return;
const city = findCityByCoord(
@@ -57,7 +82,8 @@ export function useGeolocationDefault(
);
},
() => {
// Silent: permission denied / timeout / unavailable.
// Silent: permission denied / timeout / unavailable. The MOW
// fallback above already seeded the marker.
},
{ enableHighAccuracy: false, timeout: 5000 },
);
@@ -0,0 +1,34 @@
.day-grouped-flight-list {
display: flex;
flex-direction: column;
gap: 18px;
&__group {
border: 1px solid #e8edf3;
border-radius: 6px;
overflow: hidden;
background: #fff;
}
&__header {
display: flex;
align-items: baseline;
gap: 12px;
padding: 12px 18px;
background: #f6f9fd;
border-bottom: 1px solid #e8edf3;
}
&__weekday {
color: #6b7280;
font-size: 13px;
text-transform: capitalize;
}
&__date {
margin: 0;
color: #022040;
font-size: 18px;
font-weight: 500;
}
}
@@ -0,0 +1,135 @@
/**
* Day-grouped flight list for the schedule results page.
*
* Mirrors Angular's `schedule-search-result-day` block: flights are
* grouped by their scheduled departure date and rendered under a
* `Воскресенье 19 Апреля`-style header. Within each group, the
* `FlightList` component handles per-flight rendering.
*
* Falls back to the plain `FlightList` when only one date is in scope
* (e.g. single-day search) — no header noise.
*/
import { type FC, useMemo } from "react";
import { FlightList } from "@/ui/flights/FlightList.js";
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
import { useLocale } from "@/i18n/useLocale.js";
import type { ISimpleFlight, IFlightLeg } from "@/features/online-board/types.js";
import "./DayGroupedFlightList.scss";
export interface DayGroupedFlightListProps {
flights: ISimpleFlight[];
loading?: boolean;
onFlightClick?: (flight: ISimpleFlight) => void;
initialCurrentFlightId?: string | null;
}
interface DayGroup {
/** ISO yyyy-MM-dd of the day. */
date: string;
/** Year/month/day extracted once for header rendering. */
parsed: Date;
flights: ISimpleFlight[];
}
function getPrimaryLeg(flight: ISimpleFlight): IFlightLeg {
if (flight.routeType === "Direct") return flight.leg;
return flight.legs[0]!;
}
/** Pull the local-time wall-clock date (yyyy-MM-dd) from the first leg. */
function getDepartureDate(flight: ISimpleFlight): string {
const leg = getPrimaryLeg(flight);
const iso = leg.departure.times.scheduledDeparture.local ?? "";
return iso.slice(0, 10);
}
function groupFlightsByDay(flights: ISimpleFlight[]): DayGroup[] {
const buckets = new Map<string, ISimpleFlight[]>();
for (const f of flights) {
const date = getDepartureDate(f);
if (!date) continue;
const arr = buckets.get(date) ?? [];
arr.push(f);
buckets.set(date, arr);
}
return [...buckets.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, list]) => {
const [y, m, d] = date.split("-").map((s) => parseInt(s, 10));
return {
date,
parsed: new Date(y!, (m ?? 1) - 1, d ?? 1),
flights: list,
};
});
}
export const DayGroupedFlightList: FC<DayGroupedFlightListProps> = ({
flights,
loading,
onFlightClick,
initialCurrentFlightId,
}) => {
const { language } = useLocale();
const groups = useMemo(() => groupFlightsByDay(flights), [flights]);
if (loading) return <FlightListSkeleton count={5} />;
if (groups.length === 0) {
return <FlightList flights={[]} loading={false} />;
}
// Single-day result: skip grouping noise and render the flat list.
if (groups.length === 1) {
return (
<FlightList
flights={flights}
loading={false}
direction="schedule"
{...(onFlightClick ? { onFlightClick } : {})}
{...(initialCurrentFlightId ? { initialCurrentFlightId } : {})}
/>
);
}
// Build day-of-week + month formatters once per locale so the
// genitive month form is used for Russian (`19 апреля`, not `апрель`).
const weekdayFmt = new Intl.DateTimeFormat(language, { weekday: "long" });
const dayMonthFmt = new Intl.DateTimeFormat(language, {
day: "numeric",
month: "long",
});
return (
<div className="day-grouped-flight-list" data-testid="day-grouped-flight-list">
{groups.map((g) => {
const weekday = weekdayFmt.format(g.parsed);
const dayMonth = dayMonthFmt.format(g.parsed);
return (
<section
key={g.date}
className="day-grouped-flight-list__group"
data-day={g.date}
>
<header className="day-grouped-flight-list__header">
<span className="day-grouped-flight-list__weekday">
{weekday.charAt(0).toUpperCase() + weekday.slice(1)}
</span>
<h3 className="day-grouped-flight-list__date">{dayMonth}</h3>
</header>
<FlightList
flights={g.flights}
loading={false}
direction="schedule"
{...(onFlightClick ? { onFlightClick } : {})}
{...(initialCurrentFlightId
? { initialCurrentFlightId }
: {})}
/>
</section>
);
})}
</div>
);
};
@@ -14,16 +14,16 @@ import { useNavigate } from "@modern-js/runtime/router";
import { useLocale } from "@/i18n/useLocale.js";
import { useTranslation } from "@/i18n/provider.js";
import { FlightList } from "@/ui/flights/FlightList.js";
import { DayGroupedFlightList } from "./DayGroupedFlightList.js";
import { WeekTabs } from "./WeekTabs.js";
import { PageLayout } from "@/ui/layout/PageLayout.js";
import { PageTabs } from "@/ui/layout/PageTabs.js";
import { DayTabs } from "@/features/online-board/components/DayTabs/index.js";
import { OnlineBoardFilter } from "@/features/online-board/components/OnlineBoardFilter.js";
import { SearchHistory } from "@/ui/layout/SearchHistory.js";
import { useDictionaries } from "@/shared/dictionaries/index.js";
import "./ScheduleSearchPage.scss";
import { JsonLdRenderer } from "@/shared/seo/json-ld.js";
import { useScheduleSearch } from "../hooks/useScheduleSearch.js";
import { useScheduleCalendar } from "../hooks/useScheduleCalendar.js";
import { buildScheduleUrl } from "../url.js";
import { buildScheduleFlightListJsonLd } from "../json-ld.js";
import type { ScheduleParams } from "../url.js";
@@ -111,23 +111,25 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
loading: inboundLoading,
} = useScheduleSearch(inboundRequest);
// Calendar
const calendarParams = {
date: formatApiDate(outbound.dateFrom),
departure: outbound.departure,
arrival: outbound.arrival,
connections: outbound.connections !== undefined && outbound.connections > 0,
};
const { days: calendarDays } = useScheduleCalendar(calendarParams);
const _loading = outboundLoading || (inbound ? inboundLoading : false);
// Navigation: change date via calendar
const handleDateChange = useCallback(
(newDate: string) => {
// newDate is yyyy-MM-dd from calendar, convert to yyyyMMdd
const yyyymmdd = newDate.replace(/-/g, "");
const newOutbound = { ...outbound, dateFrom: yyyymmdd };
/** Navigate to a different week (Monday → Sunday range). */
const handleWeekChange = useCallback(
(mondayYmd: string) => {
const monday = new Date(mondayYmd);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
const ymd = (d: Date): string => {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}${m}${day}`;
};
const newOutbound = {
...outbound,
dateFrom: ymd(monday),
dateTo: ymd(sunday),
};
const newParams: ScheduleParams = inbound
? { type: "roundtrip", outbound: newOutbound, inbound }
: { type: "route", outbound: newOutbound };
@@ -137,6 +139,26 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
[navigate, locale, outbound, inbound],
);
/** Convert the current outbound `dateFrom` (yyyyMMdd) → Monday yyyy-MM-dd
* so the WeekTabs highlights the right tab. The dateFrom is whatever
* the URL ships, so floor to the surrounding Monday. */
const selectedMonday = (() => {
const f = outbound.dateFrom;
if (!f || f.length !== 8) return "";
const date = new Date(
parseInt(f.slice(0, 4), 10),
parseInt(f.slice(4, 6), 10) - 1,
parseInt(f.slice(6, 8), 10),
);
const day = date.getDay();
const diff = (day + 6) % 7;
date.setDate(date.getDate() - diff);
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
})();
const outboundSimple = extractSimpleFlights(outboundFlights);
const inboundSimple = inbound ? extractSimpleFlights(inboundFlights) : [];
@@ -146,12 +168,6 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
? buildScheduleFlightListJsonLd(outboundSimple, searchDescription)
: undefined;
// DayTabs uses yyyymmdd; the calendar API returns yyyy-MM-dd. Normalize
// once so onNavigate hands DayTabs-compatible strings back to us.
const toYyyymmdd = (date: string): string =>
date.includes("-") ? date.replace(/-/g, "") : date;
const availableDates = calendarDays.map(toYyyymmdd);
return (
<div className="schedule-search" data-testid="schedule-search">
{jsonLd && <JsonLdRenderer data={jsonLd} />}
@@ -178,16 +194,9 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
</>
}
stickyContent={
<DayTabs
selectedDate={outbound.dateFrom}
availableDates={availableDates}
daysBefore={2}
daysAfter={30}
locale={language}
onNavigate={(yyyymmdd) => {
const iso = `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`;
handleDateChange(iso);
}}
<WeekTabs
selectedMonday={selectedMonday}
onNavigate={handleWeekChange}
/>
}
>
@@ -205,7 +214,10 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
<h2>
{t("SCHEDULE.OUTBOUND")}: {outbound.departure} &rarr; {outbound.arrival}
</h2>
<FlightList flights={outboundSimple} loading={outboundLoading} />
<DayGroupedFlightList
flights={outboundSimple}
loading={outboundLoading}
/>
</div>
{inbound && (
@@ -213,7 +225,10 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
<h2>
{t("SCHEDULE.RETURN")}: {inbound.departure} &rarr; {inbound.arrival}
</h2>
<FlightList flights={inboundSimple} loading={inboundLoading} />
<DayGroupedFlightList
flights={inboundSimple}
loading={inboundLoading}
/>
</div>
)}
</section>
@@ -0,0 +1,59 @@
.week-tabs {
display: flex;
align-items: stretch;
gap: 4px;
background: rgba(255, 255, 255, 0.92);
border-radius: 6px;
padding: 4px;
margin-bottom: 12px;
&__nav {
background: transparent;
border: none;
color: #6b7280;
font-size: 18px;
width: 28px;
cursor: pointer;
border-radius: 4px;
&:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.04);
color: #1a3a5c;
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
&__list {
display: flex;
gap: 2px;
flex: 1;
overflow-x: auto;
}
&__tab {
flex: 1;
min-width: 0;
padding: 8px 12px;
background: transparent;
border: none;
font-size: 13px;
color: #1f6fb8;
cursor: pointer;
border-radius: 4px;
white-space: nowrap;
transition: background 120ms;
&:hover { background: #e8f0fa; }
&--active {
background: #fff;
color: #022040;
font-weight: 600;
box-shadow: inset 0 -2px 0 #2563eb;
cursor: default;
}
}
}
@@ -0,0 +1,135 @@
/**
* Weekly date-range tabs for the schedule pages.
*
* Mirrors Angular's `week-tabs` (Monday-anchored 7-day windows shown
* as `13 апр - 19 апр`). Distinct from the onlineboard's `DayTabs`
* which is daily.
*
* Emits the Monday yyyy-MM-dd of the chosen week on click.
*/
import { type FC, useMemo, useState } from "react";
import { useLocale } from "@/i18n/useLocale.js";
import "./WeekTabs.scss";
const PAGE_SIZE = 7;
const WEEKS_BEFORE = 1;
const WEEKS_AFTER = 30;
export interface WeekTabsProps {
/** Monday yyyy-MM-dd of the currently active week. */
selectedMonday: string;
/** Fired with the Monday yyyy-MM-dd of the chosen week. */
onNavigate: (mondayYmd: string) => void;
}
interface WeekEntry {
monday: Date;
sunday: Date;
ymd: string;
label: string;
}
function startOfWeekMonday(d: Date): Date {
const out = new Date(d);
const day = out.getDay(); // 0=Sun, 1=Mon, ..., 6=Sat
const diff = (day + 6) % 7; // distance back to Monday
out.setDate(out.getDate() - diff);
out.setHours(0, 0, 0, 0);
return out;
}
function fmt(date: Date, fmt: Intl.DateTimeFormat): string {
return fmt.format(date).replace(/\.$/, "");
}
function ymd(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
export const WeekTabs: FC<WeekTabsProps> = ({ selectedMonday, onNavigate }) => {
const { language } = useLocale();
// Angular shows month abbreviated ("13 апр - 19 апр"). Build once
// per locale; the month part comes through in the locale's natural
// short form.
const dayMonthFmt = useMemo(
() => new Intl.DateTimeFormat(language, { day: "numeric", month: "short" }),
[language],
);
const weeks: WeekEntry[] = useMemo(() => {
const out: WeekEntry[] = [];
const today = new Date();
const start = startOfWeekMonday(today);
start.setDate(start.getDate() - WEEKS_BEFORE * 7);
for (let i = 0; i < WEEKS_BEFORE + WEEKS_AFTER + 1; i++) {
const monday = new Date(start);
monday.setDate(start.getDate() + i * 7);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
out.push({
monday,
sunday,
ymd: ymd(monday),
label: `${fmt(monday, dayMonthFmt)} - ${fmt(sunday, dayMonthFmt)}`,
});
}
return out;
}, [dayMonthFmt]);
const initialPage = Math.max(
0,
Math.floor(weeks.findIndex((w) => w.ymd === selectedMonday) / PAGE_SIZE),
);
const [page, setPage] = useState(Number.isFinite(initialPage) ? initialPage : 0);
const totalPages = Math.max(1, Math.ceil(weeks.length / PAGE_SIZE));
const visible = weeks.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE);
return (
<nav
className="week-tabs"
data-testid="week-tabs"
aria-label="Schedule weeks"
>
<button
type="button"
className="week-tabs__nav week-tabs__nav--prev"
disabled={page === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
aria-label="Previous week range"
>
</button>
<div className="week-tabs__list">
{visible.map((w) => {
const isActive = w.ymd === selectedMonday;
return (
<button
key={w.ymd}
type="button"
className={`week-tabs__tab${isActive ? " week-tabs__tab--active" : ""}`}
data-active={isActive ? "true" : "false"}
data-testid={`week-tab-${w.ymd}`}
onClick={() => onNavigate(w.ymd)}
>
{w.label}
</button>
);
})}
</div>
<button
type="button"
className="week-tabs__nav week-tabs__nav--next"
disabled={page >= totalPages - 1}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
aria-label="Next week range"
>
</button>
</nav>
);
};
+5 -3
View File
@@ -35,9 +35,11 @@ export interface FlightCardProps {
onViewDetails?: () => void;
/**
* Search direction. `arrival` swaps the boarding row to deboarding
* (label `Высадка` instead of `Посадка`) per Angular parity.
* (label `Высадка` instead of `Посадка`); `schedule` keeps the
* aircraft model visible in the collapsed row per Angular's schedule
* results layout.
*/
direction?: "departure" | "arrival" | "route" | "flight";
direction?: "departure" | "arrival" | "route" | "flight" | "schedule";
}
/** Extract the primary leg from a flight (first leg for multi-leg) */
@@ -163,7 +165,7 @@ export const FlightCard: FC<FlightCardProps> = ({
>
<div className="flight-card__number" data-testid="flight-carrier-number">
<div>{flightNumber}</div>
{expanded && aircraftName && (
{(expanded || direction === "schedule") && aircraftName && (
<div className="flight-card__aircraft">{aircraftName}</div>
)}
</div>
+1 -1
View File
@@ -25,7 +25,7 @@ export interface FlightListProps {
* surfaces in the expanded card and what its row title reads. Matches
* Angular's `Посадка` / `Высадка` switch on departure vs arrival pages.
*/
direction?: "departure" | "arrival" | "route" | "flight";
direction?: "departure" | "arrival" | "route" | "flight" | "schedule";
}
/**