Align Online-Board page titles with TZ Table 6 (сегодня/завтра/ДД.ММ.ГГГГ date display)

- Add formatDateForTitle helper: returns today/tomorrow labels or dd.MM.yyyy
- Switch all search page title builders to use formatDateForTitle; descriptions keep dd.MM.yyyy
- FLIGHT-DETAILS title now uses routeCities (no date) per TZ rows 6-8; adds TITLE-NO-ROUTE fallback for SSR when cities not yet loaded
- buildFlightDetailsSeoFromId accepts optional cityNames param
- Update ru/en i18n TITLE strings to TZ Table 6 format; add TITLE-NO-ROUTE to all 9 locales
- Tests: 32 cases covering today/tomorrow/arbitrary-date branches and routeCities logic
This commit is contained in:
2026-04-21 17:18:59 +03:00
parent 4f840486b8
commit f03562e4cd
11 changed files with 279 additions and 40 deletions
+173 -2
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi, afterEach } from "vitest";
import {
buildOnlineBoardStartSeo,
buildFlightSearchSeo,
@@ -6,6 +6,7 @@ import {
buildArrivalSearchSeo,
buildRouteSearchSeo,
buildFlightDetailsSeo,
buildFlightDetailsSeoFromId,
} from "./seo.js";
import type { ISimpleFlight } from "./types.js";
@@ -20,8 +21,92 @@ function stubT(key: string, opts?: Record<string, unknown>): string {
return key;
}
/**
* Stub t() that resolves SHARED.TODAY / SHARED.TOMORROW to their Russian
* equivalents so we can assert the rendered value directly.
*/
function stubTRu(key: string, opts?: Record<string, unknown>): string {
if (key === "SHARED.TODAY") return "сегодня";
if (key === "SHARED.TOMORROW") return "завтра";
return stubT(key, opts);
}
const CANONICAL = "https://www.aeroflot.ru";
// ---------------------------------------------------------------------------
// formatDateForTitle — via buildFlightSearchSeo with frozen clock
// ---------------------------------------------------------------------------
describe("formatDateForTitle date branches", () => {
afterEach(() => {
vi.useRealTimers();
});
it("returns 'сегодня' when date matches today (local)", () => {
// Freeze clock to 2026-05-15T12:00:00 local
vi.useFakeTimers({ now: new Date(2026, 4, 15, 12, 0, 0) });
const params = {
type: "flight" as const,
carrier: "SU",
flightNumber: "0100",
date: "20260515",
};
const result = buildFlightSearchSeo(stubTRu, params, "ru", CANONICAL);
expect(result.title).toContain("date=сегодня");
// description must use dd.MM.yyyy, not "сегодня"
expect(result.description).toContain("date=15.05.2026");
});
it("returns 'завтра' when date is today + 1", () => {
vi.useFakeTimers({ now: new Date(2026, 4, 15, 12, 0, 0) });
const params = {
type: "flight" as const,
carrier: "SU",
flightNumber: "0100",
date: "20260516",
};
const result = buildFlightSearchSeo(stubTRu, params, "ru", CANONICAL);
expect(result.title).toContain("date=завтра");
expect(result.description).toContain("date=16.05.2026");
});
it("returns dd.MM.yyyy for an arbitrary past date", () => {
vi.useFakeTimers({ now: new Date(2026, 4, 15, 12, 0, 0) });
const params = {
type: "flight" as const,
carrier: "SU",
flightNumber: "0100",
date: "20250115",
};
const result = buildFlightSearchSeo(stubTRu, params, "ru", CANONICAL);
expect(result.title).toContain("date=15.01.2025");
});
it("accepts yyyy-MM-dd API shape for today", () => {
vi.useFakeTimers({ now: new Date(2026, 4, 15, 12, 0, 0) });
const params = {
type: "departure" as const,
station: "SVO",
date: "2026-05-15",
};
const result = buildDepartureSearchSeo(stubTRu, params, "ru", CANONICAL);
expect(result.title).toContain("date=сегодня");
expect(result.description).toContain("date=15.05.2026");
});
});
// ---------------------------------------------------------------------------
// buildOnlineBoardStartSeo
// ---------------------------------------------------------------------------
describe("buildOnlineBoardStartSeo", () => {
it("uses MAIN translation keys for title and description", () => {
const result = buildOnlineBoardStartSeo(stubT, "ru", CANONICAL);
@@ -62,6 +147,10 @@ describe("buildOnlineBoardStartSeo", () => {
});
});
// ---------------------------------------------------------------------------
// buildFlightSearchSeo
// ---------------------------------------------------------------------------
describe("buildFlightSearchSeo", () => {
const params = {
type: "flight" as const,
@@ -78,6 +167,14 @@ describe("buildFlightSearchSeo", () => {
expect(result.description).toContain("SEO.BOARD.FLIGHT-SEARCH.DESCRIPTION");
});
it("title date uses formatDateForTitle; description uses dd.MM.yyyy", () => {
// 20250115 is not today/tomorrow, so title date = "15.01.2025"
const result = buildFlightSearchSeo(stubT, params, "ru", CANONICAL);
expect(result.title).toContain("date=15.01.2025");
expect(result.description).toContain("date=15.01.2025");
});
it("sets canonical to the flight search URL", () => {
const result = buildFlightSearchSeo(stubT, params, "ru", CANONICAL);
@@ -93,6 +190,10 @@ describe("buildFlightSearchSeo", () => {
});
});
// ---------------------------------------------------------------------------
// buildDepartureSearchSeo
// ---------------------------------------------------------------------------
describe("buildDepartureSearchSeo", () => {
const params = {
type: "departure" as const,
@@ -119,6 +220,13 @@ describe("buildDepartureSearchSeo", () => {
expect(result.title).toContain("departureCity=SVO");
});
it("title date uses formatDateForTitle; description keeps dd.MM.yyyy", () => {
const result = buildDepartureSearchSeo(stubT, params, "ru", CANONICAL);
expect(result.title).toContain("date=15.01.2025");
expect(result.description).toContain("date=15.01.2025");
});
it("sets canonical to the departure search URL", () => {
const result = buildDepartureSearchSeo(stubT, params, "en", CANONICAL);
@@ -128,6 +236,10 @@ describe("buildDepartureSearchSeo", () => {
});
});
// ---------------------------------------------------------------------------
// buildArrivalSearchSeo
// ---------------------------------------------------------------------------
describe("buildArrivalSearchSeo", () => {
const params = {
type: "arrival" as const,
@@ -154,6 +266,13 @@ describe("buildArrivalSearchSeo", () => {
expect(result.title).toContain("arrivalCity=JFK");
});
it("title date uses formatDateForTitle; description keeps dd.MM.yyyy", () => {
const result = buildArrivalSearchSeo(stubT, params, "ru", CANONICAL);
expect(result.title).toContain("date=15.01.2025");
expect(result.description).toContain("date=15.01.2025");
});
it("sets canonical to the arrival search URL", () => {
const result = buildArrivalSearchSeo(stubT, params, "ru", CANONICAL);
@@ -163,6 +282,10 @@ describe("buildArrivalSearchSeo", () => {
});
});
// ---------------------------------------------------------------------------
// buildRouteSearchSeo
// ---------------------------------------------------------------------------
describe("buildRouteSearchSeo", () => {
const params = {
type: "route" as const,
@@ -192,6 +315,13 @@ describe("buildRouteSearchSeo", () => {
expect(result.title).toContain("arrivalCity=JFK");
});
it("title date uses formatDateForTitle; description keeps dd.MM.yyyy", () => {
const result = buildRouteSearchSeo(stubT, params, "ru", CANONICAL);
expect(result.title).toContain("date=15.01.2025");
expect(result.description).toContain("date=15.01.2025");
});
it("sets canonical to the route search URL", () => {
const result = buildRouteSearchSeo(stubT, params, "en", CANONICAL);
@@ -201,6 +331,10 @@ describe("buildRouteSearchSeo", () => {
});
});
// ---------------------------------------------------------------------------
// buildFlightDetailsSeo
// ---------------------------------------------------------------------------
describe("buildFlightDetailsSeo", () => {
const flight: ISimpleFlight = {
id: "SU100-20250115",
@@ -263,11 +397,48 @@ describe("buildFlightDetailsSeo", () => {
},
};
it("uses FLIGHT-DETAILS translation keys", () => {
it("uses FLIGHT-DETAILS.TITLE with flightNumber and routeCities (no date)", () => {
const result = buildFlightDetailsSeo(stubT, flight, "ru", CANONICAL);
expect(result.title).toContain("SEO.BOARD.FLIGHT-DETAILS.TITLE");
expect(result.title).toContain("flightNumber=SU 0100");
expect(result.title).toContain("routeCities=Moscow-New York");
// No date interpolation in title
expect(result.title).not.toContain("date=");
});
it("description keeps dd.MM.yyyy date", () => {
const result = buildFlightDetailsSeo(stubT, flight, "ru", CANONICAL);
expect(result.description).toContain("SEO.BOARD.FLIGHT-DETAILS.DESCRIPTION");
expect(result.description).toContain("flightNumber=SU 0100");
expect(result.description).toContain("date=15.01.2025");
});
it("uses TITLE-NO-ROUTE when cityNames not available (from id only)", () => {
const result = buildFlightDetailsSeoFromId(
stubT,
{ carrier: "SU", flightNumber: "0100", date: "20250115" },
"ru",
CANONICAL,
// no cityNames provided
);
expect(result.title).toContain("SEO.BOARD.FLIGHT-DETAILS.TITLE-NO-ROUTE");
expect(result.title).toContain("flightNumber=SU 0100");
expect(result.title).not.toContain("routeCities=");
});
it("uses explicit cityNames over leg cities when both present", () => {
const result = buildFlightDetailsSeo(
stubT,
flight,
"ru",
CANONICAL,
{ departure: "Москва", arrival: "Нью-Йорк" },
);
expect(result.title).toContain("routeCities=Москва-Нью-Йорк");
});
it("sets canonical to the details URL", () => {
+78 -19
View File
@@ -47,6 +47,7 @@ const SITE_NAME = "Aeroflot";
/**
* Format a date string to dd.MM.yyyy for display in SEO strings.
* Accepts both 'yyyyMMdd' (URL param shape) and 'yyyy-MM-dd' (API response).
* Used for descriptions only — titles use formatDateForTitle.
*/
function formatDateForSeo(input: string): string {
const digits = input.replace(/-/g, "");
@@ -57,6 +58,33 @@ function formatDateForSeo(input: string): string {
return `${day}.${month}.${year}`;
}
/**
* Format a date for title display per TZ §4.1.3 Table 6:
* - t("SHARED.TODAY") if date is today (local)
* - t("SHARED.TOMORROW") if date is today + 1 day (local)
* - "dd.MM.yyyy" otherwise
*
* Input: "yyyyMMdd" (URL shape) or "yyyy-MM-dd" (API shape).
*/
function formatDateForTitle(input: string, t: TFunction): string {
const digits = input.replace(/-/g, "");
if (digits.length !== 8) return input;
const y = Number(digits.slice(0, 4));
const m = Number(digits.slice(4, 6)) - 1;
const d = Number(digits.slice(6, 8));
const inputDate = new Date(y, m, d);
inputDate.setHours(0, 0, 0, 0);
const today = new Date();
today.setHours(0, 0, 0, 0);
const deltaDays = Math.round((inputDate.getTime() - today.getTime()) / 86_400_000);
if (deltaDays === 0) return t("SHARED.TODAY");
if (deltaDays === 1) return t("SHARED.TOMORROW");
return `${digits.slice(6, 8)}.${digits.slice(4, 6)}.${digits.slice(0, 4)}`;
}
/**
* Build the canonical URL for a given set of params + locale.
*/
@@ -153,15 +181,16 @@ export function buildFlightSearchSeo(
canonicalOrigin: string,
): SeoHeadProps {
const flightDisplay = `${params.carrier} ${params.flightNumber}${params.suffix ?? ""}`;
const dateDisplay = formatDateForSeo(params.date);
const dateForTitle = formatDateForTitle(params.date, t);
const dateForDesc = formatDateForSeo(params.date);
const title = t("SEO.BOARD.FLIGHT-SEARCH.TITLE", {
flightNumber: flightDisplay,
date: dateDisplay,
date: dateForTitle,
});
const description = t("SEO.BOARD.FLIGHT-SEARCH.DESCRIPTION", {
flightNumber: flightDisplay,
date: dateDisplay,
date: dateForDesc,
});
const canonical = buildCanonical(canonicalOrigin, locale, params);
const hreflangPath = buildPathWithoutLocale(params);
@@ -193,15 +222,16 @@ export function buildDepartureSearchSeo(
cityNames?: CityNames,
): SeoHeadProps {
const departureCity = cityNames?.departure ?? params.station;
const dateDisplay = formatDateForSeo(params.date);
const dateForTitle = formatDateForTitle(params.date, t);
const dateForDesc = formatDateForSeo(params.date);
const title = t("SEO.BOARD.DEPARTURE-SEARCH.TITLE", {
departureCity,
date: dateDisplay,
date: dateForTitle,
});
const description = t("SEO.BOARD.DEPARTURE-SEARCH.DESCRIPTION", {
departureCity,
date: dateDisplay,
date: dateForDesc,
});
const canonical = buildCanonical(canonicalOrigin, locale, params);
const hreflangPath = buildPathWithoutLocale(params);
@@ -233,15 +263,16 @@ export function buildArrivalSearchSeo(
cityNames?: CityNames,
): SeoHeadProps {
const arrivalCity = cityNames?.arrival ?? params.station;
const dateDisplay = formatDateForSeo(params.date);
const dateForTitle = formatDateForTitle(params.date, t);
const dateForDesc = formatDateForSeo(params.date);
const title = t("SEO.BOARD.ARRIVAL-SEARCH.TITLE", {
arrivalCity,
date: dateDisplay,
date: dateForTitle,
});
const description = t("SEO.BOARD.ARRIVAL-SEARCH.DESCRIPTION", {
arrivalCity,
date: dateDisplay,
date: dateForDesc,
});
const canonical = buildCanonical(canonicalOrigin, locale, params);
const hreflangPath = buildPathWithoutLocale(params);
@@ -274,17 +305,18 @@ export function buildRouteSearchSeo(
): SeoHeadProps {
const departureCity = cityNames?.departure ?? params.departure;
const arrivalCity = cityNames?.arrival ?? params.arrival;
const dateDisplay = formatDateForSeo(params.date);
const dateForTitle = formatDateForTitle(params.date, t);
const dateForDesc = formatDateForSeo(params.date);
const title = t("SEO.BOARD.ROUTE-SEARCH.TITLE", {
departureCity,
arrivalCity,
date: dateDisplay,
date: dateForTitle,
});
const description = t("SEO.BOARD.ROUTE-SEARCH.DESCRIPTION", {
departureCity,
arrivalCity,
date: dateDisplay,
date: dateForDesc,
});
const canonical = buildCanonical(canonicalOrigin, locale, params);
const hreflangPath = buildPathWithoutLocale(params);
@@ -310,37 +342,64 @@ export function buildRouteSearchSeo(
* Accepts a plain object so the route page can render SeoHead
* synchronously without waiting for the flight data to load — SSR
* needs the title + meta in the <head> of the first response.
*
* @param cityNames - Optional departure/arrival city names. When available
* (e.g. after flight data has loaded client-side), supply these so the
* title includes the route cities per TZ Table 6 rows 6-8. When not
* available at SSR time the title will omit the route segment.
*/
export function buildFlightDetailsSeoFromId(
t: TFunction,
flightId: { carrier: string; flightNumber: string; suffix?: string; date: string },
locale: string,
canonicalOrigin: string,
cityNames?: CityNames,
): SeoHeadProps {
const flight: ISimpleFlight = { flightId } as unknown as ISimpleFlight;
return buildFlightDetailsSeo(t, flight, locale, canonicalOrigin);
return buildFlightDetailsSeo(t, flight, locale, canonicalOrigin, cityNames);
}
/**
* SEO props for flight details page.
* Title per TZ Table 6 rows 6-8: "Информация о рейсе: {flightNumber}, {fromCity}-{toCity}"
* No date in the title. Description keeps the full dd.MM.yyyy date.
*/
export function buildFlightDetailsSeo(
t: TFunction,
flight: ISimpleFlight,
locale: string,
canonicalOrigin: string,
cityNames?: CityNames,
): SeoHeadProps {
const { carrier, flightNumber, suffix, date } = flight.flightId;
const flightDisplay = `${carrier} ${flightNumber}${suffix ?? ""}`;
const dateDisplay = formatDateForSeo(date);
const dateForDesc = formatDateForSeo(date);
// Derive route cities: prefer explicit cityNames, then flight leg data.
let departureCity = cityNames?.departure;
let arrivalCity = cityNames?.arrival;
if (!departureCity || !arrivalCity) {
if (flight.routeType === "Direct") {
departureCity ??= flight.leg.departure.scheduled.city;
arrivalCity ??= flight.leg.arrival.scheduled.city;
} else if (flight.routeType === "MultiLeg" && flight.legs.length > 0) {
const firstLeg = flight.legs[0];
const lastLeg = flight.legs[flight.legs.length - 1];
if (firstLeg) departureCity ??= firstLeg.departure.scheduled.city;
if (lastLeg) arrivalCity ??= lastLeg.arrival.scheduled.city;
}
}
const routeCities =
departureCity && arrivalCity ? `${departureCity}-${arrivalCity}` : undefined;
const title = routeCities
? t("SEO.BOARD.FLIGHT-DETAILS.TITLE", { flightNumber: flightDisplay, routeCities })
: t("SEO.BOARD.FLIGHT-DETAILS.TITLE-NO-ROUTE", { flightNumber: flightDisplay });
const title = t("SEO.BOARD.FLIGHT-DETAILS.TITLE", {
flightNumber: flightDisplay,
date: dateDisplay,
});
const description = t("SEO.BOARD.FLIGHT-DETAILS.DESCRIPTION", {
flightNumber: flightDisplay,
date: dateDisplay,
date: dateForDesc,
});
const detailsParams: OnlineBoardParams = suffix
+2 -1
View File
@@ -201,7 +201,8 @@
},
"FLIGHT-DETAILS": {
"DESCRIPTION": "",
"TITLE": ""
"TITLE": "",
"TITLE-NO-ROUTE": ""
},
"FLIGHT-SEARCH": {
"DESCRIPTION": "",
+7 -6
View File
@@ -226,27 +226,28 @@
"BOARD": {
"ARRIVAL-SEARCH": {
"DESCRIPTION": "Up-to-date list of Aeroflot flights arriving on {date}. Online arrivals board for {arrivalCity}.",
"TITLE": "Online arrivals board for {arrivalCity} | Aeroflot flights arriving {date}"
"TITLE": "Arrivals: {arrivalCity}, {date}"
},
"DEPARTURE-SEARCH": {
"DESCRIPTION": "Up-to-date list of Aeroflot flights departing on {date}. Online departures board for {departureCity}.",
"TITLE": "Online departures board for {departureCity} | Aeroflot flights departing {date}"
"TITLE": "Departures: {departureCity}, {date}"
},
"FLIGHT-DETAILS": {
"DESCRIPTION": "Real-time departure and arrival information for flight {flightNumber}. Departure time, arrival time, and current flight status on the official Aeroflot website.",
"TITLE": "Flight status {flightNumber} {date} | Aeroflot"
"TITLE": "Flight info: {flightNumber}, {routeCities}",
"TITLE-NO-ROUTE": "Flight info: {flightNumber}"
},
"FLIGHT-SEARCH": {
"DESCRIPTION": "Departure and arrival information for flight {flightNumber} on {date}.",
"TITLE": "Flight {flightNumber} Online arrivals and departures board {date} | Aeroflot"
"TITLE": "Flight: {flightNumber}, {date}"
},
"MAIN": {
"DESCRIPTION": "Arrivals and departures board for Aeroflot airline. Real-time flight arrival and departure information.",
"TITLE": "Online departures and arrivals board for Aeroflot flights | Aeroflot"
"TITLE": "Online Board"
},
"ROUTE-SEARCH": {
"DESCRIPTION": "Arrivals and departures board for Aeroflot flights on the {departureCity} - {arrivalCity} route. Real-time flight information for {date}.",
"TITLE": "Arrivals and departures {departureCity} - {arrivalCity} {date} | Aeroflot"
"TITLE": "Route: {departureCity}-{arrivalCity}, {date}"
}
},
"SCHEDULE": {
+2 -1
View File
@@ -201,7 +201,8 @@
},
"FLIGHT-DETAILS": {
"DESCRIPTION": "",
"TITLE": ""
"TITLE": "",
"TITLE-NO-ROUTE": ""
},
"FLIGHT-SEARCH": {
"DESCRIPTION": "",
+2 -1
View File
@@ -201,7 +201,8 @@
},
"FLIGHT-DETAILS": {
"DESCRIPTION": "",
"TITLE": ""
"TITLE": "",
"TITLE-NO-ROUTE": ""
},
"FLIGHT-SEARCH": {
"DESCRIPTION": "",
+2 -1
View File
@@ -201,7 +201,8 @@
},
"FLIGHT-DETAILS": {
"DESCRIPTION": "",
"TITLE": ""
"TITLE": "",
"TITLE-NO-ROUTE": ""
},
"FLIGHT-SEARCH": {
"DESCRIPTION": "",
+2 -1
View File
@@ -201,7 +201,8 @@
},
"FLIGHT-DETAILS": {
"DESCRIPTION": "",
"TITLE": ""
"TITLE": "",
"TITLE-NO-ROUTE": ""
},
"FLIGHT-SEARCH": {
"DESCRIPTION": "",
+2 -1
View File
@@ -201,7 +201,8 @@
},
"FLIGHT-DETAILS": {
"DESCRIPTION": "",
"TITLE": ""
"TITLE": "",
"TITLE-NO-ROUTE": ""
},
"FLIGHT-SEARCH": {
"DESCRIPTION": "",
+7 -6
View File
@@ -226,27 +226,28 @@
"BOARD": {
"ARRIVAL-SEARCH": {
"DESCRIPTION": "Актуальный список рейсов авиакомпании Аэрофлот, прибывающих {date}. Онлайн-табло прилетов в {arrivalCity}.",
"TITLE": "Онлайн-табло прилетов в {arrivalCity} | Прибытие рейсов от Аэрофлот {date}"
"TITLE": "Прилет: {arrivalCity}, {date}"
},
"DEPARTURE-SEARCH": {
"DESCRIPTION": "Актуальный список рейсов авиакомпании Аэрофлот, отправляющихся {date}. Онлайн-табло вылетов из {departureCity}.",
"TITLE": "Онлайн-табло вылетов из {departureCity} | Отправление рейсов Аэрофлот {date}"
"TITLE": "Вылет: {departureCity}, {date}"
},
"FLIGHT-DETAILS": {
"DESCRIPTION": "Информация об отправлении и прибытии рейса {flightNumber} в режиме онлайн! Время вылета, время прилета, актуальный статус рейса на официальном сайте авиакомпании Аэрофлот.",
"TITLE": "Статус рейса {flightNumber} {date} | Аэрофлот"
"TITLE": "Информация о рейсе: {flightNumber}, {routeCities}",
"TITLE-NO-ROUTE": "Информация о рейсе: {flightNumber}"
},
"FLIGHT-SEARCH": {
"DESCRIPTION": "Информация об отправлении и прибытии рейса {flightNumber} {date}.",
"TITLE": "Рейс {flightNumber} – Онлайн-табло прилета и вылета {date} | Аэрофлот"
"TITLE": "Рейс: {flightNumber}, {date}"
},
"MAIN": {
"DESCRIPTION": "Табло прибытия и отправления рейсов авиакомпании 'Аэрофлот'. Информация о прилетах и вылетах в режиме онлайн.",
"TITLE": "Онлайн-табло вылетов и прилетов рейсов авиакомпании Аэрофлот | Аэрофлот"
"TITLE": "Онлайн-Табло"
},
"ROUTE-SEARCH": {
"DESCRIPTION": "Табло прибытия и отправления рейсов авиакомпании Аэрофлот по направлению {departureCity} - {arrivalCity}. Информация о прилетах и вылетах в режиме онлайн на {date}.",
"TITLE": "Прибытие и отправление рейсов {departureCity} - {arrivalCity} {date} | Аэрофлот"
"TITLE": "Маршрут: {departureCity}-{arrivalCity}, {date}"
}
},
"SCHEDULE": {
+2 -1
View File
@@ -201,7 +201,8 @@
},
"FLIGHT-DETAILS": {
"DESCRIPTION": "",
"TITLE": ""
"TITLE": "",
"TITLE-NO-ROUTE": ""
},
"FLIGHT-SEARCH": {
"DESCRIPTION": "",