Add day tabs (B.3) design spec
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user