diff --git a/src/features/online-board/components/OnlineBoardFilter.test.tsx b/src/features/online-board/components/OnlineBoardFilter.test.tsx
index 8f27379c..26c5a3e1 100644
--- a/src/features/online-board/components/OnlineBoardFilter.test.tsx
+++ b/src/features/online-board/components/OnlineBoardFilter.test.tsx
@@ -410,6 +410,22 @@ describe("OnlineBoardFilter – operating days calendar parity (TIRREDESIGN-12)"
expect(disabledDates).toContain("2026-05-04");
expect(disabledDates).toContain("2026-05-05");
});
+
+ it("requests flight calendar days with a normalized suffix flight number", () => {
+ render(
+ ,
+ );
+
+ expect(calendarDaysMock.params).toContainEqual({
+ date: "2026-05-04",
+ searchType: "flight",
+ flightNumber: "SU0038D",
+ });
+ });
});
describe("OnlineBoardFilter – flight-number validation per TZ §4.1.9.3", () => {
@@ -451,7 +467,26 @@ describe("OnlineBoardFilter – flight-number validation per TZ §4.1.9.3", () =
expect(url).toMatch(/SU1234/);
});
- it("4.1.9.3-R: letters in flight number show error and block submit", () => {
+ it("TIRREDESIGN-13: suffix flight number submits as normalized SU0038D URL", () => {
+ render();
+ fireEvent.change(screen.getByTestId("flight-number-input"), {
+ target: { value: "38d" },
+ });
+ fireEvent.submit(screen.getByTestId("search-form"));
+ expect(mockNavigate).toHaveBeenCalled();
+ const url = mockNavigate.mock.calls[0]![0] as string;
+ expect(url).toContain("/ru-ru/onlineboard/flight/SU0038D-20260601");
+ });
+
+ it("TIRREDESIGN-13: suffix flight number normalizes on blur like Angular", () => {
+ render();
+ const input = screen.getByTestId("flight-number-input") as HTMLInputElement;
+ fireEvent.change(input, { target: { value: "123d" } });
+ fireEvent.blur(input);
+ expect(input.value).toBe("0123D");
+ });
+
+ it("4.1.9.3-R: letters without digits show error and block submit", () => {
render();
fireEvent.change(screen.getByTestId("flight-number-input"), {
target: { value: "abc" },
diff --git a/src/features/online-board/components/OnlineBoardFilter.tsx b/src/features/online-board/components/OnlineBoardFilter.tsx
index 6aed0160..6d84ea6a 100644
--- a/src/features/online-board/components/OnlineBoardFilter.tsx
+++ b/src/features/online-board/components/OnlineBoardFilter.tsx
@@ -53,28 +53,45 @@ function dateToIsoYmd(value: Date): string {
return `${y}-${m}-${d}`;
}
+interface NormalizedFlightNumber {
+ flightNumber: string;
+ suffix?: string;
+ display: string;
+}
+
/**
- * Validates a flight number string per TZ §4.1.9.3:
- * - Must be 1-4 digits only (no letters, no spaces).
- * - Error message: "только из цифр и не должен быть длиннее 4-х символов".
- * Shorter inputs (1-3 digits) are valid; they get zero-padded at submit time.
+ * Normalise the Angular flight-number field:
+ * `38` -> `0038`, `38D` -> `0038D`, `123d` -> `0123D`.
*/
+function normalizeFlightNumberInput(value: string): NormalizedFlightNumber | null {
+ const raw = value.trim();
+ const match = /^(\d{1,4})([A-Za-z])?$/.exec(raw);
+ if (!match) return null;
+ const digits = match[1];
+ if (!digits) return null;
+ const flightNumber = digits.padStart(4, "0");
+ const suffix = match[2]?.toUpperCase();
+ return suffix
+ ? { flightNumber, suffix, display: `${flightNumber}${suffix}` }
+ : { flightNumber, display: flightNumber };
+}
+
+function formatFlightNumberInput(value: string): string {
+ const normalized = normalizeFlightNumberInput(value);
+ return normalized?.display ?? value.toUpperCase();
+}
+
function validateFlightNumber(value: string): string | null {
- if (!value.trim()) {
+ const raw = value.trim();
+ if (!raw) {
return "BOARD.FLIGHT_NUMBER-ERROR-EMPTY";
}
- const reg = /^\d{1,4}$/;
- if (!reg.test(value.trim())) {
+ if (!normalizeFlightNumberInput(raw)) {
return "BOARD.FLIGHT_NUMBER-ERROR-ONLY-NUMBER";
}
return null;
}
-/** Zero-pads a 1-3 digit flight number to 4 digits per TZ §4.1.9.3 (e.g. "38" → "0038"). */
-function padFlightNumber(value: string): string {
- return value.trim().padStart(4, "0");
-}
-
export interface OnlineBoardFilterProps {
/** Pre-populate filter from URL params on search results pages */
initialDeparture?: string;
@@ -217,12 +234,12 @@ export const OnlineBoardFilter: FC = ({
// flight number or empty route produces no API call.
const flightCalendarParams = useMemo(() => {
if (activeTab !== "flight") return null;
- const digits = flightNumber.trim();
- if (digits.length < 1 || !/^\d{1,4}$/.test(digits)) return null;
+ const normalized = normalizeFlightNumberInput(flightNumber);
+ if (!normalized) return null;
return {
date: boardCalendarBaseDate,
searchType: "flight",
- flightNumber: `SU${padFlightNumber(digits)}`,
+ flightNumber: `SU${normalized.flightNumber}${normalized.suffix ?? ""}`,
};
}, [activeTab, flightNumber, boardCalendarBaseDate]);
@@ -415,14 +432,13 @@ export const OnlineBoardFilter: FC = ({
// the placeholder ДД.ММ.ГГГГ is left untouched.
const dateParam = dateToYyyymmdd(flightDate ?? new Date());
const carrier = "SU";
- // TZ §4.1.9.3: zero-pad to 4 digits (38 → 0038, 383 → 0383).
- const num = padFlightNumber(flightNumber);
- if (!num) return;
+ const normalized = normalizeFlightNumberInput(flightNumber);
+ if (!normalized) return;
// TZ §4.1.8: persist filter snapshot for cross-section hydration.
setBoardFilter({
mode: "flight-number",
- flightNumber: `${carrier}${num}`,
+ flightNumber: `${carrier}${normalized.flightNumber}${normalized.suffix ?? ""}`,
date: dateParam,
timeFrom: "0000",
timeTo: "2400",
@@ -434,7 +450,21 @@ export const OnlineBoardFilter: FC = ({
setSubmitLockedUntil(Date.now() + 30_000);
setNow(Date.now());
- const url = buildOnlineBoardUrl({ type: "flight", carrier, flightNumber: num, date: dateParam });
+ const urlParams = normalized.suffix
+ ? {
+ type: "flight" as const,
+ carrier,
+ flightNumber: normalized.flightNumber,
+ suffix: normalized.suffix,
+ date: dateParam,
+ }
+ : {
+ type: "flight" as const,
+ carrier,
+ flightNumber: normalized.flightNumber,
+ date: dateParam,
+ };
+ const url = buildOnlineBoardUrl(urlParams);
void navigate(`/${locale}/${url}`);
},
[flightNumber, flightDate, navigate, locale, isSubmitLocked, flightSearchSignature],
@@ -579,6 +609,7 @@ export const OnlineBoardFilter: FC = ({
setFlightNumber(e.target.value);
if (flightNumberError) setFlightNumberError(null);
}}
+ onBlur={(e) => setFlightNumber(formatFlightNumberInput(e.target.value))}
data-testid="flight-number-input"
/>
{flightNumber && (
diff --git a/src/features/online-board/components/OnlineBoardSearchPage.test.tsx b/src/features/online-board/components/OnlineBoardSearchPage.test.tsx
index 64b59f2b..a34dbd2a 100644
--- a/src/features/online-board/components/OnlineBoardSearchPage.test.tsx
+++ b/src/features/online-board/components/OnlineBoardSearchPage.test.tsx
@@ -34,8 +34,12 @@ vi.mock("@/ui/layout/PageTabs.js", () => ({
PageTabs: () => ,
}));
+let capturedFilterProps: Record | undefined;
vi.mock("./OnlineBoardFilter.js", () => ({
- OnlineBoardFilter: () => ,
+ OnlineBoardFilter: (props: Record) => {
+ capturedFilterProps = props;
+ return ;
+ },
}));
vi.mock("@/shared/hooks/useSearchHistory.js", () => ({
@@ -46,13 +50,17 @@ vi.mock("@/features/flights-map/hooks/useFeatureFlag.js", () => ({
useFeatureFlag: () => false,
}));
+let capturedSearchParams: Record | undefined;
vi.mock("../hooks/useOnlineBoard.js", () => ({
- useOnlineBoard: () => ({
- flights: [],
- loading: false,
- error: null,
- refresh: vi.fn(),
- }),
+ useOnlineBoard: (params: Record) => {
+ capturedSearchParams = params;
+ return {
+ flights: [],
+ loading: false,
+ error: null,
+ refresh: vi.fn(),
+ };
+ },
}));
vi.mock("../hooks/useLiveBoardSearch.js", () => ({
@@ -143,6 +151,8 @@ describe("OnlineBoardSearchPage", () => {
beforeEach(() => {
vi.clearAllMocks();
capturedInitialCurrentFlightId = undefined;
+ capturedFilterProps = undefined;
+ capturedSearchParams = undefined;
});
afterEach(() => {
@@ -176,6 +186,23 @@ describe("OnlineBoardSearchPage", () => {
expect(screen.getByTestId("online-board-search")).toBeTruthy();
});
+ it("TIRREDESIGN-13: preserves suffix in flight API params and filter value", () => {
+ render(
+ ,
+ );
+
+ expect(capturedSearchParams?.flightNumber).toBe("SU0100D");
+ expect(capturedFilterProps?.initialFlightNumber).toBe("0100D");
+ });
+
it("renders for route search type", () => {
render(
= ({
: params.type === "arrival" ? params.station
: undefined;
const flightNumber = isFlightNumber
- ? `${params.carrier} ${params.flightNumber}`
+ ? `${params.carrier} ${params.flightNumber}${params.type === "flight" ? params.suffix ?? "" : ""}`
: undefined;
const labelParts: string[] = [];
if (departure) labelParts.push(departure);
@@ -256,6 +256,7 @@ export const OnlineBoardSearchPage: FC = ({
: undefined,
params.type === "flight" ? params.carrier : undefined,
params.type === "flight" ? params.flightNumber : undefined,
+ params.type === "flight" ? params.suffix : undefined,
params.date,
]);
@@ -295,7 +296,7 @@ export const OnlineBoardSearchPage: FC = ({
if (dateLabel) searchHeading += `, ${dateLabel}`;
break;
case "flight":
- searchHeading = `${t("SHARED.NUMBER")}: ${params.carrier} ${params.flightNumber}`;
+ searchHeading = `${t("SHARED.NUMBER")}: ${params.carrier} ${params.flightNumber}${params.suffix ?? ""}`;
if (dateLabel) searchHeading += `, ${dateLabel}`;
break;
default:
@@ -503,7 +504,7 @@ export const OnlineBoardSearchPage: FC = ({
}
: params.type === "flight"
? {
- initialFlightNumber: params.flightNumber,
+ initialFlightNumber: `${params.flightNumber}${params.suffix ?? ""}`,
initialDate: params.date,
initialTab: "flight" as const,
}
diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json
index 08fc79c4..54c984f9 100644
--- a/src/i18n/locales/en/common.json
+++ b/src/i18n/locales/en/common.json
@@ -23,7 +23,7 @@
"FLIGHT_NUMBER": "Flight number",
"FLIGHT_NUMBER-ERROR-BIG": "Incorrect flight number. The flight number may not be longer than 4 digits",
"FLIGHT_NUMBER-ERROR-EMPTY": "Specify flight number",
- "FLIGHT_NUMBER-ERROR-ONLY-NUMBER": "Incorrect flight number. The flight number may only consist of digits and must not exceed 4 characters.",
+ "FLIGHT_NUMBER-ERROR-ONLY-NUMBER": "Incorrect flight number. The flight number must consist of 1-4 digits and may include one Latin-letter suffix at the end.",
"GPS-BUTTON": "Detect my location",
"GPS-HELP": "Enable geolocation in your browser to detect the city automatically. Geolocation will not work if any anonymizers are enabled.",
"NOT-FOUND-LOCATION": "You are seeing this page because we could not access your current location. \nAllow the app to access your location to view flights to your destination.",
diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json
index b4f00646..ff0c97c3 100644
--- a/src/i18n/locales/ru/common.json
+++ b/src/i18n/locales/ru/common.json
@@ -23,7 +23,7 @@
"FLIGHT_NUMBER": "Номер рейса",
"FLIGHT_NUMBER-ERROR-BIG": "Неверно указан номер рейса. Номер рейса не должен быть длиннее 4-х символов",
"FLIGHT_NUMBER-ERROR-EMPTY": "Укажите номер рейса",
- "FLIGHT_NUMBER-ERROR-ONLY-NUMBER": "Неверно указан номер рейса. Номер рейса может состоять только из цифр и не должен быть длиннее 4-х символов.",
+ "FLIGHT_NUMBER-ERROR-ONLY-NUMBER": "Неверно указан номер рейса. Номер рейса должен состоять из 1-4 цифр и может содержать в конце одну латинскую букву-суффикс.",
"GPS-BUTTON": "Определить мое местоположение",
"GPS-HELP": "Для автоматического определения города разрешите браузеру доступ к геолокации. Определение может не работать при включенных анонимайзерах.",
"NOT-FOUND-LOCATION": "Мы не смогли определить Ваше месторасположение — поэтому Вы видите эту страницу. \nРазрешите определение месторасположения — сразу будет открыта страница рейсов в ваш город.",