Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }>;
}

/**
Expand All @@ -13,6 +16,21 @@ interface Props {
*/
export const dynamic = 'force-dynamic';

export async function generateMetadata(
{ params }: Props,
parent: ResolvingMetadata,
): Promise<Metadata> {
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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
formatProvidersSorted,
generatePageTitle,
generateDescriptionMetaTag,
generateMapPageTitle,
generateMapDescriptionMetaTag,
} from '../../../../../screens/Feed/Feed.functions';

/**
Expand Down Expand Up @@ -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`,
},
};
}
20 changes: 20 additions & 0 deletions src/app/[locale]/feeds/[feedDataType]/[feedId]/static/map/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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).
*
Expand Down
45 changes: 45 additions & 0 deletions src/app/screens/Feed/Feed.functions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, string>) => 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,
Expand Down
73 changes: 73 additions & 0 deletions src/app/screens/Feed/Feed.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
formatProvidersSorted,
generatePageTitle,
generateDescriptionMetaTag,
generateMapPageTitle,
generateMapDescriptionMetaTag,
} from './Feed.functions';
import FeedTitle from './components/FeedTitle';

Expand Down Expand Up @@ -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, string>) => 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('');
});
});