# 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<string, unknown>;
  };
}

/**
 * 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<AppSettingsResponse> {
  return client.get<AppSettingsResponse>("/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<Omit<UseAppSettingsResult, "loading" | "error">>(DEFAULTS);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(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<ISimpleFlight[]>([]);
  const [daysOfFlight, setDaysOfFlight] = useState<string[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<ApiError | null>(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(<DayTabButton date="20260416" isActive={false} isDisabled={false} locale="en" onClick={() => {}} />);
    expect(screen.getByTestId("day-tab-20260416")).toBeTruthy();
  });

  it("renders weekday, day number, and month", () => {
    render(<DayTabButton date="20260416" isActive={false} isDisabled={false} locale="en" onClick={() => {}} />);
    // 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(<DayTabButton date="20260416" isActive={false} isDisabled={false} locale="en" onClick={onClick} />);
    fireEvent.click(screen.getByTestId("day-tab-20260416"));
    expect(onClick).toHaveBeenCalledWith("20260416");
  });

  it("does not call onClick when disabled", () => {
    const onClick = vi.fn();
    render(<DayTabButton date="20260416" isActive={false} isDisabled={true} locale="en" onClick={onClick} />);
    fireEvent.click(screen.getByTestId("day-tab-20260416"));
    expect(onClick).not.toHaveBeenCalled();
  });

  it("applies --active modifier when active", () => {
    render(<DayTabButton date="20260416" isActive={true} isDisabled={false} locale="en" onClick={() => {}} />);
    const el = screen.getByTestId("day-tab-20260416");
    expect(el.className).toMatch(/--active/);
  });

  it("applies --disabled modifier when disabled", () => {
    render(<DayTabButton date="20260416" isActive={false} isDisabled={true} locale="en" onClick={() => {}} />);
    const el = screen.getByTestId("day-tab-20260416");
    expect(el.className).toMatch(/--disabled/);
  });

  it("is disabled attribute set when disabled", () => {
    render(<DayTabButton date="20260416" isActive={false} isDisabled={true} locale="en" onClick={() => {}} />);
    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<DayTabButtonProps> = ({
  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 (
    <button
      type="button"
      className={classes}
      disabled={isDisabled}
      data-testid={`day-tab-${date}`}
      onClick={() => {
        if (!isDisabled) onClick(date);
      }}
    >
      <span className="day-tab__weekday">{weekday}</span>
      <span className="day-tab__day">{day}</span>
      <span className="day-tab__month">{month}</span>
    </button>
  );
};
```

- [ ] **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(<DaySelect selectedDate="20260416" availableDates={["20260416"]} locale="en" onNavigate={() => {}} />);
    expect(screen.getByTestId("day-select")).toBeTruthy();
  });

  it("renders one option per available date", () => {
    render(
      <DaySelect
        selectedDate="20260416"
        availableDates={["20260415", "20260416", "20260417"]}
        locale="en"
        onNavigate={() => {}}
      />,
    );
    const select = screen.getByTestId("day-select") as HTMLSelectElement;
    expect(select.options).toHaveLength(3);
  });

  it("has selectedDate as select value", () => {
    render(
      <DaySelect
        selectedDate="20260416"
        availableDates={["20260415", "20260416", "20260417"]}
        locale="en"
        onNavigate={() => {}}
      />,
    );
    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(
      <DaySelect
        selectedDate="20260416"
        availableDates={["20260415", "20260416", "20260417"]}
        locale="en"
        onNavigate={onNavigate}
      />,
    );
    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<DaySelectProps> = ({
  selectedDate,
  availableDates,
  locale,
  onNavigate,
}) => {
  return (
    <select
      className="day-select"
      data-testid="day-select"
      value={selectedDate}
      onChange={(e) => onNavigate(e.target.value)}
    >
      {availableDates.map((date) => (
        <option key={date} value={date}>
          {formatLabel(date, locale)}
        </option>
      ))}
    </select>
  );
};
```

- [ ] **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(
      <DayTabs
        selectedDate="20260416"
        availableDates={["20260416"]}
        daysBefore={2}
        daysAfter={14}
        locale="en"
        onNavigate={() => {}}
      />,
    );
    expect(screen.getByTestId("day-tabs")).toBeTruthy();
  });

  it("renders 7 tabs on first page", () => {
    render(
      <DayTabs
        selectedDate="20260414"  // on first page
        availableDates={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")}`;
        })}
        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(
      <DayTabs
        selectedDate="20260414"
        availableDates={["20260415"]}  // only one available
        daysBefore={2}
        daysAfter={4}
        locale="en"
        onNavigate={() => {}}
      />,
    );
    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(
      <DayTabs
        selectedDate="20260414"
        availableDates={["20260414"]}
        daysBefore={2}
        daysAfter={14}
        locale="en"
        onNavigate={() => {}}
      />,
    );
    const prev = screen.getByTestId("day-tabs-prev") as HTMLButtonElement;
    expect(prev.disabled).toBe(true);
  });

  it("next arrow changes page", () => {
    render(
      <DayTabs
        selectedDate="20260414"  // page 0
        availableDates={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")}`;
        })}
        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(
      <DayTabs
        selectedDate="20260416"
        availableDates={["20260415", "20260416", "20260417"]}
        daysBefore={2}
        daysAfter={4}
        locale="en"
        onNavigate={onNavigate}
      />,
    );
    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(
      <DayTabs
        selectedDate="20260425"
        availableDates={available}
        daysBefore={2}
        daysAfter={14}
        locale="en"
        onNavigate={() => {}}
      />,
    );

    // 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(
      <DayTabs
        selectedDate="20260416"
        availableDates={["20260416"]}
        daysBefore={2}
        daysAfter={14}
        locale="en"
        onNavigate={() => {}}
      />,
    );
    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<DayTabsProps> = ({
  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 (
    <div className="day-tabs-wrap" data-testid="day-tabs">
      <div className="day-tabs">
        <button
          type="button"
          className="day-tabs__arrow"
          data-testid="day-tabs-prev"
          disabled={!canGoPrev}
          onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
          aria-label="Previous page"
        >
          &lsaquo;
        </button>
        <div className="day-tabs__list">
          {visibleDates.map((date) => (
            <DayTabButton
              key={date}
              date={date}
              isActive={date === selectedDate}
              isDisabled={!availableSet.has(date)}
              locale={locale}
              onClick={onNavigate}
            />
          ))}
        </div>
        <button
          type="button"
          className="day-tabs__arrow"
          data-testid="day-tabs-next"
          disabled={!canGoNext}
          onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
          aria-label="Next page"
        >
          &rsaquo;
        </button>
      </div>
      <DaySelect
        selectedDate={selectedDate}
        availableDates={availableDates}
        locale={locale}
        onNavigate={onNavigate}
      />
    </div>
  );
};
```

- [ ] **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(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://example.com" />);
    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
<PageLayout
  headerLeft={<PageTabs viewType="onlineboard" />}
  title={...}
  breadcrumbs={...}
  contentLeft={<FlightsMiniList ... />}
  stickyContent={
    <DayTabs
      selectedDate={flightId.date}
      availableDates={daysOfFlight}
      daysBefore={onlineboardSearchFrom}
      daysAfter={onlineboardSearchTo}
      locale={locale}
      onNavigate={handleNavigateDate}
    />
  }
>
```

**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**
