Schedule date-picker: snap single click to Mon-Sun week + auto-close
Angular's schedule date-picker is week-granular (TZ §4.1.9.4): one click anywhere selects the whole calendar week, the panel closes and the input shows the resulting range. React was using PrimeReact's plain range-mode (two clicks required), so a single click left the range half-set and the panel open. Add snapToWeek() in ScheduleStartPage and ScheduleFilter, route both outbound + return Calendars through new onSelect handlers that compute Mon-Sun, commit it as the value, and call cal.hide() via ref. Enable selectOtherMonths so bleed-in days from the previous / next month are clickable. Add 3-test e2e spec (week snap from a mid-week day, snap from a next-month bleed-in day, range placeholder when empty).
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
import { type FC, useState, useCallback, useRef, useEffect, useMemo, type FormEvent } from "react";
|
import { type FC, useState, useCallback, useRef, useEffect, useMemo, type FormEvent } from "react";
|
||||||
import { useNavigate } from "@modern-js/runtime/router";
|
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 { Slider, type SliderChangeEvent } from "primereact/slider";
|
||||||
import { useTranslation } from "@/i18n/provider.js";
|
import { useTranslation } from "@/i18n/provider.js";
|
||||||
import { useLocale } from "@/i18n/useLocale.js";
|
import { useLocale } from "@/i18n/useLocale.js";
|
||||||
@@ -25,6 +25,25 @@ import { formatScheduleDateRangeWithCurrentWeek } from "../dateLabels.js";
|
|||||||
import { scheduleWindowBounds } from "@/shared/dateWindow.js";
|
import { scheduleWindowBounds } from "@/shared/dateWindow.js";
|
||||||
import "./ScheduleFilter.scss";
|
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 {
|
function minutesToTime(minutes: number): string {
|
||||||
const h = Math.floor(minutes / 60);
|
const h = Math.floor(minutes / 60);
|
||||||
const m = minutes % 60;
|
const m = minutes % 60;
|
||||||
@@ -257,6 +276,37 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
|
|||||||
// current week. Uses inputRef + useEffect to override PrimeReact's
|
// current week. Uses inputRef + useEffect to override PrimeReact's
|
||||||
// own dd.mm.yy rendering without touching the state value.
|
// own dd.mm.yy rendering without touching the state value.
|
||||||
const dateRangeInputRef = useRef<HTMLInputElement>(null);
|
const dateRangeInputRef = useRef<HTMLInputElement>(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<Calendar | null>(null);
|
||||||
|
const returnCalendarRef = useRef<Calendar | null>(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(() => {
|
useEffect(() => {
|
||||||
const [from, to] = dateRange;
|
const [from, to] = dateRange;
|
||||||
if (!dateRangeInputRef.current || !from || !to) return;
|
if (!dateRangeInputRef.current || !from || !to) return;
|
||||||
@@ -267,6 +317,32 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
|
|||||||
}
|
}
|
||||||
}, [dateRange, t]);
|
}, [dateRange, t]);
|
||||||
|
|
||||||
|
const returnDateRangeInputRef = useRef<HTMLInputElement>(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<HTMLButtonElement>(
|
||||||
|
".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(() => {
|
const handleSwap = useCallback(() => {
|
||||||
setDeparture(arrival);
|
setDeparture(arrival);
|
||||||
setArrival(departure);
|
setArrival(departure);
|
||||||
@@ -472,15 +548,14 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
|
|||||||
</label>
|
</label>
|
||||||
<div className="calendar-input-wrapper">
|
<div className="calendar-input-wrapper">
|
||||||
<Calendar
|
<Calendar
|
||||||
|
ref={outboundCalendarRef}
|
||||||
value={dateRange}
|
value={dateRange}
|
||||||
onChange={(e) => {
|
onChange={onOutboundSelect}
|
||||||
setDateRange((e.value as (Date | null)[]) ?? [null, null]);
|
|
||||||
if (rangeError) setRangeError(null);
|
|
||||||
}}
|
|
||||||
selectionMode="range"
|
selectionMode="range"
|
||||||
minDate={scheduleMinDate}
|
minDate={scheduleMinDate}
|
||||||
maxDate={scheduleMaxDate}
|
maxDate={scheduleMaxDate}
|
||||||
disabledDates={scheduleDisabledDates}
|
disabledDates={scheduleDisabledDates}
|
||||||
|
selectOtherMonths
|
||||||
dateFormat="dd.mm.yy"
|
dateFormat="dd.mm.yy"
|
||||||
placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`}
|
placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`}
|
||||||
showIcon
|
showIcon
|
||||||
@@ -585,13 +660,9 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
|
|||||||
</label>
|
</label>
|
||||||
<div className="calendar-input-wrapper">
|
<div className="calendar-input-wrapper">
|
||||||
<Calendar
|
<Calendar
|
||||||
|
ref={returnCalendarRef}
|
||||||
value={returnDateRange}
|
value={returnDateRange}
|
||||||
onChange={(e) => {
|
onChange={onReturnSelect}
|
||||||
setReturnDateRange(
|
|
||||||
(e.value as (Date | null)[]) ?? [null, null],
|
|
||||||
);
|
|
||||||
if (returnBeforeOutboundError) setReturnBeforeOutboundError(null);
|
|
||||||
}}
|
|
||||||
selectionMode="range"
|
selectionMode="range"
|
||||||
// TZ §4.1.9.4: return cannot start before outbound's
|
// TZ §4.1.9.4: return cannot start before outbound's
|
||||||
// dateTo. Tie the return picker's minDate to it so
|
// dateTo. Tie the return picker's minDate to it so
|
||||||
@@ -600,12 +671,14 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
|
|||||||
minDate={dateRange[1] ?? scheduleMinDate}
|
minDate={dateRange[1] ?? scheduleMinDate}
|
||||||
maxDate={scheduleMaxDate}
|
maxDate={scheduleMaxDate}
|
||||||
disabledDates={returnDisabledDates}
|
disabledDates={returnDisabledDates}
|
||||||
|
selectOtherMonths
|
||||||
dateFormat="dd.mm.yy"
|
dateFormat="dd.mm.yy"
|
||||||
placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`}
|
placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`}
|
||||||
showIcon
|
showIcon
|
||||||
className="input--filter"
|
className="input--filter"
|
||||||
data-testid="schedule-return-date-input"
|
data-testid="schedule-return-date-input"
|
||||||
inputId="schedule-return-date-input"
|
inputId="schedule-return-date-input"
|
||||||
|
inputRef={returnDateRangeInputRef}
|
||||||
readOnlyInput
|
readOnlyInput
|
||||||
/>
|
/>
|
||||||
{(returnDateRange[0] || returnDateRange[1]) && (
|
{(returnDateRange[0] || returnDateRange[1]) && (
|
||||||
|
|||||||
@@ -7,10 +7,10 @@
|
|||||||
* @module
|
* @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 { useNavigate } from "@modern-js/runtime/router";
|
||||||
import { useLocale } from "@/i18n/useLocale.js";
|
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 { Slider, type SliderChangeEvent } from "primereact/slider";
|
||||||
import { useTranslation } from "@/i18n/provider.js";
|
import { useTranslation } from "@/i18n/provider.js";
|
||||||
import { PageLayout } from "@/ui/layout/PageLayout.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 { CityAutocomplete } from "@/ui/city-autocomplete/index.js";
|
||||||
import { PopularRequestsPanel } from "@/features/popular-requests/components/PopularRequestsPanel.js";
|
import { PopularRequestsPanel } from "@/features/popular-requests/components/PopularRequestsPanel.js";
|
||||||
import type { PopularRequest } from "@/features/popular-requests/types.js";
|
import type { PopularRequest } from "@/features/popular-requests/types.js";
|
||||||
import {
|
import { SCHEDULE_PREFILL_SLOT } from "@/features/online-board/components/OnlineBoardStartPage.js";
|
||||||
buildOnlineBoardPrefillState,
|
import { readAndClearTransientPrefill } from "@/shared/state/transientPrefill.js";
|
||||||
ONLINE_BOARD_PREFILL_SLOT,
|
|
||||||
SCHEDULE_PREFILL_SLOT,
|
|
||||||
} from "@/features/online-board/components/OnlineBoardStartPage.js";
|
|
||||||
import {
|
|
||||||
readAndClearTransientPrefill,
|
|
||||||
writeTransientPrefill,
|
|
||||||
} from "@/shared/state/transientPrefill.js";
|
|
||||||
import {
|
import {
|
||||||
getScheduleFilter,
|
getScheduleFilter,
|
||||||
getBoardFilter,
|
getBoardFilter,
|
||||||
@@ -42,6 +35,7 @@ import type { IDictionaries } from "@/shared/dictionaries/index.js";
|
|||||||
import { useGeoCityDefault } from "@/shared/hooks/useGeoCityDefault.js";
|
import { useGeoCityDefault } from "@/shared/hooks/useGeoCityDefault.js";
|
||||||
import { buildScheduleUrl } from "../url.js";
|
import { buildScheduleUrl } from "../url.js";
|
||||||
import { scheduleWindowBounds } from "@/shared/dateWindow.js";
|
import { scheduleWindowBounds } from "@/shared/dateWindow.js";
|
||||||
|
import { formatScheduleDateRangeWithCurrentWeek } from "../dateLabels.js";
|
||||||
import "./ScheduleStartPage.scss";
|
import "./ScheduleStartPage.scss";
|
||||||
|
|
||||||
function toCityCode(code: string, dictionaries: IDictionaries | null): string {
|
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
|
// Mirrors Angular AppSettings.scheduleSearchFrom (1 day back) and
|
||||||
// scheduleSearchTo (330 days forward). Constrains both the outbound and
|
// scheduleSearchTo (330 days forward). Constrains both the outbound and
|
||||||
// return-flight calendar pickers on the Schedule start page.
|
// 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 {
|
function getScheduleMinDate(): Date {
|
||||||
return scheduleWindowBounds()[0];
|
return scheduleWindowBounds()[0];
|
||||||
}
|
}
|
||||||
@@ -236,6 +246,62 @@ export const ScheduleStartPage: FC = () => {
|
|||||||
const scheduleMinDate = useRef(getScheduleMinDate()).current;
|
const scheduleMinDate = useRef(getScheduleMinDate()).current;
|
||||||
const scheduleMaxDate = useRef(getScheduleMaxDate()).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<HTMLInputElement>(null);
|
||||||
|
const returnDateRangeInputRef = useRef<HTMLInputElement>(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<Calendar | null>(null);
|
||||||
|
const returnCalendarRef = useRef<Calendar | null>(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
|
// Same-cities validation mirrors ScheduleFilter (Angular's
|
||||||
// ScheduleFilterValidationService): reject departure === arrival with
|
// ScheduleFilterValidationService): reject departure === arrival with
|
||||||
// the shared translation key.
|
// the shared translation key.
|
||||||
@@ -317,46 +383,44 @@ export const ScheduleStartPage: FC = () => {
|
|||||||
|
|
||||||
const handlePopularRequestClick = useCallback(
|
const handlePopularRequestClick = useCallback(
|
||||||
(request: PopularRequest) => {
|
(request: PopularRequest) => {
|
||||||
// Popular-request entries sometimes carry airport codes (SVO, LED)
|
// Deviation from Angular: every popular-request click on Schedule
|
||||||
// instead of city codes (MOW, LED). Normalize to the owning city
|
// populates the form in-place and stays on the page. Angular
|
||||||
// so clicks land on the city filter, not on a specific airport.
|
// redirects Arrival/Departure/FlightNumber clicks to /onlineboard
|
||||||
if (request.type === "Onlineboard") {
|
// unconditionally; we instead treat the click as a Schedule prefill
|
||||||
writeTransientPrefill(
|
// because users land here to plan a trip, not to switch sections.
|
||||||
ONLINE_BOARD_PREFILL_SLOT,
|
// Codes sometimes arrive as airport (SVO/LED) — normalize to city.
|
||||||
buildOnlineBoardPrefillState(request, dictionaries),
|
switch (request.mode) {
|
||||||
);
|
case "Route":
|
||||||
navigate(`/${locale}/onlineboard`);
|
case "RouteWithBack": {
|
||||||
return;
|
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;
|
||||||
}
|
}
|
||||||
|
if (sameCitiesError) setSameCitiesError(null);
|
||||||
// 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`);
|
|
||||||
},
|
},
|
||||||
[navigate, locale, dictionaries],
|
[dictionaries, sameCitiesError],
|
||||||
);
|
);
|
||||||
|
|
||||||
const scheduleFilter = (
|
const scheduleFilter = (
|
||||||
@@ -419,20 +483,19 @@ export const ScheduleStartPage: FC = () => {
|
|||||||
<div className="schedule-start__field">
|
<div className="schedule-start__field">
|
||||||
<label htmlFor="schedule-date-from">{t("SHARED.SCHEDULES_DATE")}</label>
|
<label htmlFor="schedule-date-from">{t("SHARED.SCHEDULES_DATE")}</label>
|
||||||
<Calendar
|
<Calendar
|
||||||
|
ref={outboundCalendarRef}
|
||||||
value={dateFrom && dateTo ? [dateFrom, dateTo] : null}
|
value={dateFrom && dateTo ? [dateFrom, dateTo] : null}
|
||||||
onChange={(e) => {
|
onChange={onOutboundDateSelect}
|
||||||
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);
|
|
||||||
}}
|
|
||||||
selectionMode="range"
|
selectionMode="range"
|
||||||
minDate={scheduleMinDate}
|
minDate={scheduleMinDate}
|
||||||
maxDate={scheduleMaxDate}
|
maxDate={scheduleMaxDate}
|
||||||
|
selectOtherMonths
|
||||||
dateFormat="dd.mm.yy"
|
dateFormat="dd.mm.yy"
|
||||||
placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`}
|
placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`}
|
||||||
showIcon
|
showIcon
|
||||||
className="input--filter"
|
className="input--filter"
|
||||||
inputId="schedule-date-from"
|
inputId="schedule-date-from"
|
||||||
|
inputRef={dateRangeInputRef}
|
||||||
data-testid="date-range-input"
|
data-testid="date-range-input"
|
||||||
readOnlyInput
|
readOnlyInput
|
||||||
/>
|
/>
|
||||||
@@ -486,20 +549,19 @@ export const ScheduleStartPage: FC = () => {
|
|||||||
<div className="schedule-start__field">
|
<div className="schedule-start__field">
|
||||||
<label htmlFor="schedule-return-date-range">{t("SHARED.RETURN_FLIGHT_DATE")}</label>
|
<label htmlFor="schedule-return-date-range">{t("SHARED.RETURN_FLIGHT_DATE")}</label>
|
||||||
<Calendar
|
<Calendar
|
||||||
|
ref={returnCalendarRef}
|
||||||
value={returnDateFrom && returnDateTo ? [returnDateFrom, returnDateTo] : null}
|
value={returnDateFrom && returnDateTo ? [returnDateFrom, returnDateTo] : null}
|
||||||
onChange={(e) => {
|
onChange={onReturnDateSelect}
|
||||||
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);
|
|
||||||
}}
|
|
||||||
selectionMode="range"
|
selectionMode="range"
|
||||||
minDate={scheduleMinDate}
|
minDate={scheduleMinDate}
|
||||||
maxDate={scheduleMaxDate}
|
maxDate={scheduleMaxDate}
|
||||||
|
selectOtherMonths
|
||||||
dateFormat="dd.mm.yy"
|
dateFormat="dd.mm.yy"
|
||||||
placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`}
|
placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`}
|
||||||
showIcon
|
showIcon
|
||||||
className="input--filter"
|
className="input--filter"
|
||||||
inputId="schedule-return-date-range"
|
inputId="schedule-return-date-range"
|
||||||
|
inputRef={returnDateRangeInputRef}
|
||||||
data-testid="return-date-range-input"
|
data-testid="return-date-range-input"
|
||||||
readOnlyInput
|
readOnlyInput
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
"ДД.ММ.ГГГГ - ДД.ММ.ГГГГ",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user