diff --git a/src/features/schedule/seo.test.ts b/src/features/schedule/seo.test.ts index 61f8366c..ed0816eb 100644 --- a/src/features/schedule/seo.test.ts +++ b/src/features/schedule/seo.test.ts @@ -195,12 +195,146 @@ describe("buildScheduleDetailsSeo", () => { }, }; - it("uses FLIGHT-DETAILS translation keys", () => { + it("uses TITLE-DIRECT key for single flight number (direct)", () => { const result = buildScheduleDetailsSeo(stubT, [flight], "ru", CANONICAL, flightIds); - expect(result.title).toContain("SEO.SCHEDULE.FLIGHT-DETAILS.TITLE"); + expect(result.title).toContain("SEO.SCHEDULE.FLIGHT-DETAILS.TITLE-DIRECT"); expect(result.title).toContain("flightNumber=SU 0012"); - expect(result.title).toContain("date=27.05.2022"); + expect(result.title).toContain("routeCities=Moscow-Saint Petersburg"); + }); + + it("uses TITLE-CONNECTING key for multiple distinct flight numbers (connecting)", () => { + const connectingFlightIds: IScheduleFlightId[] = [ + { carrier: "SU", flightNumber: "1234", date: "20220527" }, + { carrier: "SU", flightNumber: "5678", date: "20220527" }, + ]; + + const result = buildScheduleDetailsSeo(stubT, [flight], "ru", CANONICAL, connectingFlightIds); + + expect(result.title).toContain("SEO.SCHEDULE.FLIGHT-DETAILS.TITLE-CONNECTING"); + expect(result.title).toContain("flightNumbers=SU 1234, SU 5678"); + }); + + it("uses TITLE-DIRECT for multi-segment flight (same flight number across legs)", () => { + const multiSegFlight: ISimpleFlight = { + id: "SU0012-20220527", + routeType: "MultiLeg", + flightId: { carrier: "SU", flightNumber: "0012", suffix: "", date: "20220527" }, + flyingTime: "3h 00m", + operatingBy: {}, + status: "Scheduled", + legs: [ + { + index: 0, + status: "Scheduled", + flyingTime: "1h 30m", + updated: "2022-05-27T10:00:00Z", + dayChange: 0, + equipment: { name: "Airbus A320", code: "320" }, + flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false }, + operatingBy: {}, + departure: { + scheduled: { + airport: "Sheremetyevo", + airportCode: "SVO", + city: "Moscow", + cityCode: "MOW", + countryCode: "RU", + }, + checkingStatus: "closed", + times: { + scheduledDeparture: { + dayChange: { value: 0, title: "" }, + local: "2022-05-27T10:00:00", + localTime: "10:00", + tzOffset: 3, + utc: "2022-05-27T07:00:00Z", + }, + }, + }, + arrival: { + scheduled: { + airport: "Tolmachevo", + airportCode: "OVB", + city: "Novosibirsk", + cityCode: "OVB", + countryCode: "RU", + }, + times: { + scheduledArrival: { + dayChange: { value: 0, title: "" }, + local: "2022-05-27T14:00:00", + localTime: "14:00", + tzOffset: 7, + utc: "2022-05-27T07:00:00Z", + }, + }, + }, + }, + { + index: 1, + status: "Scheduled", + flyingTime: "1h 30m", + updated: "2022-05-27T14:30:00Z", + dayChange: 0, + equipment: { name: "Airbus A320", code: "320" }, + flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false }, + operatingBy: {}, + departure: { + scheduled: { + airport: "Tolmachevo", + airportCode: "OVB", + city: "Novosibirsk", + cityCode: "OVB", + countryCode: "RU", + }, + checkingStatus: "closed", + times: { + scheduledDeparture: { + dayChange: { value: 0, title: "" }, + local: "2022-05-27T14:30:00", + localTime: "14:30", + tzOffset: 7, + utc: "2022-05-27T07:30:00Z", + }, + }, + }, + arrival: { + scheduled: { + airport: "Vladivostok", + airportCode: "VVO", + city: "Vladivostok", + cityCode: "VVO", + countryCode: "RU", + }, + times: { + scheduledArrival: { + dayChange: { value: 0, title: "" }, + local: "2022-05-27T17:00:00", + localTime: "17:00", + tzOffset: 10, + utc: "2022-05-27T07:00:00Z", + }, + }, + }, + }, + ], + }; + + // Multi-segment = same flight number on all legs → treated as direct (singular) + const result = buildScheduleDetailsSeo(stubT, [multiSegFlight], "ru", CANONICAL, flightIds); + + expect(result.title).toContain("SEO.SCHEDULE.FLIGHT-DETAILS.TITLE-DIRECT"); + expect(result.title).toContain("flightNumber=SU 0012"); + expect(result.title).toContain("routeCities=Moscow-Vladivostok"); + }); + + it("uses TITLE-NO-ROUTE-DIRECT when flight data is empty (SSR without loaded data)", () => { + const result = buildScheduleDetailsSeo(stubT, [], "ru", CANONICAL, flightIds); + + expect(result.title).toContain("SEO.SCHEDULE.FLIGHT-DETAILS.TITLE-NO-ROUTE-DIRECT"); + expect(result.title).toContain("flightNumber=SU 0012"); + expect(result.title).not.toContain("routeCities"); }); it("sets canonical to the details URL", () => { @@ -222,15 +356,4 @@ describe("buildScheduleDetailsSeo", () => { expect(result.og.type).toBe("article"); }); - - it("handles multiple flights in display", () => { - const multiFlightIds: IScheduleFlightId[] = [ - { carrier: "SU", flightNumber: "0012", date: "20220527" }, - { carrier: "SU", flightNumber: "0013", date: "20220527" }, - ]; - - const result = buildScheduleDetailsSeo(stubT, [flight], "ru", CANONICAL, multiFlightIds); - - expect(result.title).toContain("SU 0012, SU 0013"); - }); }); diff --git a/src/features/schedule/seo.ts b/src/features/schedule/seo.ts index 54ac14f0..fb554f3d 100644 --- a/src/features/schedule/seo.ts +++ b/src/features/schedule/seo.ts @@ -171,6 +171,33 @@ export function buildScheduleSearchSeo( }; } +// --------------------------------------------------------------------------- +// Internal helpers (details) +// --------------------------------------------------------------------------- + +/** + * Derive "{depCity}-{arrCity}" from the loaded flight data. + * Returns empty string when flights aren't available (SSR without data). + */ +function deriveRouteCities(flights: ISimpleFlight[]): string { + const first = flights[0]; + const last = flights[flights.length - 1] ?? first; + if (!first || !last) return ""; + + const depCity = + first.routeType === "Direct" + ? first.leg.departure.scheduled.city + : first.legs[0]?.departure.scheduled.city ?? ""; + + const arrCity = + last.routeType === "Direct" + ? last.leg.arrival.scheduled.city + : last.legs[last.legs.length - 1]?.arrival.scheduled.city ?? ""; + + if (!depCity || !arrCity) return ""; + return `${depCity}-${arrCity}`; +} + /** * SEO props for schedule details page. */ @@ -181,6 +208,9 @@ export function buildScheduleDetailsSeo( canonicalOrigin: string, flightIds: IScheduleFlightId[], ): SeoHeadProps { + const uniqueNumbers = new Set(flightIds.map((f) => f.flightNumber)); + const isConnecting = uniqueNumbers.size > 1; + const flightDisplay = flightIds .map((f) => `${f.carrier} ${f.flightNumber}${f.suffix ?? ""}`) .join(", "); @@ -188,10 +218,27 @@ export function buildScheduleDetailsSeo( ? formatDateForSeo(flightIds[0].date) : ""; - const title = t("SEO.SCHEDULE.FLIGHT-DETAILS.TITLE", { - flightNumber: flightDisplay, - date: dateDisplay, - }); + let title: string; + if (isConnecting) { + title = t("SEO.SCHEDULE.FLIGHT-DETAILS.TITLE-CONNECTING", { + flightNumbers: flightDisplay, + }); + } else { + const first = flightIds[0]!; + const flightNumber = `${first.carrier} ${first.flightNumber}${first.suffix ?? ""}`; + const routeCities = deriveRouteCities(flights); + if (routeCities) { + title = t("SEO.SCHEDULE.FLIGHT-DETAILS.TITLE-DIRECT", { + flightNumber, + routeCities, + }); + } else { + title = t("SEO.SCHEDULE.FLIGHT-DETAILS.TITLE-NO-ROUTE-DIRECT", { + flightNumber, + }); + } + } + const description = t("SEO.SCHEDULE.FLIGHT-DETAILS.DESCRIPTION", { flightNumber: flightDisplay, date: dateDisplay, diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 634b9860..7b475968 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -220,7 +220,9 @@ "SCHEDULE": { "FLIGHT-DETAILS": { "DESCRIPTION": "", - "TITLE": "" + "TITLE-DIRECT": "", + "TITLE-NO-ROUTE-DIRECT": "", + "TITLE-CONNECTING": "" }, "MAIN": { "DESCRIPTION": "", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 025e7375..565f6428 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -253,7 +253,9 @@ "SCHEDULE": { "FLIGHT-DETAILS": { "DESCRIPTION": "Live departure and arrival information for flight {flightNumber}. Departure time, arrival time and current status on the official Aeroflot website.", - "TITLE": "Flight {flightNumber} – Flight schedule for {date} | Aeroflot" + "TITLE-DIRECT": "Schedule: flight {flightNumber}, {routeCities}", + "TITLE-NO-ROUTE-DIRECT": "Schedule: flight {flightNumber}", + "TITLE-CONNECTING": "Schedule: flights {flightNumbers}" }, "MAIN": { "DESCRIPTION": "Aeroflot flight schedule for Russian and international destinations. List of available flights and current departure / arrival times.", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 0583ca34..43532479 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -220,7 +220,9 @@ "SCHEDULE": { "FLIGHT-DETAILS": { "DESCRIPTION": "", - "TITLE": "" + "TITLE-DIRECT": "", + "TITLE-NO-ROUTE-DIRECT": "", + "TITLE-CONNECTING": "" }, "MAIN": { "DESCRIPTION": "", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 0501462e..70653dc3 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -220,7 +220,9 @@ "SCHEDULE": { "FLIGHT-DETAILS": { "DESCRIPTION": "", - "TITLE": "" + "TITLE-DIRECT": "", + "TITLE-NO-ROUTE-DIRECT": "", + "TITLE-CONNECTING": "" }, "MAIN": { "DESCRIPTION": "", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index ae3b89f2..e54dc8c9 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -220,7 +220,9 @@ "SCHEDULE": { "FLIGHT-DETAILS": { "DESCRIPTION": "", - "TITLE": "" + "TITLE-DIRECT": "", + "TITLE-NO-ROUTE-DIRECT": "", + "TITLE-CONNECTING": "" }, "MAIN": { "DESCRIPTION": "", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index ac876b74..212188df 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -220,7 +220,9 @@ "SCHEDULE": { "FLIGHT-DETAILS": { "DESCRIPTION": "", - "TITLE": "" + "TITLE-DIRECT": "", + "TITLE-NO-ROUTE-DIRECT": "", + "TITLE-CONNECTING": "" }, "MAIN": { "DESCRIPTION": "", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index eb8bb4d9..22ffcf5b 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -220,7 +220,9 @@ "SCHEDULE": { "FLIGHT-DETAILS": { "DESCRIPTION": "", - "TITLE": "" + "TITLE-DIRECT": "", + "TITLE-NO-ROUTE-DIRECT": "", + "TITLE-CONNECTING": "" }, "MAIN": { "DESCRIPTION": "", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 53095496..9f901245 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -253,7 +253,9 @@ "SCHEDULE": { "FLIGHT-DETAILS": { "DESCRIPTION": "Информация об отправлении и прибытии рейса {flightNumber} в режиме онлайн! Время вылета, время прилета, актуальный статус рейса на официальном сайте авиакомпании Аэрофлот.", - "TITLE": "Рейс {flightNumber} – Расписание рейсов на {date} | Аэрофлот" + "TITLE-DIRECT": "Расписание рейса: {flightNumber}, {routeCities}", + "TITLE-NO-ROUTE-DIRECT": "Расписание рейса: {flightNumber}", + "TITLE-CONNECTING": "Расписание рейсов: {flightNumbers}" }, "MAIN": { "DESCRIPTION": "Расписание полетов 'Аэрофлот' по России и международным направлениям. Список доступных рейсов и актуальная информация о времени вылета и прилета.", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index f064cb6e..c240d88c 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -220,7 +220,9 @@ "SCHEDULE": { "FLIGHT-DETAILS": { "DESCRIPTION": "", - "TITLE": "" + "TITLE-DIRECT": "", + "TITLE-NO-ROUTE-DIRECT": "", + "TITLE-CONNECTING": "" }, "MAIN": { "DESCRIPTION": "",