Add back button + flight schedule timeline (B.6) implementation plan
This commit is contained in:
@@ -0,0 +1,953 @@
|
||||
# Back Button + Flight Schedule Timeline (B.6) 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:** Add Angular-parity navigation (back button in header) and a schedule timeline (accordion with scheduled times + duration + days-of-week strip + week date-range note) to the React Online Board details page.
|
||||
|
||||
**Architecture:** Two independent feature directories. `DetailsBackButton` is a simple `<Link>`. `FlightSchedule` is a PrimeReact `Accordion` wrapping a Schedule tab, plus a persistent days-of-week strip below. A small `weekDateRange` helper uses `date-fns` ISO week functions. `IFlightLeg` gains an optional `daysOfWeek` field.
|
||||
|
||||
**Tech Stack:** React 18, PrimeReact `Accordion` + `AccordionTab`, `date-fns` (`startOfISOWeek`, `endOfISOWeek`, `parseISO`, `format`), React Router `<Link>`, Vitest + React Testing Library.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### New files
|
||||
|
||||
- `src/features/online-board/components/DetailsBackButton/DetailsBackButton.tsx`
|
||||
- `src/features/online-board/components/DetailsBackButton/DetailsBackButton.scss`
|
||||
- `src/features/online-board/components/DetailsBackButton/DetailsBackButton.test.tsx`
|
||||
- `src/features/online-board/components/DetailsBackButton/index.ts`
|
||||
- `src/features/online-board/components/FlightSchedule/weekDateRange.ts`
|
||||
- `src/features/online-board/components/FlightSchedule/weekDateRange.test.ts`
|
||||
- `src/features/online-board/components/FlightSchedule/DaysOfWeekStrip.tsx`
|
||||
- `src/features/online-board/components/FlightSchedule/DaysOfWeekStrip.test.tsx`
|
||||
- `src/features/online-board/components/FlightSchedule/FlightSchedule.tsx`
|
||||
- `src/features/online-board/components/FlightSchedule/FlightSchedule.scss`
|
||||
- `src/features/online-board/components/FlightSchedule/FlightSchedule.test.tsx`
|
||||
- `src/features/online-board/components/FlightSchedule/index.ts`
|
||||
|
||||
### Modified files
|
||||
|
||||
- `src/features/online-board/types.ts` — add `IDaysOfWeek`, extend `IFlightLeg` with optional `daysOfWeek`
|
||||
- `src/features/online-board/types.test.ts` — add test case
|
||||
- `src/features/online-board/components/OnlineBoardDetailsPage.tsx` — swap PageTabs→DetailsBackButton, insert FlightSchedule
|
||||
- `src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` — update mocks/assertions
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Extend `IFlightLeg` with `daysOfWeek`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/features/online-board/types.ts`
|
||||
- Modify: `src/features/online-board/types.test.ts`
|
||||
|
||||
- [ ] **Step 1: Add failing test**
|
||||
|
||||
Append to `src/features/online-board/types.test.ts` inside the existing `describe("online-board types extension")` block:
|
||||
|
||||
```typescript
|
||||
it("IDaysOfWeek has current and flight bit strings", () => {
|
||||
const d: IDaysOfWeek = { current: "1000010", flight: "1111111" };
|
||||
expect(d.current).toBe("1000010");
|
||||
expect(d.flight).toBe("1111111");
|
||||
});
|
||||
|
||||
it("IFlightLeg accepts optional daysOfWeek", () => {
|
||||
const leg: Partial<IFlightLeg> = {
|
||||
daysOfWeek: { current: "1000010", flight: "1111111" },
|
||||
};
|
||||
expect(leg.daysOfWeek?.flight).toBe("1111111");
|
||||
});
|
||||
```
|
||||
|
||||
Also update the imports at the top of `types.test.ts` to include `IDaysOfWeek`:
|
||||
|
||||
Find the existing `import type {` block and add `IDaysOfWeek,` to the list.
|
||||
|
||||
- [ ] **Step 2: Run test to verify fail**
|
||||
|
||||
Run: `pnpm vitest run src/features/online-board/types.test.ts`
|
||||
|
||||
Expected: FAIL — `IDaysOfWeek` is not exported.
|
||||
|
||||
- [ ] **Step 3: Extend `src/features/online-board/types.ts`**
|
||||
|
||||
Add a new exported interface near the `IFlightLegFlags` (just before the `IFlightLeg` definition):
|
||||
|
||||
```typescript
|
||||
// ---------------------------------------------------------------------------
|
||||
// Days of week
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Bit-string indicator of flight operation days.
|
||||
* Position 0 = Monday, position 6 = Sunday. `"1"` means active.
|
||||
*/
|
||||
export interface IDaysOfWeek {
|
||||
current: string;
|
||||
flight: string;
|
||||
}
|
||||
```
|
||||
|
||||
Then add `daysOfWeek?: IDaysOfWeek;` to the `IFlightLeg` interface (add at the end, preserving other fields):
|
||||
|
||||
```typescript
|
||||
export interface IFlightLeg {
|
||||
arrival: IFlightLegArrivalStation;
|
||||
dayChange: number;
|
||||
departure: IFlightLegDepartureStation;
|
||||
equipment: { name?: string; code?: string } & IEquipmentFull;
|
||||
flags: IFlightLegFlags;
|
||||
flyingTime: string;
|
||||
index: number;
|
||||
operatingBy: { carrier?: string; flightNumber?: string };
|
||||
status: FlightStatus;
|
||||
updated: string;
|
||||
transition?: IFlightTransitions;
|
||||
daysOfWeek?: IDaysOfWeek;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify pass**
|
||||
|
||||
Run: `pnpm vitest run src/features/online-board/types.test.ts`
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/online-board/types.ts src/features/online-board/types.test.ts
|
||||
git commit -m "Extend IFlightLeg with optional daysOfWeek field"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `weekDateRange` Helper
|
||||
|
||||
**Files:**
|
||||
- Create: `src/features/online-board/components/FlightSchedule/weekDateRange.ts`
|
||||
- Create: `src/features/online-board/components/FlightSchedule/weekDateRange.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing test**
|
||||
|
||||
```typescript
|
||||
// weekDateRange.test.ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getWeekDateRange } from "./weekDateRange.js";
|
||||
|
||||
describe("getWeekDateRange", () => {
|
||||
it("returns Monday/Sunday for a mid-week date", () => {
|
||||
// 2026-04-15 is a Wednesday
|
||||
const result = getWeekDateRange("2026-04-15T10:00:00");
|
||||
expect(result.start).toBe("13.04.2026"); // Monday
|
||||
expect(result.end).toBe("19.04.2026"); // Sunday
|
||||
});
|
||||
|
||||
it("returns Monday/Sunday for a Sunday input (same ISO week)", () => {
|
||||
// 2026-04-19 is a Sunday
|
||||
const result = getWeekDateRange("2026-04-19T10:00:00");
|
||||
expect(result.start).toBe("13.04.2026");
|
||||
expect(result.end).toBe("19.04.2026");
|
||||
});
|
||||
|
||||
it("returns Monday/Sunday for a Monday input", () => {
|
||||
const result = getWeekDateRange("2026-04-13T10:00:00");
|
||||
expect(result.start).toBe("13.04.2026");
|
||||
expect(result.end).toBe("19.04.2026");
|
||||
});
|
||||
|
||||
it("returns empty strings for invalid input", () => {
|
||||
const result = getWeekDateRange("not-a-date");
|
||||
expect(result).toEqual({ start: "", end: "" });
|
||||
});
|
||||
|
||||
it("returns empty strings for undefined input", () => {
|
||||
const result = getWeekDateRange(undefined);
|
||||
expect(result).toEqual({ start: "", end: "" });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify fail**
|
||||
|
||||
Run: `pnpm vitest run src/features/online-board/components/FlightSchedule/weekDateRange.test.ts`
|
||||
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Create `weekDateRange.ts`**
|
||||
|
||||
```typescript
|
||||
import { parseISO, startOfISOWeek, endOfISOWeek, format, isValid } from "date-fns";
|
||||
|
||||
/**
|
||||
* Given a scheduled local-time ISO string, return the ISO-week bounds
|
||||
* (Monday/Sunday) as `dd.MM.yyyy` formatted strings.
|
||||
* Returns `{ start: "", end: "" }` for invalid or missing input.
|
||||
*/
|
||||
export function getWeekDateRange(scheduledLocal: string | undefined): {
|
||||
start: string;
|
||||
end: string;
|
||||
} {
|
||||
if (!scheduledLocal) return { start: "", end: "" };
|
||||
const d = parseISO(scheduledLocal);
|
||||
if (!isValid(d)) return { start: "", end: "" };
|
||||
return {
|
||||
start: format(startOfISOWeek(d), "dd.MM.yyyy"),
|
||||
end: format(endOfISOWeek(d), "dd.MM.yyyy"),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify pass**
|
||||
|
||||
Run: `pnpm vitest run src/features/online-board/components/FlightSchedule/weekDateRange.test.ts`
|
||||
|
||||
Expected: 5 tests pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/online-board/components/FlightSchedule/weekDateRange.ts src/features/online-board/components/FlightSchedule/weekDateRange.test.ts
|
||||
git commit -m "Add weekDateRange helper for flight schedule note"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `DaysOfWeekStrip` Component
|
||||
|
||||
**Files:**
|
||||
- Create: `src/features/online-board/components/FlightSchedule/DaysOfWeekStrip.tsx`
|
||||
- Create: `src/features/online-board/components/FlightSchedule/DaysOfWeekStrip.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write failing test**
|
||||
|
||||
```tsx
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { DaysOfWeekStrip } from "./DaysOfWeekStrip.js";
|
||||
|
||||
vi.mock("@/i18n/provider.js", () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
describe("DaysOfWeekStrip", () => {
|
||||
it("renders 7 day boxes", () => {
|
||||
render(<DaysOfWeekStrip flightBitString="1111111" />);
|
||||
const boxes = screen.getAllByTestId(/^day-of-week-\d$/);
|
||||
expect(boxes).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("marks first 3 active, last 4 inactive for '1110000'", () => {
|
||||
render(<DaysOfWeekStrip flightBitString="1110000" />);
|
||||
expect(screen.getByTestId("day-of-week-0").className).not.toMatch(/--inactive/);
|
||||
expect(screen.getByTestId("day-of-week-1").className).not.toMatch(/--inactive/);
|
||||
expect(screen.getByTestId("day-of-week-2").className).not.toMatch(/--inactive/);
|
||||
expect(screen.getByTestId("day-of-week-3").className).toMatch(/--inactive/);
|
||||
expect(screen.getByTestId("day-of-week-6").className).toMatch(/--inactive/);
|
||||
});
|
||||
|
||||
it("marks all active for '1111111'", () => {
|
||||
render(<DaysOfWeekStrip flightBitString="1111111" />);
|
||||
for (let i = 0; i < 7; i++) {
|
||||
expect(screen.getByTestId(`day-of-week-${i}`).className).not.toMatch(/--inactive/);
|
||||
}
|
||||
});
|
||||
|
||||
it("marks all inactive for '0000000'", () => {
|
||||
render(<DaysOfWeekStrip flightBitString="0000000" />);
|
||||
for (let i = 0; i < 7; i++) {
|
||||
expect(screen.getByTestId(`day-of-week-${i}`).className).toMatch(/--inactive/);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders DAYS.1 through DAYS.7 labels in order", () => {
|
||||
render(<DaysOfWeekStrip flightBitString="1111111" />);
|
||||
expect(screen.getByTestId("day-of-week-0").textContent).toContain("DAYS.1");
|
||||
expect(screen.getByTestId("day-of-week-6").textContent).toContain("DAYS.7");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify fail**
|
||||
|
||||
Run: `pnpm vitest run src/features/online-board/components/FlightSchedule/DaysOfWeekStrip.test.tsx`
|
||||
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Create `DaysOfWeekStrip.tsx`**
|
||||
|
||||
```tsx
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
|
||||
export interface DaysOfWeekStripProps {
|
||||
flightBitString: string;
|
||||
}
|
||||
|
||||
const DAY_INDEXES = [0, 1, 2, 3, 4, 5, 6] as const;
|
||||
|
||||
export const DaysOfWeekStrip: FC<DaysOfWeekStripProps> = ({ flightBitString }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="days-of-week-strip">
|
||||
{DAY_INDEXES.map((i) => {
|
||||
const isActive = flightBitString[i] === "1";
|
||||
const className = isActive ? "day" : "day day--inactive";
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={className}
|
||||
data-testid={`day-of-week-${i}`}
|
||||
>
|
||||
{t(`DAYS.${i + 1}`)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify pass**
|
||||
|
||||
Run: `pnpm vitest run src/features/online-board/components/FlightSchedule/DaysOfWeekStrip.test.tsx`
|
||||
|
||||
Expected: 5 tests pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/online-board/components/FlightSchedule/DaysOfWeekStrip.tsx src/features/online-board/components/FlightSchedule/DaysOfWeekStrip.test.tsx
|
||||
git commit -m "Add DaysOfWeekStrip component for flight schedule"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `FlightSchedule` Component + SCSS + Barrel
|
||||
|
||||
**Files:**
|
||||
- Create: `src/features/online-board/components/FlightSchedule/FlightSchedule.tsx`
|
||||
- Create: `src/features/online-board/components/FlightSchedule/FlightSchedule.scss`
|
||||
- Create: `src/features/online-board/components/FlightSchedule/FlightSchedule.test.tsx`
|
||||
- Create: `src/features/online-board/components/FlightSchedule/index.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing test**
|
||||
|
||||
```tsx
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { FlightSchedule } from "./FlightSchedule.js";
|
||||
import type { ISimpleFlight } from "../../types.js";
|
||||
|
||||
vi.mock("@/i18n/provider.js", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (k: string) => {
|
||||
if (k === "SHARED.NOTE-TIME-SCHEDULE") {
|
||||
return "Valid from {START_DATE} to {END_DATE}";
|
||||
}
|
||||
return k;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
function makeFlight(overrides: { daysOfWeek?: { current: string; flight: string } }): ISimpleFlight {
|
||||
return {
|
||||
id: "X", routeType: "Direct", flyingTime: "2h 30m", status: "Scheduled",
|
||||
flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: "20260415" },
|
||||
operatingBy: { carrier: "SU", flightNumber: "0022" },
|
||||
leg: {
|
||||
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: "", tzOffset: 0, utc: "" } },
|
||||
},
|
||||
dayChange: 0,
|
||||
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: "2026-04-15T10:00:00", localTime: "10:00", tzOffset: 0, utc: "" } },
|
||||
},
|
||||
equipment: {},
|
||||
flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
|
||||
flyingTime: "2h 30m", index: 0, operatingBy: {}, status: "Scheduled", updated: "",
|
||||
...overrides,
|
||||
},
|
||||
} as ISimpleFlight;
|
||||
}
|
||||
|
||||
describe("FlightSchedule", () => {
|
||||
it("returns null when firstLeg has no daysOfWeek", () => {
|
||||
const { container } = render(<FlightSchedule flight={makeFlight({})} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when daysOfWeek.flight is empty string", () => {
|
||||
const { container } = render(
|
||||
<FlightSchedule flight={makeFlight({ daysOfWeek: { current: "0000000", flight: "" } })} />,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders with data-testid=flight-schedule when daysOfWeek present", () => {
|
||||
render(
|
||||
<FlightSchedule flight={makeFlight({ daysOfWeek: { current: "1000010", flight: "1111111" } })} />,
|
||||
);
|
||||
expect(screen.getByTestId("flight-schedule")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders 7 day boxes from daysOfWeek.flight", () => {
|
||||
render(
|
||||
<FlightSchedule flight={makeFlight({ daysOfWeek: { current: "1000010", flight: "1110000" } })} />,
|
||||
);
|
||||
const boxes = screen.getAllByTestId(/^day-of-week-\d$/);
|
||||
expect(boxes).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("substitutes {START_DATE} and {END_DATE} into the note", () => {
|
||||
render(
|
||||
<FlightSchedule flight={makeFlight({ daysOfWeek: { current: "1000010", flight: "1111111" } })} />,
|
||||
);
|
||||
// 2026-04-15 is a Wednesday; ISO week Mon 13 - Sun 19
|
||||
expect(screen.getByTestId("flight-schedule-note").textContent).toContain("13.04.2026");
|
||||
expect(screen.getByTestId("flight-schedule-note").textContent).toContain("19.04.2026");
|
||||
});
|
||||
|
||||
it("renders scheduled departure/arrival times and duration", () => {
|
||||
render(
|
||||
<FlightSchedule flight={makeFlight({ daysOfWeek: { current: "1000010", flight: "1111111" } })} />,
|
||||
);
|
||||
// Note: accordion open by default; times visible in content
|
||||
// departure time: the local ISO string's time component
|
||||
expect(screen.getByText("10:00")).toBeTruthy();
|
||||
expect(screen.getByText("12:30")).toBeTruthy();
|
||||
expect(screen.getByText("2h 30m")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify fail**
|
||||
|
||||
Run: `pnpm vitest run src/features/online-board/components/FlightSchedule/FlightSchedule.test.tsx`
|
||||
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Create `FlightSchedule.scss`**
|
||||
|
||||
```scss
|
||||
.flight-schedule {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 16px 24px;
|
||||
margin-top: 16px;
|
||||
|
||||
.p-accordion-content {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
&__label { color: #666; }
|
||||
&__value { font-weight: 500; }
|
||||
|
||||
&__days-section {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__note {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.days-of-week-strip {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.day {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
background: #e6f1fb;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1a3a5c;
|
||||
|
||||
&--inactive {
|
||||
background: #f6f6f6;
|
||||
color: rgba(102, 102, 102, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create `FlightSchedule.tsx`**
|
||||
|
||||
```tsx
|
||||
import type { FC } from "react";
|
||||
import { Accordion, AccordionTab } from "primereact/accordion";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import type { ISimpleFlight } from "../../types.js";
|
||||
import { DaysOfWeekStrip } from "./DaysOfWeekStrip.js";
|
||||
import { getWeekDateRange } from "./weekDateRange.js";
|
||||
import "./FlightSchedule.scss";
|
||||
|
||||
export interface FlightScheduleProps {
|
||||
flight: ISimpleFlight;
|
||||
}
|
||||
|
||||
function formatLocalTime(iso: string | undefined): string {
|
||||
if (!iso) return "";
|
||||
// If it's already "HH:mm" (no date), return as-is
|
||||
if (/^\d{2}:\d{2}$/.test(iso)) return iso;
|
||||
// Otherwise try to extract time part from ISO: "2026-04-15T10:00:00" → "10:00"
|
||||
const match = /T(\d{2}:\d{2})/.exec(iso);
|
||||
return match ? match[1]! : iso;
|
||||
}
|
||||
|
||||
export const FlightSchedule: FC<FlightScheduleProps> = ({ flight }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const firstLeg = flight.routeType === "Direct" ? flight.leg : flight.legs[0];
|
||||
const lastLeg = flight.routeType === "Direct" ? flight.leg : flight.legs[flight.legs.length - 1];
|
||||
|
||||
if (!firstLeg?.daysOfWeek?.flight) return null;
|
||||
if (!lastLeg) return null;
|
||||
|
||||
const depLocal = firstLeg.departure.times.scheduledDeparture.local;
|
||||
const arrLocal = lastLeg.arrival.times.scheduledArrival.local;
|
||||
|
||||
const { start, end } = getWeekDateRange(depLocal);
|
||||
const noteTemplate = t("SHARED.NOTE-TIME-SCHEDULE");
|
||||
const note = noteTemplate.replace("{START_DATE}", start).replace("{END_DATE}", end);
|
||||
|
||||
return (
|
||||
<section className="flight-schedule" data-testid="flight-schedule">
|
||||
<Accordion multiple={false} activeIndex={0}>
|
||||
<AccordionTab header={t("SHARED.SCHEDULE-FLIGHT")}>
|
||||
<div className="flight-schedule__row">
|
||||
<span className="flight-schedule__label">{t("SHARED.DEPARTURE-SCHEDULED")}</span>
|
||||
<span className="flight-schedule__value">{formatLocalTime(depLocal)}</span>
|
||||
</div>
|
||||
<div className="flight-schedule__row">
|
||||
<span className="flight-schedule__label">{t("SHARED.ARRIVAL-SCHEDULED")}</span>
|
||||
<span className="flight-schedule__value">{formatLocalTime(arrLocal)}</span>
|
||||
</div>
|
||||
<div className="flight-schedule__row">
|
||||
<span className="flight-schedule__label">{t("SHARED.PATH-TIME")}</span>
|
||||
<span className="flight-schedule__value">{flight.flyingTime}</span>
|
||||
</div>
|
||||
</AccordionTab>
|
||||
</Accordion>
|
||||
|
||||
<div className="flight-schedule__days-section">
|
||||
<div className="flight-schedule__section-title">
|
||||
{t("SHARED.DAYS-EXECUTE-FLIGHT")}
|
||||
</div>
|
||||
<DaysOfWeekStrip flightBitString={firstLeg.daysOfWeek.flight} />
|
||||
<div className="flight-schedule__note" data-testid="flight-schedule-note">{note}</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Create `index.ts`**
|
||||
|
||||
```typescript
|
||||
export { FlightSchedule } from "./FlightSchedule.js";
|
||||
export type { FlightScheduleProps } from "./FlightSchedule.js";
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify pass**
|
||||
|
||||
Run: `pnpm vitest run src/features/online-board/components/FlightSchedule/FlightSchedule.test.tsx`
|
||||
|
||||
Expected: 6 tests pass.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/online-board/components/FlightSchedule/
|
||||
git commit -m "Add FlightSchedule accordion with days-of-week strip"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `DetailsBackButton` Component
|
||||
|
||||
**Files:**
|
||||
- Create: `src/features/online-board/components/DetailsBackButton/DetailsBackButton.tsx`
|
||||
- Create: `src/features/online-board/components/DetailsBackButton/DetailsBackButton.scss`
|
||||
- Create: `src/features/online-board/components/DetailsBackButton/DetailsBackButton.test.tsx`
|
||||
- Create: `src/features/online-board/components/DetailsBackButton/index.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing test**
|
||||
|
||||
```tsx
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { DetailsBackButton } from "./DetailsBackButton.js";
|
||||
|
||||
vi.mock("@/i18n/provider.js", () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
vi.mock("@modern-js/runtime/router", () => ({
|
||||
Link: ({ children, to, ...props }: { children: React.ReactNode; to: string; [k: string]: unknown }) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("DetailsBackButton", () => {
|
||||
it("has data-testid=details-back-button", () => {
|
||||
render(<DetailsBackButton locale="ru" />);
|
||||
expect(screen.getByTestId("details-back-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("links to /{locale}/onlineboard", () => {
|
||||
render(<DetailsBackButton locale="ru" />);
|
||||
const a = screen.getByTestId("details-back-button") as HTMLAnchorElement;
|
||||
expect(a.getAttribute("href")).toBe("/ru/onlineboard");
|
||||
});
|
||||
|
||||
it("uses the provided locale in the link", () => {
|
||||
render(<DetailsBackButton locale="en" />);
|
||||
const a = screen.getByTestId("details-back-button") as HTMLAnchorElement;
|
||||
expect(a.getAttribute("href")).toBe("/en/onlineboard");
|
||||
});
|
||||
|
||||
it("renders the BACK-BOARD label", () => {
|
||||
render(<DetailsBackButton locale="ru" />);
|
||||
expect(screen.getByText("SHARED.BACK-BOARD")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify fail**
|
||||
|
||||
Run: `pnpm vitest run src/features/online-board/components/DetailsBackButton/DetailsBackButton.test.tsx`
|
||||
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Create `DetailsBackButton.scss`**
|
||||
|
||||
```scss
|
||||
.details-back-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: #e3f0ff;
|
||||
color: #1a3a5c;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #c7dff5;
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create `DetailsBackButton.tsx`**
|
||||
|
||||
```tsx
|
||||
import type { FC } from "react";
|
||||
import { Link } from "@modern-js/runtime/router";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import "./DetailsBackButton.scss";
|
||||
|
||||
export interface DetailsBackButtonProps {
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export const DetailsBackButton: FC<DetailsBackButtonProps> = ({ locale }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Link
|
||||
to={`/${locale}/onlineboard`}
|
||||
className="details-back-button"
|
||||
data-testid="details-back-button"
|
||||
>
|
||||
<span className="details-back-button__arrow" aria-hidden="true">{"\u2190"}</span>
|
||||
<span>{t("SHARED.BACK-BOARD")}</span>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Create `index.ts`**
|
||||
|
||||
```typescript
|
||||
export { DetailsBackButton } from "./DetailsBackButton.js";
|
||||
export type { DetailsBackButtonProps } from "./DetailsBackButton.js";
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify pass**
|
||||
|
||||
Run: `pnpm vitest run src/features/online-board/components/DetailsBackButton/DetailsBackButton.test.tsx`
|
||||
|
||||
Expected: 4 tests pass.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/online-board/components/DetailsBackButton/
|
||||
git commit -m "Add DetailsBackButton component for header navigation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Wire Both into `OnlineBoardDetailsPage`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/features/online-board/components/OnlineBoardDetailsPage.tsx`
|
||||
- Modify: `src/features/online-board/components/OnlineBoardDetailsPage.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Add integration tests**
|
||||
|
||||
Append to the outer describe block in `OnlineBoardDetailsPage.test.tsx`:
|
||||
|
||||
```tsx
|
||||
describe("back button integration", () => {
|
||||
it("renders DetailsBackButton in headerLeft", () => {
|
||||
mockState = { flight: mockFlight, allFlights: [mockFlight], daysOfFlight: ["20260416"], loading: false, error: null };
|
||||
render(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://example.com" />);
|
||||
expect(screen.getByTestId("details-back-button")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("flight schedule integration", () => {
|
||||
it("renders FlightSchedule when firstLeg.daysOfWeek is present", () => {
|
||||
const flightWithDaysOfWeek = {
|
||||
...mockFlight,
|
||||
leg: {
|
||||
...mockFlight.leg,
|
||||
daysOfWeek: { current: "1000010", flight: "1111111" },
|
||||
},
|
||||
};
|
||||
mockState = { flight: flightWithDaysOfWeek, allFlights: [flightWithDaysOfWeek], daysOfFlight: ["20260416"], loading: false, error: null };
|
||||
render(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://example.com" />);
|
||||
expect(screen.getByTestId("flight-schedule")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render FlightSchedule when daysOfWeek is absent", () => {
|
||||
mockState = { flight: mockFlight, allFlights: [mockFlight], daysOfFlight: ["20260416"], loading: false, error: null };
|
||||
render(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://example.com" />);
|
||||
expect(screen.queryByTestId("flight-schedule")).toBeNull();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests — expect failure**
|
||||
|
||||
Run: `pnpm vitest run src/features/online-board/components/OnlineBoardDetailsPage.test.tsx`
|
||||
|
||||
Expected: the 3 new tests fail (testids not present).
|
||||
|
||||
- [ ] **Step 3: Update imports in `OnlineBoardDetailsPage.tsx`**
|
||||
|
||||
Add near other component imports:
|
||||
|
||||
```tsx
|
||||
import { DetailsBackButton } from "./DetailsBackButton/index.js";
|
||||
import { FlightSchedule } from "./FlightSchedule/index.js";
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Swap PageTabs for DetailsBackButton**
|
||||
|
||||
In `OnlineBoardDetailsPage.tsx`, find `const commonLayoutProps` (around line 194):
|
||||
|
||||
```tsx
|
||||
const commonLayoutProps = {
|
||||
headerLeft: <PageTabs viewType="onlineboard" />,
|
||||
breadcrumbs: [
|
||||
{ label: t("BREADCRUMBS.ONLINEBOARD"), url: onlineboardHref },
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
Change to:
|
||||
|
||||
```tsx
|
||||
const commonLayoutProps = {
|
||||
headerLeft: <DetailsBackButton locale={locale} />,
|
||||
breadcrumbs: [
|
||||
{ label: t("BREADCRUMBS.ONLINEBOARD"), url: onlineboardHref },
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
Also find the main happy-path return's `<PageLayout>` opening:
|
||||
|
||||
```tsx
|
||||
<PageLayout
|
||||
headerLeft={<PageTabs viewType="onlineboard" />}
|
||||
title={<h1 className="flight-details__flight-number">{flightNumber}</h1>}
|
||||
```
|
||||
|
||||
Change to:
|
||||
|
||||
```tsx
|
||||
<PageLayout
|
||||
headerLeft={<DetailsBackButton locale={locale} />}
|
||||
title={<h1 className="flight-details__flight-number">{flightNumber}</h1>}
|
||||
```
|
||||
|
||||
The `PageTabs` import becomes unused — remove it from the import list at the top of the file.
|
||||
|
||||
- [ ] **Step 5: Insert `<FlightSchedule />` after the flight legs**
|
||||
|
||||
In the main return, find the closing tag after the flying-time div (after `<FlightLegs legs={legs} />` + the flying-time div, just before closing `</div>` of `.flight-details`):
|
||||
|
||||
```tsx
|
||||
{/* Flying time */}
|
||||
<div className="flight-details__flying-time" data-testid="flying-time">
|
||||
Total flying time: {displayFlight.flyingTime}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Insert `<FlightSchedule flight={displayFlight} />` after the flying-time div (before the `</div>` closing `.flight-details`):
|
||||
|
||||
```tsx
|
||||
{/* Flying time */}
|
||||
<div className="flight-details__flying-time" data-testid="flying-time">
|
||||
Total flying time: {displayFlight.flyingTime}
|
||||
</div>
|
||||
|
||||
<FlightSchedule flight={displayFlight} />
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run tests — expect pass**
|
||||
|
||||
Run: `pnpm vitest run src/features/online-board/components/OnlineBoardDetailsPage.test.tsx`
|
||||
|
||||
Expected: all tests pass, including the 3 new integration tests.
|
||||
|
||||
- [ ] **Step 7: Full suite + typecheck**
|
||||
|
||||
Run: `pnpm test`
|
||||
Run: `pnpm typecheck`
|
||||
|
||||
Expected: no new failures, no new type errors. If any integration tests elsewhere (e.g., `tests/integration/online-board/*.test.tsx`) reference `PageTabs` via the details page, they may need the `DetailsBackButton` testid instead of the `onlineboard-tab` testid — fix those test assertions minimally.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/online-board/components/OnlineBoardDetailsPage.tsx src/features/online-board/components/OnlineBoardDetailsPage.test.tsx
|
||||
git commit -m "Wire DetailsBackButton and FlightSchedule into OnlineBoardDetailsPage"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Manual Browser Verification
|
||||
|
||||
**Files:** None — observational.
|
||||
|
||||
- [ ] **Step 1: Ensure dev:full is running**
|
||||
|
||||
Run `pnpm dev:full`. Verify `/api/appSettings` returns 200.
|
||||
|
||||
- [ ] **Step 2: Run the verification script**
|
||||
|
||||
```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);
|
||||
|
||||
const depUtc = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||||
await page.route("**/onlineboard/details*", (route) => {
|
||||
route.fulfill({
|
||||
status: 200, contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
partners: [],
|
||||
routes: [{
|
||||
id: "SU0022-X", routeType: "Direct", flyingTime: "1h 30m", status: "Scheduled",
|
||||
flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: "20260418", dateLT: "20260418" },
|
||||
operatingBy: { carrier: "SU", flightNumber: "0022" },
|
||||
leg: {
|
||||
index: 0, flyingTime: "1h 30m", status: "Scheduled", updated: "2026-04-17T10:00:00Z", dayChange: 0,
|
||||
flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
|
||||
operatingBy: { carrier: "SU", flightNumber: "0022" },
|
||||
daysOfWeek: { current: "1000010", flight: "1111100" },
|
||||
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: "2026-04-18T10:00:00", localTime: "10:00", tzOffset: 3, utc: depUtc } },
|
||||
},
|
||||
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: "2026-04-18T12:30:00", localTime: "12:30", tzOffset: 3, utc: "" } },
|
||||
},
|
||||
equipment: {},
|
||||
},
|
||||
}],
|
||||
daysOfFlight: ["20260418"],
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("http://localhost:8080/ru/onlineboard/SU0022-20260418", { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const backBtn = await page.locator('[data-testid="details-back-button"]').count();
|
||||
const schedule = await page.locator('[data-testid="flight-schedule"]').count();
|
||||
const dayBoxes = await page.locator('[data-testid^="day-of-week-"]').count();
|
||||
const noteText = await page.locator('[data-testid="flight-schedule-note"]').textContent().catch(() => "");
|
||||
|
||||
console.log("backBtn:", backBtn);
|
||||
console.log("schedule:", schedule);
|
||||
console.log("dayBoxes:", dayBoxes);
|
||||
console.log("note:", noteText);
|
||||
|
||||
await page.screenshot({ path: "/tmp/b6-verify.png", fullPage: true });
|
||||
|
||||
await browser.close();
|
||||
SCRIPT
|
||||
```
|
||||
|
||||
Expected: `backBtn: 1, schedule: 1, dayBoxes: 7, note: "Valid from 13.04.2026 to 19.04.2026"` (or Russian equivalent depending on the real i18n key value).
|
||||
|
||||
- [ ] **Step 3: No commit — observational**
|
||||
Reference in New Issue
Block a user