diff --git a/modern.config.ts b/modern.config.ts index 309e2d7a..24801015 100644 --- a/modern.config.ts +++ b/modern.config.ts @@ -3,6 +3,10 @@ import { moduleFederationPlugin } from "@module-federation/modern-js"; const buildTarget = process.env["BUILD_TARGET"]; const isRemote = buildTarget === "remote"; +const plugins = [ + appTools({ bundler: "rspack" }), + ...(isRemote ? [moduleFederationPlugin()] : []), +]; // Runtime env values that must reach the client bundle. Rspack resolves // `process.env` at BUILD time to an empty-ish polyfill in the browser, so @@ -35,7 +39,7 @@ const PUBLIC_ENV_SCRIPT = `window.__ENV__=Object.assign(window.__ENV__||Object.create(null),JSON.parse(atob("${publicEnvB64}")));`; export default defineConfig({ - plugins: [appTools({ bundler: "rspack" }), moduleFederationPlugin()], + plugins, source: { entriesDir: "./src", }, @@ -95,6 +99,9 @@ export default defineConfig({ }, }, tools: { + rspack(config) { + config.cache = false; + }, cssLoader: { url: false, }, diff --git a/scripts/dev-server.mjs b/scripts/dev-server.mjs index 4abdf2ac..cef87529 100644 --- a/scripts/dev-server.mjs +++ b/scripts/dev-server.mjs @@ -39,11 +39,11 @@ console.log(`Starting Modern.js on :${MODERNJS_PORT}...`); const modernBin = resolve("node_modules", ".bin", "modern"); const modernProcess = existsSync(modernBin) ? spawn(modernBin, ["dev"], { - stdio: "inherit", + stdio: ["ignore", "inherit", "inherit"], env: { ...process.env, PORT: String(MODERNJS_PORT) }, }) : spawn(process.execPath, [resolve("node_modules", "@modern-js/app-tools", "bin", "modern.js"), "dev"], { - stdio: "inherit", + stdio: ["ignore", "inherit", "inherit"], env: { ...process.env, PORT: String(MODERNJS_PORT) }, }); modernProcess.on("error", (err) => { diff --git a/src/routes/[lang]/schedule/_guards.test.ts b/src/routes/[lang]/schedule/_guards.test.ts index f3265de0..d6529ea2 100644 --- a/src/routes/[lang]/schedule/_guards.test.ts +++ b/src/routes/[lang]/schedule/_guards.test.ts @@ -46,4 +46,26 @@ describe("scheduleDateRedirect (clock frozen 2026-05-15)", () => { // 2026-05-15 + 100 = 2026-08-23 expect(scheduleDateRedirect("ru-ru", "20260823")).toBeNull(); }); + + it("allows the first schedule week when the range starts before the strict window", () => { + // Window starts on Thu 2026-05-14; Angular accepts the containing Mon-Sun week. + expect(scheduleDateRedirect("ru-ru", "20260511", "20260517")).toBeNull(); + }); + + it("allows the last schedule week when the range ends after the strict window", () => { + // Window ends on Sat 2027-04-10; Angular accepts the containing Mon-Sun week. + expect(scheduleDateRedirect("ru-ru", "20270405", "20270411")).toBeNull(); + }); + + it("redirects a range before the first schedule week", () => { + expect(scheduleDateRedirect("ru-ru", "20260504", "20260510")).toBe("/ru-ru/schedule"); + }); + + it("redirects a range after the last schedule week", () => { + expect(scheduleDateRedirect("ru-ru", "20270412", "20270418")).toBe("/ru-ru/schedule"); + }); + + it("redirects an inverted date range", () => { + expect(scheduleDateRedirect("ru-ru", "20260518", "20260517")).toBe("/ru-ru/schedule"); + }); }); diff --git a/src/routes/[lang]/schedule/_guards.ts b/src/routes/[lang]/schedule/_guards.ts index 25d5979f..f6f0d52e 100644 --- a/src/routes/[lang]/schedule/_guards.ts +++ b/src/routes/[lang]/schedule/_guards.ts @@ -5,15 +5,55 @@ * Parse failures (malformed URLs) continue to produce 404 via existing logic. */ -import { isInScheduleWindow } from "@/shared/dateWindow.js"; +import { isInScheduleWindow, scheduleWindowBounds } from "@/shared/dateWindow.js"; + +function parseYyyymmdd(s: string): Date | null { + if (!/^\d{8}$/.test(s)) return null; + const y = Number(s.slice(0, 4)); + const m = Number(s.slice(4, 6)); + const d = Number(s.slice(6, 8)); + const dt = new Date(y, m - 1, d); + if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) return null; + dt.setHours(0, 0, 0, 0); + return dt; +} + +function startOfWeekMonday(date: Date): Date { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + const offset = (d.getDay() + 6) % 7; + d.setDate(d.getDate() - offset); + return d; +} + +function endOfWeekSunday(date: Date): Date { + const d = startOfWeekMonday(date); + d.setDate(d.getDate() + 6); + return d; +} + +function isScheduleRangeAllowed(dateFrom: string, dateTo: string): boolean { + const from = parseYyyymmdd(dateFrom); + const to = parseYyyymmdd(dateTo); + if (!from || !to || from.getTime() > to.getTime()) return false; + + const [windowMin, windowMax] = scheduleWindowBounds(); + const minWeekStart = startOfWeekMonday(windowMin); + const maxWeekEnd = endOfWeekSunday(windowMax); + + return from.getTime() >= minWeekStart.getTime() && to.getTime() <= maxWeekEnd.getTime(); +} /** - * Returns the redirect target path when the given yyyymmdd date falls outside - * the Schedule [-1, +330] day window. Returns null to allow normal render. + * Returns the redirect target path when the given yyyymmdd date/range falls + * outside the Schedule [-1, +330] day window. Week-granular schedule ranges + * are allowed when they overlap the first or last valid week, matching Angular. + * Returns null to allow normal render. * * Only called after a successful URL parse — malformed dates never reach here. */ -export function scheduleDateRedirect(locale: string, yyyymmdd: string): string | null { - if (!isInScheduleWindow(yyyymmdd)) return `/${locale}/schedule`; +export function scheduleDateRedirect(locale: string, dateFrom: string, dateTo?: string): string | null { + const isAllowed = dateTo ? isScheduleRangeAllowed(dateFrom, dateTo) : isInScheduleWindow(dateFrom); + if (!isAllowed) return `/${locale}/schedule`; return null; } diff --git a/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx b/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx index f69ebb3b..67e70f87 100644 --- a/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx +++ b/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx @@ -42,8 +42,8 @@ export default function ScheduleRoundTripSearchPage(): JSX.Element { if (!outbound || !inbound) return ; const redirect = - scheduleDateRedirect(locale, outbound.dateFrom) ?? - scheduleDateRedirect(locale, inbound.dateFrom); + scheduleDateRedirect(locale, outbound.dateFrom, outbound.dateTo) ?? + scheduleDateRedirect(locale, inbound.dateFrom, inbound.dateTo); if (redirect) return ; const canonicalOrigin = getEnv().PROD_ORIGIN; diff --git a/src/routes/[lang]/schedule/route/[params]/page.tsx b/src/routes/[lang]/schedule/route/[params]/page.tsx index 6a9ac6b5..f1b7d5f9 100644 --- a/src/routes/[lang]/schedule/route/[params]/page.tsx +++ b/src/routes/[lang]/schedule/route/[params]/page.tsx @@ -38,7 +38,7 @@ export default function ScheduleRouteSearchPage(): JSX.Element { if (!parsed) return ; - const redirect = scheduleDateRedirect(locale, parsed.dateFrom); + const redirect = scheduleDateRedirect(locale, parsed.dateFrom, parsed.dateTo); if (redirect) return ; const canonicalOrigin = getEnv().PROD_ORIGIN; diff --git a/tests/e2e/schedule-current-week-route.spec.ts b/tests/e2e/schedule-current-week-route.spec.ts new file mode 100644 index 00000000..03a5e87f --- /dev/null +++ b/tests/e2e/schedule-current-week-route.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from "./fixtures/console-gate"; + +function addDays(base: Date, days: number): Date { + const d = new Date(base); + d.setDate(d.getDate() + days); + return d; +} + +function startOfWeekMonday(base: Date): Date { + const d = new Date(base); + d.setHours(0, 0, 0, 0); + const offset = (d.getDay() + 6) % 7; + d.setDate(d.getDate() - offset); + return d; +} + +function yyyymmdd(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + return `${y}${m}${d}`; +} + +function currentScheduleWeekRange(): [string, string] { + const scheduleMinDate = addDays(new Date(), -1); + const monday = startOfWeekMonday(scheduleMinDate); + return [yyyymmdd(monday), yyyymmdd(addDays(monday, 6))]; +} + +function nextScheduleWeekRange(): [string, string] { + const [currentWeekStart] = currentScheduleWeekRange(); + const monday = startOfWeekMonday( + new Date( + Number(currentWeekStart.slice(0, 4)), + Number(currentWeekStart.slice(4, 6)) - 1, + Number(currentWeekStart.slice(6, 8)), + ), + ); + const nextMonday = addDays(monday, 7); + return [yyyymmdd(nextMonday), yyyymmdd(addDays(nextMonday, 6))]; +} + +test.describe("Schedule VVO-MJZ week route parity", () => { + test("current schedule week route does not reset to the start page", async ({ + page, + consoleMessages, + }) => { + const [dateFrom, dateTo] = currentScheduleWeekRange(); + await page.goto(`/ru-ru/schedule/route/VVO-MJZ-${dateFrom}-${dateTo}`); + + await expect(page.locator("h1")).toContainText(/(Владивосток.*Мирный|VVO.*MJZ)/, { + timeout: 30000, + }); + await expect(page).toHaveURL(new RegExp(`/schedule/route/VVO-MJZ-${dateFrom}-${dateTo}`)); + }); + + test("next schedule week route renders without the search error page", async ({ + page, + consoleMessages, + }) => { + const [dateFrom, dateTo] = nextScheduleWeekRange(); + await page.goto(`/ru-ru/schedule/route/VVO-MJZ-${dateFrom}-${dateTo}`); + + await expect(page.locator("h1")).toContainText(/(Владивосток.*Мирный|VVO.*MJZ)/, { + timeout: 30000, + }); + await expect(page.getByText("Что-то пошло не так")).toBeHidden(); + await expect(page).toHaveURL(new RegExp(`/schedule/route/VVO-MJZ-${dateFrom}-${dateTo}`)); + }); +});