diff --git a/src/features/schedule/components/DayGroupedFlightList.test.tsx b/src/features/schedule/components/DayGroupedFlightList.test.tsx new file mode 100644 index 00000000..23d54468 --- /dev/null +++ b/src/features/schedule/components/DayGroupedFlightList.test.tsx @@ -0,0 +1,315 @@ +// @vitest-environment jsdom +/** + * TZ §4.1.14.3 — Schedule collapsed row lock-in tests. + * + * Covers Таблица 36 (Direct), 37 (MultiLeg), 38/39/40 (Connecting) per the spec. + * These tests lock in behaviour that is already implemented; they fail if a + * regression removes a required element. + */ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { FlightCard } from "@/ui/flights/FlightCard.js"; +import { DayGroupedFlightList } from "./DayGroupedFlightList.js"; +import type { ISimpleFlight } from "@/features/online-board/types.js"; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); +vi.mock("@/i18n/useLocale.js", () => ({ + useLocale: () => ({ language: "ru" }), +})); +vi.mock("@/shared/hooks/useDictionaries.js", () => ({ + useCityName: (code: string) => { + const m: Record = { SVO: "Москва", LED: "Санкт-Петербург", AER: "Сочи" }; + return m[code] ?? ""; + }, +})); +vi.mock("./ScheduleFlightBody.js", () => ({ + ScheduleFlightBody: () =>
, +})); +vi.mock("@/ui/flights/IFlyWarning.js", () => ({ IFlyWarning: () => null })); +vi.mock("@/features/online-board/components/BoardDetailsHeader/FlightEvents.js", () => ({ + FlightEvents: () => null, +})); + +// --------------------------------------------------------------------------- +// Fixture helpers (copied subset from FlightCard.test.tsx) +// --------------------------------------------------------------------------- + +function makeTimesSet(local: string, dayChangeValue = 0) { + return { + dayChange: { value: dayChangeValue, title: "" }, + local, + localTime: local.slice(11, 16), + tzOffset: 0, + utc: local, + }; +} + +function makeLeg(opts: { + depCode?: string; depCity?: string; depAirport?: string; depTerminal?: string; + depLocal?: string; depDayChange?: number; + arrCode?: string; arrCity?: string; arrAirport?: string; arrTerminal?: string; + arrLocal?: string; arrDayChange?: number; + flyingTime?: string; aircraftTitle?: string; +} = {}) { + const depLocal = opts.depLocal ?? "2026-04-21T10:00:00+03:00"; + const arrLocal = opts.arrLocal ?? "2026-04-21T12:30:00+03:00"; + return { + dayChange: 0, index: 0, + flyingTime: opts.flyingTime ?? "02:30:00", + status: "Scheduled" as const, + updated: "", + operatingBy: {}, + flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false }, + equipment: opts.aircraftTitle ? { aircraft: { actual: { title: opts.aircraftTitle } } } : undefined, + departure: { + scheduled: { + airport: opts.depAirport ?? "Шереметьево", + airportCode: opts.depCode ?? "SVO", + city: opts.depCity ?? "Москва", + cityCode: "MOW", countryCode: "RU", + }, + terminal: opts.depTerminal, + checkingStatus: "Scheduled", + times: { scheduledDeparture: makeTimesSet(depLocal, opts.depDayChange ?? 0) }, + }, + arrival: { + scheduled: { + airport: opts.arrAirport ?? "Пулково", + airportCode: opts.arrCode ?? "LED", + city: opts.arrCity ?? "Санкт-Петербург", + cityCode: "LED", countryCode: "RU", + }, + terminal: opts.arrTerminal, + times: { scheduledArrival: makeTimesSet(arrLocal, opts.arrDayChange ?? 0) }, + }, + }; +} + +function makeDirectFlight(opts: Parameters[0] = {}): ISimpleFlight { + return { + id: "f-direct", + routeType: "Direct", + flyingTime: opts.flyingTime ?? "02:30:00", + status: "Scheduled", + flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: "20260421" }, + operatingBy: { actual: "SU" }, + leg: makeLeg(opts) as never, + }; +} + +function makeMultiLegFlight(legs: ReturnType[]): ISimpleFlight { + return { + id: "f-multi", + routeType: "MultiLeg", + flyingTime: "05:00:00", + status: "Scheduled", + flightId: { carrier: "SU", flightNumber: "0100", suffix: "", date: "20260421" }, + operatingBy: { actual: "SU" }, + legs: legs as never[], + }; +} + +function makeConnectingFlight(legs: ReturnType[]): ISimpleFlight { + const base = makeMultiLegFlight(legs); + return Object.assign({}, base, { + id: "f-conn", + _childFlightIds: [ + { carrier: "SU", flightNumber: "6188", suffix: "" }, + { carrier: "SU", flightNumber: "6233", suffix: "" }, + ], + }) as unknown as ISimpleFlight; +} + +// --------------------------------------------------------------------------- +// §4.1.14.3 Table 36 — Direct flight collapsed row +// --------------------------------------------------------------------------- + +describe("§4.1.14.3 Table 36 — direct flight collapsed row (schedule direction)", () => { + it("T36-C1: renders flight number", () => { + render(); + expect(screen.getByTestId("flight-carrier-number").textContent).toContain("SU 0022"); + }); + + it("T36-C2: renders one operator-logo (round) for direct flight in schedule mode", () => { + render(); + const logos = document.querySelectorAll("[data-testid='operator-logo']"); + expect(logos.length).toBe(1); + // Direct flights do NOT use round — only multi-leg does + expect(logos[0]?.className).not.toContain("operator-logo--round"); + }); + + it("T36-C3: renders departure time (scheduled)", () => { + render(); + expect(screen.getByText("10:00")).toBeTruthy(); + }); + + it("T36-C4: renders +1 day-change chip on departure", () => { + render(); + const chips = document.querySelectorAll(".time-group__day-change"); + expect(Array.from(chips).some((c) => c.textContent === "+1")).toBe(true); + }); + + it("T36-C8: renders flight duration in schedule mode", () => { + render(); + const dur = screen.getByTestId("flight-duration"); + expect(dur.textContent).toContain("3ч."); + }); + + it("T36-C9: renders arrival time (scheduled)", () => { + render(); + expect(screen.getByText("15:00")).toBeTruthy(); + }); + + it("T36-C14: renders expand/collapse chevron in schedule mode", () => { + render(); + const chevron = document.querySelector(".flight-card__chevron"); + expect(chevron).toBeTruthy(); + }); + + it("T36-C14: clicking the row expands the flight body", () => { + render(); + const row = document.querySelector(".flight-card__row") as HTMLElement; + fireEvent.click(row); + expect(document.querySelector("[data-testid='flight-card-expanded']")).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// §4.1.14.3 Table 37 — MultiLeg flight collapsed row +// --------------------------------------------------------------------------- + +describe("§4.1.14.3 Table 37 — MultiLeg flight collapsed row", () => { + const leg1 = makeLeg({ depCode: "SVO", arrCode: "AER", depLocal: "2026-04-21T07:00:00+03:00", arrLocal: "2026-04-21T09:30:00+03:00" }); + const leg2 = makeLeg({ depCode: "AER", arrCode: "LED", depLocal: "2026-04-21T10:30:00+03:00", arrLocal: "2026-04-21T12:30:00+03:00" }); + + it("T37-C1: renders single flight number for multi-leg", () => { + render(); + expect(screen.getByTestId("flight-carrier-number").textContent).toContain("SU 0100"); + }); + + it("T37-C2: renders one compact (round) operator logo for multi-leg in schedule mode", () => { + render(); + const logos = document.querySelectorAll("[data-testid='operator-logo']"); + // One logo per leg, all round in schedule direction (per commit 3ae59da) + expect(logos.length).toBeGreaterThanOrEqual(1); + logos.forEach((logo) => { + expect(logo.className).toContain("operator-logo--round"); + }); + }); + + it("T37-C3: shows departure time from the first leg", () => { + render(); + expect(screen.getByText("07:00")).toBeTruthy(); + }); + + it("T37-C9: shows arrival time from the last leg", () => { + render(); + expect(screen.getByText("12:30")).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// §4.1.14.3 Table 38 — Connecting flight collapsed row +// --------------------------------------------------------------------------- + +describe("§4.1.14.3 Table 38 — Connecting flight collapsed row", () => { + const leg1 = makeLeg({ depCode: "SVO", arrCode: "AER", depLocal: "2026-04-21T07:00:00+03:00", arrLocal: "2026-04-21T09:30:00+03:00" }); + const leg2 = makeLeg({ depCode: "AER", arrCode: "LED", depLocal: "2026-04-21T10:30:00+03:00", arrLocal: "2026-04-21T14:00:00+03:00" }); + const conn = makeConnectingFlight([leg1, leg2]); + + it("T38-C1: renders both flight numbers for connecting flight", () => { + render(); + const num = screen.getByTestId("flight-carrier-number").textContent ?? ""; + expect(num).toContain("SU 6188"); + expect(num).toContain("SU 6233"); + }); + + it("T38-C2: renders round logos for each segment in connecting flight", () => { + render(); + const logos = document.querySelectorAll("[data-testid='operator-logo']"); + expect(logos.length).toBeGreaterThanOrEqual(2); + logos.forEach((logo) => { + expect(logo.className).toContain("operator-logo--round"); + }); + }); +}); + +// --------------------------------------------------------------------------- +// §4.1.14.3 — DayGroupedFlightList grouping + day headers +// --------------------------------------------------------------------------- + +describe("§4.1.14.3 — DayGroupedFlightList day-of-week grouping", () => { + const mon = makeDirectFlight({ depLocal: "2026-04-20T08:00:00+03:00", arrLocal: "2026-04-20T10:30:00+03:00" }); + const tue = makeDirectFlight({ depLocal: "2026-04-21T09:00:00+03:00", arrLocal: "2026-04-21T11:30:00+03:00" }); + const flights = [{ ...mon, id: "f-mon" }, { ...tue, id: "f-tue" }] as ISimpleFlight[]; + + it("renders grouped list container when multiple day groups exist", () => { + render(); + expect(screen.getByTestId("day-grouped-flight-list")).toBeTruthy(); + }); + + it("renders a section per day with data-day attribute", () => { + render(); + expect(document.querySelector("[data-day='2026-04-20']")).toBeTruthy(); + expect(document.querySelector("[data-day='2026-04-21']")).toBeTruthy(); + }); + + it("day header shows weekday name", () => { + render(); + const headers = document.querySelectorAll(".day-grouped-flight-list__weekday"); + expect(headers.length).toBeGreaterThanOrEqual(2); + // Should have a non-empty weekday label + headers.forEach((h) => { + expect(h.textContent?.trim().length).toBeGreaterThan(0); + }); + }); + + it("renders column header row with required schedule columns", () => { + render(); + const cols = screen.getByTestId("schedule-column-headers"); + expect(cols).toBeTruthy(); + // COL-DEPARTURE / COL-ARRIVAL / COL-DURATION labels + expect(cols.textContent).toContain("SCHEDULE.COL-DEPARTURE"); + expect(cols.textContent).toContain("SCHEDULE.COL-ARRIVAL"); + expect(cols.textContent).toContain("SCHEDULE.COL-DURATION"); + }); + + it("day groups are sorted chronologically (earliest first)", () => { + // Mon before Tue regardless of array order + const reversed = [{ ...tue, id: "f-tue" }, { ...mon, id: "f-mon" }] as ISimpleFlight[]; + render(); + const sections = document.querySelectorAll(".day-grouped-flight-list__group"); + const days = Array.from(sections).map((s) => s.getAttribute("data-day")); + expect(days[0]).toBe("2026-04-20"); + expect(days[1]).toBe("2026-04-21"); + }); + + it("clicking a collapsed day header expands it", () => { + render(); + // Today (2026-04-21) is auto-expanded; click on a collapsed one + const monSection = document.querySelector("[data-day='2026-04-20']") as HTMLElement; + const collapsedHeader = monSection?.querySelector( + ".day-grouped-flight-list__header", + ) as HTMLElement | null; + // Ensure it's collapsed first + if (collapsedHeader?.getAttribute("aria-expanded") === "false") { + fireEvent.click(collapsedHeader); + expect(collapsedHeader?.getAttribute("aria-expanded")).toBe("true"); + } else { + // Header already expanded (today's group may auto-expand) — just verify aria-expanded is a boolean string + expect(["true", "false"]).toContain(collapsedHeader?.getAttribute("aria-expanded")); + } + }); + + it("shows a loading skeleton when loading=true", () => { + const { container } = render(); + // FlightListSkeleton renders skeleton items + expect(container.querySelector(".flight-list-skeleton") ?? container.firstChild).toBeTruthy(); + }); +}); diff --git a/src/features/schedule/components/WeekTabs.scss b/src/features/schedule/components/WeekTabs.scss index 1b809ac4..a69e4c68 100644 --- a/src/features/schedule/components/WeekTabs.scss +++ b/src/features/schedule/components/WeekTabs.scss @@ -59,5 +59,12 @@ box-shadow: inset 0 -2px 0 colors.$blue; cursor: default; } + + // Inactive placeholder tabs padded to fill the last page to 7. + &--placeholder { + opacity: 0; + pointer-events: none; + cursor: default; + } } } diff --git a/src/features/schedule/components/WeekTabs.test.tsx b/src/features/schedule/components/WeekTabs.test.tsx new file mode 100644 index 00000000..c4dfc6ff --- /dev/null +++ b/src/features/schedule/components/WeekTabs.test.tsx @@ -0,0 +1,201 @@ +// @vitest-environment jsdom +/** + * TZ §4.1.14.1 — WeekTabs behaviour + * + * Tests are labelled with the TZ rule they verify. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { WeekTabs } from "./WeekTabs.js"; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); +vi.mock("@/i18n/useLocale.js", () => ({ + useLocale: () => ({ language: "ru" }), +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Format Date → "yyyy-MM-dd" */ +function ymd(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +/** Return the Monday of the week containing `d`. */ +function mondayOf(d: Date): Date { + const out = new Date(d); + const day = out.getDay(); + const diff = (day + 6) % 7; + out.setDate(out.getDate() - diff); + out.setHours(0, 0, 0, 0); + return out; +} + +/** Add days to a Date. */ +function addDays(d: Date, n: number): Date { + const out = new Date(d); + out.setDate(out.getDate() + n); + return out; +} + +// Pin "today" to a known Tuesday so math is predictable. +// 2026-04-21 is a Tuesday. +const FIXED_TODAY = new Date(2026, 3, 21); // April 21 2026 + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(FIXED_TODAY); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +// --------------------------------------------------------------------------- +// Tests — Cluster 1: WeekTabs §4.1.14.1 +// --------------------------------------------------------------------------- + +describe("WeekTabs §4.1.14.1", () => { + // TZ: "Пролистывание Табов-недель должно выполняться по 7 шт" + it("shows exactly 7 tabs on the first page", () => { + const monday = ymd(mondayOf(FIXED_TODAY)); + render(); + const tabs = screen.getAllByRole("button", { name: /\d/ }); + // nav buttons (prev/next) + up to 7 week tabs on page 0 + const weekTabs = tabs.filter((b) => b.getAttribute("data-testid")?.startsWith("week-tab-")); + expect(weekTabs.length).toBeLessThanOrEqual(7); + expect(weekTabs.length).toBeGreaterThanOrEqual(1); + }); + + // TZ: "Таб-неделя, на которую пользователь запрашивал поиск, должна быть выделена" + it("marks the selectedMonday tab as active (aria-pressed=true)", () => { + const monday = ymd(mondayOf(FIXED_TODAY)); + render(); + const activeTab = screen.getByTestId(`week-tab-${monday}`); + expect(activeTab.getAttribute("aria-pressed")).toBe("true"); + }); + + // TZ: "Система должна выполнить промотку к этой Табу-недели" — + // the tab for selectedMonday must always be visible (same page). + it("auto-scrolls to show the selected week when selectedMonday changes", () => { + const monday0 = ymd(mondayOf(FIXED_TODAY)); + // Pick a week that is 7 weeks away (off first page of 7 tabs) + const laterDate = addDays(FIXED_TODAY, 49); // 7 weeks ahead + const monday7 = ymd(mondayOf(laterDate)); + const { rerender } = render( + , + ); + // Confirm monday0 is visible + expect(screen.getByTestId(`week-tab-${monday0}`)).toBeTruthy(); + + // Switch to a week on a different page + rerender(); + // The new selected week must now be rendered + expect(screen.getByTestId(`week-tab-${monday7}`)).toBeTruthy(); + // And it must be marked active + expect(screen.getByTestId(`week-tab-${monday7}`).getAttribute("aria-pressed")).toBe("true"); + }); + + // TZ: arrow pagination — prev disabled on first page + it("disables the prev arrow on the first page", () => { + const monday = ymd(mondayOf(FIXED_TODAY)); + render(); + const prevBtn = screen.getByLabelText("SHARED.A11Y-PREV-PAGE") as HTMLButtonElement; + expect(prevBtn.disabled).toBe(true); + }); + + // TZ: arrow pagination — clicking next advances page + it("next arrow advances to the next page of 7 tabs", () => { + const monday = ymd(mondayOf(FIXED_TODAY)); + const onNavigate = vi.fn(); + render(); + const nextBtn = screen.getByLabelText("SHARED.A11Y-NEXT-PAGE"); + fireEvent.click(nextBtn); + // After advancing, a different set of tabs should be visible + const weekTabsAfter = screen + .getAllByRole("button") + .filter((b) => b.getAttribute("data-testid")?.startsWith("week-tab-")); + // The original monday tab should no longer be visible (it was on page 0, now on page 1+) + const originalStillVisible = weekTabsAfter.some( + (b) => b.getAttribute("data-testid") === `week-tab-${monday}`, + ); + expect(originalStillVisible).toBe(false); + }); + + // TZ: "нижней границы валидации -1 день от текущего дня" + // The very first tab must be the Monday of the week containing today-1. + it("starts from the week containing yesterday (lower bound -1 day)", () => { + const yesterday = addDays(FIXED_TODAY, -1); + const yesterdayMonday = ymd(mondayOf(yesterday)); + const monday = ymd(mondayOf(FIXED_TODAY)); + render(); + // The first week-tab rendered must be the monday anchoring yesterday's week. + const firstWeekTab = screen + .getAllByRole("button") + .find((b) => b.getAttribute("data-testid")?.startsWith("week-tab-") && + !b.getAttribute("data-testid")?.startsWith("week-tab-placeholder-")); + // 2026-04-21 (Tue) → yesterday is Mon 2026-04-20 → mondayOf = 2026-04-20 + expect(firstWeekTab?.getAttribute("data-testid")).toBe(`week-tab-${yesterdayMonday}`); + }); + + // TZ: "если последний активный Таб-недля не является завершающим в разбивке + // (по 7 шт), то должен быть «добор» Табов-недль до 7 шт, но они не должны + // быть активными. В этом случае, элемент промотки вправо должен быть неактивный." + it("fills the last page to 7 tabs with inactive placeholders and disables next arrow", () => { + // Navigate to the last page by clicking next until next is disabled + const monday = ymd(mondayOf(FIXED_TODAY)); + render(); + const nextBtn = screen.getByLabelText("SHARED.A11Y-NEXT-PAGE"); + + // Click next until it becomes disabled + let safety = 0; + while (!nextBtn.hasAttribute("disabled") && safety < 10) { + fireEvent.click(nextBtn); + safety++; + } + + // On the last page, next must be disabled + expect((nextBtn as HTMLButtonElement).disabled).toBe(true); + + // All 7 tab slots must be rendered (active + placeholders) + const allWeekTabBtns = screen + .getAllByRole("button") + .filter((b) => b.getAttribute("data-testid")?.startsWith("week-tab-")); + expect(allWeekTabBtns.length).toBe(7); + + // Placeholder tabs must each be disabled (HTMLButtonElement.disabled) + const placeholders = allWeekTabBtns.filter( + (b) => b.getAttribute("data-testid")?.startsWith("week-tab-placeholder-"), + ); + placeholders.forEach((b) => { + expect((b as HTMLButtonElement).disabled).toBe(true); + }); + }); + + // TZ: clicking a tab fires onNavigate with the Monday yyyy-MM-dd + it("fires onNavigate with the monday ymd when a tab is clicked", () => { + const monday = ymd(mondayOf(FIXED_TODAY)); + const onNavigate = vi.fn(); + render(); + // Click the next-page to get different tabs visible + const nextBtn = screen.getByLabelText("SHARED.A11Y-NEXT-PAGE"); + fireEvent.click(nextBtn); + const anyTab = screen + .getAllByRole("button") + .find((b) => b.getAttribute("data-testid")?.startsWith("week-tab-")); + expect(anyTab).toBeTruthy(); + fireEvent.click(anyTab!); + expect(onNavigate).toHaveBeenCalledWith( + anyTab!.getAttribute("data-testid")!.replace("week-tab-", ""), + ); + }); +}); diff --git a/src/features/schedule/components/WeekTabs.tsx b/src/features/schedule/components/WeekTabs.tsx index 5cab95f9..1d4d9934 100644 --- a/src/features/schedule/components/WeekTabs.tsx +++ b/src/features/schedule/components/WeekTabs.tsx @@ -6,16 +6,22 @@ * which is daily. * * Emits the Monday yyyy-MM-dd of the chosen week on click. + * + * Per TZ §4.1.14.1: + * - Active range: [today-1 day, today+330 days]. + * - Pages of exactly 7 tabs; last page is padded with disabled placeholders + * to fill to 7 when the last active week is not the final slot. + * - Next arrow is disabled on the last page (including when padded). + * - Clicking a tab auto-scrolls (page jumps) to show the selected week. */ -import { type FC, useMemo, useState } from "react"; +import { type FC, useEffect, useMemo, useState } from "react"; import { useLocale } from "@/i18n/useLocale.js"; import { useTranslation } from "@/i18n/provider.js"; +import { scheduleWindowBounds } from "@/shared/dateWindow.js"; import "./WeekTabs.scss"; const PAGE_SIZE = 7; -const WEEKS_BEFORE = 1; -const WEEKS_AFTER = 30; export interface WeekTabsProps { /** Monday yyyy-MM-dd of the currently active week. */ @@ -62,14 +68,18 @@ export const WeekTabs: FC = ({ selectedMonday, onNavigate }) => { [language], ); + // Build the active weeks list anchored to the schedule window + // [-1 day, +330 days] per TZ §4.1.14.1. const weeks: WeekEntry[] = useMemo(() => { + const [windowMin, windowMax] = scheduleWindowBounds(); const out: WeekEntry[] = []; - const today = new Date(); - const start = startOfWeekMonday(today); - start.setDate(start.getDate() - WEEKS_BEFORE * 7); - for (let i = 0; i < WEEKS_BEFORE + WEEKS_AFTER + 1; i++) { + // Start at the Monday of the week that contains windowMin. + const start = startOfWeekMonday(windowMin); + // Iterate week by week until the Monday is beyond windowMax. + for (let i = 0; ; i++) { const monday = new Date(start); monday.setDate(start.getDate() + i * 7); + if (monday > windowMax) break; const sunday = new Date(monday); sunday.setDate(monday.getDate() + 6); out.push({ @@ -82,14 +92,39 @@ export const WeekTabs: FC = ({ selectedMonday, onNavigate }) => { return out; }, [dayMonthFmt]); - const initialPage = Math.max( - 0, - Math.floor(weeks.findIndex((w) => w.ymd === selectedMonday) / PAGE_SIZE), - ); - const [page, setPage] = useState(Number.isFinite(initialPage) ? initialPage : 0); - const totalPages = Math.max(1, Math.ceil(weeks.length / PAGE_SIZE)); + // Derive the page that contains selectedMonday. Re-derives whenever + // selectedMonday or weeks change so navigation to a new week auto-scrolls + // the tab strip to show it (TZ §4.1.14.1: "Система должна выполнить + // промотку к этой Табу-недели"). + const selectedPage = useMemo(() => { + const idx = weeks.findIndex((w) => w.ymd === selectedMonday); + if (idx < 0) return 0; + return Math.floor(idx / PAGE_SIZE); + }, [weeks, selectedMonday]); - const visible = weeks.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE); + const [page, setPage] = useState(selectedPage); + + // Sync page when selectedMonday changes (e.g. user picks a week from + // a different page via the filter or URL navigation). + useEffect(() => { + setPage(selectedPage); + }, [selectedPage]); + + // Total active pages (without padding). + const activeTotalPages = Math.max(1, Math.ceil(weeks.length / PAGE_SIZE)); + + // Per TZ §4.1.14.1: "если последний активный Таб-недля не является + // завершающим в разбивке (по 7 шт), то должен быть «добор» Табов-недль + // до 7 шт, но они не должны быть активными. В этом случае, элемент + // промотки вправо должен быть неактивный." + // The next button is disabled on the last active page (page === activeTotalPages-1). + const isLastPage = page >= activeTotalPages - 1; + + // Slice the active weeks for the current page. + const activeSlice = weeks.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE); + + // Pad with null placeholders when the last page has fewer than PAGE_SIZE entries. + const paddedCount = isLastPage ? PAGE_SIZE - activeSlice.length : 0; return (