Audit Schedule week-tabs + collapsed row per TZ 4.1.14.1 + 4.1.14.3

WeekTabs (§4.1.14.1):
- Fix active range: derive weeks from scheduleWindowBounds() [-1,+330 days]
  instead of hardcoded WEEKS_AFTER=30 (≈210 days, less than required 330).
- Fix auto-scroll: sync page via useEffect when selectedMonday prop changes
  so navigating to a different week always reveals its tab.
- Add fill-to-7: pad last page with disabled placeholder tabs when the
  final active week does not end a complete group of 7; disable next arrow.

Collapsed row (§4.1.14.3): already implemented — add lock-in tests for
Tables 36–40 (direct / multi-leg / connecting) covering flight number,
operator logos (round for multi-leg per commit 3ae59da), dep/arr times,
day-change chips, duration column, expand chevron, and DayGroupedFlightList
day-grouping + column headers.
This commit is contained in:
2026-04-21 23:11:32 +03:00
parent 9f6623786f
commit 6f67c06786
4 changed files with 589 additions and 17 deletions
@@ -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<string, string> = { SVO: "Москва", LED: "Санкт-Петербург", AER: "Сочи" };
return m[code] ?? "";
},
}));
vi.mock("./ScheduleFlightBody.js", () => ({
ScheduleFlightBody: () => <div data-testid="schedule-flight-body" />,
}));
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<typeof makeLeg>[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<typeof makeLeg>[]): 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<typeof makeLeg>[]): 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(<FlightCard flight={makeDirectFlight()} direction="schedule" expandable />);
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(<FlightCard flight={makeDirectFlight()} direction="schedule" expandable />);
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(<FlightCard flight={makeDirectFlight({ depLocal: "2026-04-21T10:00:00+03:00" })} direction="schedule" expandable />);
expect(screen.getByText("10:00")).toBeTruthy();
});
it("T36-C4: renders +1 day-change chip on departure", () => {
render(<FlightCard flight={makeDirectFlight({ depDayChange: 1 })} direction="schedule" expandable />);
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(<FlightCard flight={makeDirectFlight({ flyingTime: "03:00:00" })} direction="schedule" expandable />);
const dur = screen.getByTestId("flight-duration");
expect(dur.textContent).toContain("3ч.");
});
it("T36-C9: renders arrival time (scheduled)", () => {
render(<FlightCard flight={makeDirectFlight({ arrLocal: "2026-04-21T15:00:00+03:00" })} direction="schedule" expandable />);
expect(screen.getByText("15:00")).toBeTruthy();
});
it("T36-C14: renders expand/collapse chevron in schedule mode", () => {
render(<FlightCard flight={makeDirectFlight()} direction="schedule" expandable />);
const chevron = document.querySelector(".flight-card__chevron");
expect(chevron).toBeTruthy();
});
it("T36-C14: clicking the row expands the flight body", () => {
render(<FlightCard flight={makeDirectFlight()} direction="schedule" expandable />);
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(<FlightCard flight={makeMultiLegFlight([leg1, leg2])} direction="schedule" expandable />);
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(<FlightCard flight={makeMultiLegFlight([leg1, leg2])} direction="schedule" expandable />);
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(<FlightCard flight={makeMultiLegFlight([leg1, leg2])} direction="schedule" expandable />);
expect(screen.getByText("07:00")).toBeTruthy();
});
it("T37-C9: shows arrival time from the last leg", () => {
render(<FlightCard flight={makeMultiLegFlight([leg1, leg2])} direction="schedule" expandable />);
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(<FlightCard flight={conn} direction="schedule" expandable />);
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(<FlightCard flight={conn} direction="schedule" expandable />);
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(<DayGroupedFlightList flights={flights} />);
expect(screen.getByTestId("day-grouped-flight-list")).toBeTruthy();
});
it("renders a section per day with data-day attribute", () => {
render(<DayGroupedFlightList flights={flights} />);
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(<DayGroupedFlightList flights={flights} />);
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(<DayGroupedFlightList flights={flights} />);
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(<DayGroupedFlightList flights={reversed} />);
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(<DayGroupedFlightList flights={flights} />);
// 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(<DayGroupedFlightList flights={[]} loading />);
// FlightListSkeleton renders skeleton items
expect(container.querySelector(".flight-list-skeleton") ?? container.firstChild).toBeTruthy();
});
});
@@ -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;
}
}
}
@@ -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(<WeekTabs selectedMonday={monday} onNavigate={vi.fn()} />);
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(<WeekTabs selectedMonday={monday} onNavigate={vi.fn()} />);
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(
<WeekTabs selectedMonday={monday0} onNavigate={vi.fn()} />,
);
// Confirm monday0 is visible
expect(screen.getByTestId(`week-tab-${monday0}`)).toBeTruthy();
// Switch to a week on a different page
rerender(<WeekTabs selectedMonday={monday7} onNavigate={vi.fn()} />);
// 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(<WeekTabs selectedMonday={monday} onNavigate={vi.fn()} />);
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(<WeekTabs selectedMonday={monday} onNavigate={onNavigate} />);
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(<WeekTabs selectedMonday={monday} onNavigate={vi.fn()} />);
// 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(<WeekTabs selectedMonday={monday} onNavigate={vi.fn()} />);
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(<WeekTabs selectedMonday={monday} onNavigate={onNavigate} />);
// 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-", ""),
);
});
});
+66 -17
View File
@@ -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<WeekTabsProps> = ({ 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<WeekTabsProps> = ({ 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 (
<nav
@@ -107,7 +142,7 @@ export const WeekTabs: FC<WeekTabsProps> = ({ selectedMonday, onNavigate }) => {
</button>
<div className="week-tabs__list">
{visible.map((w) => {
{activeSlice.map((w) => {
const isActive = w.ymd === selectedMonday;
return (
<button
@@ -123,12 +158,26 @@ export const WeekTabs: FC<WeekTabsProps> = ({ selectedMonday, onNavigate }) => {
</button>
);
})}
{/* Inactive placeholder tabs to fill the last page to PAGE_SIZE */}
{Array.from({ length: paddedCount }).map((_, i) => (
<button
key={`placeholder-${i}`}
type="button"
className="week-tabs__tab week-tabs__tab--placeholder"
aria-pressed={false}
disabled
data-testid={`week-tab-placeholder-${i}`}
tabIndex={-1}
>
{" "}
</button>
))}
</div>
<button
type="button"
className="week-tabs__nav week-tabs__nav--next"
disabled={page >= totalPages - 1}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={isLastPage}
onClick={() => setPage((p) => Math.min(activeTotalPages - 1, p + 1))}
aria-label={t("SHARED.A11Y-NEXT-PAGE")}
>