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:
2026-04-23 13:29:04 +03:00
parent c6055d94ba
commit 49a19a7f63
3 changed files with 276 additions and 70 deletions
@@ -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<ScheduleFilterProps> = ({
// current week. Uses inputRef + useEffect to override PrimeReact's
// own dd.mm.yy rendering without touching the state value.
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(() => {
const [from, to] = dateRange;
if (!dateRangeInputRef.current || !from || !to) return;
@@ -267,6 +317,32 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
}
}, [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(() => {
setDeparture(arrival);
setArrival(departure);
@@ -472,15 +548,14 @@ export const ScheduleFilter: FC<ScheduleFilterProps> = ({
</label>
<div className="calendar-input-wrapper">
<Calendar
ref={outboundCalendarRef}
value={dateRange}
onChange={(e) => {
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<ScheduleFilterProps> = ({
</label>
<div className="calendar-input-wrapper">
<Calendar
ref={returnCalendarRef}
value={returnDateRange}
onChange={(e) => {
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<ScheduleFilterProps> = ({
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]) && (
@@ -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<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
// 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 = () => {
<div className="schedule-start__field">
<label htmlFor="schedule-date-from">{t("SHARED.SCHEDULES_DATE")}</label>
<Calendar
ref={outboundCalendarRef}
value={dateFrom && dateTo ? [dateFrom, dateTo] : null}
onChange={(e) => {
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 = () => {
<div className="schedule-start__field">
<label htmlFor="schedule-return-date-range">{t("SHARED.RETURN_FLIGHT_DATE")}</label>
<Calendar
ref={returnCalendarRef}
value={returnDateFrom && returnDateTo ? [returnDateFrom, returnDateTo] : null}
onChange={(e) => {
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
/>
+71
View File
@@ -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 Apr3 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 Apr3 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",
"ДД.ММ.ГГГГ - ДД.ММ.ГГГГ",
);
});
});