From 69706e023dbab5ff11aa32c7db170b789b3d883c Mon Sep 17 00:00:00 2001 From: gnezim Date: Sun, 19 Apr 2026 20:52:41 +0300 Subject: [PATCH] Schedule + flights-map structural parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- scripts/fetch-api-fixtures.sh | 95 ++++++++++++ .../hooks/useGeolocationDefault.test.tsx | 11 +- .../hooks/useGeolocationDefault.ts | 28 +++- .../components/DayGroupedFlightList.scss | 34 +++++ .../components/DayGroupedFlightList.tsx | 135 ++++++++++++++++++ .../components/ScheduleSearchPage.tsx | 85 ++++++----- .../schedule/components/WeekTabs.scss | 59 ++++++++ src/features/schedule/components/WeekTabs.tsx | 135 ++++++++++++++++++ src/ui/flights/FlightCard.tsx | 8 +- src/ui/flights/FlightList.tsx | 2 +- 10 files changed, 548 insertions(+), 44 deletions(-) create mode 100755 scripts/fetch-api-fixtures.sh create mode 100644 src/features/schedule/components/DayGroupedFlightList.scss create mode 100644 src/features/schedule/components/DayGroupedFlightList.tsx create mode 100644 src/features/schedule/components/WeekTabs.scss create mode 100644 src/features/schedule/components/WeekTabs.tsx diff --git a/scripts/fetch-api-fixtures.sh b/scripts/fetch-api-fixtures.sh new file mode 100755 index 00000000..9ca81bd6 --- /dev/null +++ b/scripts/fetch-api-fixtures.sh @@ -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)" diff --git a/src/features/flights-map/hooks/useGeolocationDefault.test.tsx b/src/features/flights-map/hooks/useGeolocationDefault.test.tsx index 6ae84e06..13c74252 100644 --- a/src/features/flights-map/hooks/useGeolocationDefault.test.tsx +++ b/src/features/flights-map/hooks/useGeolocationDefault.test.tsx @@ -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", () => { diff --git a/src/features/flights-map/hooks/useGeolocationDefault.ts b/src/features/flights-map/hooks/useGeolocationDefault.ts index 700ab306..2c8610d9 100644 --- a/src/features/flights-map/hooks/useGeolocationDefault.ts +++ b/src/features/flights-map/hooks/useGeolocationDefault.ts @@ -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 }, ); diff --git a/src/features/schedule/components/DayGroupedFlightList.scss b/src/features/schedule/components/DayGroupedFlightList.scss new file mode 100644 index 00000000..a886bc04 --- /dev/null +++ b/src/features/schedule/components/DayGroupedFlightList.scss @@ -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; + } +} diff --git a/src/features/schedule/components/DayGroupedFlightList.tsx b/src/features/schedule/components/DayGroupedFlightList.tsx new file mode 100644 index 00000000..9b169f69 --- /dev/null +++ b/src/features/schedule/components/DayGroupedFlightList.tsx @@ -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(); + 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 = ({ + flights, + loading, + onFlightClick, + initialCurrentFlightId, +}) => { + const { language } = useLocale(); + const groups = useMemo(() => groupFlightsByDay(flights), [flights]); + + if (loading) return ; + + if (groups.length === 0) { + return ; + } + + // Single-day result: skip grouping noise and render the flat list. + if (groups.length === 1) { + return ( + + ); + } + + // 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 ( +
+ {groups.map((g) => { + const weekday = weekdayFmt.format(g.parsed); + const dayMonth = dayMonthFmt.format(g.parsed); + return ( +
+
+ + {weekday.charAt(0).toUpperCase() + weekday.slice(1)} + +

{dayMonth}

+
+ +
+ ); + })} +
+ ); +}; diff --git a/src/features/schedule/components/ScheduleSearchPage.tsx b/src/features/schedule/components/ScheduleSearchPage.tsx index 8f1fc868..584620e0 100644 --- a/src/features/schedule/components/ScheduleSearchPage.tsx +++ b/src/features/schedule/components/ScheduleSearchPage.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 (
{jsonLd && } @@ -178,16 +194,9 @@ export const ScheduleSearchPage: FC = ({ params }) => { } stickyContent={ - { - const iso = `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`; - handleDateChange(iso); - }} + } > @@ -205,7 +214,10 @@ export const ScheduleSearchPage: FC = ({ params }) => {

{t("SCHEDULE.OUTBOUND")}: {outbound.departure} → {outbound.arrival}

- +
{inbound && ( @@ -213,7 +225,10 @@ export const ScheduleSearchPage: FC = ({ params }) => {

{t("SCHEDULE.RETURN")}: {inbound.departure} → {inbound.arrival}

- + )} diff --git a/src/features/schedule/components/WeekTabs.scss b/src/features/schedule/components/WeekTabs.scss new file mode 100644 index 00000000..ddf65fec --- /dev/null +++ b/src/features/schedule/components/WeekTabs.scss @@ -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; + } + } +} diff --git a/src/features/schedule/components/WeekTabs.tsx b/src/features/schedule/components/WeekTabs.tsx new file mode 100644 index 00000000..a0d0c274 --- /dev/null +++ b/src/features/schedule/components/WeekTabs.tsx @@ -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 = ({ 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 ( + + ); +}; diff --git a/src/ui/flights/FlightCard.tsx b/src/ui/flights/FlightCard.tsx index 20ccb87c..6b41b5be 100644 --- a/src/ui/flights/FlightCard.tsx +++ b/src/ui/flights/FlightCard.tsx @@ -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 = ({ >
{flightNumber}
- {expanded && aircraftName && ( + {(expanded || direction === "schedule") && aircraftName && (
{aircraftName}
)}
diff --git a/src/ui/flights/FlightList.tsx b/src/ui/flights/FlightList.tsx index 01d561b0..4e93bb55 100644 --- a/src/ui/flights/FlightList.tsx +++ b/src/ui/flights/FlightList.tsx @@ -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"; } /**