Fix popular-flight Search-button no-op when today is mid-week

User report: clicking a 'Расписание туда' popular tile on /onlineboard
filled the Schedule form but clicking Search did nothing.

Two bugs:
1. OnlineBoardStartPage's Schedule-bound popular-click handler wrote
   only { departure, arrival, withReturn } to the transient prefill —
   it skipped dateFrom/dateTo entirely. The Schedule calendar rendered
   empty, and on submit the form defaulted to today..today+7 (acceptable
   but TZ §4.1.5 mandates current-week prefill).
2. currentWeekBounds() returned the raw Monday of the ISO week. When
   today is mid-week (Tue-Sun), that Monday is N days in the past, so
   the Schedule route guard (§4.1.2, -1/+330 window) rejected the URL
   and silently redirected back to /schedule — user saw 'Search does
   nothing'.

Fix: populate dateFrom/dateTo (and returnDateFrom/returnDateTo for
RouteWithBack) in the Schedule prefill from both handlers, and clamp
the `from` end of currentWeekBounds() to max(Mon, today-1) so the
prefilled range is always inside the window. nextWeekBounds now derives
from the raw Monday (not the clamped `from`) so next-week is always
the true next ISO week.

Live retest: popular 'Москва — Мурманск' → Schedule prefilled with
cities + '21.04.2026 - 26.04.2026' → Search navigates to
/schedule/route/MOW-MMK-20260421-20260426. 0 console errors.
2044 tests pass, typecheck clean.
This commit is contained in:
2026-04-22 03:26:46 +03:00
parent b5b5131eee
commit c18b4b212e
3 changed files with 105 additions and 20 deletions
@@ -83,6 +83,57 @@ function todayYyyymmdd(): string {
return `${y}${m}${day}`;
}
function dateToYyyymmdd(value: Date): string {
const y = value.getFullYear().toString();
const m = (value.getMonth() + 1).toString().padStart(2, "0");
const d = value.getDate().toString().padStart(2, "0");
return `${y}${m}${d}`;
}
/**
* Mon-Sun bounds of the ISO week containing `base` (or today), CLAMPED to
* the Schedule date window's lower edge (today - 1). When today falls mid-week,
* returning a Monday that is already past the -1-day window would make the
* subsequent route guard redirect back to the start page, so we clamp the
* `from` end to max(mon, today - 1). Used by TZ §4.1.5 Schedule popular-click
* prefill — "показать расписание на = текущая неделя".
*/
function currentWeekBoundsYyyymmdd(base = new Date()): { from: string; to: string } {
const d = new Date(base);
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);
// Clamp `from` to today 1 so the prefilled range is always inside
// Schedule's [1, +330] window and the route guard never rejects it.
const minFrom = new Date(d);
minFrom.setDate(d.getDate() - 1);
const from = mon.getTime() < minFrom.getTime() ? minFrom : mon;
return { from: dateToYyyymmdd(from), to: dateToYyyymmdd(sun) };
}
/**
* MonSun bounds of the ISO week AFTER the one containing `base`.
* Used by TZ §4.1.5 round-trip prefill — "дата обратного рейса = следующая неделя".
* Derived from the raw Monday of the current week (not the clamped
* `currentWeekBoundsYyyymmdd().from`) so the next-Mon is always correct
* regardless of how mid-week `base` is.
*/
function nextWeekBoundsYyyymmdd(base = new Date()): { from: string; to: string } {
const d = new Date(base);
d.setHours(0, 0, 0, 0);
const dow = d.getDay() === 0 ? 7 : d.getDay();
const thisMon = new Date(d);
thisMon.setDate(d.getDate() - (dow - 1));
const nextMon = new Date(thisMon);
nextMon.setDate(thisMon.getDate() + 7);
const nextSun = new Date(nextMon);
nextSun.setDate(nextMon.getDate() + 6);
return { from: dateToYyyymmdd(nextMon), to: dateToYyyymmdd(nextSun) };
}
export function buildOnlineBoardPrefillState(
request: PopularRequest,
dictionaries: IDictionaries | null = null,
@@ -192,13 +243,28 @@ export const OnlineBoardStartPage: FC = () => {
// so the destination form pre-fills with a city rather than an
// airport pin.
if (request.type === "Schedule") {
const state =
// TZ §4.1.5: Schedule-bound popular clicks prefill the date range
// to the current ISO week (Mon-Sun); round-trip additionally sets
// the return range to the following ISO week. Without these the
// Schedule calendar renders empty until submit.
const state: Record<string, unknown> =
request.mode === "Route" || request.mode === "RouteWithBack"
? {
departure: toCityCode(request.departure, dictionaries),
arrival: toCityCode(request.arrival, dictionaries),
withReturn: request.mode === "RouteWithBack",
}
? (() => {
const base: Record<string, unknown> = {
departure: toCityCode(request.departure, dictionaries),
arrival: toCityCode(request.arrival, dictionaries),
withReturn: request.mode === "RouteWithBack",
};
const cur = currentWeekBoundsYyyymmdd();
base.dateFrom = cur.from;
base.dateTo = cur.to;
if (request.mode === "RouteWithBack") {
const nxt = nextWeekBoundsYyyymmdd();
base.returnDateFrom = nxt.from;
base.returnDateTo = nxt.to;
}
return base;
})()
: {};
writeTransientPrefill(SCHEDULE_PREFILL_SLOT, state);
navigate(`/${locale}/schedule`);
@@ -187,28 +187,31 @@ describe("ScheduleStartPage", () => {
});
});
it("4.1.5-S1: one-way Route click prefills current ISO week dates + no return", () => {
// 2026-05-15 (Fri) → week Mon 2026-05-11 Sun 2026-05-17
it("4.1.5-S1: one-way Route click prefills current ISO week dates (from clamped to today-1) + no return", () => {
// 2026-05-15 (Fri) → raw Mon 2026-05-11, raw Sun 2026-05-17
// `from` is clamped to today1 = 2026-05-14 so the route guard does
// not redirect the search back to the start page.
render(<ScheduleStartPage />);
fireEvent.click(screen.getByTestId("popular-click-route"));
const stored = JSON.parse(sessionStore.getRaw("afl-prefill:schedule")!);
expect(stored.departure).toBe("SVO");
expect(stored.arrival).toBe("LED");
expect(stored.withReturn).toBe(false);
expect(stored.dateFrom).toBe("20260511"); // Mon
expect(stored.dateFrom).toBe("20260514"); // clamped to today1 (raw Mon was 2026-05-11)
expect(stored.dateTo).toBe("20260517"); // Sun
expect(stored.returnDateFrom).toBeUndefined();
expect(stored.returnDateTo).toBeUndefined();
expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule");
});
it("4.1.5-S2: round-trip RouteWithBack click prefills current + next week dates", () => {
// current week: 20260511-20260517; next week: 20260518-20260524
it("4.1.5-S2: round-trip RouteWithBack click prefills current + next week dates (outbound from clamped)", () => {
// current week raw: 20260511-20260517 (clamped from: 20260514-20260517)
// next week: 20260518-20260524 (unclamped — future)
render(<ScheduleStartPage />);
fireEvent.click(screen.getByTestId("popular-click-roundtrip"));
const stored = JSON.parse(sessionStore.getRaw("afl-prefill:schedule")!);
expect(stored.withReturn).toBe(true);
expect(stored.dateFrom).toBe("20260511");
expect(stored.dateFrom).toBe("20260514"); // clamped
expect(stored.dateTo).toBe("20260517");
expect(stored.returnDateFrom).toBe("20260518"); // next Mon
expect(stored.returnDateTo).toBe("20260524"); // next Sun
@@ -288,8 +291,9 @@ describe("4.1.9-R: Current-Week label substitution", () => {
render(<ScheduleStartPage />);
fireEvent.click(screen.getByTestId("popular-click-route"));
const stored = JSON.parse(sessionStore.getRaw("afl-prefill:schedule")!);
// Current week Mon-Sun for 2026-05-15
expect(stored.dateFrom).toBe("20260511");
// Current week Sun for 2026-05-15 is 2026-05-17; `from` is clamped to
// today1 = 2026-05-14 so the range is inside Schedule's 1/+330 window.
expect(stored.dateFrom).toBe("20260514");
expect(stored.dateTo).toBe("20260517");
});
});
@@ -76,8 +76,12 @@ function yyyymmddToDate(yyyymmdd: string): Date {
}
/**
* Returns MonSun bounds of the ISO week containing `base` (or today).
* TZ §4.1.5: "показать расписание на = текущая неделя".
* Returns MonSun bounds of the ISO week containing `base` (or today),
* clamped to today 1 on the `from` end so the range is always inside
* Schedule's [1, +330] date window. TZ §4.1.5 says "показать расписание
* на = текущая неделя"; when today falls mid-week the unclamped Monday
* would be past the window's lower edge and the route guard would redirect
* the search back to the start page.
*/
function currentWeekBounds(base = new Date()): { from: Date; to: Date } {
const d = new Date(base);
@@ -88,17 +92,28 @@ function currentWeekBounds(base = new Date()): { from: Date; to: Date } {
mon.setDate(d.getDate() - (dow - 1));
const sun = new Date(mon);
sun.setDate(mon.getDate() + 6);
return { from: mon, to: sun };
const minFrom = new Date(d);
minFrom.setDate(d.getDate() - 1);
const from = mon.getTime() < minFrom.getTime() ? minFrom : mon;
return { from, to: sun };
}
/**
* Returns MonSun bounds of the week *after* the ISO week containing `base`.
* TZ §4.1.5: "дата обратного рейса = следующая неделя".
*
* Derived from the raw Monday of `base`'s ISO week (not the clamped
* `currentWeekBounds().from`) so the return-leg always starts on the true
* next Monday regardless of how mid-week `base` is.
*/
function nextWeekBounds(base = new Date()): { from: Date; to: Date } {
const cur = currentWeekBounds(base);
const nextMon = new Date(cur.from);
nextMon.setDate(cur.from.getDate() + 7);
const d = new Date(base);
d.setHours(0, 0, 0, 0);
const dow = d.getDay() === 0 ? 7 : d.getDay();
const thisMon = new Date(d);
thisMon.setDate(d.getDate() - (dow - 1));
const nextMon = new Date(thisMon);
nextMon.setDate(thisMon.getDate() + 7);
const nextSun = new Date(nextMon);
nextSun.setDate(nextMon.getDate() + 6);
return { from: nextMon, to: nextSun };