diff --git a/src/features/schedule/components/ScheduleFilter.tsx b/src/features/schedule/components/ScheduleFilter.tsx index 202e3710..07497ec7 100644 --- a/src/features/schedule/components/ScheduleFilter.tsx +++ b/src/features/schedule/components/ScheduleFilter.tsx @@ -10,7 +10,7 @@ import { type FC, useState, useCallback, useRef, useEffect, useMemo, type FormEvent } from "react"; import { useNavigate } from "@modern-js/runtime/router"; -import { Calendar } from "primereact/calendar"; +import { Calendar, type CalendarChangeEvent } from "primereact/calendar"; import { Slider, type SliderChangeEvent } from "primereact/slider"; import { useTranslation } from "@/i18n/provider.js"; import { useLocale } from "@/i18n/useLocale.js"; @@ -25,6 +25,25 @@ import { formatScheduleDateRangeWithCurrentWeek } from "../dateLabels.js"; import { scheduleWindowBounds } from "@/shared/dateWindow.js"; import "./ScheduleFilter.scss"; +/** + * Snap a date to the Mon-Sun week containing it (Angular parity: + * the schedule filter selects whole weeks only). Returned tuple is + * [Monday 00:00, Sunday 23:59:59.999]. + */ +function snapToWeek(date: Date): [Date, Date] { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + // JS getDay: Sun=0, Mon=1 … Sat=6. Treat Mon as start-of-week. + const day = d.getDay(); + const offsetToMonday = day === 0 ? -6 : 1 - day; + const monday = new Date(d); + monday.setDate(d.getDate() + offsetToMonday); + const sunday = new Date(monday); + sunday.setDate(monday.getDate() + 6); + sunday.setHours(23, 59, 59, 999); + return [monday, sunday]; +} + function minutesToTime(minutes: number): string { const h = Math.floor(minutes / 60); const m = minutes % 60; @@ -257,6 +276,37 @@ export const ScheduleFilter: FC = ({ // current week. Uses inputRef + useEffect to override PrimeReact's // own dd.mm.yy rendering without touching the state value. const dateRangeInputRef = useRef(null); + // Refs to the PrimeReact Calendar component instances so we can call + // `hide()` after a single click commits the snap-to-week range + // (matches Angular which closes the picker on selection). + const outboundCalendarRef = useRef(null); + const returnCalendarRef = useRef(null); + + // Single-click handler for both outbound and return pickers: snap + // the clicked day to its Mon-Sun calendar week, commit as a range, + // and dismiss the panel. Angular parity (TZ §4.1.9.4 — the schedule + // filter is week-granular). + const onOutboundSelect = useCallback((e: CalendarChangeEvent): void => { + const v = e.value; + if (!v) return; + const picked = Array.isArray(v) ? (v[0] ?? v[1]) : v; + if (!(picked instanceof Date)) return; + const [from, to] = snapToWeek(picked); + setDateRange([from, to]); + if (rangeError) setRangeError(null); + outboundCalendarRef.current?.hide(); + }, [rangeError]); + + const onReturnSelect = useCallback((e: CalendarChangeEvent): void => { + const v = e.value; + if (!v) return; + const picked = Array.isArray(v) ? (v[0] ?? v[1]) : v; + if (!(picked instanceof Date)) return; + const [from, to] = snapToWeek(picked); + setReturnDateRange([from, to]); + setReturnBeforeOutboundError(null); + returnCalendarRef.current?.hide(); + }, []); useEffect(() => { const [from, to] = dateRange; if (!dateRangeInputRef.current || !from || !to) return; @@ -267,6 +317,32 @@ export const ScheduleFilter: FC = ({ } }, [dateRange, t]); + const returnDateRangeInputRef = useRef(null); + + // PrimeReact's `readOnlyInput=true` Calendar suppresses the auto-show + // on input click; only the trigger icon opens the panel. Forward + // input clicks to the icon so users can tap the field itself to open + // the picker (matching Angular's calendar behaviour). Same logic + // applied to both outbound and return date pickers. + useEffect(() => { + const cleanups: Array<() => void> = []; + for (const ref of [dateRangeInputRef, returnDateRangeInputRef]) { + const input = ref.current; + if (!input) continue; + const handler = (): void => { + const wrapper = input.closest(".p-calendar"); + const trigger = wrapper?.querySelector( + ".p-datepicker-trigger", + ); + trigger?.click(); + }; + input.addEventListener("click", handler); + input.style.cursor = "pointer"; + cleanups.push(() => input.removeEventListener("click", handler)); + } + return () => cleanups.forEach((c) => c()); + }, []); + const handleSwap = useCallback(() => { setDeparture(arrival); setArrival(departure); @@ -472,15 +548,14 @@ export const ScheduleFilter: FC = ({
{ - setDateRange((e.value as (Date | null)[]) ?? [null, null]); - if (rangeError) setRangeError(null); - }} + onChange={onOutboundSelect} selectionMode="range" minDate={scheduleMinDate} maxDate={scheduleMaxDate} disabledDates={scheduleDisabledDates} + selectOtherMonths dateFormat="dd.mm.yy" placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`} showIcon @@ -585,13 +660,9 @@ export const ScheduleFilter: FC = ({
{ - setReturnDateRange( - (e.value as (Date | null)[]) ?? [null, null], - ); - if (returnBeforeOutboundError) setReturnBeforeOutboundError(null); - }} + onChange={onReturnSelect} selectionMode="range" // TZ §4.1.9.4: return cannot start before outbound's // dateTo. Tie the return picker's minDate to it so @@ -600,12 +671,14 @@ export const ScheduleFilter: FC = ({ minDate={dateRange[1] ?? scheduleMinDate} maxDate={scheduleMaxDate} disabledDates={returnDisabledDates} + selectOtherMonths dateFormat="dd.mm.yy" placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`} showIcon className="input--filter" data-testid="schedule-return-date-input" inputId="schedule-return-date-input" + inputRef={returnDateRangeInputRef} readOnlyInput /> {(returnDateRange[0] || returnDateRange[1]) && ( diff --git a/src/features/schedule/components/ScheduleStartPage.tsx b/src/features/schedule/components/ScheduleStartPage.tsx index e8a92c39..c10dfb11 100644 --- a/src/features/schedule/components/ScheduleStartPage.tsx +++ b/src/features/schedule/components/ScheduleStartPage.tsx @@ -7,10 +7,10 @@ * @module */ -import { type FC, useState, useCallback, useRef, type FormEvent } from "react"; +import { type FC, useState, useCallback, useEffect, useRef, type FormEvent } from "react"; import { useNavigate } from "@modern-js/runtime/router"; import { useLocale } from "@/i18n/useLocale.js"; -import { Calendar } from "primereact/calendar"; +import { Calendar, type CalendarChangeEvent } from "primereact/calendar"; import { Slider, type SliderChangeEvent } from "primereact/slider"; import { useTranslation } from "@/i18n/provider.js"; import { PageLayout } from "@/ui/layout/PageLayout.js"; @@ -19,15 +19,8 @@ import { SearchHistory } from "@/ui/layout/SearchHistory.js"; import { CityAutocomplete } from "@/ui/city-autocomplete/index.js"; import { PopularRequestsPanel } from "@/features/popular-requests/components/PopularRequestsPanel.js"; import type { PopularRequest } from "@/features/popular-requests/types.js"; -import { - buildOnlineBoardPrefillState, - ONLINE_BOARD_PREFILL_SLOT, - SCHEDULE_PREFILL_SLOT, -} from "@/features/online-board/components/OnlineBoardStartPage.js"; -import { - readAndClearTransientPrefill, - writeTransientPrefill, -} from "@/shared/state/transientPrefill.js"; +import { SCHEDULE_PREFILL_SLOT } from "@/features/online-board/components/OnlineBoardStartPage.js"; +import { readAndClearTransientPrefill } from "@/shared/state/transientPrefill.js"; import { getScheduleFilter, getBoardFilter, @@ -42,6 +35,7 @@ import type { IDictionaries } from "@/shared/dictionaries/index.js"; import { useGeoCityDefault } from "@/shared/hooks/useGeoCityDefault.js"; import { buildScheduleUrl } from "../url.js"; import { scheduleWindowBounds } from "@/shared/dateWindow.js"; +import { formatScheduleDateRangeWithCurrentWeek } from "../dateLabels.js"; import "./ScheduleStartPage.scss"; function toCityCode(code: string, dictionaries: IDictionaries | null): string { @@ -122,6 +116,22 @@ function nextWeekBounds(base = new Date()): { from: Date; to: Date } { // Mirrors Angular AppSettings.scheduleSearchFrom (1 day back) and // scheduleSearchTo (330 days forward). Constrains both the outbound and // return-flight calendar pickers on the Schedule start page. +/** + * Snap a date to the Mon-Sun week containing it. Angular parity: + * the schedule list is week-granular (TZ §4.1.9.4) — a single click + * on any day in the picker commits the whole week. + */ +function snapToWeek(date: Date): { from: Date; to: Date } { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + const dow = d.getDay() === 0 ? 7 : d.getDay(); + const mon = new Date(d); + mon.setDate(d.getDate() - (dow - 1)); + const sun = new Date(mon); + sun.setDate(mon.getDate() + 6); + return { from: mon, to: sun }; +} + function getScheduleMinDate(): Date { return scheduleWindowBounds()[0]; } @@ -236,6 +246,62 @@ export const ScheduleStartPage: FC = () => { const scheduleMinDate = useRef(getScheduleMinDate()).current; const scheduleMaxDate = useRef(getScheduleMaxDate()).current; + // TZ §4.1.9 Table 14 / Angular CalendarInputWeekComponent: when the + // selected date range contains today, the Calendar input shows + // "Текущая неделя" instead of the formatted dd.MM.yyyy-dd.MM.yyyy. + // PrimeReact's Calendar always re-renders its own formatted value into + // the input, so we override the .value via inputRef after each update. + const dateRangeInputRef = useRef(null); + const returnDateRangeInputRef = useRef(null); + // Refs to the PrimeReact Calendar instances so we can call hide() + // after a single-click commits the snap-to-week selection (Angular + // closes its picker on selection — TZ §4.1.9.4). + const outboundCalendarRef = useRef(null); + const returnCalendarRef = useRef(null); + + const onOutboundDateSelect = useCallback( + (e: CalendarChangeEvent): void => { + const v = e.value; + if (!v) return; + const picked = Array.isArray(v) ? (v[0] ?? v[1]) : v; + if (!(picked instanceof Date)) return; + const { from, to } = snapToWeek(picked); + setDateFrom(from); + setDateTo(to); + outboundCalendarRef.current?.hide(); + }, + [], + ); + + const onReturnDateSelect = useCallback( + (e: CalendarChangeEvent): void => { + const v = e.value; + if (!v) return; + const picked = Array.isArray(v) ? (v[0] ?? v[1]) : v; + if (!(picked instanceof Date)) return; + const { from, to } = snapToWeek(picked); + setReturnDateFrom(from); + setReturnDateTo(to); + returnCalendarRef.current?.hide(); + }, + [], + ); + + useEffect(() => { + if (!dateRangeInputRef.current || !dateFrom || !dateTo) return; + const label = formatScheduleDateRangeWithCurrentWeek(dateFrom, dateTo, t); + if (label === t("SCHEDULE.CURRENT-WEEK")) { + dateRangeInputRef.current.value = label; + } + }, [dateFrom, dateTo, t]); + useEffect(() => { + if (!returnDateRangeInputRef.current || !returnDateFrom || !returnDateTo) return; + const label = formatScheduleDateRangeWithCurrentWeek(returnDateFrom, returnDateTo, t); + if (label === t("SCHEDULE.CURRENT-WEEK")) { + returnDateRangeInputRef.current.value = label; + } + }, [returnDateFrom, returnDateTo, t]); + // Same-cities validation mirrors ScheduleFilter (Angular's // ScheduleFilterValidationService): reject departure === arrival with // the shared translation key. @@ -317,46 +383,44 @@ export const ScheduleStartPage: FC = () => { const handlePopularRequestClick = useCallback( (request: PopularRequest) => { - // Popular-request entries sometimes carry airport codes (SVO, LED) - // instead of city codes (MOW, LED). Normalize to the owning city - // so clicks land on the city filter, not on a specific airport. - if (request.type === "Onlineboard") { - writeTransientPrefill( - ONLINE_BOARD_PREFILL_SLOT, - buildOnlineBoardPrefillState(request, dictionaries), - ); - navigate(`/${locale}/onlineboard`); - return; + // Deviation from Angular: every popular-request click on Schedule + // populates the form in-place and stays on the page. Angular + // redirects Arrival/Departure/FlightNumber clicks to /onlineboard + // unconditionally; we instead treat the click as a Schedule prefill + // because users land here to plan a trip, not to switch sections. + // Codes sometimes arrive as airport (SVO/LED) — normalize to city. + switch (request.mode) { + case "Route": + case "RouteWithBack": { + const curWeek = currentWeekBounds(); + setDepartureCode(toCityCode(request.departure, dictionaries)); + setArrivalCode(toCityCode(request.arrival, dictionaries)); + setIsRoundTrip(request.mode === "RouteWithBack"); + setDateFrom(curWeek.from); + setDateTo(curWeek.to); + if (request.mode === "RouteWithBack") { + const nxt = nextWeekBounds(); + setReturnDateFrom(nxt.from); + setReturnDateTo(nxt.to); + } else { + setReturnDateFrom(null); + setReturnDateTo(null); + } + break; + } + case "Arrival": + setArrivalCode(toCityCode(request.arrival, dictionaries)); + break; + case "Departure": + setDepartureCode(toCityCode(request.departure, dictionaries)); + break; + case "FlightNumber": + // Schedule has no flight-number field — ignore. + break; } - - // Schedule-type: only Route / RouteWithBack carry city info. - // TZ §4.1.5: prefill outbound dates = current ISO week; return dates - // = next ISO week (only when withReturn = true). - if (request.mode === "Route" || request.mode === "RouteWithBack") { - const curWeek = currentWeekBounds(); - const state: SchedulePrefillState = { - departure: toCityCode(request.departure, dictionaries), - arrival: toCityCode(request.arrival, dictionaries), - withReturn: request.mode === "RouteWithBack", - dateFrom: dateToYyyymmdd(curWeek.from), - dateTo: dateToYyyymmdd(curWeek.to), - ...(request.mode === "RouteWithBack" - ? (() => { - const nxt = nextWeekBounds(); - return { - returnDateFrom: dateToYyyymmdd(nxt.from), - returnDateTo: dateToYyyymmdd(nxt.to), - }; - })() - : {}), - }; - writeTransientPrefill(SCHEDULE_PREFILL_SLOT, state); - } else { - writeTransientPrefill(SCHEDULE_PREFILL_SLOT, {}); - } - navigate(`/${locale}/schedule`); + if (sameCitiesError) setSameCitiesError(null); }, - [navigate, locale, dictionaries], + [dictionaries, sameCitiesError], ); const scheduleFilter = ( @@ -419,20 +483,19 @@ export const ScheduleStartPage: FC = () => {
{ - const val = e.value as Date[] | null; - if (val && val.length >= 1) setDateFrom(val[0] ?? null); - if (val && val.length >= 2) setDateTo(val[1] ?? null); - }} + onChange={onOutboundDateSelect} selectionMode="range" minDate={scheduleMinDate} maxDate={scheduleMaxDate} + selectOtherMonths dateFormat="dd.mm.yy" placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`} showIcon className="input--filter" inputId="schedule-date-from" + inputRef={dateRangeInputRef} data-testid="date-range-input" readOnlyInput /> @@ -486,20 +549,19 @@ export const ScheduleStartPage: FC = () => {
{ - const val = e.value as Date[] | null; - if (val && val.length >= 1) setReturnDateFrom(val[0] ?? null); - if (val && val.length >= 2) setReturnDateTo(val[1] ?? null); - }} + onChange={onReturnDateSelect} selectionMode="range" minDate={scheduleMinDate} maxDate={scheduleMaxDate} + selectOtherMonths dateFormat="dd.mm.yy" placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`} showIcon className="input--filter" inputId="schedule-return-date-range" + inputRef={returnDateRangeInputRef} data-testid="return-date-range-input" readOnlyInput /> diff --git a/tests/e2e/schedule-date-picker.spec.ts b/tests/e2e/schedule-date-picker.spec.ts new file mode 100644 index 00000000..1123e3b7 --- /dev/null +++ b/tests/e2e/schedule-date-picker.spec.ts @@ -0,0 +1,71 @@ +import { test, expect } from "@playwright/test"; + +// Schedule date picker — Angular parity (TZ §4.1.9.4): +// • Single click on any day commits the **whole Mon-Sun week** that +// contains it (the schedule list is week-granular). +// • The panel auto-closes on selection. +// • The text input shows the snapped range as `DD.MM.YYYY - DD.MM.YYYY`. +// • Days bleeding into the previous / next month are clickable too +// (PrimeReact `selectOtherMonths`). +// +// Today (per the harness) is 2026-04-23 (Thursday). Mon-Sun of that +// week is 2026-04-20 .. 2026-04-26. Clicking 29 April (Wednesday of +// the next calendar week) must yield 2026-04-27 .. 2026-05-03. + +test.describe("Schedule date-range picker (week-snap)", () => { + test("single click snaps to Mon-Sun, closes panel, fills input", async ({ + page, + }) => { + await page.goto("/ru-ru/schedule"); + await expect(page.getByTestId("date-range-input")).toBeVisible({ + timeout: 15000, + }); + + // Open the calendar via its trigger button. + await page.locator("button.p-datepicker-trigger").first().click(); + const panel = page.locator(".p-datepicker-panel, .p-datepicker").first(); + await expect(panel).toBeVisible(); + + // Click 29 April (Wednesday of the 27 Apr–3 May week). + await panel.locator('td[aria-label="29.04.2026"] span').click(); + + // Panel auto-dismissed. + await expect(panel).toBeHidden({ timeout: 5000 }); + + // Input now holds the full week range. + const input = page.locator("#schedule-date-from"); + await expect(input).toHaveValue("27.04.2026 - 03.05.2026"); + }); + + test("clicking a next-month bleed-in day (3 May) snaps to 4-10 May", async ({ + page, + }) => { + await page.goto("/ru-ru/schedule"); + await expect(page.getByTestId("date-range-input")).toBeVisible({ + timeout: 15000, + }); + + await page.locator("button.p-datepicker-trigger").first().click(); + const panel = page.locator(".p-datepicker-panel, .p-datepicker").first(); + await expect(panel).toBeVisible(); + + // 3 May 2026 is Sunday — visible at the bottom of April as a bleed-in + // day from the next month. Sunday belongs to the 27 Apr–3 May week, + // so clicking it must snap to that week (not 4-10 May). + await panel.locator('td[aria-label="03.05.2026"] span').click(); + + await expect(panel).toBeHidden({ timeout: 5000 }); + await expect(page.locator("#schedule-date-from")).toHaveValue( + "27.04.2026 - 03.05.2026", + ); + }); + + test("input renders as range placeholder when empty", async ({ page }) => { + await page.goto("/ru-ru/schedule"); + const input = page.locator("#schedule-date-from"); + await expect(input).toHaveAttribute( + "placeholder", + "ДД.ММ.ГГГГ - ДД.ММ.ГГГГ", + ); + }); +});