Add day tabs (B.3) design spec

This commit is contained in:
2026-04-16 23:59:49 +03:00
parent b2a6770143
commit 95b3a909b0
@@ -0,0 +1,306 @@
# Day Tabs (B.3)
## Goal
Add a horizontal row of day tabs above the main flight details content, letting users navigate the same flight number across different dates. Matches Angular's behavior: paginated carousel on desktop (7 days per page), dropdown on mobile, tabs disabled for dates not operating, auto-scroll to the active tab on mount.
## Scope
Sub-feature **B.3** of the Flight Details parity work. Online Board only; Schedule day tabs are a separate concern.
## Current State
The React details page has no date navigation. `useFlightDetails` discards the `daysOfFlight` array from the API response. The app has no `useAppSettings` hook (the dev-server mocks the endpoint but nothing consumes it).
## Approach
Four additions:
1. **`useAppSettings` hook** — fetches `/api/appSettings`, parses `searchFrom`/`searchTo` strings (e.g., `"2d"``2`), caches across the app.
2. **Extend `useFlightDetails`** — also return `daysOfFlight: string[]` from the response.
3. **`DayTabs` component family** (container, `DayTabButton`, `DaySelect`) — generates tabs from `today - searchFrom` to `today + searchTo`, disables dates not in `daysOfFlight`, paginates 7 at a time, auto-scrolls to the page containing the selected date.
4. **Integrate into `OnlineBoardDetailsPage`** — render `DayTabs` as `stickyContent` in `PageLayout`. Click handler navigates to `/lang/onlineboard/{carrier}{flight}-{yyyymmdd}`.
## Architecture
### Hook: `useAppSettings`
New hook at `src/shared/hooks/useAppSettings.ts`.
```typescript
export interface UseAppSettingsResult {
onlineboardSearchFrom: number; // days before today, e.g. 2
onlineboardSearchTo: number; // days after today, e.g. 14
scheduleSearchFrom: number;
scheduleSearchTo: number;
loading: boolean;
error: ApiError | null;
}
export function useAppSettings(): UseAppSettingsResult;
```
Implementation fetches the `/api/appSettings` endpoint via the existing API client. Parses strings matching `/^(\d+)d$/` into numbers. Returns zeros (with `loading: false, error`) on parse failure or missing fields.
Fallback defaults when values are unavailable: `onlineboardSearchFrom: 2, onlineboardSearchTo: 14, scheduleSearchFrom: 30, scheduleSearchTo: 30`.
### Hook extension: `useFlightDetails`
Add `daysOfFlight: string[]` to the return. Same pattern as `allFlights` (stored from `response.data.daysOfFlight`).
```typescript
export interface UseFlightDetailsResult {
flight: ISimpleFlight | null;
allFlights: ISimpleFlight[];
daysOfFlight: string[]; // NEW
loading: boolean;
error: ApiError | null;
}
```
### Component: `DayTabs` (container)
File: `src/features/online-board/components/DayTabs/DayTabs.tsx`.
```typescript
export interface DayTabsProps {
selectedDate: string; // yyyymmdd, matching URL
availableDates: string[]; // yyyymmdd list from daysOfFlight
daysBefore: number;
daysAfter: number;
onNavigate: (date: string) => void;
}
```
Logic:
- Compute `allDates`: array of yyyymmdd strings spanning `today - daysBefore` to `today + daysAfter` inclusive.
- `availableSet = new Set(availableDates)` for O(1) lookup.
- `pageSize = 7`.
- Initialize `currentPage` state to the page index containing `selectedDate` (or 0 if not found).
- Visible slice = `allDates.slice(currentPage * 7, (currentPage + 1) * 7)`.
- Prev/next arrows call `setCurrentPage(n - 1)` / `setCurrentPage(n + 1)` clamped to `[0, totalPages - 1]`.
- Renders `DayTabButton` for each visible date.
- Renders a single `DaySelect` alongside (hidden on desktop via CSS).
### Component: `DayTabButton`
File: `src/features/online-board/components/DayTabs/DayTabButton.tsx`.
```typescript
export interface DayTabButtonProps {
date: string; // yyyymmdd
isActive: boolean;
isDisabled: boolean;
locale: string; // for weekday + month labels
onClick: (date: string) => void;
}
```
Renders a button showing:
- Weekday short (`Intl.DateTimeFormat(locale, { weekday: "short" })`)
- Day number (`Intl.DateTimeFormat(locale, { day: "numeric" })`)
- Month short (`Intl.DateTimeFormat(locale, { month: "short" })`)
Click calls `onClick(date)` unless `isDisabled`. Applies `.day-tab--active` / `.day-tab--disabled` modifiers. `data-testid="day-tab-{yyyymmdd}"`.
### Component: `DaySelect` (mobile)
File: `src/features/online-board/components/DayTabs/DaySelect.tsx`.
```typescript
export interface DaySelectProps {
selectedDate: string;
availableDates: string[];
locale: string;
onNavigate: (date: string) => void;
}
```
Native `<select>` with one `<option value="{yyyymmdd}">{label}</option>` per available date. Disabled dates (outside `availableDates`) are omitted entirely. `onChange` calls `onNavigate(e.target.value)`. `data-testid="day-select"`.
Label format: `"Mon, Apr 16"` or locale equivalent.
### Integration
In `OnlineBoardDetailsPage.tsx`:
```tsx
const { flight, allFlights, daysOfFlight, loading, error } = useFlightDetails(detailsParams);
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,
});
navigate(`/${locale}/${url}`);
}, [flightId, locale, navigate]);
// In the PageLayout:
<PageLayout
...
stickyContent={
<DayTabs
selectedDate={flightId.date}
availableDates={daysOfFlight}
daysBefore={onlineboardSearchFrom}
daysAfter={onlineboardSearchTo}
onNavigate={handleNavigateDate}
/>
}
>
```
`PageLayout` already has a `stickyContent` slot.
## Styling
`DayTabs.scss`:
```scss
.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;
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
&__list {
flex: 1;
display: grid;
grid-template-columns: repeat(7, 1fr);
}
@media (max-width: 768px) {
display: none;
}
}
.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; }
}
.day-select {
display: none;
@media (max-width: 768px) {
display: block;
width: 100%;
padding: 12px;
border-radius: 8px;
background: #fff;
border: 1px solid #d0dae5;
}
}
```
## Testing
### `useAppSettings.test.ts`
- Parses `"2d"``2`, `"14d"``14`
- Returns `loading: true` before response
- Returns `error` when fetch fails
- Returns defaults (2/14/30/30) when `uiOptions.filter.*` fields are missing
- Handles non-matching patterns (`"blah"`) as default
### `DayTabButton.test.tsx`
- Renders weekday short, day number, month short
- Click fires `onClick(date)` when enabled
- Click does nothing when disabled
- Applies `--active` / `--disabled` modifiers correctly
- Has `data-testid="day-tab-{yyyymmdd}"`
### `DayTabs.test.tsx`
- Renders 7 tabs on first page
- Dates outside `availableDates` are disabled
- Prev arrow disabled on page 0
- Next arrow disabled on last page
- Prev/next arrows change page
- Initializes `currentPage` to the page containing `selectedDate`
- Click on a day tab fires `onNavigate(date)`
- Has `data-testid="day-tabs"`
### `DaySelect.test.tsx`
- Renders one `<option>` per date in `availableDates`
- `<select>` value is `selectedDate`
- onChange fires `onNavigate(new_date)`
- Has `data-testid="day-select"`
### `useFlightDetails.test.ts` (extend)
- Response with `daysOfFlight: ["20260415", "20260416"]` → hook returns that array
- Empty response → `daysOfFlight: []`
### `OnlineBoardDetailsPage.test.tsx` (update)
- `stickyContent` includes `DayTabs` (look for `data-testid="day-tabs"`)
- Existing accordion + mini-list tests still pass
## i18n Keys
No new keys. `DayTabButton` and `DaySelect` generate labels entirely from `Intl.DateTimeFormat(locale, ...)`. Locale comes from the existing `locale` prop.
## Out of Scope
- Schedule day tabs (different data source).
- Fetching `daysOfFlight` from a separate endpoint.
- Keyboard navigation between tabs (left/right arrow keys).
- Custom month/weekday labels beyond `Intl` defaults.
## Files Touched
### New
- `src/shared/hooks/useAppSettings.ts`
- `src/shared/hooks/useAppSettings.test.ts`
- `src/features/online-board/components/DayTabs/DayTabs.tsx`
- `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`
- `src/features/online-board/components/DayTabs/DayTabButton.test.tsx`
- `src/features/online-board/components/DayTabs/DaySelect.tsx`
- `src/features/online-board/components/DayTabs/DaySelect.test.tsx`
- `src/features/online-board/components/DayTabs/index.ts`
### Modified
- `src/features/online-board/hooks/useFlightDetails.ts` — add `daysOfFlight` to return
- `src/features/online-board/hooks/useFlightDetails.test.ts` — add test case
- `src/features/online-board/components/OnlineBoardDetailsPage.tsx` — wire DayTabs into PageLayout.stickyContent
- `src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` — update existing tests for new structure