From c18b4b212ecfdc9aa6fa6e57cf228a71425bf6b2 Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 22 Apr 2026 03:26:46 +0300 Subject: [PATCH] Fix popular-flight Search-button no-op when today is mid-week MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/OnlineBoardStartPage.tsx | 78 +++++++++++++++++-- .../components/ScheduleStartPage.test.tsx | 20 +++-- .../schedule/components/ScheduleStartPage.tsx | 27 +++++-- 3 files changed, 105 insertions(+), 20 deletions(-) diff --git a/src/features/online-board/components/OnlineBoardStartPage.tsx b/src/features/online-board/components/OnlineBoardStartPage.tsx index 84a24916..e3b151fd 100644 --- a/src/features/online-board/components/OnlineBoardStartPage.tsx +++ b/src/features/online-board/components/OnlineBoardStartPage.tsx @@ -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) }; +} + +/** + * Mon–Sun 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 = request.mode === "Route" || request.mode === "RouteWithBack" - ? { - departure: toCityCode(request.departure, dictionaries), - arrival: toCityCode(request.arrival, dictionaries), - withReturn: request.mode === "RouteWithBack", - } + ? (() => { + const base: Record = { + 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`); diff --git a/src/features/schedule/components/ScheduleStartPage.test.tsx b/src/features/schedule/components/ScheduleStartPage.test.tsx index 6ac02568..055f7325 100644 --- a/src/features/schedule/components/ScheduleStartPage.test.tsx +++ b/src/features/schedule/components/ScheduleStartPage.test.tsx @@ -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 today−1 = 2026-05-14 so the route guard does + // not redirect the search back to the start page. render(); 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 today−1 (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(); 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(); 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 + // today−1 = 2026-05-14 so the range is inside Schedule's −1/+330 window. + expect(stored.dateFrom).toBe("20260514"); expect(stored.dateTo).toBe("20260517"); }); }); diff --git a/src/features/schedule/components/ScheduleStartPage.tsx b/src/features/schedule/components/ScheduleStartPage.tsx index 01327868..dc1593f2 100644 --- a/src/features/schedule/components/ScheduleStartPage.tsx +++ b/src/features/schedule/components/ScheduleStartPage.tsx @@ -76,8 +76,12 @@ function yyyymmddToDate(yyyymmdd: string): Date { } /** - * Returns Mon–Sun bounds of the ISO week containing `base` (or today). - * TZ §4.1.5: "показать расписание на = текущая неделя". + * Returns Mon–Sun 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 Mon–Sun 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 };