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}`));
+ });
+});