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:
Executable
+95
@@ -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} → {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} → {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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user