From c2437a5538d7cbd9d1f0bf46c7ee159ce4c99de5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 12:37:36 +0000 Subject: [PATCH 1/2] Initial plan From 6c5ea842be4166bc3028a53ba503e3babbc02731 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 12:46:31 +0000 Subject: [PATCH 2/2] feat: add unique metadata to feed detail map pages - Add generateMapPageTitle and generateMapDescriptionMetaTag to Feed.functions.tsx - Add mapPageDescription translation key to en.json and fr.json - Add generateMapFeedMetadata to generate-feed-metadata.ts with map-specific canonical URL - Add generateMetadata export to authed/map/page.tsx and static/map/page.tsx - Add unit tests for new title and description functions Co-authored-by: Alessandro100 <18631060+Alessandro100@users.noreply.github.com> Agent-Logs-Url: https://github.com/MobilityData/mobilitydatabase-web/sessions/bb3c0799-e6bf-491c-a0a8-a37dbc5fec2e --- messages/en.json | 1 + messages/fr.json | 1 + .../[feedId]/authed/map/page.tsx | 20 ++++- .../[feedId]/lib/generate-feed-metadata.ts | 57 +++++++++++++++ .../[feedId]/static/map/page.tsx | 20 +++++ src/app/screens/Feed/Feed.functions.tsx | 45 ++++++++++++ src/app/screens/Feed/Feed.spec.tsx | 73 +++++++++++++++++++ 7 files changed, 216 insertions(+), 1 deletion(-) diff --git a/messages/en.json b/messages/en.json index 54989ee1..e5761da5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -214,6 +214,7 @@ "isAuthRequired": "Is authentication required for the feed?", "isAuthRequiredDetails": " Select \"Yes\" if a user has to login or provide credentials to download the feed", "detailPageDescription": "Explore the {formattedName} {dataTypeVerbose} feed details with access to a quality data insights", + "mapPageDescription": "Explore the {formattedName} {dataTypeVerbose} feed on an interactive map showing routes, stops, and transit coverage.", "officialFeed": "Official Feed", "officialFeedTooltip": "The transit provider has confirmed this feed should be shared with riders. This has been confirmed either by the transit provider providing the feed on their website or from personalized confirmation with the Mobility Database team.", "officialFeedTooltipShort": "Verified feed: Confirmed by the transit provider or the Mobility Database team for rider use.", diff --git a/messages/fr.json b/messages/fr.json index 4c5ce805..68687d7f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -214,6 +214,7 @@ "isAuthRequired": "Is authentication required for the feed?", "isAuthRequiredDetails": " Select \"Yes\" if a user has to login or provide credentials to download the feed", "detailPageDescription": "Explore the {formattedName} {dataTypeVerbose} feed details with access to a quality data insights", + "mapPageDescription": "Explore the {formattedName} {dataTypeVerbose} feed on an interactive map showing routes, stops, and transit coverage.", "officialFeed": "Official Feed", "officialFeedTooltip": "The transit provider has confirmed this feed should be shared with riders. This has been confirmed either by the transit provider providing the feed on their website or from personalized confirmation with the Mobility Database team.", "officialFeedTooltipShort": "Verified feed: Confirmed by the transit provider or the Mobility Database team for rider use.", diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/map/page.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/map/page.tsx index c0916fe8..8421b81c 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/map/page.tsx +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/map/page.tsx @@ -1,10 +1,13 @@ import FullMapView from '../../../../../../screens/Feed/components/FullMapView'; import { type ReactElement } from 'react'; import { notFound } from 'next/navigation'; +import type { Metadata, ResolvingMetadata } from 'next'; +import { getTranslations } from 'next-intl/server'; import { fetchCompleteFeedData } from '../../lib/feed-data'; +import { generateMapFeedMetadata } from '../../lib/generate-feed-metadata'; interface Props { - params: Promise<{ feedDataType: string; feedId: string }>; + params: Promise<{ locale: string; feedDataType: string; feedId: string }>; } /** @@ -13,6 +16,21 @@ interface Props { */ export const dynamic = 'force-dynamic'; +export async function generateMetadata( + { params }: Props, + parent: ResolvingMetadata, +): Promise { + const { locale, feedId, feedDataType } = await params; + const t = await getTranslations({ locale }); + + const feedData = await fetchCompleteFeedData(feedDataType, feedId); + + return generateMapFeedMetadata({ + feed: feedData?.feed, + t, + }); +} + /** * Full map view page for AUTHENTICATED users. * diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/generate-feed-metadata.ts b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/generate-feed-metadata.ts index a050c7d3..252264e0 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/generate-feed-metadata.ts +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/generate-feed-metadata.ts @@ -9,6 +9,8 @@ import { formatProvidersSorted, generatePageTitle, generateDescriptionMetaTag, + generateMapPageTitle, + generateMapDescriptionMetaTag, } from '../../../../../screens/Feed/Feed.functions'; /** @@ -335,3 +337,58 @@ export function generateFeedMetadata({ }, }; } + +/** + * Shared metadata generation logic for feed map pages (authed and static). + * + * Produces a unique title and description that reflect the interactive map + * view, so that search engines can distinguish the map page from the main + * feed detail page. + * + * @param feed - The feed data + * @param t - Translation function + */ +export function generateMapFeedMetadata({ + feed, + t, +}: GenerateFeedMetadataParams): Metadata { + if (feed == null) { + return { + title: 'Feed Not Found | Mobility Database', + }; + } + const feedDataType = feed.data_type; + const feedId = feed.id; + const sortedProviders = formatProvidersSorted(feed?.provider ?? ''); + const title = generateMapPageTitle( + sortedProviders, + feedDataType as 'gtfs' | 'gtfs_rt' | 'gbfs', + (feed as { feed_name?: string })?.feed_name, + ); + const description = generateMapDescriptionMetaTag( + t, + sortedProviders, + feedDataType as 'gtfs' | 'gtfs_rt' | 'gbfs', + (feed as { feed_name?: string })?.feed_name, + ); + + return { + title, + description, + openGraph: { + title, + description, + url: `https://mobilitydatabase.org/feeds/${feedDataType}/${feedId}/map`, + siteName: 'Mobility Database', + type: 'website', + }, + twitter: { + card: 'summary', + title, + description, + }, + alternates: { + canonical: `https://mobilitydatabase.org/feeds/${feedDataType}/${feedId}/map`, + }, + }; +} diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/map/page.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/map/page.tsx index ec3c8cad..986e24c9 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/map/page.tsx +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/map/page.tsx @@ -1,12 +1,32 @@ import FullMapView from '../../../../../../screens/Feed/components/FullMapView'; import { type ReactElement } from 'react'; +import type { Metadata, ResolvingMetadata } from 'next'; +import { getTranslations } from 'next-intl/server'; import { fetchGuestFeedData } from '../../lib/guest-feed-data'; import { type FeedDataResult } from '../../lib/feed-data-shared'; +import { generateMapFeedMetadata } from '../../lib/generate-feed-metadata'; interface Props { params: Promise<{ locale: string; feedDataType: string; feedId: string }>; } +export async function generateMetadata( + { params }: Props, + parent: ResolvingMetadata, +): Promise { + const { feedId, feedDataType } = await params; + + const [t, feedData] = await Promise.all([ + getTranslations(), + fetchGuestFeedData(feedDataType, feedId), + ]); + + return generateMapFeedMetadata({ + feed: feedData.feed, + t, + }); +} + /** * Full map view page for feed visualization (GUEST/ISR-cacheable version). * diff --git a/src/app/screens/Feed/Feed.functions.tsx b/src/app/screens/Feed/Feed.functions.tsx index a0393ff1..7790dcfa 100644 --- a/src/app/screens/Feed/Feed.functions.tsx +++ b/src/app/screens/Feed/Feed.functions.tsx @@ -81,6 +81,51 @@ export function generatePageTitle( return newDocTitle; } +export function generateMapPageTitle( + sortedProviders: string[], + dataType: 'gtfs' | 'gtfs_rt' | 'gbfs' | undefined, + feedName?: string, +): string { + let newDocTitle = getFeedFormattedName(sortedProviders, feedName); + + if (newDocTitle !== '') { + if (dataType === 'gtfs') { + newDocTitle += ' GTFS Schedule Feed Map - '; + } else if (dataType === 'gtfs_rt') { + newDocTitle += ' GTFS Realtime Feed Map - '; + } else if (dataType === 'gbfs') { + newDocTitle += ' GBFS Feed Map - '; + } + } + + newDocTitle += 'Mobility Database'; + return newDocTitle; +} + +export function generateMapDescriptionMetaTag( + t: (key: string, options?: Record) => string, + sortedProviders: string[], + dataType: 'gtfs' | 'gtfs_rt' | 'gbfs' | undefined, + feedName?: string, +): string { + const formattedName = getFeedFormattedName(sortedProviders, feedName); + if ( + sortedProviders.length === 0 && + (feedName === undefined || feedName === '') + ) { + return ''; + } + let dataTypeVerbose = ''; + if (dataType === 'gtfs') { + dataTypeVerbose = t('common.gtfsSchedule'); + } else if (dataType === 'gtfs_rt') { + dataTypeVerbose = t('common.gtfsRealtime'); + } else if (dataType === 'gbfs') { + dataTypeVerbose = t('common.gbfs'); + } + return t('feeds.mapPageDescription', { formattedName, dataTypeVerbose }); +} + export const formatServiceDateRange = ( dateStart: string, dateEnd: string, diff --git a/src/app/screens/Feed/Feed.spec.tsx b/src/app/screens/Feed/Feed.spec.tsx index a9ee160a..f1731500 100644 --- a/src/app/screens/Feed/Feed.spec.tsx +++ b/src/app/screens/Feed/Feed.spec.tsx @@ -9,6 +9,8 @@ import { formatProvidersSorted, generatePageTitle, generateDescriptionMetaTag, + generateMapPageTitle, + generateMapDescriptionMetaTag, } from './Feed.functions'; import FeedTitle from './components/FeedTitle'; @@ -265,4 +267,75 @@ describe('Feed page', () => { ); expect(descriptionAllEmpty).toEqual(''); }); + + it('should generate the correct map page title', () => { + const titleAllInfo = generateMapPageTitle( + ['Department of Transport', 'Public Transport'], + 'gtfs', + 'Darwin public bus network', + ); + expect(titleAllInfo).toEqual( + 'Department of Transport, Darwin public bus network GTFS Schedule Feed Map - Mobility Database', + ); + + const titleAllInfoRT = generateMapPageTitle( + ['Department of Transport', 'Public Transport'], + 'gtfs_rt', + 'Darwin public bus network', + ); + expect(titleAllInfoRT).toEqual( + 'Department of Transport, Darwin public bus network GTFS Realtime Feed Map - Mobility Database', + ); + + const titleAllEmpty = generateMapPageTitle([], 'gtfs', ''); + expect(titleAllEmpty).toEqual('Mobility Database'); + + const gbfsTitle = generateMapPageTitle(['Flamingo Porirua'], 'gbfs'); + expect(gbfsTitle).toEqual( + 'Flamingo Porirua GBFS Feed Map - Mobility Database', + ); + }); + + it('should generate the correct map page description', () => { + const mockT = jest.fn((key, params) => { + switch (key) { + case 'common.gtfsSchedule': + return 'GTFS schedule'; + case 'common.gtfsRealtime': + return 'GTFS realtime'; + case 'common.gbfs': + return 'GBFS'; + case 'feeds.mapPageDescription': + return `Explore the ${params.formattedName} ${params.dataTypeVerbose} feed on an interactive map showing routes, stops, and transit coverage.`; + } + }) as unknown as (key: string, options?: Record) => string; + + const descriptionAllInfo = generateMapDescriptionMetaTag( + mockT, + ['Department of Transport', 'Public Transport'], + 'gtfs', + 'Darwin public bus network', + ); + expect(descriptionAllInfo).toEqual( + 'Explore the Department of Transport, Darwin public bus network GTFS schedule feed on an interactive map showing routes, stops, and transit coverage.', + ); + + const descriptionNoProviders = generateMapDescriptionMetaTag( + mockT, + [], + 'gtfs', + 'Darwin public bus network', + ); + expect(descriptionNoProviders).toEqual( + 'Explore the Darwin public bus network GTFS schedule feed on an interactive map showing routes, stops, and transit coverage.', + ); + + const descriptionAllEmpty = generateMapDescriptionMetaTag( + mockT, + [], + 'gtfs', + '', + ); + expect(descriptionAllEmpty).toEqual(''); + }); });