diff --git a/docs/superpowers/plans/2026-04-17-day-tabs.md b/docs/superpowers/plans/2026-04-17-day-tabs.md new file mode 100644 index 00000000..1a79c749 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-day-tabs.md @@ -0,0 +1,1474 @@ +# Day Tabs (B.3) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a horizontal day-tabs navigator above the Online Board flight details content — 7 tabs per page with prev/next arrows on desktop, dropdown on mobile, disabling dates not operating, auto-scrolling to the page containing the active date on mount. + +**Architecture:** Add a `useAppSettings` hook to fetch `/api/appSettings` and parse `searchFrom`/`searchTo` into numbers. Extend `useFlightDetails` to also return `daysOfFlight`. Build `DayTabs` + `DayTabButton` + `DaySelect` components. Wire into `OnlineBoardDetailsPage` as `PageLayout.stickyContent`. + +**Tech Stack:** React 18, TypeScript, `Intl.DateTimeFormat` for date labels, existing `ApiClient` + `useApiClient` pattern, Modern.js Router's `useNavigate`, Vitest + React Testing Library. + +--- + +## File Structure + +### New files + +- `src/shared/hooks/useAppSettings.ts` — Hook: fetch settings, parse `{searchFrom, searchTo}` +- `src/shared/hooks/useAppSettings.test.ts` +- `src/shared/api/appSettings.ts` — `getAppSettings(client)` API function +- `src/features/online-board/components/DayTabs/DayTabs.tsx` — Container with pagination +- `src/features/online-board/components/DayTabs/DayTabs.scss` +- `src/features/online-board/components/DayTabs/DayTabs.test.tsx` +- `src/features/online-board/components/DayTabs/DayTabButton.tsx` — Single tab +- `src/features/online-board/components/DayTabs/DayTabButton.test.tsx` +- `src/features/online-board/components/DayTabs/DaySelect.tsx` — Mobile dropdown +- `src/features/online-board/components/DayTabs/DaySelect.test.tsx` +- `src/features/online-board/components/DayTabs/dateRange.ts` — Pure helpers (generateDateRange, formatYyyymmdd) +- `src/features/online-board/components/DayTabs/dateRange.test.ts` +- `src/features/online-board/components/DayTabs/index.ts` — Barrel + +### Modified files + +- `src/features/online-board/hooks/useFlightDetails.ts` — Add `daysOfFlight` to return +- `src/features/online-board/hooks/useFlightDetails.test.ts` — Test `daysOfFlight` return +- `src/features/online-board/components/OnlineBoardDetailsPage.tsx` — Wire `DayTabs` into `stickyContent` +- `src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` — Add integration tests + +--- + +### Task 1: Date Range Helpers (Pure Functions) + +**Files:** +- Create: `src/features/online-board/components/DayTabs/dateRange.ts` +- Create: `src/features/online-board/components/DayTabs/dateRange.test.ts` + +- [ ] **Step 1: Write failing tests** + +```typescript +// dateRange.test.ts +import { describe, it, expect } from "vitest"; +import { formatYyyymmdd, parseYyyymmdd, generateDateRange, findPageIndex } from "./dateRange.js"; + +describe("formatYyyymmdd", () => { + it("formats a Date to yyyymmdd", () => { + expect(formatYyyymmdd(new Date(2026, 3, 16))).toBe("20260416"); // month is 0-indexed + }); + + it("pads single-digit month and day", () => { + expect(formatYyyymmdd(new Date(2026, 0, 5))).toBe("20260105"); + }); +}); + +describe("parseYyyymmdd", () => { + it("parses yyyymmdd into Date", () => { + const d = parseYyyymmdd("20260416"); + expect(d.getFullYear()).toBe(2026); + expect(d.getMonth()).toBe(3); + expect(d.getDate()).toBe(16); + }); +}); + +describe("generateDateRange", () => { + it("generates dates from today-daysBefore to today+daysAfter inclusive", () => { + const today = new Date(2026, 3, 16); + const range = generateDateRange(today, 2, 3); + expect(range).toHaveLength(6); // 2 before + today + 3 after + expect(range[0]).toBe("20260414"); + expect(range[2]).toBe("20260416"); + expect(range[5]).toBe("20260419"); + }); + + it("handles zero-range", () => { + const today = new Date(2026, 3, 16); + expect(generateDateRange(today, 0, 0)).toEqual(["20260416"]); + }); + + it("handles month boundaries", () => { + const today = new Date(2026, 3, 1); // Apr 1 + const range = generateDateRange(today, 2, 0); + expect(range).toEqual(["20260330", "20260331", "20260401"]); + }); +}); + +describe("findPageIndex", () => { + it("finds page index for the given date at pageSize=7", () => { + const dates = ["20260414", "20260415", "20260416", "20260417", "20260418", "20260419", "20260420", "20260421", "20260422"]; + expect(findPageIndex(dates, "20260414", 7)).toBe(0); + expect(findPageIndex(dates, "20260420", 7)).toBe(0); + expect(findPageIndex(dates, "20260421", 7)).toBe(1); + expect(findPageIndex(dates, "20260422", 7)).toBe(1); + }); + + it("returns 0 when date not found", () => { + const dates = ["20260414", "20260415"]; + expect(findPageIndex(dates, "20260999", 7)).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run to verify fail** + +Run: `pnpm vitest run src/features/online-board/components/DayTabs/dateRange.test.ts` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement the helpers** + +```typescript +// dateRange.ts + +/** Format a Date as yyyymmdd (zero-padded). */ +export function formatYyyymmdd(d: Date): string { + const y = d.getFullYear().toString(); + const m = (d.getMonth() + 1).toString().padStart(2, "0"); + const day = d.getDate().toString().padStart(2, "0"); + return `${y}${m}${day}`; +} + +/** Parse a yyyymmdd string into a local Date. */ +export function parseYyyymmdd(s: string): Date { + const y = parseInt(s.slice(0, 4), 10); + const m = parseInt(s.slice(4, 6), 10) - 1; + const day = parseInt(s.slice(6, 8), 10); + return new Date(y, m, day); +} + +/** + * Generate an inclusive list of yyyymmdd strings spanning + * [base - daysBefore, base + daysAfter]. + */ +export function generateDateRange(base: Date, daysBefore: number, daysAfter: number): string[] { + const result: string[] = []; + for (let offset = -daysBefore; offset <= daysAfter; offset++) { + const d = new Date(base.getFullYear(), base.getMonth(), base.getDate() + offset); + result.push(formatYyyymmdd(d)); + } + return result; +} + +/** + * Find the page index (0-based) containing the given date, paginated by pageSize. + * Returns 0 if the date is not in the list. + */ +export function findPageIndex(dates: string[], target: string, pageSize: number): number { + const idx = dates.indexOf(target); + if (idx < 0) return 0; + return Math.floor(idx / pageSize); +} +``` + +- [ ] **Step 4: Verify pass** + +Run: `pnpm vitest run src/features/online-board/components/DayTabs/dateRange.test.ts` + +Expected: All 9 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/online-board/components/DayTabs/dateRange.ts src/features/online-board/components/DayTabs/dateRange.test.ts +git commit -m "Add date range helpers for day tabs" +``` + +--- + +### Task 2: `getAppSettings` API Function + +**Files:** +- Create: `src/shared/api/appSettings.ts` +- Create: `src/shared/api/appSettings.test.ts` + +- [ ] **Step 1: Write failing test** + +```typescript +// appSettings.test.ts +import { describe, it, expect, vi } from "vitest"; +import { getAppSettings } from "./appSettings.js"; +import type { ApiClient } from "./client.js"; + +describe("getAppSettings", () => { + it("calls /appSettings endpoint", async () => { + const mockResponse = { + uiOptions: { + filter: { + onlineboard: { searchFrom: "2d", searchTo: "14d" }, + schedule: { searchFrom: "30d", searchTo: "30d" }, + }, + }, + }; + const client = { + get: vi.fn().mockResolvedValue(mockResponse), + } as unknown as ApiClient; + + const result = await getAppSettings(client); + + expect(client.get).toHaveBeenCalledWith("/appSettings"); + expect(result).toEqual(mockResponse); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/shared/api/appSettings.test.ts` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Create `appSettings.ts`** + +```typescript +import type { ApiClient } from "./client.js"; + +export interface AppSettingsFilterOptions { + searchFrom?: string; // e.g. "2d" + searchTo?: string; // e.g. "14d" + timeStep?: string; +} + +export interface AppSettingsResponse { + showDebugVersion?: string; + uiOptions?: { + isTestVersion?: string; + filter?: { + onlineboard?: AppSettingsFilterOptions; + schedule?: AppSettingsFilterOptions; + }; + buttons?: Record; + }; +} + +/** + * Fetch the global UI configuration from the backend. Includes date range + * limits for online-board and schedule searches. + */ +export async function getAppSettings(client: ApiClient): Promise { + return client.get("/appSettings"); +} +``` + +- [ ] **Step 4: Verify pass** + +Run: `pnpm vitest run src/shared/api/appSettings.test.ts` + +Expected: 1 test passes. + +- [ ] **Step 5: Commit** + +```bash +git add src/shared/api/appSettings.ts src/shared/api/appSettings.test.ts +git commit -m "Add getAppSettings API function" +``` + +--- + +### Task 3: `useAppSettings` Hook + +**Files:** +- Create: `src/shared/hooks/useAppSettings.ts` +- Create: `src/shared/hooks/useAppSettings.test.ts` + +- [ ] **Step 1: Write failing test** + +```typescript +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { useAppSettings } from "./useAppSettings.js"; +import type { AppSettingsResponse } from "@/shared/api/appSettings.js"; + +const mockGetAppSettings = vi.fn(); + +vi.mock("@/shared/api/appSettings.js", () => ({ + getAppSettings: (...args: unknown[]) => mockGetAppSettings(...args), +})); + +vi.mock("@/shared/api/provider.js", () => ({ + useApiClient: () => ({ get: vi.fn() }), +})); + +describe("useAppSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("parses searchFrom/searchTo strings into numbers", async () => { + const response: AppSettingsResponse = { + uiOptions: { + filter: { + onlineboard: { searchFrom: "2d", searchTo: "14d" }, + schedule: { searchFrom: "30d", searchTo: "45d" }, + }, + }, + }; + mockGetAppSettings.mockResolvedValue(response); + + const { result } = renderHook(() => useAppSettings()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.onlineboardSearchFrom).toBe(2); + expect(result.current.onlineboardSearchTo).toBe(14); + expect(result.current.scheduleSearchFrom).toBe(30); + expect(result.current.scheduleSearchTo).toBe(45); + expect(result.current.error).toBeNull(); + }); + + it("returns defaults when uiOptions.filter is missing", async () => { + const response: AppSettingsResponse = {}; + mockGetAppSettings.mockResolvedValue(response); + + const { result } = renderHook(() => useAppSettings()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.onlineboardSearchFrom).toBe(2); + expect(result.current.onlineboardSearchTo).toBe(14); + expect(result.current.scheduleSearchFrom).toBe(30); + expect(result.current.scheduleSearchTo).toBe(30); + }); + + it("returns defaults when a specific value does not match /\\d+d/", async () => { + const response: AppSettingsResponse = { + uiOptions: { + filter: { + onlineboard: { searchFrom: "blah", searchTo: "14d" }, + schedule: { searchFrom: "30d", searchTo: "30d" }, + }, + }, + }; + mockGetAppSettings.mockResolvedValue(response); + + const { result } = renderHook(() => useAppSettings()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.onlineboardSearchFrom).toBe(2); // default + expect(result.current.onlineboardSearchTo).toBe(14); + }); + + it("starts with loading=true and returns error on failure", async () => { + const err = new Error("network"); + mockGetAppSettings.mockRejectedValue(err); + + const { result } = renderHook(() => useAppSettings()); + + expect(result.current.loading).toBe(true); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBe(err); + // Defaults still returned even on error + expect(result.current.onlineboardSearchFrom).toBe(2); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/shared/hooks/useAppSettings.test.ts` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement the hook** + +```typescript +// useAppSettings.ts +import { useEffect, useState } from "react"; +import { useApiClient } from "@/shared/api/provider.js"; +import { getAppSettings } from "@/shared/api/appSettings.js"; + +const DAYS_PATTERN = /^(\d+)d$/; + +const DEFAULTS = { + onlineboardSearchFrom: 2, + onlineboardSearchTo: 14, + scheduleSearchFrom: 30, + scheduleSearchTo: 30, +} as const; + +function parseDays(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const match = DAYS_PATTERN.exec(value); + if (!match) return fallback; + return parseInt(match[1]!, 10); +} + +export interface UseAppSettingsResult { + onlineboardSearchFrom: number; + onlineboardSearchTo: number; + scheduleSearchFrom: number; + scheduleSearchTo: number; + loading: boolean; + error: Error | null; +} + +/** + * Fetches the global app settings and exposes day-range numbers. + * On error or parse failure, returns default values (2/14/30/30). + */ +export function useAppSettings(): UseAppSettingsResult { + const client = useApiClient(); + const [state, setState] = useState>(DEFAULTS); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + getAppSettings(client) + .then((response) => { + if (cancelled) return; + const ob = response.uiOptions?.filter?.onlineboard; + const sc = response.uiOptions?.filter?.schedule; + setState({ + onlineboardSearchFrom: parseDays(ob?.searchFrom, DEFAULTS.onlineboardSearchFrom), + onlineboardSearchTo: parseDays(ob?.searchTo, DEFAULTS.onlineboardSearchTo), + scheduleSearchFrom: parseDays(sc?.searchFrom, DEFAULTS.scheduleSearchFrom), + scheduleSearchTo: parseDays(sc?.searchTo, DEFAULTS.scheduleSearchTo), + }); + setLoading(false); + }) + .catch((err: Error) => { + if (cancelled) return; + setError(err); + setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [client]); + + return { ...state, loading, error }; +} +``` + +- [ ] **Step 4: Verify pass** + +Run: `pnpm vitest run src/shared/hooks/useAppSettings.test.ts` + +Expected: All 4 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/shared/hooks/useAppSettings.ts src/shared/hooks/useAppSettings.test.ts +git commit -m "Add useAppSettings hook for parsing app config day ranges" +``` + +--- + +### Task 4: Extend `useFlightDetails` with `daysOfFlight` + +**Files:** +- Modify: `src/features/online-board/hooks/useFlightDetails.ts` +- Modify: `src/features/online-board/hooks/useFlightDetails.test.ts` + +- [ ] **Step 1: Add failing test** + +Append to the existing `useFlightDetails.test.ts`: + +```typescript +it("returns daysOfFlight from response", async () => { + const response: IBoardResponse = { + data: { + partners: [], + routes: [makeFlight("SU0022-20260416")], + daysOfFlight: ["20260415", "20260416", "20260417"], + }, + }; + // Mock appropriately per existing test setup (adjust import/mock strategy + // to match what this test file already uses). If the file mocks + // getFlightDetails via vi.mock, update the mock's return value to `response`. + + // ... call renderHook, wait for loading=false ... + // expect(result.current.daysOfFlight).toEqual(["20260415", "20260416", "20260417"]); +}); +``` + +**Note:** Read the existing `useFlightDetails.test.ts` first to match its mock pattern (it uses `vi.mock("@/shared/api/provider.js")` + `vi.mock("../api.js")`). Adapt the test to its existing style — the test body above is pseudocode, replace with a proper test following the file's convention. + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/hooks/useFlightDetails.test.ts` + +Expected: new test fails (property `daysOfFlight` missing). + +- [ ] **Step 3: Update `useFlightDetails.ts`** + +Edit the hook to track `daysOfFlight` alongside `allFlights`: + +```typescript +// Replace the existing hook body: +import { useState, useEffect, useRef } from "react"; +import { useApiClient } from "@/shared/api/provider.js"; +import { getFlightDetails } from "../api.js"; +import type { FlightDetailsParams } from "../api.js"; +import type { ISimpleFlight } from "../types.js"; +import type { ApiError } from "@/shared/api/errors.js"; + +export interface UseFlightDetailsResult { + flight: ISimpleFlight | null; + allFlights: ISimpleFlight[]; + daysOfFlight: string[]; + loading: boolean; + error: ApiError | null; +} + +export function useFlightDetails( + params: FlightDetailsParams, +): UseFlightDetailsResult { + const client = useApiClient(); + const [allFlights, setAllFlights] = useState([]); + const [daysOfFlight, setDaysOfFlight] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const paramsRef = useRef(params); + paramsRef.current = params; + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + + getFlightDetails(client, paramsRef.current) + .then((response) => { + if (!cancelled) { + setAllFlights(response.data.routes); + setDaysOfFlight(response.data.daysOfFlight ?? []); + setLoading(false); + } + }) + .catch((err: ApiError) => { + if (!cancelled) { + setError(err); + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [client, params.flights, params.dates]); + + return { + flight: allFlights[0] ?? null, + allFlights, + daysOfFlight, + loading, + error, + }; +} +``` + +- [ ] **Step 4: Verify pass** + +Run: `pnpm vitest run src/features/online-board/hooks/useFlightDetails.test.ts` + +Expected: All tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/online-board/hooks/useFlightDetails.ts src/features/online-board/hooks/useFlightDetails.test.ts +git commit -m "Expose daysOfFlight from useFlightDetails for day-tabs navigation" +``` + +--- + +### Task 5: DayTabButton Component + +**Files:** +- Create: `src/features/online-board/components/DayTabs/DayTabButton.tsx` +- Create: `src/features/online-board/components/DayTabs/DayTabButton.test.tsx` + +- [ ] **Step 1: Write failing tests** + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { DayTabButton } from "./DayTabButton.js"; + +describe("DayTabButton", () => { + it("has data-testid based on date", () => { + render( {}} />); + expect(screen.getByTestId("day-tab-20260416")).toBeTruthy(); + }); + + it("renders weekday, day number, and month", () => { + render( {}} />); + // Intl.DateTimeFormat for en-US gives "Apr", "16", "Thu" (Apr 16 2026 is a Thursday) + expect(screen.getByText("16")).toBeTruthy(); + expect(screen.getByText(/Apr/i)).toBeTruthy(); + expect(screen.getByText(/Thu/i)).toBeTruthy(); + }); + + it("calls onClick with date when enabled", () => { + const onClick = vi.fn(); + render(); + fireEvent.click(screen.getByTestId("day-tab-20260416")); + expect(onClick).toHaveBeenCalledWith("20260416"); + }); + + it("does not call onClick when disabled", () => { + const onClick = vi.fn(); + render(); + fireEvent.click(screen.getByTestId("day-tab-20260416")); + expect(onClick).not.toHaveBeenCalled(); + }); + + it("applies --active modifier when active", () => { + render( {}} />); + const el = screen.getByTestId("day-tab-20260416"); + expect(el.className).toMatch(/--active/); + }); + + it("applies --disabled modifier when disabled", () => { + render( {}} />); + const el = screen.getByTestId("day-tab-20260416"); + expect(el.className).toMatch(/--disabled/); + }); + + it("is disabled attribute set when disabled", () => { + render( {}} />); + const btn = screen.getByTestId("day-tab-20260416") as HTMLButtonElement; + expect(btn.disabled).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/DayTabs/DayTabButton.test.tsx` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Create `DayTabButton.tsx`** + +```tsx +import type { FC } from "react"; +import { parseYyyymmdd } from "./dateRange.js"; +import "./DayTabs.scss"; + +export interface DayTabButtonProps { + date: string; + isActive: boolean; + isDisabled: boolean; + locale: string; + onClick: (date: string) => void; +} + +export const DayTabButton: FC = ({ + date, + isActive, + isDisabled, + locale, + onClick, +}) => { + const d = parseYyyymmdd(date); + const weekday = new Intl.DateTimeFormat(locale, { weekday: "short" }).format(d); + const day = new Intl.DateTimeFormat(locale, { day: "numeric" }).format(d); + const month = new Intl.DateTimeFormat(locale, { month: "short" }).format(d); + + const classes = [ + "day-tab", + isActive ? "day-tab--active" : "", + isDisabled ? "day-tab--disabled" : "", + ] + .filter(Boolean) + .join(" "); + + return ( + + ); +}; +``` + +- [ ] **Step 4: Create `DayTabs.scss` (empty-ish for now — filled by later tasks)** + +```scss +.day-tab { + padding: 12px 8px; + text-align: center; + cursor: pointer; + background: #e8f0f7; + color: #2060c0; + border: none; + border-right: 1px solid #d0dae5; + + &:last-child { + border-right: none; + } + + &--active { + background: #fff; + color: #1a3a5c; + font-weight: 600; + } + + &--disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &__weekday { + font-size: 12px; + display: block; + } + + &__day { + font-size: 20px; + font-weight: 500; + display: block; + } + + &__month { + font-size: 12px; + display: block; + } +} +``` + +- [ ] **Step 5: Verify pass** + +Run: `pnpm vitest run src/features/online-board/components/DayTabs/DayTabButton.test.tsx` + +Expected: All 7 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/features/online-board/components/DayTabs/DayTabButton.tsx src/features/online-board/components/DayTabs/DayTabButton.test.tsx src/features/online-board/components/DayTabs/DayTabs.scss +git commit -m "Add DayTabButton component for day tabs navigator" +``` + +--- + +### Task 6: DaySelect (Mobile Dropdown) + +**Files:** +- Create: `src/features/online-board/components/DayTabs/DaySelect.tsx` +- Create: `src/features/online-board/components/DayTabs/DaySelect.test.tsx` + +- [ ] **Step 1: Write failing tests** + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { DaySelect } from "./DaySelect.js"; + +describe("DaySelect", () => { + it("renders a select with data-testid", () => { + render( {}} />); + expect(screen.getByTestId("day-select")).toBeTruthy(); + }); + + it("renders one option per available date", () => { + render( + {}} + />, + ); + const select = screen.getByTestId("day-select") as HTMLSelectElement; + expect(select.options).toHaveLength(3); + }); + + it("has selectedDate as select value", () => { + render( + {}} + />, + ); + const select = screen.getByTestId("day-select") as HTMLSelectElement; + expect(select.value).toBe("20260416"); + }); + + it("fires onNavigate with new date on change", () => { + const onNavigate = vi.fn(); + render( + , + ); + fireEvent.change(screen.getByTestId("day-select"), { target: { value: "20260417" } }); + expect(onNavigate).toHaveBeenCalledWith("20260417"); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/DayTabs/DaySelect.test.tsx` + +Expected: FAIL. + +- [ ] **Step 3: Create `DaySelect.tsx`** + +```tsx +import type { FC } from "react"; +import { parseYyyymmdd } from "./dateRange.js"; +import "./DayTabs.scss"; + +export interface DaySelectProps { + selectedDate: string; + availableDates: string[]; + locale: string; + onNavigate: (date: string) => void; +} + +function formatLabel(date: string, locale: string): string { + const d = parseYyyymmdd(date); + return new Intl.DateTimeFormat(locale, { + weekday: "short", + month: "short", + day: "numeric", + }).format(d); +} + +export const DaySelect: FC = ({ + selectedDate, + availableDates, + locale, + onNavigate, +}) => { + return ( + + ); +}; +``` + +- [ ] **Step 4: Add mobile-only styles to `DayTabs.scss`** + +Append to `src/features/online-board/components/DayTabs/DayTabs.scss`: + +```scss +.day-select { + display: none; + + @media (max-width: 768px) { + display: block; + width: 100%; + padding: 12px; + border-radius: 8px; + background: #fff; + border: 1px solid #d0dae5; + } +} +``` + +- [ ] **Step 5: Verify pass** + +Run: `pnpm vitest run src/features/online-board/components/DayTabs/DaySelect.test.tsx` + +Expected: All 4 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/features/online-board/components/DayTabs/DaySelect.tsx src/features/online-board/components/DayTabs/DaySelect.test.tsx src/features/online-board/components/DayTabs/DayTabs.scss +git commit -m "Add DaySelect component for mobile day navigation" +``` + +--- + +### Task 7: DayTabs Container + +**Files:** +- Create: `src/features/online-board/components/DayTabs/DayTabs.tsx` +- Create: `src/features/online-board/components/DayTabs/DayTabs.test.tsx` +- Create: `src/features/online-board/components/DayTabs/index.ts` + +- [ ] **Step 1: Write failing tests** + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { DayTabs } from "./DayTabs.js"; + +describe("DayTabs", () => { + // Fix "today" so generated ranges are deterministic + const FIXED_TODAY = new Date(2026, 3, 16); // Apr 16 2026 + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(FIXED_TODAY); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders container with data-testid", () => { + render( + {}} + />, + ); + expect(screen.getByTestId("day-tabs")).toBeTruthy(); + }); + + it("renders 7 tabs on first page", () => { + render( + { + const d = new Date(2026, 3, 14 + i); + return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`; + })} + daysBefore={2} + daysAfter={14} + locale="en" + onNavigate={() => {}} + />, + ); + const tabs = screen.getAllByTestId(/^day-tab-\d{8}$/); + expect(tabs).toHaveLength(7); + }); + + it("disables tabs not in availableDates", () => { + render( + {}} + />, + ); + const unavailable = screen.getByTestId("day-tab-20260414") as HTMLButtonElement; + const available = screen.getByTestId("day-tab-20260415") as HTMLButtonElement; + expect(unavailable.disabled).toBe(true); + expect(available.disabled).toBe(false); + }); + + it("prev arrow disabled on first page", () => { + render( + {}} + />, + ); + const prev = screen.getByTestId("day-tabs-prev") as HTMLButtonElement; + expect(prev.disabled).toBe(true); + }); + + it("next arrow changes page", () => { + render( + { + const d = new Date(2026, 3, 14 + i); + return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`; + })} + daysBefore={2} + daysAfter={14} + locale="en" + onNavigate={() => {}} + />, + ); + // First page: 20260414..20260420 + expect(screen.queryByTestId("day-tab-20260421")).toBeNull(); + + fireEvent.click(screen.getByTestId("day-tabs-next")); + + // Second page: 20260421..20260427 + expect(screen.getByTestId("day-tab-20260421")).toBeTruthy(); + expect(screen.queryByTestId("day-tab-20260414")).toBeNull(); + }); + + it("clicking an available tab calls onNavigate", () => { + const onNavigate = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByTestId("day-tab-20260417")); + expect(onNavigate).toHaveBeenCalledWith("20260417"); + }); + + it("initializes currentPage to the page containing selectedDate", () => { + // Today = 20260416, daysBefore=2, daysAfter=14 + // Full range: 20260414..20260430 (17 days) + // Page 0: 20260414..20260420 + // Page 1: 20260421..20260427 + // Page 2: 20260428..20260430 + // selectedDate=20260425 is on page 1 + const available = Array.from({ length: 17 }, (_, i) => { + const d = new Date(2026, 3, 14 + i); + return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`; + }); + + render( + {}} + />, + ); + + // Expect page 1 tabs visible (20260421..20260427) + expect(screen.getByTestId("day-tab-20260425")).toBeTruthy(); + expect(screen.queryByTestId("day-tab-20260414")).toBeNull(); + }); + + it("renders DaySelect for mobile (via data-testid)", () => { + render( + {}} + />, + ); + expect(screen.getByTestId("day-select")).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Verify fail** + +Run: `pnpm vitest run src/features/online-board/components/DayTabs/DayTabs.test.tsx` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Create `DayTabs.tsx`** + +```tsx +import { type FC, useMemo, useState } from "react"; +import { DayTabButton } from "./DayTabButton.js"; +import { DaySelect } from "./DaySelect.js"; +import { generateDateRange, findPageIndex } from "./dateRange.js"; +import "./DayTabs.scss"; + +const PAGE_SIZE = 7; + +export interface DayTabsProps { + selectedDate: string; + availableDates: string[]; + daysBefore: number; + daysAfter: number; + locale: string; + onNavigate: (date: string) => void; +} + +export const DayTabs: FC = ({ + selectedDate, + availableDates, + daysBefore, + daysAfter, + locale, + onNavigate, +}) => { + const allDates = useMemo( + () => generateDateRange(new Date(), daysBefore, daysAfter), + [daysBefore, daysAfter], + ); + + const totalPages = Math.max(1, Math.ceil(allDates.length / PAGE_SIZE)); + + const initialPage = useMemo( + () => findPageIndex(allDates, selectedDate, PAGE_SIZE), + [allDates, selectedDate], + ); + + const [currentPage, setCurrentPage] = useState(initialPage); + + const availableSet = useMemo(() => new Set(availableDates), [availableDates]); + + const visibleDates = allDates.slice( + currentPage * PAGE_SIZE, + (currentPage + 1) * PAGE_SIZE, + ); + + const canGoPrev = currentPage > 0; + const canGoNext = currentPage < totalPages - 1; + + return ( +
+
+ +
+ {visibleDates.map((date) => ( + + ))} +
+ +
+ +
+ ); +}; +``` + +- [ ] **Step 4: Add container styles** + +Append to `src/features/online-board/components/DayTabs/DayTabs.scss`: + +```scss +.day-tabs-wrap { + margin-bottom: 8px; +} + +.day-tabs { + display: flex; + align-items: stretch; + background: #e8f0f7; + border-radius: 8px 8px 0 0; + + &__arrow { + width: 48px; + flex-shrink: 0; + background: transparent; + border: none; + cursor: pointer; + color: #2060c0; + font-size: 20px; + + &:disabled { + opacity: 0.3; + cursor: not-allowed; + } + } + + &__list { + flex: 1; + display: grid; + grid-template-columns: repeat(7, 1fr); + } + + @media (max-width: 768px) { + display: none; + } +} +``` + +- [ ] **Step 5: Create `index.ts`** + +```typescript +export { DayTabs } from "./DayTabs.js"; +export type { DayTabsProps } from "./DayTabs.js"; +export { DayTabButton } from "./DayTabButton.js"; +export type { DayTabButtonProps } from "./DayTabButton.js"; +export { DaySelect } from "./DaySelect.js"; +export type { DaySelectProps } from "./DaySelect.js"; +``` + +- [ ] **Step 6: Verify pass** + +Run: `pnpm vitest run src/features/online-board/components/DayTabs/DayTabs.test.tsx` + +Expected: All 8 tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/features/online-board/components/DayTabs/DayTabs.tsx src/features/online-board/components/DayTabs/DayTabs.test.tsx src/features/online-board/components/DayTabs/DayTabs.scss src/features/online-board/components/DayTabs/index.ts +git commit -m "Add DayTabs container with pagination and auto-scroll-to-active" +``` + +--- + +### Task 8: Wire DayTabs into OnlineBoardDetailsPage + +**Files:** +- Modify: `src/features/online-board/components/OnlineBoardDetailsPage.tsx` +- Modify: `src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` + +- [ ] **Step 1: Add integration test** + +Append to `OnlineBoardDetailsPage.test.tsx`: + +```tsx +describe("day tabs integration", () => { + it("renders DayTabs as sticky content", () => { + mockState = { flight: mockFlight, allFlights: [mockFlight], daysOfFlight: ["20260416"], loading: false, error: null }; + render(); + expect(screen.getByTestId("day-tabs")).toBeTruthy(); + }); +}); +``` + +**Note:** Update every `mockState =` assignment in the file to include `daysOfFlight`. Also mock `useAppSettings` at the top of the file (matching existing mock patterns): + +```tsx +vi.mock("@/shared/hooks/useAppSettings.js", () => ({ + useAppSettings: () => ({ + onlineboardSearchFrom: 2, + onlineboardSearchTo: 14, + scheduleSearchFrom: 30, + scheduleSearchTo: 30, + loading: false, + error: null, + }), +})); +``` + +- [ ] **Step 2: Verify test fails** + +Run: `pnpm vitest run src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` + +Expected: New test fails (no `day-tabs` testid). + +Also: existing tests might fail because `daysOfFlight` missing from mock state. Update them as part of this step. + +- [ ] **Step 3: Wire DayTabs into the page** + +Edit `src/features/online-board/components/OnlineBoardDetailsPage.tsx`. Add imports: + +```tsx +import { useCallback } from "react"; +import { useNavigate } from "@modern-js/runtime/router"; +import { useAppSettings } from "@/shared/hooks/useAppSettings.js"; +import { DayTabs } from "./DayTabs/index.js"; +import { buildOnlineBoardUrl } from "../url.js"; +``` + +(Note: if `useCallback`/`useNavigate`/`buildOnlineBoardUrl` already imported, skip those.) + +Inside the component, after `useFlightDetails`: + +```tsx +const { onlineboardSearchFrom, onlineboardSearchTo } = useAppSettings(); +const navigate = useNavigate(); + +const handleNavigateDate = useCallback((newDate: string) => { + const url = buildOnlineBoardUrl({ + type: "details", + carrier: flightId.carrier, + flightNumber: flightId.flightNumber, + ...(flightId.suffix ? { suffix: flightId.suffix } : {}), + date: newDate, + }); + void navigate(`/${locale}/${url}`); +}, [flightId.carrier, flightId.flightNumber, flightId.suffix, locale, navigate]); +``` + +Destructure `daysOfFlight` from `useFlightDetails`: + +```tsx +const { flight: firstFlight, allFlights, daysOfFlight, loading, error } = useFlightDetails(detailsParams); +``` + +In the `PageLayout` rendering, add `stickyContent`: + +```tsx +} + title={...} + breadcrumbs={...} + contentLeft={} + stickyContent={ + + } +> +``` + +**Important:** Also update the loading/error/not-found branches that return a PageLayout — they currently don't include `stickyContent`. Don't add `DayTabs` to those branches since `daysOfFlight` may be empty during loading. + +- [ ] **Step 4: Verify tests pass** + +Run: `pnpm vitest run src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` + +Expected: All tests pass. + +- [ ] **Step 5: Full suite + typecheck** + +Run: `pnpm test` +Run: `pnpm typecheck` + +Expected: No new failures or type errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/features/online-board/components/OnlineBoardDetailsPage.tsx src/features/online-board/components/OnlineBoardDetailsPage.test.tsx +git commit -m "Wire DayTabs into OnlineBoardDetailsPage stickyContent" +``` + +--- + +### Task 9: Manual Browser Verification + +**Files:** None — observational. + +- [ ] **Step 1: Ensure both dev servers are running** + +- Angular: `cd ClientApp && NODE_OPTIONS=--openssl-legacy-provider npx ng serve --port 4200` +- React: `pnpm dev:full` (provides proxy for `/api/appSettings`) + +- [ ] **Step 2: Playwright verification script** + +Run: + +```bash +cat << 'SCRIPT' | npx tsx --input-type=module - +import { chromium } from "@playwright/test"; +import { mockAngularAPIs } from "./tests/e2e-angular/support/angular-api-mock.js"; + +const browser = await chromium.launch({ headless: true }); +const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); +const page = await ctx.newPage(); +await mockAngularAPIs(page); + +// Return a response with multiple daysOfFlight +await page.route("**/onlineboard/details*", (route) => { + route.fulfill({ + status: 200, contentType: "application/json", + body: JSON.stringify({ + data: { + partners: [], + routes: [{ + id: "SU0022-20260417", routeType: "Direct", flyingTime: "1h 30m", status: "Scheduled", + flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: "20260417", dateLT: "20260417" }, + operatingBy: { carrier: "SU", flightNumber: "0022" }, + leg: { + index: 0, flyingTime: "1h 30m", status: "Scheduled", updated: "", dayChange: 0, + flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false }, + operatingBy: { carrier: "SU", flightNumber: "0022" }, + departure: { + scheduled: { airport: "SVO", airportCode: "SVO", city: "MOW", cityCode: "MOW", countryCode: "RU" }, + latest: { airport: "SVO", airportCode: "SVO", city: "MOW", cityCode: "MOW", countryCode: "RU" }, + dispatch: "", gate: "", terminal: "", checkingStatus: "Scheduled", parkingStand: "", + times: { scheduledDeparture: { dayChange: { value: 0, title: "" }, local: "10:00", localTime: "10:00", tzOffset: 3, utc: "07:00" } }, + }, + arrival: { + scheduled: { airport: "LED", airportCode: "LED", city: "SPB", cityCode: "LED", countryCode: "RU" }, + latest: { airport: "LED", airportCode: "LED", city: "SPB", cityCode: "LED", countryCode: "RU" }, + dispatch: "", gate: "", terminal: "", + times: { scheduledArrival: { dayChange: { value: 0, title: "" }, local: "12:30", localTime: "12:30", tzOffset: 3, utc: "09:30" } }, + }, + equipment: {}, + }, + }], + daysOfFlight: ["20260415", "20260416", "20260417", "20260418", "20260420"], + }, + }), + }); +}); + +await page.goto("http://localhost:8080/ru/onlineboard/SU0022-20260417", { waitUntil: "networkidle" }); +await page.waitForTimeout(3000); + +const dayTabs = await page.locator('[data-testid="day-tabs"]').count(); +console.log("DayTabs container:", dayTabs); + +const tabs = await page.locator('[data-testid^="day-tab-"]').evaluateAll(els => + els.map(el => ({ + testid: el.getAttribute("data-testid"), + disabled: (el as HTMLButtonElement).disabled, + active: el.className.includes("--active"), + })), +); +console.log("Tabs:", tabs); + +// Click a different date and check URL changed +const urlBefore = page.url(); +await page.locator('[data-testid="day-tab-20260418"]').click(); +await page.waitForTimeout(1500); +const urlAfter = page.url(); +console.log("URL changed:", urlBefore !== urlAfter, urlAfter); + +await page.screenshot({ path: "/tmp/b3-daytabs-verify.png", fullPage: true }); +console.log("Screenshot: /tmp/b3-daytabs-verify.png"); + +await browser.close(); +SCRIPT +``` + +Expected output: +- `DayTabs container: 1` +- `Tabs:` — array of 7 tab objects with correct `disabled` flags (20260419 should be disabled, rest enabled) +- Active flag `true` on `day-tab-20260417` +- URL changed: `true` — now ends in `/SU0022-20260418` + +- [ ] **Step 3: No commit — observational**