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 };