From 1a961a38bcf253222edfde2a27dc29f27da4ca7e Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Mon, 2 Nov 2026 13:42:34 -0500 Subject: [PATCH 01/11] feat: add feed location directory Turn the Feeds header item into a dropdown (Search, Locations), add a Locations footer link, and add the /locations directory: continent > country > subdivision > municipality with GTFS / GTFS-RT / GBFS counts, breadcrumbs, and a paginated, relevance-sorted feed list using the search page's compact view. Also move the translation hook in SearchTable's data type cell into a component, so changing the number of rows no longer breaks the hook order. Co-Authored-By: Claude Opus 5.5 --- messages/en.json | 39 +- messages/fr.json | 39 +- .../locations/[[...location]]/page.tsx | 241 ++++++++ .../locations/components/FeedCountSummary.tsx | 85 +++ .../components/LocationBreadcrumbs.tsx | 60 ++ .../components/LocationChildrenGrid.tsx | 145 +++++ .../components/LocationFeedsList.tsx | 86 +++ .../locations/lib/location-directory-data.ts | 146 +++++ .../locations/lib/location-directory.spec.ts | 178 ++++++ .../locations/lib/location-directory.ts | 184 ++++++ src/app/components/Footer.tsx | 1 + src/app/components/Header.tsx | 60 +- src/app/components/HeaderFeedsMenu.tsx | 124 ++++ src/app/components/HeaderMobileDrawer.tsx | 65 ++- src/app/constants/Navigation.ts | 16 + src/app/screens/Feeds/SearchTable.tsx | 56 +- src/app/services/feeds/index.ts | 74 ++- src/app/services/feeds/types.ts | 189 +++++++ src/mocks/handlers.ts | 23 + src/mocks/locationDirectory.spec.ts | 77 +++ src/mocks/locationDirectory.ts | 532 ++++++++++++++++++ 21 files changed, 2351 insertions(+), 69 deletions(-) create mode 100644 src/app/[locale]/locations/[[...location]]/page.tsx create mode 100644 src/app/[locale]/locations/components/FeedCountSummary.tsx create mode 100644 src/app/[locale]/locations/components/LocationBreadcrumbs.tsx create mode 100644 src/app/[locale]/locations/components/LocationChildrenGrid.tsx create mode 100644 src/app/[locale]/locations/components/LocationFeedsList.tsx create mode 100644 src/app/[locale]/locations/lib/location-directory-data.ts create mode 100644 src/app/[locale]/locations/lib/location-directory.spec.ts create mode 100644 src/app/[locale]/locations/lib/location-directory.ts create mode 100644 src/app/components/HeaderFeedsMenu.tsx create mode 100644 src/mocks/locationDirectory.spec.ts create mode 100644 src/mocks/locationDirectory.ts diff --git a/messages/en.json b/messages/en.json index 8990e62d..3a7876ad 100644 --- a/messages/en.json +++ b/messages/en.json @@ -125,7 +125,9 @@ "metricsAdminOnly": "Metrics - Admin Only", "languageSelect": "Language select", "transitFeedsRedirectTitle": "You've been redirected from TransitFeeds", - "transitFeedsRedirectBody": "This page now lives on MobilityDatabase.org, where you'll find the most up-to-date transit data." + "transitFeedsRedirectBody": "This page now lives on MobilityDatabase.org, where you'll find the most up-to-date transit data.", + "locations": "Locations", + "feedsMenu": "Feeds menu" }, "emailVerification": { "loadingTitle": "Verifying your email", @@ -1254,7 +1256,8 @@ "contactUs": "Contact Us", "shareFeedback": "Share Feedback", "privacyPolicy": "Privacy Policy", - "termsAndConditions": "Terms and Conditions" + "termsAndConditions": "Terms and Conditions", + "locations": "Locations" }, "aria": { "github": "GitHub", @@ -1262,5 +1265,37 @@ "linkedin": "LinkedIn", "logo": "Mobility Database logo" } + }, + "locations": { + "root": "Locations", + "title": "Feeds by location", + "rootIntro": "Browse open GTFS Schedule, GTFS Realtime and GBFS feeds by continent, country, region and municipality.", + "nodeIntro": "{total, plural, =0 {No feeds are} one {# feed is} other {# feeds are}} located in {place}.", + "breadcrumbAria": "Location breadcrumb", + "feedCountsAria": "Feed counts by data type", + "shortLabels": { + "gtfs": "GTFS", + "gtfs_rt": "GTFS-RT", + "gbfs": "GBFS" + }, + "childrenHeading": { + "continent": "Continents", + "country": "Countries", + "subdivision": "Regions", + "municipality": "Municipalities" + }, + "childFeedTotal": "{count, plural, one {# feed} other {# feeds}}", + "feedsHeading": "Feeds in {place}", + "allFeedsHeading": "All feeds", + "resultsRange": "Showing {start}–{end} of {total} feeds", + "noFeeds": "No feeds are listed for this location yet.", + "paginationAria": "Feed list pages", + "meta": { + "rootTitle": "Transit Feeds by Location | Mobility Database", + "rootDescription": "Browse open GTFS Schedule, GTFS Realtime and GBFS transit and mobility feeds by continent, country, region and municipality.", + "nodeTitle": "{place} Transit Feeds | Mobility Database", + "nodeTitlePaged": "{place} Transit Feeds, Page {page} | Mobility Database", + "nodeDescription": "Browse {total} open transit and mobility feeds located in {place}: {gtfs} GTFS Schedule, {gtfs_rt} GTFS Realtime and {gbfs} GBFS." + } } } diff --git a/messages/fr.json b/messages/fr.json index 81336666..8c14b11c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -125,7 +125,9 @@ "metricsAdminOnly": "Métriques - Admin uniquement", "languageSelect": "Sélection de la langue", "transitFeedsRedirectTitle": "Vous avez été redirigé depuis TransitFeeds", - "transitFeedsRedirectBody": "Cette page se trouve désormais sur MobilityDatabase.org, où vous trouverez les données de transit les plus récentes." + "transitFeedsRedirectBody": "Cette page se trouve désormais sur MobilityDatabase.org, où vous trouverez les données de transit les plus récentes.", + "locations": "Lieux", + "feedsMenu": "Menu des flux" }, "emailVerification": { "loadingTitle": "Vérification de votre e-mail", @@ -1254,7 +1256,8 @@ "contactUs": "Contactez-nous", "shareFeedback": "Donner un avis", "privacyPolicy": "Politique de confidentialité", - "termsAndConditions": "Conditions d'utilisation" + "termsAndConditions": "Conditions d'utilisation", + "locations": "Lieux" }, "aria": { "github": "GitHub", @@ -1262,5 +1265,37 @@ "linkedin": "LinkedIn", "logo": "Logo Mobility Database" } + }, + "locations": { + "root": "Lieux", + "title": "Flux par lieu", + "rootIntro": "Parcourez les flux ouverts GTFS Schedule, GTFS Realtime et GBFS par continent, pays, région et municipalité.", + "nodeIntro": "{total, plural, =0 {Aucun flux n’est situé} one {# flux est situé} other {# flux sont situés}} dans : {place}.", + "breadcrumbAria": "Fil d’Ariane des lieux", + "feedCountsAria": "Nombre de flux par type de données", + "shortLabels": { + "gtfs": "GTFS", + "gtfs_rt": "GTFS-RT", + "gbfs": "GBFS" + }, + "childrenHeading": { + "continent": "Continents", + "country": "Pays", + "subdivision": "Régions", + "municipality": "Municipalités" + }, + "childFeedTotal": "{count, plural, one {# flux} other {# flux}}", + "feedsHeading": "Flux dans : {place}", + "allFeedsHeading": "Tous les flux", + "resultsRange": "{start}–{end} sur {total} flux", + "noFeeds": "Aucun flux n’est encore répertorié pour ce lieu.", + "paginationAria": "Pages de la liste des flux", + "meta": { + "rootTitle": "Flux de transport par lieu | Mobility Database", + "rootDescription": "Parcourez les flux ouverts de transport et de mobilité GTFS Schedule, GTFS Realtime et GBFS par continent, pays, région et municipalité.", + "nodeTitle": "Flux de transport : {place} | Mobility Database", + "nodeTitlePaged": "Flux de transport : {place}, page {page} | Mobility Database", + "nodeDescription": "Parcourez {total} flux ouverts de transport et de mobilité situés dans : {place}. {gtfs} GTFS Schedule, {gtfs_rt} GTFS Realtime et {gbfs} GBFS." + } } } diff --git a/src/app/[locale]/locations/[[...location]]/page.tsx b/src/app/[locale]/locations/[[...location]]/page.tsx new file mode 100644 index 00000000..4f62ee40 --- /dev/null +++ b/src/app/[locale]/locations/[[...location]]/page.tsx @@ -0,0 +1,241 @@ +import { type ReactElement } from 'react'; +import { type Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { Box, Container, Typography } from '@mui/material'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import LocationBreadcrumbs, { + type LocationCrumb, +} from '../components/LocationBreadcrumbs'; +import FeedCountSummary from '../components/FeedCountSummary'; +import LocationChildrenGrid from '../components/LocationChildrenGrid'; +import LocationFeedsList from '../components/LocationFeedsList'; +import { + buildLocationHref, + buildPlaceName, + FEEDS_SECTION_ID, + getBreadcrumbSlugs, + getChildLevel, + getPageCount, + getTotalFeedCount, + type LocationQuery, + parseLocationSegments, + parsePageParam, +} from '../lib/location-directory'; +import { + fetchLocationDirectory, + fetchLocationDirectoryFeeds, +} from '../lib/location-directory-data'; + +const SITE_URL = 'https://mobilitydatabase.org'; + +interface PageProps { + params: Promise<{ locale: string; location?: string[] }>; + searchParams: Promise>; +} + +interface ParsedRequest { + query: LocationQuery; + page: number; +} + +async function parseRequest({ + params, + searchParams, +}: PageProps): Promise<{ locale: string } & (ParsedRequest | { query: null })> { + const [{ locale, location }, { page: rawPage }] = await Promise.all([ + params, + searchParams, + ]); + const query = parseLocationSegments(location); + const page = parsePageParam(rawPage); + if (query === null || page === null) return { locale, query: null }; + return { locale, query, page }; +} + +function fetchDirectory( + query: LocationQuery, +): ReturnType { + return fetchLocationDirectory( + query.continent, + query.country_code, + query.subdivision, + query.municipality, + ); +} + +function fetchFeedsPage( + query: LocationQuery, + page: number, +): ReturnType { + return fetchLocationDirectoryFeeds( + page, + query.continent, + query.country_code, + query.subdivision, + query.municipality, + ); +} + +export async function generateMetadata(props: PageProps): Promise { + const request = await parseRequest(props); + if (request.query === null) return {}; + const { query, page } = request; + // Same calls as the page: deduped by React cache() within the request. + const [directory, feeds, t] = await Promise.all([ + fetchDirectory(query), + fetchFeedsPage(query, page), + getTranslations({ locale: request.locale, namespace: 'locations.meta' }), + ]); + if ( + directory === null || + feeds === null || + page > getPageCount(feeds.total ?? 0) + ) { + return {}; + } + + const place = buildPlaceName(directory.breadcrumb); + const counts = directory.feed_counts; + const isRoot = place === ''; + const title = isRoot + ? t('rootTitle') + : page > 1 + ? t('nodeTitlePaged', { place, page }) + : t('nodeTitle', { place }); + const description = isRoot + ? t('rootDescription') + : t('nodeDescription', { + place, + total: getTotalFeedCount(counts), + gtfs: counts?.gtfs ?? 0, + gtfs_rt: counts?.gtfs_rt ?? 0, + gbfs: counts?.gbfs ?? 0, + }); + const url = `${SITE_URL}${buildLocationHref( + getBreadcrumbSlugs(directory.breadcrumb), + page, + )}`; + + return { + title, + description, + alternates: { canonical: url }, + openGraph: { + title, + description, + url, + siteName: 'Mobility Database', + type: 'website', + }, + }; +} + +export default async function LocationsPage( + props: PageProps, +): Promise { + const request = await parseRequest(props); + if (request.query === null) notFound(); + const { locale, query, page } = request; + setRequestLocale(locale); + + // Directory node and feed page are independent: fetch them in parallel. + const [directory, feeds, t] = await Promise.all([ + fetchDirectory(query), + fetchFeedsPage(query, page), + getTranslations('locations'), + ]); + if (directory === null || feeds === null) notFound(); + + const total = feeds.total ?? 0; + const pageCount = getPageCount(total); + if (page > pageCount) notFound(); + + const breadcrumb = directory.breadcrumb ?? []; + const slugs = getBreadcrumbSlugs(breadcrumb); + const place = buildPlaceName(breadcrumb); + const isRoot = breadcrumb.length === 0; + const children = directory.children ?? []; + const childLevel = getChildLevel(directory.level); + + const crumbs: LocationCrumb[] = [ + { label: t('root'), href: isRoot ? undefined : buildLocationHref([]) }, + ...breadcrumb.map((node, index) => ({ + label: node.name ?? node.slug ?? '', + href: + index === breadcrumb.length - 1 + ? undefined + : buildLocationHref(slugs.slice(0, index + 1)), + })), + ]; + + return ( + + + + {isRoot ? t('title') : breadcrumb[breadcrumb.length - 1]?.name} + + + {isRoot + ? t('rootIntro') + : t('nodeIntro', { + total: getTotalFeedCount(directory.feed_counts), + place, + })} + + + + + {childLevel !== null && children.length > 0 ? ( + + + {t(`childrenHeading.${childLevel}`)}{' '} + + ({children.length}) + + + + + ) : null} + + + + {isRoot ? t('allFeedsHeading') : t('feedsHeading', { place })} + + + + + ); +} diff --git a/src/app/[locale]/locations/components/FeedCountSummary.tsx b/src/app/[locale]/locations/components/FeedCountSummary.tsx new file mode 100644 index 00000000..16580e77 --- /dev/null +++ b/src/app/[locale]/locations/components/FeedCountSummary.tsx @@ -0,0 +1,85 @@ +import { type ReactElement } from 'react'; +import { Box, Typography } from '@mui/material'; +import { getTranslations } from 'next-intl/server'; +import { + formatCount, + getFeedCountEntries, + type FeedCountDataType, + type LocationDirectoryFeedCounts, +} from '../lib/location-directory'; + +interface Props { + counts: LocationDirectoryFeedCounts | undefined; + locale: string; +} + +const LABEL_KEYS: Record = { + gtfs: 'gtfsSchedule', + gtfs_rt: 'gtfsRealtime', + gbfs: 'gbfs', +}; + +/** The node's feed totals split by GTFS Schedule / GTFS Realtime / GBFS. */ +export default async function FeedCountSummary({ + counts, + locale, +}: Props): Promise { + const [tCommon, t] = await Promise.all([ + getTranslations('common'), + getTranslations('locations'), + ]); + + return ( + + {getFeedCountEntries(counts).map(({ dataType, count }) => ( + + + {tCommon(LABEL_KEYS[dataType])} + + 0 ? 'primary.main' : 'text.disabled', + }} + > + {formatCount(count, locale)} + + + ))} + + ); +} diff --git a/src/app/[locale]/locations/components/LocationBreadcrumbs.tsx b/src/app/[locale]/locations/components/LocationBreadcrumbs.tsx new file mode 100644 index 00000000..27e62281 --- /dev/null +++ b/src/app/[locale]/locations/components/LocationBreadcrumbs.tsx @@ -0,0 +1,60 @@ +import { type ReactElement } from 'react'; +import { Breadcrumbs, Typography } from '@mui/material'; +import NavigateNextIcon from '@mui/icons-material/NavigateNext'; +import { Link } from '../../../../i18n/navigation'; + +export interface LocationCrumb { + label: string; + /** Omitted on the trailing crumb, the page the user is on. */ + href?: string; +} + +interface Props { + crumbs: LocationCrumb[]; + ariaLabel: string; +} + +export default function LocationBreadcrumbs({ + crumbs, + ariaLabel, +}: Props): ReactElement { + return ( + } + data-testid='location-breadcrumbs' + sx={{ + mb: 1, + '& .MuiBreadcrumbs-ol': { rowGap: 0.5 }, + '& a': { + color: 'text.secondary', + textDecoration: 'none', + borderRadius: '4px', + }, + '& a:hover': { color: 'primary.main', textDecoration: 'underline' }, + '& a:focus-visible': { + outline: '2px solid', + outlineColor: 'primary.main', + outlineOffset: '2px', + }, + }} + > + {crumbs.map((crumb) => + crumb.href != undefined ? ( + + {crumb.label} + + ) : ( + + {crumb.label} + + ), + )} + + ); +} diff --git a/src/app/[locale]/locations/components/LocationChildrenGrid.tsx b/src/app/[locale]/locations/components/LocationChildrenGrid.tsx new file mode 100644 index 00000000..d69724b0 --- /dev/null +++ b/src/app/[locale]/locations/components/LocationChildrenGrid.tsx @@ -0,0 +1,145 @@ +import { type ReactElement } from 'react'; +import { Box, Typography } from '@mui/material'; +import { getTranslations } from 'next-intl/server'; +import { getEmojiFlag, type TCountryCode } from 'countries-list'; +import { Link } from '../../../../i18n/navigation'; +import { + buildLocationHref, + formatCount, + getFeedCountEntries, + getTotalFeedCount, + type LocationDirectoryNode, +} from '../lib/location-directory'; + +interface Props { + nodes: LocationDirectoryNode[]; + /** Slugs of the current node; each child links one level deeper. */ + parentSlugs: string[]; + locale: string; +} + +function getDisplayName(node: LocationDirectoryNode): string { + const name = node.name ?? node.slug ?? ''; + if (node.level === 'country' && node.code != undefined) { + const flag = getEmojiFlag(node.code.toUpperCase() as TCountryCode); + return flag !== '' ? `${flag} ${name}` : name; + } + return name; +} + +/** Child nodes as link tiles, each with its GTFS / GTFS-RT / GBFS counts. */ +export default async function LocationChildrenGrid({ + nodes, + parentSlugs, + locale, +}: Props): Promise { + const t = await getTranslations('locations'); + + return ( + + {nodes.map((node) => { + const counts = getFeedCountEntries(node.feed_counts); + const href = buildLocationHref([...parentSlugs, node.slug ?? '']); + return ( +
  • + + + + {getDisplayName(node)} + + + {t('childFeedTotal', { + count: getTotalFeedCount(node.feed_counts), + })} + + + + {counts.map(({ dataType, count }) => ( + 0 ? 'text.secondary' : 'text.disabled', + whiteSpace: 'nowrap', + }} + > + {t(`shortLabels.${dataType}`)}{' '} + 0 ? 'text.primary' : 'inherit', + }} + > + {formatCount(count, locale)} + + + ))} + + +
  • + ); + })} +
    + ); +} diff --git a/src/app/[locale]/locations/components/LocationFeedsList.tsx b/src/app/[locale]/locations/components/LocationFeedsList.tsx new file mode 100644 index 00000000..257430de --- /dev/null +++ b/src/app/[locale]/locations/components/LocationFeedsList.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { type ReactElement } from 'react'; +import { Box, Pagination, PaginationItem, Typography } from '@mui/material'; +import { useTranslations } from 'next-intl'; +import SearchTable from '../../../screens/Feeds/SearchTable'; +import { useSealOfReliabilityFilterAccess } from '../../../hooks/useSealOfReliabilityFilterAccess'; +import { type SearchFeedItem } from '../../../services/feeds/utils'; +import { Link } from '../../../../i18n/navigation'; +import { + buildLocationHref, + FEEDS_SECTION_ID, + getPageOffset, + LOCATION_FEEDS_PAGE_SIZE, +} from '../lib/location-directory'; + +interface Props { + feeds: SearchFeedItem[]; + total: number; + page: number; + pageCount: number; + /** Slugs of the current node, used to build the page links. */ + slugs: string[]; +} + +/** + * The feeds of a directory node in the same compact table as the /feeds + * search (simple view), paginated with real links so every page is + * crawlable and reflected in the URL. + */ +export default function LocationFeedsList({ + feeds, + total, + page, + pageCount, + slugs, +}: Props): ReactElement { + const t = useTranslations('locations'); + // Same flag the /feeds search uses to show the seal badge on rows. + const { isFeatureLive: isSealEnabled } = useSealOfReliabilityFilterAccess(); + + if (feeds.length === 0) { + return {t('noFeeds')}; + } + + const start = getPageOffset(page) + 1; + const end = Math.min(start + LOCATION_FEEDS_PAGE_SIZE - 1, total); + + return ( + <> + + {t('resultsRange', { start, end, total })} + + + + + {pageCount > 1 ? ( + ( + + )} + /> + ) : null} + + ); +} diff --git a/src/app/[locale]/locations/lib/location-directory-data.ts b/src/app/[locale]/locations/lib/location-directory-data.ts new file mode 100644 index 00000000..da3957fe --- /dev/null +++ b/src/app/[locale]/locations/lib/location-directory-data.ts @@ -0,0 +1,146 @@ +/** + * Cacheable data fetching for the /locations directory. + * + * The directory is public, catalog-wide data, so these functions use the + * shared guest token and must NOT call cookies() or headers() directly or + * indirectly (same rule as the guest feed pages, see guest-feed-data.ts). + */ +import 'server-only'; +import { cache } from 'react'; +import { unstable_cache } from 'next/cache'; +import { getGuestGcipIdToken } from '../../../utils/auth-server'; +import { + ApiError, + getLocationDirectory, + getLocationDirectoryFeeds, + type LocationDirectoryFeedsResponse, + type LocationDirectoryResponse, +} from '../../../services/feeds'; +import { + getPageOffset, + LOCATION_FEEDS_PAGE_SIZE, + type LocationQuery, +} from './location-directory'; + +/** 1 day; also flushed on demand with the existing `guest-feeds` tag. */ +export const LOCATION_DIRECTORY_REVALIDATE_SECONDS = 86400; +const CACHE_TAGS = ['location-directory', 'guest-feeds']; + +/** + * 400 (invalid hierarchy) and 404 (nothing located there) both mean the URL + * does not exist; they resolve to null so the page can call notFound(). Any + * other failure is rethrown so an error is never cached. + */ +async function nullWhenMissing( + request: () => Promise, +): Promise { + try { + const data = await request(); + if (data == undefined) { + throw new Error('Empty response from the location directory API'); + } + return data; + } catch (error) { + if ( + error instanceof ApiError && + (error.status === 400 || error.status === 404) + ) { + return null; + } + throw error; + } +} + +function toQuery( + continent: string | undefined, + countryCode: string | undefined, + subdivision: string | undefined, + municipality: string | undefined, +): LocationQuery { + const query: LocationQuery = {}; + if (continent != undefined) query.continent = continent; + if (countryCode != undefined) query.country_code = countryCode; + if (subdivision != undefined) query.subdivision = subdivision; + if (municipality != undefined) query.municipality = municipality; + return query; +} + +// unstable_cache keys on the key parts plus the call arguments, so the +// arguments are kept primitive. +const cachedDirectory = unstable_cache( + async ( + continent?: string, + countryCode?: string, + subdivision?: string, + municipality?: string, + ): Promise => { + const accessToken = await getGuestGcipIdToken(); + return await nullWhenMissing( + async () => + await getLocationDirectory( + toQuery(continent, countryCode, subdivision, municipality), + accessToken, + ), + ); + }, + ['location-directory'], + { tags: CACHE_TAGS, revalidate: LOCATION_DIRECTORY_REVALIDATE_SECONDS }, +); + +const cachedDirectoryFeeds = unstable_cache( + async ( + offset: number, + continent?: string, + countryCode?: string, + subdivision?: string, + municipality?: string, + ): Promise => { + const accessToken = await getGuestGcipIdToken(); + return await nullWhenMissing( + async () => + await getLocationDirectoryFeeds( + { + ...toQuery(continent, countryCode, subdivision, municipality), + limit: LOCATION_FEEDS_PAGE_SIZE, + offset, + }, + accessToken, + ), + ); + }, + ['location-directory-feeds'], + { tags: CACHE_TAGS, revalidate: LOCATION_DIRECTORY_REVALIDATE_SECONDS }, +); + +/** + * Directory node (breadcrumb, counts, children), or null when the location + * does not exist. React cache() dedupes the generateMetadata + page calls + * within one request; it compares arguments by identity, hence primitives. + */ +export const fetchLocationDirectory = cache( + async ( + continent?: string, + countryCode?: string, + subdivision?: string, + municipality?: string, + ): Promise => + await cachedDirectory(continent, countryCode, subdivision, municipality), +); + +/** One page (1-based) of the feeds located in a node, or null if missing. */ +export const fetchLocationDirectoryFeeds = cache( + async ( + page: number, + continent?: string, + countryCode?: string, + subdivision?: string, + municipality?: string, + ): Promise => + await cachedDirectoryFeeds( + getPageOffset(page), + continent, + countryCode, + subdivision, + municipality, + ), +); diff --git a/src/app/[locale]/locations/lib/location-directory.spec.ts b/src/app/[locale]/locations/lib/location-directory.spec.ts new file mode 100644 index 00000000..b954f6c2 --- /dev/null +++ b/src/app/[locale]/locations/lib/location-directory.spec.ts @@ -0,0 +1,178 @@ +import { + buildLocationHref, + buildPlaceName, + formatCount, + getBreadcrumbSlugs, + getChildLevel, + getFeedCountEntries, + getPageCount, + getPageOffset, + getTotalFeedCount, + type LocationDirectoryNode, + parseLocationSegments, + parsePageParam, +} from './location-directory'; + +const breadcrumb: LocationDirectoryNode[] = [ + { + level: 'continent', + slug: 'north-america', + code: 'NA', + name: 'North America', + }, + { level: 'country', slug: 'ca', code: 'CA', name: 'Canada' }, + { level: 'subdivision', slug: 'quebec', code: null, name: 'Québec' }, + { level: 'municipality', slug: 'montreal', code: null, name: 'Montréal' }, +]; + +describe('parseLocationSegments', () => { + it('returns an empty query at the root', () => { + expect(parseLocationSegments(undefined)).toEqual({}); + expect(parseLocationSegments([])).toEqual({}); + }); + + it('maps each segment to its API parameter, untouched', () => { + expect(parseLocationSegments(['north-america'])).toEqual({ + continent: 'north-america', + }); + expect( + parseLocationSegments(['north-america', 'ca', 'quebec', 'montreal']), + ).toEqual({ + continent: 'north-america', + country_code: 'ca', + subdivision: 'quebec', + municipality: 'montreal', + }); + }); + + it('rejects paths deeper than a municipality', () => { + expect( + parseLocationSegments(['north-america', 'ca', 'quebec', 'montreal', 'x']), + ).toBeNull(); + }); + + it('rejects empty segments', () => { + expect(parseLocationSegments(['north-america', ' '])).toBeNull(); + }); +}); + +describe('parsePageParam', () => { + it('defaults to page 1 when absent', () => { + expect(parsePageParam(undefined)).toBe(1); + }); + + it('accepts positive integers', () => { + expect(parsePageParam('1')).toBe(1); + expect(parsePageParam('2')).toBe(2); + expect(parsePageParam('120')).toBe(120); + }); + + it.each(['', '0', '-1', '+2', '01', '1.5', '1e3', 'abc', ' 2', '2 '])( + 'rejects %p', + (raw) => { + expect(parsePageParam(raw)).toBeNull(); + }, + ); + + it('rejects repeated params and unsafe integers', () => { + expect(parsePageParam(['1', '2'])).toBeNull(); + expect(parsePageParam('99999999999999999999')).toBeNull(); + }); +}); + +describe('pagination math', () => { + it('computes the page count with a floor of 1', () => { + expect(getPageCount(0)).toBe(1); + expect(getPageCount(1)).toBe(1); + expect(getPageCount(20)).toBe(1); + expect(getPageCount(21)).toBe(2); + expect(getPageCount(45, 10)).toBe(5); + }); + + it('computes the API offset of a 1-based page', () => { + expect(getPageOffset(1)).toBe(0); + expect(getPageOffset(2)).toBe(20); + expect(getPageOffset(3, 10)).toBe(20); + }); +}); + +describe('buildLocationHref', () => { + it('builds the root and nested paths', () => { + expect(buildLocationHref([])).toBe('/locations'); + expect( + buildLocationHref(['north-america', 'ca', 'quebec', 'montreal']), + ).toBe('/locations/north-america/ca/quebec/montreal'); + }); + + it('adds ?page only after the first page', () => { + expect(buildLocationHref(['europe'], 1)).toBe('/locations/europe'); + expect(buildLocationHref(['europe'], 2)).toBe('/locations/europe?page=2'); + expect(buildLocationHref([], 3)).toBe('/locations?page=3'); + }); + + it('encodes unsafe characters in segments', () => { + expect(buildLocationHref(['a b', 'c/d'])).toBe('/locations/a%20b/c%2Fd'); + }); + + it('round-trips with the breadcrumb slugs', () => { + expect(buildLocationHref(getBreadcrumbSlugs(breadcrumb), 2)).toBe( + '/locations/north-america/ca/quebec/montreal?page=2', + ); + expect(getBreadcrumbSlugs(undefined)).toEqual([]); + }); +}); + +describe('feed counts', () => { + it('lists GTFS, GTFS-RT and GBFS in order, defaulting to 0', () => { + expect(getFeedCountEntries({ gtfs: 4, gbfs: 1 })).toEqual([ + { dataType: 'gtfs', count: 4 }, + { dataType: 'gtfs_rt', count: 0 }, + { dataType: 'gbfs', count: 1 }, + ]); + expect(getFeedCountEntries(undefined).map((e) => e.count)).toEqual([ + 0, 0, 0, + ]); + }); + + it('prefers the distinct total over the per-type sum', () => { + expect(getTotalFeedCount({ gtfs: 2, gtfs_rt: 2, gbfs: 1, total: 4 })).toBe( + 4, + ); + expect(getTotalFeedCount({ gtfs: 2, gtfs_rt: 2, gbfs: 1 })).toBe(5); + expect(getTotalFeedCount(undefined)).toBe(0); + }); + + it('formats counts for the locale', () => { + expect(formatCount(1234, 'en')).toBe('1,234'); + expect(formatCount(undefined, 'en')).toBe('0'); + // fr groups with a narrow no-break space + expect(formatCount(1234, 'fr').replace(/\s/g, ' ')).toBe('1 234'); + }); +}); + +describe('getChildLevel', () => { + it('walks one level down and stops at municipalities', () => { + expect(getChildLevel('root')).toBe('continent'); + expect(getChildLevel(undefined)).toBe('continent'); + expect(getChildLevel('continent')).toBe('country'); + expect(getChildLevel('country')).toBe('subdivision'); + expect(getChildLevel('subdivision')).toBe('municipality'); + expect(getChildLevel('municipality')).toBeNull(); + }); +}); + +describe('buildPlaceName', () => { + it('is empty at the root', () => { + expect(buildPlaceName([])).toBe(''); + expect(buildPlaceName(undefined)).toBe(''); + }); + + it('names a continent on its own', () => { + expect(buildPlaceName(breadcrumb.slice(0, 1))).toBe('North America'); + }); + + it('lists the most specific place first, without the continent', () => { + expect(buildPlaceName(breadcrumb.slice(0, 2))).toBe('Canada'); + expect(buildPlaceName(breadcrumb)).toBe('Montréal, Québec, Canada'); + }); +}); diff --git a/src/app/[locale]/locations/lib/location-directory.ts b/src/app/[locale]/locations/lib/location-directory.ts new file mode 100644 index 00000000..010d8912 --- /dev/null +++ b/src/app/[locale]/locations/lib/location-directory.ts @@ -0,0 +1,184 @@ +/** + * Pure helpers for the /locations directory: URL segment and page parsing, + * URL building and count formatting. Safe to import from server and client + * components; holds no data fetching. + */ +import type { components } from '../../../services/feeds/types'; + +export type LocationDirectoryNode = + components['schemas']['LocationDirectoryNode']; +export type LocationDirectoryLevel = + components['schemas']['LocationDirectoryLevel']; +export type LocationDirectoryFeedCounts = + components['schemas']['LocationDirectoryFeedCounts']; + +export const LOCATIONS_BASE_PATH = '/locations'; +export const LOCATION_FEEDS_PAGE_SIZE = 20; +/** continent > country > subdivision > municipality */ +export const MAX_LOCATION_DEPTH = 4; +/** Anchor of the feed list; pagination links jump back to it. */ +export const FEEDS_SECTION_ID = 'feeds'; + +/** Query parameters shared by both directory endpoints. */ +export interface LocationQuery { + continent?: string; + country_code?: string; + subdivision?: string; + municipality?: string; +} + +const QUERY_KEYS_BY_DEPTH = [ + 'continent', + 'country_code', + 'subdivision', + 'municipality', +] as const; + +/** + * Maps the optional catch-all segments to the directory query. Segments are + * passed through untouched: API slugs are exactly the URL segments. + * + * @returns the query (empty at the root), or null when the path is deeper + * than a municipality or contains an empty segment. + */ +export function parseLocationSegments( + segments: readonly string[] | undefined, +): LocationQuery | null { + if (segments == undefined) return {}; + if (segments.length > MAX_LOCATION_DEPTH) return null; + const query: LocationQuery = {}; + for (let depth = 0; depth < segments.length; depth++) { + const segment = segments[depth].trim(); + if (segment === '') return null; + query[QUERY_KEYS_BY_DEPTH[depth]] = segment; + } + return query; +} + +const PAGE_PATTERN = /^[1-9]\d*$/; + +/** + * Parses the 1-based `?page=` search param. + * + * @returns 1 when absent, the page number when it is a positive integer + * written without leading zeros or signs, otherwise null. + */ +export function parsePageParam( + raw: string | string[] | undefined, +): number | null { + if (raw == undefined) return 1; + if (typeof raw !== 'string' || !PAGE_PATTERN.test(raw)) return null; + const page = Number(raw); + return Number.isSafeInteger(page) ? page : null; +} + +/** Number of pages needed for `total` items; never less than 1. */ +export function getPageCount( + total: number, + pageSize: number = LOCATION_FEEDS_PAGE_SIZE, +): number { + if (total <= 0) return 1; + return Math.ceil(total / pageSize); +} + +/** API offset of the first item of a 1-based page. */ +export function getPageOffset( + page: number, + pageSize: number = LOCATION_FEEDS_PAGE_SIZE, +): number { + return (page - 1) * pageSize; +} + +/** + * Locale-less path to a directory node, e.g. + * `/locations/north-america/ca/quebec?page=2`. Page 1 has no query string so + * that it stays the canonical URL of the node. Pass the result to the + * next-intl `Link`, which adds the locale prefix. + */ +export function buildLocationHref( + slugs: readonly string[], + page: number = 1, +): string { + const path = [LOCATIONS_BASE_PATH, ...slugs.map(encodeURIComponent)].join( + '/', + ); + return page > 1 ? `${path}?page=${page}` : path; +} + +/** Slugs of every breadcrumb node, from the continent down. */ +export function getBreadcrumbSlugs( + breadcrumb: readonly LocationDirectoryNode[] | undefined, +): string[] { + return (breadcrumb ?? []).map((node) => node.slug ?? ''); +} + +export const FEED_COUNT_DATA_TYPES = ['gtfs', 'gtfs_rt', 'gbfs'] as const; +export type FeedCountDataType = (typeof FEED_COUNT_DATA_TYPES)[number]; + +export interface FeedCountEntry { + dataType: FeedCountDataType; + count: number; +} + +/** GTFS, GTFS-RT and GBFS counts in display order; missing counts are 0. */ +export function getFeedCountEntries( + counts: LocationDirectoryFeedCounts | undefined, +): FeedCountEntry[] { + return FEED_COUNT_DATA_TYPES.map((dataType) => ({ + dataType, + count: counts?.[dataType] ?? 0, + })); +} + +/** Total distinct feeds of a node, falling back to the sum by data type. */ +export function getTotalFeedCount( + counts: LocationDirectoryFeedCounts | undefined, +): number { + if (counts?.total != undefined) return counts.total; + return getFeedCountEntries(counts).reduce( + (sum, entry) => sum + entry.count, + 0, + ); +} + +/** Locale-aware integer formatting, e.g. 1234 -> "1,234" (en). */ +export function formatCount(value: number | undefined, locale: string): string { + return new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format( + value ?? 0, + ); +} + +const CHILD_LEVEL: Record< + LocationDirectoryLevel, + LocationDirectoryLevel | null +> = { + root: 'continent', + continent: 'country', + country: 'subdivision', + subdivision: 'municipality', + municipality: null, +}; + +/** The level of a node's children, or null for municipalities. */ +export function getChildLevel( + level: LocationDirectoryLevel | undefined, +): LocationDirectoryLevel | null { + return CHILD_LEVEL[level ?? 'root']; +} + +/** + * Human-readable place for titles, most specific first: + * `Montréal, Québec, Canada`. The continent is dropped once a country is + * known; a continent on its own is returned as is. Empty at the root. + */ +export function buildPlaceName( + breadcrumb: readonly LocationDirectoryNode[] | undefined, +): string { + const names = (breadcrumb ?? []) + .filter( + (node, index, all) => !(node.level === 'continent' && all.length > 1), + ) + .map((node) => node.name ?? node.slug ?? '') + .filter((name) => name !== ''); + return names.reverse().join(', '); +} diff --git a/src/app/components/Footer.tsx b/src/app/components/Footer.tsx index 35606b1e..5846b895 100644 --- a/src/app/components/Footer.tsx +++ b/src/app/components/Footer.tsx @@ -73,6 +73,7 @@ const Footer: React.FC = () => { {t('columns.platform')} {t('links.feeds')} + {t('links.locations')} {t('links.addFeed')} - {navigationItems.map((item) => ( - - - - ))} + + + ), + )} (null); + const [openedByKeyboard, setOpenedByKeyboard] = React.useState(false); + const closeTimer = React.useRef>(undefined); + + React.useEffect(() => { + return () => { + clearTimeout(closeTimer.current); + }; + }, []); + + const handleOpen = (e: React.MouseEvent): void => { + clearTimeout(closeTimer.current); + setOpenedByKeyboard(false); + setAnchorEl(e.currentTarget); + }; + + const handleClose = (): void => { + closeTimer.current = setTimeout(() => { + setAnchorEl(null); + }, 80); + }; + + const closeNow = (): void => { + clearTimeout(closeTimer.current); + setAnchorEl(null); + }; + + return ( + + + { + clearTimeout(closeTimer.current); + }, + onMouseLeave: handleClose, + sx: { pointerEvents: 'auto', minWidth: 200 }, + }, + }} + > + + {feedsMenuItems.map((item) => ( + + {t(item.titleKey)} + + ))} + + + + ); +} diff --git a/src/app/components/HeaderMobileDrawer.tsx b/src/app/components/HeaderMobileDrawer.tsx index 962f3dbe..f0e3984f 100644 --- a/src/app/components/HeaderMobileDrawer.tsx +++ b/src/app/components/HeaderMobileDrawer.tsx @@ -21,6 +21,7 @@ import { useColorScheme } from '@mui/material/styles'; import { useRouter } from 'next/navigation'; import { ACCOUNT_TARGET, + feedsMenuItems, gbfsMetricsNavItems, gtfsMetricsNavItems, SIGN_IN_TARGET, @@ -140,20 +141,56 @@ export default function DrawerContent({ - {navigationItems.map((item) => ( - - ))} + {navigationItems.map((item) => + item.target === 'feeds' ? ( + + } + aria-controls='feeds-content' + id='feeds-header' + data-cy='drawer-feeds' + > + + {item.title} + + + + {feedsMenuItems.map((subItem) => ( + + ))} + + + ) : ( + + ), + )} diff --git a/src/app/constants/Navigation.ts b/src/app/constants/Navigation.ts index 77d4454e..c3c65a48 100644 --- a/src/app/constants/Navigation.ts +++ b/src/app/constants/Navigation.ts @@ -15,6 +15,22 @@ export const MOBILITY_DATA_LINKS = { github: 'https://github.com/MobilityData/mobility-database-catalogs', }; +export const FEEDS_TARGET = '/feeds'; +export const LOCATIONS_TARGET = '/locations'; + +/** + * Sub-items of the "Feeds" header menu (desktop dropdown and mobile drawer). + * `titleKey` is a key of the `common` messages namespace. + */ +export const feedsMenuItems = [ + { titleKey: 'search', target: FEEDS_TARGET, dataCy: 'header-feeds-search' }, + { + titleKey: 'locations', + target: LOCATIONS_TARGET, + dataCy: 'header-feeds-locations', + }, +] as const; + export const WEB_VALIDATOR_LINK = 'https://gtfs-validator.mobilitydata.org'; export const GTFS_ORG_LINK = 'https://gtfs.org'; export const GBFS_LINK = 'https://github.com/MobilityData/gbfs'; diff --git a/src/app/screens/Feeds/SearchTable.tsx b/src/app/screens/Feeds/SearchTable.tsx index 2e990d4b..130b9fad 100644 --- a/src/app/screens/Feeds/SearchTable.tsx +++ b/src/app/screens/Feeds/SearchTable.tsx @@ -39,35 +39,37 @@ const HeaderTableCell = styled(TableCell)(() => ({ border: 'none', })); +const DATA_TYPE_LABEL_KEYS = { + gtfs: 'gtfsSchedule', + gtfs_rt: 'gtfsRealtime', + gbfs: 'gbfs', +} as const; + +function DataTypeElement({ + dataType, +}: { + dataType: 'gtfs' | 'gtfs_rt' | 'gbfs'; +}): React.ReactElement { + const tCommon = useTranslations('common'); + return ( + + {tCommon(DATA_TYPE_LABEL_KEYS[dataType])} + + ); +} + +// The translation hook lives in DataTypeElement, not here: this is called once +// per row, and a hook called in a loop breaks when the row count changes +// between renders of the same table (e.g. paging to a partial last page). export const getDataTypeElement = ( dataType: 'gtfs' | 'gtfs_rt' | 'gbfs', -): React.ReactElement => { - const tCommon = useTranslations('common'); - const DataTypeHolder = ({ - children, - }: { - children: React.ReactNode; - }): React.ReactElement => { - return ( - - {children} - - ); - }; - if (dataType === 'gtfs') { - return {tCommon('gtfsSchedule')}; - } else if (dataType === 'gtfs_rt') { - return {tCommon('gtfsRealtime')}; - } else { - return {tCommon('gbfs')}; - } -}; +): React.ReactElement => ; export default function SearchTable({ feedsData, diff --git a/src/app/services/feeds/index.ts b/src/app/services/feeds/index.ts index 50b726c2..ab8ff846 100644 --- a/src/app/services/feeds/index.ts +++ b/src/app/services/feeds/index.ts @@ -17,6 +17,21 @@ const client = createClient({ }, }); +/** + * Error thrown for any API response with a status >= 400. Extends `Error` + * with the same message as before, adding the HTTP status so callers can + * tell a 404 apart from a server failure. + */ +export class ApiError extends Error { + readonly status: number; + + constructor(message: string, status: number) { + super(message); + this.name = 'ApiError'; + this.status = status; + } +} + const throwOnError: Middleware = { async onResponse(res) { if (res.status >= 400) { @@ -26,7 +41,7 @@ const throwOnError: Middleware = { } const errorMessage = typeof body === 'string' ? body : JSON.stringify(body); - throw new Error(errorMessage); + throw new ApiError(errorMessage, res.status); } return undefined; }, @@ -351,6 +366,63 @@ export const searchFeeds = async ( }); }; +export type LocationDirectoryParams = NonNullable< + paths['/v1/locations/directory']['get']['parameters']['query'] +>; + +export type LocationDirectoryFeedsParams = NonNullable< + paths['/v1/locations/directory/feeds']['get']['parameters']['query'] +>; + +export type LocationDirectoryResponse = + paths['/v1/locations/directory']['get']['responses'][200]['content']['application/json']; + +export type LocationDirectoryFeedsResponse = + paths['/v1/locations/directory/feeds']['get']['responses'][200]['content']['application/json']; + +/** + * One level of the feed location directory: the node's breadcrumb, feed + * counts and children. Throws an `ApiError` with status 400 (invalid + * hierarchy) or 404 (no feed located there). + */ +export const getLocationDirectory = async ( + params: LocationDirectoryParams, + accessToken: string, + userContextJwt?: string, +): Promise => { + const authMiddleware = generateAuthMiddlewareWithToken( + accessToken, + userContextJwt, + ); + return await withAuthMiddleware(authMiddleware, async () => { + const response = await client.GET('/v1/locations/directory', { + params: { query: params }, + }); + return response.data; + }); +}; + +/** + * A page of the feeds located within a location directory node, already + * sorted by relevance to that node. + */ +export const getLocationDirectoryFeeds = async ( + params: LocationDirectoryFeedsParams, + accessToken: string, + userContextJwt?: string, +): Promise => { + const authMiddleware = generateAuthMiddlewareWithToken( + accessToken, + userContextJwt, + ); + return await withAuthMiddleware(authMiddleware, async () => { + const response = await client.GET('/v1/locations/directory/feeds', { + params: { query: params }, + }); + return response.data; + }); +}; + export const getLicense = async ( id: string, accessToken: string, diff --git a/src/app/services/feeds/types.ts b/src/app/services/feeds/types.ts index c3be5015..585c159b 100644 --- a/src/app/services/feeds/types.ts +++ b/src/app/services/feeds/types.ts @@ -359,6 +359,40 @@ export interface paths { patch?: never; trace?: never; }; + '/v1/locations/directory': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Browse the feed location directory one level at a time: continent > country > subdivision > municipality. The level is selected by the most specific parameter provided; with no parameters the response lists continents.
    Feed counts are based on the feeds' declared locations, only include feeds with a status of `active`, `inactive`, `development` or `future`, and roll up to every ancestor level (a feed located in Montréal counts toward Québec, Canada and North America). A feed is counted at most once per node, even when it declares several locations within that node.
    Each parameter requires its parent: `country_code` requires `continent`, `subdivision` requires `country_code`, and `municipality` requires `subdivision`. */ + get: operations['getLocationDirectory']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v1/locations/directory/feeds': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description List the feeds located within a location directory node, using the same location parameters, status rules and validation as `/v1/locations/directory`.
    Feeds are sorted by relevance to the requested node: feeds whose most specific matching declared location is closest to the requested level come first (in the Québec node, a feed located in "Canada, Québec" ranks above one located in "Canada, Québec, Montréal"). Ties are broken by provider, feed name and feed id. */ + get: operations['getLocationDirectoryFeeds']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/v1/licenses': { parameters: { query?: never; @@ -1308,6 +1342,68 @@ export interface components { */ municipality?: string; }; + /** + * @description A level of the location directory: + * * `root` - the whole world; its children are continents. + * * `continent` - a continent, derived from the ISO 3166-1 country code. + * * `country` - a country, identified by its ISO 3166-1 alpha-2 code. + * * `subdivision` - a first-level subdivision as declared on the feed (e.g. a state or province). + * * `municipality` - a municipality as declared on the feed. + * @example subdivision + * @enum {string} + */ + LocationDirectoryLevel: + | 'root' + | 'continent' + | 'country' + | 'subdivision' + | 'municipality'; + /** @description Number of distinct feeds per data type within a directory node. */ + LocationDirectoryFeedCounts: { + /** @example 42 */ + gtfs?: number; + /** @example 18 */ + gtfs_rt?: number; + /** @example 7 */ + gbfs?: number; + /** + * @description Number of distinct feeds of any data type. + * @example 67 + */ + total?: number; + }; + LocationDirectoryNode: { + level?: components['schemas']['LocationDirectoryLevel']; + /** + * @description URL-safe identifier of the node, used as the matching request parameter. Continents use a fixed slug (`africa`, `antarctica`, `asia`, `europe`, `north-america`, `oceania`, `south-america`), countries use their lowercase ISO 3166-1 alpha-2 code, and subdivisions and municipalities use their name lowercased, without accents, with every run of non-alphanumeric characters replaced by a single hyphen (`Québec` -> `quebec`, `Saint-Jean-sur-Richelieu` -> `saint-jean-sur-richelieu`). + * @example quebec + */ + slug?: string; + /** + * @description The continent code (`AF`, `AN`, `AS`, `EU`, `NA`, `OC`, `SA`) or the ISO 3166-1 alpha-2 country code. Null for subdivisions and municipalities. + * @example null + */ + code?: string | null; + /** + * @description Display name of the node in English. + * @example Québec + */ + name?: string; + feed_counts?: components['schemas']['LocationDirectoryFeedCounts']; + }; + LocationDirectoryResponse: { + level?: components['schemas']['LocationDirectoryLevel']; + /** @description The path from the continent down to the requested node, inclusive. Empty at the `root` level. */ + breadcrumb?: components['schemas']['LocationDirectoryNode'][]; + feed_counts?: components['schemas']['LocationDirectoryFeedCounts']; + /** @description The nodes one level below the requested node that contain at least one feed, sorted by name. Empty at the `municipality` level. */ + children?: components['schemas']['LocationDirectoryNode'][]; + }; + LocationDirectoryFeedsResponse: { + /** @description The total number of feeds within the node regardless of limit and offset. */ + total?: number; + results?: components['schemas']['SearchFeedItemResult'][]; + }; LocationSearchResponse: { /** @description The total number of matching locations regardless of limit and offset. */ total?: number; @@ -1822,6 +1918,16 @@ export interface components { is_official_query_param: boolean; /** @description If true, only return feeds that currently hold the Seal of Reliability; if false, only feeds without it. Viewing a feed's seal is public, but filtering the catalogue by it is granted per user and requires the `isSealFilterEnabled` feature flag - other callers receive a 403. To request access or learn more, contact us at api@mobilitydata.org. */ has_seal_query_param: boolean; + /** @description Continent slug (`africa`, `antarctica`, `asia`, `europe`, `north-america`, `oceania`, `south-america`). */ + location_directory_continent: string; + /** @description ISO 3166-1 alpha-2 country code. Case-insensitive. Requires `continent`. */ + location_directory_country_code: string; + /** @description Subdivision slug as returned by `/v1/locations/directory`. Requires `country_code`. */ + location_directory_subdivision: string; + /** @description Municipality slug as returned by `/v1/locations/directory`. Requires `subdivision`. */ + location_directory_municipality: string; + /** @description The number of items to be returned. */ + limit_query_param_location_directory_feeds_endpoint: number; /** @description The number of items to be returned. */ limit_query_param_locations_endpoint: number; /** @description The number of items to be returned. */ @@ -2559,6 +2665,89 @@ export interface operations { }; }; }; + getLocationDirectory: { + parameters: { + query?: { + /** @description Continent slug (`africa`, `antarctica`, `asia`, `europe`, `north-america`, `oceania`, `south-america`). */ + continent?: components['parameters']['location_directory_continent']; + /** @description ISO 3166-1 alpha-2 country code. Case-insensitive. Requires `continent`. */ + country_code?: components['parameters']['location_directory_country_code']; + /** @description Subdivision slug as returned by `/v1/locations/directory`. Requires `country_code`. */ + subdivision?: components['parameters']['location_directory_subdivision']; + /** @description Municipality slug as returned by `/v1/locations/directory`. Requires `subdivision`. */ + municipality?: components['parameters']['location_directory_municipality']; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The requested directory node, its breadcrumb and its children. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['LocationDirectoryResponse']; + }; + }; + /** @description A parameter was provided without its parent, or the country does not belong to the continent. */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description No feed is located at the requested location. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getLocationDirectoryFeeds: { + parameters: { + query?: { + /** @description Continent slug (`africa`, `antarctica`, `asia`, `europe`, `north-america`, `oceania`, `south-america`). */ + continent?: components['parameters']['location_directory_continent']; + /** @description ISO 3166-1 alpha-2 country code. Case-insensitive. Requires `continent`. */ + country_code?: components['parameters']['location_directory_country_code']; + /** @description Subdivision slug as returned by `/v1/locations/directory`. Requires `country_code`. */ + subdivision?: components['parameters']['location_directory_subdivision']; + /** @description Municipality slug as returned by `/v1/locations/directory`. Requires `subdivision`. */ + municipality?: components['parameters']['location_directory_municipality']; + /** @description The number of items to be returned. */ + limit?: components['parameters']['limit_query_param_location_directory_feeds_endpoint']; + /** @description Offset of the first item to return. */ + offset?: components['parameters']['offset']; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The page of feeds located within the requested node. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['LocationDirectoryFeedsResponse']; + }; + }; + /** @description A parameter was provided without its parent, or the country does not belong to the continent. */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; getLicenses: { parameters: { query?: { diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 0f826fe3..8adffae9 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -7,8 +7,31 @@ import datasetsFeedJson from '../../cypress/fixtures/feed_datasets_test-516.json import feed2947 from '../../cypress/fixtures/feed_mdb-2947.json'; import gtfsFeed2947 from '../../cypress/fixtures/gtfs_feed_mdb-2947.json'; import datasets2947 from '../../cypress/fixtures/feed_datasets_mdb-2947.json'; +import { + getMockDirectory, + getMockDirectoryFeeds, + readDirectoryQuery, +} from './locationDirectory'; export const handlers = [ + // Location directory: feeds of a node (paginated, relevance-sorted) + http.get(`*/v1/locations/directory/feeds`, ({ request }) => { + const params = new URL(request.url).searchParams; + const limit = Number(params.get('limit') ?? '20'); + const offset = Number(params.get('offset') ?? '0'); + const { status, body } = getMockDirectoryFeeds( + readDirectoryQuery(params), + Number.isFinite(limit) ? limit : 20, + Number.isFinite(offset) ? offset : 0, + ); + return HttpResponse.json(body, { status }); + }), + // Location directory: breadcrumb, counts and children of a node + http.get(`*/v1/locations/directory`, ({ request }) => { + const params = new URL(request.url).searchParams; + const { status, body } = getMockDirectory(readDirectoryQuery(params)); + return HttpResponse.json(body, { status }); + }), // Mock search endpoint: return a list including test-516 and mdb-2947 http.get(`*/v1/search`, () => { return HttpResponse.json({ diff --git a/src/mocks/locationDirectory.spec.ts b/src/mocks/locationDirectory.spec.ts new file mode 100644 index 00000000..00907ef7 --- /dev/null +++ b/src/mocks/locationDirectory.spec.ts @@ -0,0 +1,77 @@ +import { + getMockDirectory, + getMockDirectoryFeeds, + readDirectoryQuery, + slugify, +} from './locationDirectory'; + +describe('location directory mocks', () => { + it('slugifies names like the API', () => { + expect(slugify('Québec')).toBe('quebec'); + expect(slugify('Saint-Jean-sur-Richelieu')).toBe( + 'saint-jean-sur-richelieu', + ); + expect(slugify('Île-de-France')).toBe('ile-de-france'); + }); + + it('lists continents at the root', () => { + const result = getMockDirectory({}); + expect(result.status).toBe(200); + if (result.status !== 200) return; + expect(result.body.level).toBe('root'); + expect(result.body.breadcrumb).toEqual([]); + expect(result.body.children?.map((node) => node.slug)).toEqual([ + 'africa', + 'europe', + 'north-america', + 'oceania', + ]); + }); + + it('resolves the Montréal breadcrumb and rolls counts up', () => { + const query = readDirectoryQuery( + new URLSearchParams( + 'continent=north-america&country_code=ca&subdivision=quebec&municipality=montreal', + ), + ); + const result = getMockDirectory(query); + expect(result.status).toBe(200); + if (result.status !== 200) return; + expect(result.body.breadcrumb?.map((node) => node.name)).toEqual([ + 'North America', + 'Canada', + 'Québec', + 'Montréal', + ]); + expect(result.body.children).toEqual([]); + const montreal = result.body.feed_counts?.total ?? 0; + const quebec = result.body.breadcrumb?.[2].feed_counts?.total ?? 0; + expect(montreal).toBeGreaterThan(20); + expect(quebec).toBeGreaterThan(montreal); + }); + + it('returns 400 on a broken hierarchy and 404 when nothing is there', () => { + expect(getMockDirectory({ country_code: 'ca' }).status).toBe(400); + expect( + getMockDirectory({ continent: 'europe', country_code: 'ca' }).status, + ).toBe(400); + expect(getMockDirectory({ continent: 'antarctica' }).status).toBe(404); + }); + + it('paginates feeds, most relevant to the node first', () => { + const query = { + continent: 'north-america', + country_code: 'ca', + subdivision: 'quebec', + }; + const first = getMockDirectoryFeeds(query, 20, 0); + const second = getMockDirectoryFeeds(query, 20, 20); + if (first.status !== 200 || second.status !== 200) { + throw new Error('expected 200'); + } + expect(first.body.results).toHaveLength(20); + expect(second.body.results?.length).toBe((first.body.total ?? 0) - 20); + // Feeds declared at the Québec level outrank municipality-level ones. + expect(first.body.results?.[0].locations?.[0].municipality).toBeUndefined(); + }); +}); diff --git a/src/mocks/locationDirectory.ts b/src/mocks/locationDirectory.ts new file mode 100644 index 00000000..3d0f58fc --- /dev/null +++ b/src/mocks/locationDirectory.ts @@ -0,0 +1,532 @@ +/** + * Mock data and logic for the location directory endpoints: + * GET /v1/locations/directory + * GET /v1/locations/directory/feeds + * + * The directory tree, the rolled-up counts, the breadcrumb and the children + * are all derived from MOCK_FEEDS' declared locations, so the mock behaves + * like the API: a feed counts once per node, 400 on a broken hierarchy, 404 + * when nothing is located there, feeds sorted by relevance to the node. + * Feed ids, URLs and names are fictional mock data. + */ +import type { components } from '../app/services/feeds/types'; + +type Feed = components['schemas']['SearchFeedItemResult']; +type DirectoryNode = components['schemas']['LocationDirectoryNode']; +type DirectoryLevel = components['schemas']['LocationDirectoryLevel']; +type FeedCounts = components['schemas']['LocationDirectoryFeedCounts']; +type DirectoryResponse = components['schemas']['LocationDirectoryResponse']; +type FeedsResponse = components['schemas']['LocationDirectoryFeedsResponse']; + +const CONTINENTS: Record = { + africa: { code: 'AF', name: 'Africa' }, + europe: { code: 'EU', name: 'Europe' }, + 'north-america': { code: 'NA', name: 'North America' }, + oceania: { code: 'OC', name: 'Oceania' }, +}; + +const COUNTRIES: Record = { + AU: { name: 'Australia', continent: 'oceania' }, + CA: { name: 'Canada', continent: 'north-america' }, + DE: { name: 'Germany', continent: 'europe' }, + FR: { name: 'France', continent: 'europe' }, + US: { name: 'United States', continent: 'north-america' }, + ZA: { name: 'South Africa', continent: 'africa' }, +}; + +/** Same slug rule as the API: `Québec` -> `quebec`. */ +export function slugify(name: string): string { + return name + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +type Place = [countryCode: string, subdivision?: string, municipality?: string]; +type FeedSeed = [ + dataType: Feed['data_type'], + provider: string, + feedName: string, + places: Place[], +]; + +const MONTREAL: Place = ['CA', 'Québec', 'Montréal']; +const EXO_SECTORS = [ + 'Chambly-Richelieu-Carignan', + 'Haut-Saint-Laurent', + 'Laurentides', + 'Le Richelain', + 'Roussillon', + 'Sorel-Varennes', + 'Sud-Ouest', + 'Terrebonne-Mascouche', + 'Vallée du Richelieu', + 'La Presqu’Île', + 'L’Assomption', + 'Deux-Montagnes', +]; + +const FEED_SEEDS: FeedSeed[] = [ + // Canada-wide + ['gtfs', 'Example Intercity Rail', 'National rail network', [['CA']]], + // Québec (subdivision level only) + ['gtfs', 'Example Coach Lines', 'Intercity coaches', [['CA', 'Québec']]], + ['gtfs_rt', 'Example Coach Lines', 'Trip updates', [['CA', 'Québec']]], + // Montréal: more than one page of feeds + ['gtfs', 'Société de transport de Montréal', 'Bus and metro', [MONTREAL]], + ['gtfs_rt', 'Société de transport de Montréal', 'Trip updates', [MONTREAL]], + ['gtfs_rt', 'Société de transport de Montréal', 'Alerts', [MONTREAL]], + ['gtfs', 'Réseau express métropolitain', 'REM light metro', [MONTREAL]], + [ + 'gtfs', + 'exo', + 'Commuter trains', + [MONTREAL, ['CA', 'Québec', 'Laval'], ['CA', 'Québec', 'Longueuil']], + ], + ['gtfs_rt', 'exo', 'Commuter trains - trip updates', [MONTREAL]], + ...EXO_SECTORS.map( + (sector): FeedSeed => ['gtfs', 'exo', `Buses - ${sector}`, [MONTREAL]], + ), + ['gbfs', 'BIXI Montréal', 'Bike share', [MONTREAL]], + ['gbfs', 'Example Scooters', 'Montréal e-scooters', [MONTREAL]], + ['gbfs', 'Example Car Share', 'Montréal car share', [MONTREAL]], + ['gtfs_rt', 'exo', 'Commuter trains - vehicle positions', [MONTREAL]], + // Elsewhere in Québec + [ + 'gtfs', + 'Société de transport de Laval', + 'Buses', + [['CA', 'Québec', 'Laval']], + ], + [ + 'gtfs', + 'Réseau de transport de Longueuil', + 'Buses', + [['CA', 'Québec', 'Longueuil']], + ], + [ + 'gtfs', + 'Réseau de transport de la Capitale', + 'Buses', + [['CA', 'Québec', 'Québec']], + ], + [ + 'gtfs_rt', + 'Réseau de transport de la Capitale', + 'Vehicle positions', + [['CA', 'Québec', 'Québec']], + ], + // Ontario + [ + 'gtfs', + 'Example Regional Transit', + 'Regional rail and buses', + [['CA', 'Ontario']], + ], + [ + 'gtfs', + 'Toronto Transit Commission', + 'Surface and subway', + [['CA', 'Ontario', 'Toronto']], + ], + [ + 'gtfs_rt', + 'Toronto Transit Commission', + 'Trip updates', + [['CA', 'Ontario', 'Toronto']], + ], + ['gbfs', 'Bike Share Toronto', 'Bike share', [['CA', 'Ontario', 'Toronto']]], + // British Columbia + [ + 'gtfs', + 'Example Coast Transit', + 'Buses and SkyTrain', + [['CA', 'British Columbia', 'Vancouver']], + ], + // United States + [ + 'gtfs', + 'Example Metro Los Angeles', + 'Bus', + [['US', 'California', 'Los Angeles']], + ], + [ + 'gtfs', + 'Example Bay Area Rail', + 'Rapid transit', + [ + ['US', 'California', 'San Francisco'], + ['US', 'California', 'Oakland'], + ], + ], + [ + 'gtfs_rt', + 'Example Bay Area Rail', + 'Trip updates', + [['US', 'California', 'San Francisco']], + ], + [ + 'gbfs', + 'Example Bikes SF', + 'Bike share', + [['US', 'California', 'San Francisco']], + ], + [ + 'gtfs', + 'Example Metropolitan Transit', + 'Subway', + [['US', 'New York', 'New York']], + ], + [ + 'gtfs_rt', + 'Example Metropolitan Transit', + 'Subway trip updates', + [['US', 'New York', 'New York']], + ], + // Europe + [ + 'gtfs', + 'Example Île-de-France Mobilités', + 'Regional network', + [['FR', 'Île-de-France', 'Paris']], + ], + [ + 'gbfs', + 'Example Vélo Paris', + 'Bike share', + [['FR', 'Île-de-France', 'Paris']], + ], + [ + 'gtfs', + 'Example Verkehrsverbund Berlin', + 'Regional network', + [['DE', 'Berlin', 'Berlin']], + ], + // Oceania + [ + 'gtfs', + 'Example Transport NSW', + 'Sydney network', + [['AU', 'New South Wales', 'Sydney']], + ], + [ + 'gtfs_rt', + 'Example Transport NSW', + 'Trip updates', + [['AU', 'New South Wales', 'Sydney']], + ], + // Africa + [ + 'gtfs', + 'Example Cape Town Bus', + 'Bus rapid transit', + [['ZA', 'Western Cape', 'Cape Town']], + ], +]; + +function toFeed( + [dataType, provider, feedName, places]: FeedSeed, + index: number, +): Feed { + const id = `mock-loc-${String(index + 1).padStart(3, '0')}`; + return { + id, + data_type: dataType, + status: 'active', + official: index % 3 === 0, + seasonal: false, + provider, + feed_name: feedName, + external_ids: [{ external_id: id, source: 'mdb' }], + source_info: { + producer_url: `https://example.com/${id}/${dataType}`, + authentication_type: 0, + }, + locations: places.map(([countryCode, subdivision, municipality]) => ({ + country_code: countryCode, + country: COUNTRIES[countryCode].name, + subdivision_name: subdivision, + municipality, + })), + entity_types: dataType === 'gtfs_rt' ? ['tu', 'vp'] : undefined, + versions: dataType === 'gbfs' ? ['2.3'] : undefined, + }; +} + +export const MOCK_FEEDS: Feed[] = FEED_SEEDS.map(toFeed); + +export interface DirectoryQuery { + continent?: string; + country_code?: string; + subdivision?: string; + municipality?: string; +} + +type FeedLocation = NonNullable[number]; + +/** Depth of a declared location: 1 country, 2 subdivision, 3 municipality. */ +function locationDepth(location: FeedLocation): number { + if (location.municipality != undefined) return 3; + if (location.subdivision_name != undefined) return 2; + return 1; +} + +function matches(location: FeedLocation, query: DirectoryQuery): boolean { + const country = COUNTRIES[location.country_code ?? '']; + if (country == undefined) return false; + if (query.continent != undefined && country.continent !== query.continent) + return false; + if ( + query.country_code != undefined && + location.country_code?.toLowerCase() !== query.country_code.toLowerCase() + ) + return false; + if ( + query.subdivision != undefined && + slugify(location.subdivision_name ?? '') !== query.subdivision + ) + return false; + if ( + query.municipality != undefined && + slugify(location.municipality ?? '') !== query.municipality + ) + return false; + return true; +} + +function feedsIn(query: DirectoryQuery): Feed[] { + return MOCK_FEEDS.filter((feed) => + (feed.locations ?? []).some((location) => matches(location, query)), + ); +} + +function countFeeds(feeds: Feed[]): FeedCounts { + return { + gtfs: feeds.filter((feed) => feed.data_type === 'gtfs').length, + gtfs_rt: feeds.filter((feed) => feed.data_type === 'gtfs_rt').length, + gbfs: feeds.filter((feed) => feed.data_type === 'gbfs').length, + total: feeds.length, + }; +} + +function levelOf(query: DirectoryQuery): DirectoryLevel { + if (query.municipality != undefined) return 'municipality'; + if (query.subdivision != undefined) return 'subdivision'; + if (query.country_code != undefined) return 'country'; + if (query.continent != undefined) return 'continent'; + return 'root'; +} + +/** 400 when a parameter is missing its parent or the country is elsewhere. */ +function isValidHierarchy(query: DirectoryQuery): boolean { + if (query.country_code != undefined && query.continent == undefined) + return false; + if (query.subdivision != undefined && query.country_code == undefined) + return false; + if (query.municipality != undefined && query.subdivision == undefined) + return false; + if (query.country_code != undefined) { + const country = COUNTRIES[query.country_code.toUpperCase()]; + if (country != undefined && country.continent !== query.continent) + return false; + } + return true; +} + +function makeNode( + level: DirectoryLevel, + slug: string, + code: string | null, + name: string, + query: DirectoryQuery, +): DirectoryNode { + return { + level, + slug, + code, + name, + feed_counts: countFeeds(feedsIn(query)), + }; +} + +function nodeFor( + query: DirectoryQuery, + displayName?: string, +): DirectoryNode | undefined { + const level = levelOf(query); + switch (level) { + case 'continent': { + const slug = query.continent ?? ''; + const continent = CONTINENTS[slug]; + return continent == undefined + ? undefined + : makeNode(level, slug, continent.code, continent.name, query); + } + case 'country': { + const code = (query.country_code ?? '').toUpperCase(); + const country = COUNTRIES[code]; + return country == undefined + ? undefined + : makeNode(level, code.toLowerCase(), code, country.name, query); + } + case 'subdivision': + return makeNode( + level, + query.subdivision ?? '', + null, + displayName ?? query.subdivision ?? '', + query, + ); + case 'municipality': + return makeNode( + level, + query.municipality ?? '', + null, + displayName ?? query.municipality ?? '', + query, + ); + default: + return undefined; + } +} + +/** Original (accented) names of a subdivision/municipality, by slug. */ +function displayNameOf( + feeds: Feed[], + field: 'subdivision_name' | 'municipality', + slug: string, +): string | undefined { + for (const feed of feeds) { + for (const location of feed.locations ?? []) { + const value = location[field]; + if (value != undefined && slugify(value) === slug) return value; + } + } + return undefined; +} + +function childrenOf(query: DirectoryQuery): DirectoryNode[] { + const level = levelOf(query); + const children = new Map(); + for (const feed of feedsIn(query)) { + for (const location of feed.locations ?? []) { + if (!matches(location, query)) continue; + const countryCode = location.country_code ?? ''; + let childQuery: DirectoryQuery | undefined; + let name: string | undefined; + if (level === 'root') { + childQuery = { continent: COUNTRIES[countryCode].continent }; + } else if (level === 'continent') { + childQuery = { ...query, country_code: countryCode.toLowerCase() }; + } else if (level === 'country' && location.subdivision_name != null) { + name = location.subdivision_name; + childQuery = { ...query, subdivision: slugify(name) }; + } else if (level === 'subdivision' && location.municipality != null) { + name = location.municipality; + childQuery = { ...query, municipality: slugify(name) }; + } + if (childQuery == undefined) continue; + const key = JSON.stringify(childQuery); + if (!children.has(key)) { + const node = nodeFor(childQuery, name); + if (node != undefined) children.set(key, node); + } + } + } + return [...children.values()].sort((a, b) => + (a.name ?? '').localeCompare(b.name ?? ''), + ); +} + +function breadcrumbOf(query: DirectoryQuery): DirectoryNode[] { + const all = feedsIn(query); + const steps: DirectoryQuery[] = []; + if (query.continent != undefined) steps.push({ continent: query.continent }); + if (query.country_code != undefined) + steps.push({ ...steps[0], country_code: query.country_code }); + if (query.subdivision != undefined) + steps.push({ ...steps[1], subdivision: query.subdivision }); + if (query.municipality != undefined) + steps.push({ ...steps[2], municipality: query.municipality }); + return steps.flatMap((step) => { + const level = levelOf(step); + const name = + level === 'subdivision' + ? displayNameOf(all, 'subdivision_name', step.subdivision ?? '') + : level === 'municipality' + ? displayNameOf(all, 'municipality', step.municipality ?? '') + : undefined; + const node = nodeFor(step, name); + return node == undefined ? [] : [node]; + }); +} + +export type MockResult = + | { status: 200; body: T } + | { status: 400 | 404; body: { detail: string } }; + +export function readDirectoryQuery(params: URLSearchParams): DirectoryQuery { + const read = (key: string): string | undefined => { + const value = params.get(key); + return value == null || value === '' ? undefined : value; + }; + return { + continent: read('continent'), + country_code: read('country_code'), + subdivision: read('subdivision'), + municipality: read('municipality'), + }; +} + +export function getMockDirectory( + query: DirectoryQuery, +): MockResult { + if (!isValidHierarchy(query)) { + return { status: 400, body: { detail: 'Invalid location hierarchy.' } }; + } + const feeds = feedsIn(query); + if (feeds.length === 0) { + return { status: 404, body: { detail: 'No feed located there.' } }; + } + return { + status: 200, + body: { + level: levelOf(query), + breadcrumb: breadcrumbOf(query), + feed_counts: countFeeds(feeds), + children: childrenOf(query), + }, + }; +} + +export function getMockDirectoryFeeds( + query: DirectoryQuery, + limit: number, + offset: number, +): MockResult { + if (!isValidHierarchy(query)) { + return { status: 400, body: { detail: 'Invalid location hierarchy.' } }; + } + const relevance = (feed: Feed): number => + Math.max( + ...(feed.locations ?? []) + .filter((location) => matches(location, query)) + .map(locationDepth), + ); + const sorted = feedsIn(query).sort( + (a, b) => + relevance(a) - relevance(b) || + (a.provider ?? '').localeCompare(b.provider ?? '') || + (a.feed_name ?? '').localeCompare(b.feed_name ?? '') || + a.id.localeCompare(b.id), + ); + return { + status: 200, + body: { + total: sorted.length, + results: sorted.slice(offset, offset + limit), + }, + }; +} From 0d9253a123599cf047b4f73651a219ee8e98b9c4 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Mon, 2 Nov 2026 14:07:20 -0500 Subject: [PATCH 02/11] fix: do not cache missing locations in the directory A 400 or 404 from the location directory API was cached as a missing location for a day, so a page requested before the API route was deployed kept returning 404 after it was. Throw on a miss inside the cached functions, which unstable_cache never stores, and resolve it to null outside the cache. Co-Authored-By: Claude Opus 5.5 --- .../lib/location-directory-data.spec.ts | 108 ++++++++++++++++++ .../locations/lib/location-directory-data.ts | 59 +++++++--- 2 files changed, 151 insertions(+), 16 deletions(-) create mode 100644 src/app/[locale]/locations/lib/location-directory-data.spec.ts diff --git a/src/app/[locale]/locations/lib/location-directory-data.spec.ts b/src/app/[locale]/locations/lib/location-directory-data.spec.ts new file mode 100644 index 00000000..9c9b0bd3 --- /dev/null +++ b/src/app/[locale]/locations/lib/location-directory-data.spec.ts @@ -0,0 +1,108 @@ +/** + * @jest-environment node + */ + +import { ApiError } from '../../../services/feeds'; +import { + fetchLocationDirectory, + fetchLocationDirectoryFeeds, +} from './location-directory-data'; + +jest.mock('server-only', () => ({})); + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + cache: (fn: unknown) => fn, +})); + +// Behaves like unstable_cache: a resolved value is stored per arguments, a +// thrown error is not. +jest.mock('next/cache', () => ({ + unstable_cache: ( + fn: (...args: A) => Promise, + ): ((...args: A) => Promise) => { + const store = new Map(); + return async (...args: A): Promise => { + const key = JSON.stringify(args); + if (store.has(key)) return store.get(key) as R; + const value = await fn(...args); + store.set(key, value); + return value; + }; + }, +})); + +const mockGetLocationDirectory = jest.fn(); +const mockGetLocationDirectoryFeeds = jest.fn(); + +jest.mock('../../../services/feeds', () => ({ + ApiError: class ApiError extends Error { + readonly status: number; + constructor(message: string, status: number) { + super(message); + this.status = status; + } + }, + getLocationDirectory: (...args: unknown[]) => + mockGetLocationDirectory(...args), + getLocationDirectoryFeeds: (...args: unknown[]) => + mockGetLocationDirectoryFeeds(...args), +})); + +jest.mock('../../../utils/auth-server', () => ({ + getGuestGcipIdToken: async () => 'guest-token', +})); + +const directory = { + level: 'continent', + breadcrumb: [], + feed_counts: { gtfs: 1, gtfs_rt: 0, gbfs: 0, total: 1 }, + children: [], +}; + +describe('location directory data', () => { + beforeEach(() => { + mockGetLocationDirectory.mockReset(); + mockGetLocationDirectoryFeeds.mockReset(); + }); + + it('resolves a 404 to null without caching it', async () => { + mockGetLocationDirectory + .mockRejectedValueOnce(new ApiError('Not Found', 404)) + .mockResolvedValueOnce(directory); + + await expect(fetchLocationDirectory('europe')).resolves.toBeNull(); + await expect(fetchLocationDirectory('europe')).resolves.toEqual(directory); + expect(mockGetLocationDirectory).toHaveBeenCalledTimes(2); + }); + + it('resolves a 400 on the feeds page to null without caching it', async () => { + const feeds = { total: 0, results: [] }; + mockGetLocationDirectoryFeeds + .mockRejectedValueOnce(new ApiError('Bad Request', 400)) + .mockResolvedValueOnce(feeds); + + await expect(fetchLocationDirectoryFeeds(1, 'asia')).resolves.toBeNull(); + await expect(fetchLocationDirectoryFeeds(1, 'asia')).resolves.toEqual( + feeds, + ); + }); + + it('caches a found location', async () => { + mockGetLocationDirectory.mockResolvedValue(directory); + + await fetchLocationDirectory('oceania'); + await fetchLocationDirectory('oceania'); + expect(mockGetLocationDirectory).toHaveBeenCalledTimes(1); + }); + + it('rethrows other API errors', async () => { + mockGetLocationDirectory.mockRejectedValueOnce( + new ApiError('Server Error', 500), + ); + + await expect(fetchLocationDirectory('africa')).rejects.toThrow( + 'Server Error', + ); + }); +}); diff --git a/src/app/[locale]/locations/lib/location-directory-data.ts b/src/app/[locale]/locations/lib/location-directory-data.ts index da3957fe..4b387d3c 100644 --- a/src/app/[locale]/locations/lib/location-directory-data.ts +++ b/src/app/[locale]/locations/lib/location-directory-data.ts @@ -28,12 +28,16 @@ const CACHE_TAGS = ['location-directory', 'guest-feeds']; /** * 400 (invalid hierarchy) and 404 (nothing located there) both mean the URL - * does not exist; they resolve to null so the page can call notFound(). Any - * other failure is rethrown so an error is never cached. + * does not exist. They are thrown as this error inside the cached functions, + * because unstable_cache never stores a thrown error: caching the miss would + * keep a location missing for a whole day after it starts to exist (e.g. a + * 404 served before the API route was deployed). */ -async function nullWhenMissing( +class LocationNotFoundError extends Error {} + +async function requireData( request: () => Promise, -): Promise { +): Promise { try { const data = await request(); if (data == undefined) { @@ -45,12 +49,24 @@ async function nullWhenMissing( error instanceof ApiError && (error.status === 400 || error.status === 404) ) { - return null; + throw new LocationNotFoundError(error.message); } throw error; } } +/** Resolves a missing location to null so the page can call notFound(). */ +async function nullWhenMissing( + request: () => Promise, +): Promise { + try { + return await request(); + } catch (error) { + if (error instanceof LocationNotFoundError) return null; + throw error; + } +} + function toQuery( continent: string | undefined, countryCode: string | undefined, @@ -73,9 +89,9 @@ const cachedDirectory = unstable_cache( countryCode?: string, subdivision?: string, municipality?: string, - ): Promise => { + ): Promise => { const accessToken = await getGuestGcipIdToken(); - return await nullWhenMissing( + return await requireData( async () => await getLocationDirectory( toQuery(continent, countryCode, subdivision, municipality), @@ -94,9 +110,9 @@ const cachedDirectoryFeeds = unstable_cache( countryCode?: string, subdivision?: string, municipality?: string, - ): Promise => { + ): Promise => { const accessToken = await getGuestGcipIdToken(); - return await nullWhenMissing( + return await requireData( async () => await getLocationDirectoryFeeds( { @@ -124,7 +140,15 @@ export const fetchLocationDirectory = cache( subdivision?: string, municipality?: string, ): Promise => - await cachedDirectory(continent, countryCode, subdivision, municipality), + await nullWhenMissing( + async () => + await cachedDirectory( + continent, + countryCode, + subdivision, + municipality, + ), + ), ); /** One page (1-based) of the feeds located in a node, or null if missing. */ @@ -136,11 +160,14 @@ export const fetchLocationDirectoryFeeds = cache( subdivision?: string, municipality?: string, ): Promise => - await cachedDirectoryFeeds( - getPageOffset(page), - continent, - countryCode, - subdivision, - municipality, + await nullWhenMissing( + async () => + await cachedDirectoryFeeds( + getPageOffset(page), + continent, + countryCode, + subdivision, + municipality, + ), ), ); From d90beff71fd0f88e4a566626b64af1f35cd51f54 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Mon, 2 Nov 2026 15:10:57 -0500 Subject: [PATCH 03/11] feat: align location pages with the feed detail page style Use the feed detail page's container and breadcrumb navigation, with its back button, and show GTFS / GTFS-RT / GBFS counts as chips. Child locations are now sorted by feed count, shown as shadowed cards like the advanced search results, and listed in a scrollable box with a search field that filters them by name, ignoring accents and case. Co-Authored-By: Claude Opus 5.5 --- messages/en.json | 5 +- messages/fr.json | 5 +- .../locations/[[...location]]/page.tsx | 138 ++++++++------- .../locations/components/FeedCountChips.tsx | 46 +++++ .../locations/components/FeedCountSummary.tsx | 63 ++----- .../components/LocationBreadcrumbs.tsx | 60 ------- .../components/LocationChildrenGrid.tsx | 150 ++++------------ .../components/LocationChildrenList.tsx | 162 ++++++++++++++++++ .../locations/lib/location-directory.spec.ts | 56 ++++++ .../locations/lib/location-directory.ts | 34 ++++ 10 files changed, 427 insertions(+), 292 deletions(-) create mode 100644 src/app/[locale]/locations/components/FeedCountChips.tsx delete mode 100644 src/app/[locale]/locations/components/LocationBreadcrumbs.tsx create mode 100644 src/app/[locale]/locations/components/LocationChildrenList.tsx diff --git a/messages/en.json b/messages/en.json index 3a7876ad..b3116032 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1271,7 +1271,6 @@ "title": "Feeds by location", "rootIntro": "Browse open GTFS Schedule, GTFS Realtime and GBFS feeds by continent, country, region and municipality.", "nodeIntro": "{total, plural, =0 {No feeds are} one {# feed is} other {# feeds are}} located in {place}.", - "breadcrumbAria": "Location breadcrumb", "feedCountsAria": "Feed counts by data type", "shortLabels": { "gtfs": "GTFS", @@ -1285,6 +1284,10 @@ "municipality": "Municipalities" }, "childFeedTotal": "{count, plural, one {# feed} other {# feeds}}", + "childrenSearchPlaceholder": "Search locations", + "childrenSearchAria": "Filter the locations below by name", + "childrenSearchResults": "{count, plural, =0 {No matching locations} one {# matching location} other {# matching locations}}", + "childrenSearchEmpty": "No location matches “{query}”.", "feedsHeading": "Feeds in {place}", "allFeedsHeading": "All feeds", "resultsRange": "Showing {start}–{end} of {total} feeds", diff --git a/messages/fr.json b/messages/fr.json index 8c14b11c..bd6e12fe 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1271,7 +1271,6 @@ "title": "Flux par lieu", "rootIntro": "Parcourez les flux ouverts GTFS Schedule, GTFS Realtime et GBFS par continent, pays, région et municipalité.", "nodeIntro": "{total, plural, =0 {Aucun flux n’est situé} one {# flux est situé} other {# flux sont situés}} dans : {place}.", - "breadcrumbAria": "Fil d’Ariane des lieux", "feedCountsAria": "Nombre de flux par type de données", "shortLabels": { "gtfs": "GTFS", @@ -1285,6 +1284,10 @@ "municipality": "Municipalités" }, "childFeedTotal": "{count, plural, one {# flux} other {# flux}}", + "childrenSearchPlaceholder": "Rechercher un lieu", + "childrenSearchAria": "Filtrer les lieux ci-dessous par nom", + "childrenSearchResults": "{count, plural, =0 {Aucun lieu correspondant} one {# lieu correspondant} other {# lieux correspondants}}", + "childrenSearchEmpty": "Aucun lieu ne correspond à « {query} ».", "feedsHeading": "Flux dans : {place}", "allFeedsHeading": "Tous les flux", "resultsRange": "{start}–{end} sur {total} flux", diff --git a/src/app/[locale]/locations/[[...location]]/page.tsx b/src/app/[locale]/locations/[[...location]]/page.tsx index 4f62ee40..53d64cd7 100644 --- a/src/app/[locale]/locations/[[...location]]/page.tsx +++ b/src/app/[locale]/locations/[[...location]]/page.tsx @@ -3,9 +3,10 @@ import { type Metadata } from 'next'; import { notFound } from 'next/navigation'; import { Box, Container, Typography } from '@mui/material'; import { getTranslations, setRequestLocale } from 'next-intl/server'; -import LocationBreadcrumbs, { - type LocationCrumb, -} from '../components/LocationBreadcrumbs'; +import BreadcrumbNavigation, { + type Crumb, +} from '../../../components/BreadcrumbNavigation'; +import SectionContainer from '../../../components/SectionContainer'; import FeedCountSummary from '../components/FeedCountSummary'; import LocationChildrenGrid from '../components/LocationChildrenGrid'; import LocationFeedsList from '../components/LocationFeedsList'; @@ -139,10 +140,11 @@ export default async function LocationsPage( setRequestLocale(locale); // Directory node and feed page are independent: fetch them in parallel. - const [directory, feeds, t] = await Promise.all([ + const [directory, feeds, t, tCommon] = await Promise.all([ fetchDirectory(query), fetchFeedsPage(query, page), getTranslations('locations'), + getTranslations('common'), ]); if (directory === null || feeds === null) notFound(); @@ -157,7 +159,8 @@ export default async function LocationsPage( const children = directory.children ?? []; const childLevel = getChildLevel(directory.level); - const crumbs: LocationCrumb[] = [ + const crumbs: Crumb[] = [ + { label: tCommon('feeds'), href: '/feeds' }, { label: t('root'), href: isRoot ? undefined : buildLocationHref([]) }, ...breadcrumb.map((node, index) => ({ label: node.name ?? node.slug ?? '', @@ -167,75 +170,88 @@ export default async function LocationsPage( : buildLocationHref(slugs.slice(0, index + 1)), })), ]; + // Without history to pop, back goes one level up the directory. + const backFallbackHref = isRoot + ? '/feeds' + : buildLocationHref(slugs.slice(0, -1)); return ( - - - - {isRoot ? t('title') : breadcrumb[breadcrumb.length - 1]?.name} - - - {isRoot - ? t('rootIntro') - : t('nodeIntro', { - total: getTotalFeedCount(directory.feed_counts), - place, - })} - - - - - {childLevel !== null && children.length > 0 ? ( + + + + + {isRoot ? t('title') : breadcrumb[breadcrumb.length - 1]?.name} + + + {isRoot + ? t('rootIntro') + : t('nodeIntro', { + total: getTotalFeedCount(directory.feed_counts), + place, + })} + + + + + {childLevel !== null && children.length > 0 ? ( + + + {t(`childrenHeading.${childLevel}`)}{' '} + + ({children.length}) + + + + + ) : null} + - {t(`childrenHeading.${childLevel}`)}{' '} - - ({children.length}) - + {isRoot ? t('allFeedsHeading') : t('feedsHeading', { place })} - - ) : null} - - - - {isRoot ? t('allFeedsHeading') : t('feedsHeading', { place })} - - - + ); } diff --git a/src/app/[locale]/locations/components/FeedCountChips.tsx b/src/app/[locale]/locations/components/FeedCountChips.tsx new file mode 100644 index 00000000..9e673cd7 --- /dev/null +++ b/src/app/[locale]/locations/components/FeedCountChips.tsx @@ -0,0 +1,46 @@ +import { type ReactElement } from 'react'; +import { Box, Chip } from '@mui/material'; +import { type FeedCountDataType } from '../lib/location-directory'; + +export interface FeedCountChip { + dataType: FeedCountDataType; + count: number; + /** Already formatted, e.g. "42 GTFS Schedule". */ + label: string; +} + +interface Props { + chips: FeedCountChip[]; + size?: 'small' | 'medium'; + ariaLabel?: string; +} + +/** + * GTFS / GTFS-RT / GBFS counts as outlined chips. Hook-free, so it renders in + * both server and client components. + */ +export default function FeedCountChips({ + chips, + size = 'medium', + ariaLabel, +}: Props): ReactElement { + return ( + + {chips.map(({ dataType, count, label }) => ( + 0 ? 'primary' : 'default'} + sx={count > 0 ? { fontWeight: 700 } : { color: 'text.disabled' }} + /> + ))} + + ); +} diff --git a/src/app/[locale]/locations/components/FeedCountSummary.tsx b/src/app/[locale]/locations/components/FeedCountSummary.tsx index 16580e77..5ce5463b 100644 --- a/src/app/[locale]/locations/components/FeedCountSummary.tsx +++ b/src/app/[locale]/locations/components/FeedCountSummary.tsx @@ -1,6 +1,6 @@ import { type ReactElement } from 'react'; -import { Box, Typography } from '@mui/material'; import { getTranslations } from 'next-intl/server'; +import FeedCountChips from './FeedCountChips'; import { formatCount, getFeedCountEntries, @@ -30,56 +30,15 @@ export default async function FeedCountSummary({ ]); return ( - - {getFeedCountEntries(counts).map(({ dataType, count }) => ( - - - {tCommon(LABEL_KEYS[dataType])} - - 0 ? 'primary.main' : 'text.disabled', - }} - > - {formatCount(count, locale)} - - - ))} - +
    + ({ + dataType, + count, + label: `${formatCount(count, locale)} ${tCommon(LABEL_KEYS[dataType])}`, + }))} + /> +
    ); } diff --git a/src/app/[locale]/locations/components/LocationBreadcrumbs.tsx b/src/app/[locale]/locations/components/LocationBreadcrumbs.tsx deleted file mode 100644 index 27e62281..00000000 --- a/src/app/[locale]/locations/components/LocationBreadcrumbs.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { type ReactElement } from 'react'; -import { Breadcrumbs, Typography } from '@mui/material'; -import NavigateNextIcon from '@mui/icons-material/NavigateNext'; -import { Link } from '../../../../i18n/navigation'; - -export interface LocationCrumb { - label: string; - /** Omitted on the trailing crumb, the page the user is on. */ - href?: string; -} - -interface Props { - crumbs: LocationCrumb[]; - ariaLabel: string; -} - -export default function LocationBreadcrumbs({ - crumbs, - ariaLabel, -}: Props): ReactElement { - return ( - } - data-testid='location-breadcrumbs' - sx={{ - mb: 1, - '& .MuiBreadcrumbs-ol': { rowGap: 0.5 }, - '& a': { - color: 'text.secondary', - textDecoration: 'none', - borderRadius: '4px', - }, - '& a:hover': { color: 'primary.main', textDecoration: 'underline' }, - '& a:focus-visible': { - outline: '2px solid', - outlineColor: 'primary.main', - outlineOffset: '2px', - }, - }} - > - {crumbs.map((crumb) => - crumb.href != undefined ? ( - - {crumb.label} - - ) : ( - - {crumb.label} - - ), - )} - - ); -} diff --git a/src/app/[locale]/locations/components/LocationChildrenGrid.tsx b/src/app/[locale]/locations/components/LocationChildrenGrid.tsx index d69724b0..af197e45 100644 --- a/src/app/[locale]/locations/components/LocationChildrenGrid.tsx +++ b/src/app/[locale]/locations/components/LocationChildrenGrid.tsx @@ -1,13 +1,15 @@ import { type ReactElement } from 'react'; -import { Box, Typography } from '@mui/material'; import { getTranslations } from 'next-intl/server'; import { getEmojiFlag, type TCountryCode } from 'countries-list'; -import { Link } from '../../../../i18n/navigation'; +import LocationChildrenList, { + type LocationChildItem, +} from './LocationChildrenList'; import { buildLocationHref, formatCount, getFeedCountEntries, getTotalFeedCount, + sortByFeedCount, type LocationDirectoryNode, } from '../lib/location-directory'; @@ -18,16 +20,17 @@ interface Props { locale: string; } -function getDisplayName(node: LocationDirectoryNode): string { - const name = node.name ?? node.slug ?? ''; - if (node.level === 'country' && node.code != undefined) { - const flag = getEmojiFlag(node.code.toUpperCase() as TCountryCode); - return flag !== '' ? `${flag} ${name}` : name; - } - return name; +function getFlag(node: LocationDirectoryNode): string | undefined { + if (node.level !== 'country' || node.code == undefined) return undefined; + const flag = getEmojiFlag(node.code.toUpperCase() as TCountryCode); + return flag !== '' ? flag : undefined; } -/** Child nodes as link tiles, each with its GTFS / GTFS-RT / GBFS counts. */ +/** + * Child nodes, most feeds first. Everything that needs the server (flags, + * translations, number formatting) is resolved here so the client list only + * filters plain data. + */ export default async function LocationChildrenGrid({ nodes, parentSlugs, @@ -35,111 +38,24 @@ export default async function LocationChildrenGrid({ }: Props): Promise { const t = await getTranslations('locations'); - return ( - - {nodes.map((node) => { - const counts = getFeedCountEntries(node.feed_counts); - const href = buildLocationHref([...parentSlugs, node.slug ?? '']); - return ( -
  • - - - - {getDisplayName(node)} - - - {t('childFeedTotal', { - count: getTotalFeedCount(node.feed_counts), - })} - - - - {counts.map(({ dataType, count }) => ( - 0 ? 'text.secondary' : 'text.disabled', - whiteSpace: 'nowrap', - }} - > - {t(`shortLabels.${dataType}`)}{' '} - 0 ? 'text.primary' : 'inherit', - }} - > - {formatCount(count, locale)} - - - ))} - - -
  • - ); - })} -
    - ); + const items: LocationChildItem[] = sortByFeedCount( + nodes.map((node) => ({ + node, + name: node.name ?? node.slug ?? '', + counts: node.feed_counts, + })), + ).map(({ node, name, counts }) => ({ + slug: node.slug ?? '', + name, + flag: getFlag(node), + href: buildLocationHref([...parentSlugs, node.slug ?? '']), + totalLabel: t('childFeedTotal', { count: getTotalFeedCount(counts) }), + chips: getFeedCountEntries(counts).map(({ dataType, count }) => ({ + dataType, + count, + label: `${formatCount(count, locale)} ${t(`shortLabels.${dataType}`)}`, + })), + })); + + return ; } diff --git a/src/app/[locale]/locations/components/LocationChildrenList.tsx b/src/app/[locale]/locations/components/LocationChildrenList.tsx new file mode 100644 index 00000000..25b88e01 --- /dev/null +++ b/src/app/[locale]/locations/components/LocationChildrenList.tsx @@ -0,0 +1,162 @@ +'use client'; + +import { useMemo, useState, type ReactElement } from 'react'; +import { + Box, + Card, + CardActionArea, + InputAdornment, + TextField, + Typography, +} from '@mui/material'; +import SearchIcon from '@mui/icons-material/Search'; +import { useTranslations } from 'next-intl'; +import { Link } from '../../../../i18n/navigation'; +import FeedCountChips, { type FeedCountChip } from './FeedCountChips'; +import { filterByName } from '../lib/location-directory'; + +export interface LocationChildItem { + slug: string; + name: string; + /** Emoji flag, countries only. */ + flag?: string; + href: string; + totalLabel: string; + chips: FeedCountChip[]; +} + +interface Props { + /** Already sorted, most feeds first. */ + items: LocationChildItem[]; +} + +/** Searchable, scrollable grid of child locations. */ +export default function LocationChildrenList({ items }: Props): ReactElement { + const t = useTranslations('locations'); + const [query, setQuery] = useState(''); + const visibleItems = useMemo( + () => filterByName(items, query), + [items, query], + ); + + return ( + <> + { + setQuery(event.target.value); + }} + placeholder={t('childrenSearchPlaceholder')} + slotProps={{ + htmlInput: { + 'aria-label': t('childrenSearchAria'), + 'aria-controls': 'location-children', + 'data-testid': 'location-children-search', + }, + input: { + startAdornment: ( + + + + ), + }, + }} + sx={{ mb: 1, maxWidth: { md: 480 } }} + /> + + {query.trim() !== '' + ? t('childrenSearchResults', { count: visibleItems.length }) + : null} + + + {visibleItems.length > 0 ? ( + + {visibleItems.map((item) => ( +
  • + + + + + {item.flag != undefined + ? `${item.flag} ${item.name}` + : item.name} + + + {item.totalLabel} + + + + + +
  • + ))} +
    + ) : ( + + {t('childrenSearchEmpty', { query: query.trim() })} + + )} +
    + + ); +} diff --git a/src/app/[locale]/locations/lib/location-directory.spec.ts b/src/app/[locale]/locations/lib/location-directory.spec.ts index b954f6c2..fcb2b989 100644 --- a/src/app/[locale]/locations/lib/location-directory.spec.ts +++ b/src/app/[locale]/locations/lib/location-directory.spec.ts @@ -1,6 +1,7 @@ import { buildLocationHref, buildPlaceName, + filterByName, formatCount, getBreadcrumbSlugs, getChildLevel, @@ -8,9 +9,11 @@ import { getPageCount, getPageOffset, getTotalFeedCount, + normalizeLocationName, type LocationDirectoryNode, parseLocationSegments, parsePageParam, + sortByFeedCount, } from './location-directory'; const breadcrumb: LocationDirectoryNode[] = [ @@ -176,3 +179,56 @@ describe('buildPlaceName', () => { expect(buildPlaceName(breadcrumb)).toBe('Montréal, Québec, Canada'); }); }); + +describe('normalizeLocationName', () => { + it('drops accents and case', () => { + expect(normalizeLocationName(' Québec ')).toBe('quebec'); + expect(normalizeLocationName('São Paulo')).toBe('sao paulo'); + }); +}); + +describe('sortByFeedCount', () => { + const item = ( + name: string, + total: number, + ): { name: string; counts: { total: number } } => ({ + name, + counts: { total }, + }); + + it('puts the most feeds first, then sorts by name', () => { + const sorted = sortByFeedCount([ + item('Laval', 3), + item('Montréal', 22), + item('Québec', 3), + item('Gatineau', 1), + item('Lévis', 3), + ]); + expect(sorted.map(({ name }) => name)).toEqual([ + 'Montréal', + 'Laval', + 'Lévis', + 'Québec', + 'Gatineau', + ]); + }); + + it('does not mutate its input', () => { + const items = [item('B', 1), item('A', 2)]; + sortByFeedCount(items); + expect(items.map(({ name }) => name)).toEqual(['B', 'A']); + }); +}); + +describe('filterByName', () => { + const items = [{ name: 'Montréal' }, { name: 'Québec' }, { name: 'Laval' }]; + + it('matches anywhere in the name, ignoring accents and case', () => { + expect(filterByName(items, 'MONTRE')).toEqual([{ name: 'Montréal' }]); + expect(filterByName(items, 'bec')).toEqual([{ name: 'Québec' }]); + }); + + it('returns every item for a blank query', () => { + expect(filterByName(items, ' ')).toEqual(items); + }); +}); diff --git a/src/app/[locale]/locations/lib/location-directory.ts b/src/app/[locale]/locations/lib/location-directory.ts index 010d8912..cdce6d46 100644 --- a/src/app/[locale]/locations/lib/location-directory.ts +++ b/src/app/[locale]/locations/lib/location-directory.ts @@ -141,6 +141,40 @@ export function getTotalFeedCount( ); } +/** Lowercased and without accents, so "Québec" matches "quebec". */ +export function normalizeLocationName(name: string): string { + return name + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .toLowerCase() + .trim(); +} + +/** Most feeds first; ties by name, ignoring accents and case. */ +export function sortByFeedCount< + T extends { name: string; counts: LocationDirectoryFeedCounts | undefined }, +>(items: readonly T[]): T[] { + return [...items].sort( + (a, b) => + getTotalFeedCount(b.counts) - getTotalFeedCount(a.counts) || + normalizeLocationName(a.name).localeCompare( + normalizeLocationName(b.name), + ), + ); +} + +/** Items whose name contains the query, ignoring accents and case. */ +export function filterByName( + items: readonly T[], + query: string, +): T[] { + const normalizedQuery = normalizeLocationName(query); + if (normalizedQuery === '') return [...items]; + return items.filter((item) => + normalizeLocationName(item.name).includes(normalizedQuery), + ); +} + /** Locale-aware integer formatting, e.g. 1234 -> "1,234" (en). */ export function formatCount(value: number | undefined, locale: string): string { return new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format( From 4374d1df4702025c7918f5bdb871de86c0e55625 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Tue, 3 Nov 2026 08:12:20 -0500 Subject: [PATCH 04/11] feat: use country names in location URLs and refine directory cards - Country segments use name slugs (/locations/europe/portugal) through the API's new `country` parameter; API types regenerated. - Child cards show per-type feed counts as plain text instead of chips. - Child search field gets a white background. - No viewport prefetch on child cards and pagination links. - Directory data cache lowered from 1 day to 6 hours. Co-Authored-By: Claude Opus 5.5 --- .../locations/[[...location]]/page.tsx | 4 +- .../components/LocationChildrenGrid.tsx | 2 +- .../components/LocationChildrenList.tsx | 36 ++++++++++++++-- .../components/LocationFeedsList.tsx | 1 + .../locations/lib/location-directory-data.ts | 31 ++++++------- .../locations/lib/location-directory.spec.ts | 20 ++++++--- .../locations/lib/location-directory.ts | 7 +-- src/app/services/feeds/types.ts | 22 +++++----- src/mocks/locationDirectory.spec.ts | 8 ++-- src/mocks/locationDirectory.ts | 43 +++++++++++-------- 10 files changed, 107 insertions(+), 67 deletions(-) diff --git a/src/app/[locale]/locations/[[...location]]/page.tsx b/src/app/[locale]/locations/[[...location]]/page.tsx index 53d64cd7..97d6c089 100644 --- a/src/app/[locale]/locations/[[...location]]/page.tsx +++ b/src/app/[locale]/locations/[[...location]]/page.tsx @@ -58,7 +58,7 @@ function fetchDirectory( ): ReturnType { return fetchLocationDirectory( query.continent, - query.country_code, + query.country, query.subdivision, query.municipality, ); @@ -71,7 +71,7 @@ function fetchFeedsPage( return fetchLocationDirectoryFeeds( page, query.continent, - query.country_code, + query.country, query.subdivision, query.municipality, ); diff --git a/src/app/[locale]/locations/components/LocationChildrenGrid.tsx b/src/app/[locale]/locations/components/LocationChildrenGrid.tsx index af197e45..04ee1d69 100644 --- a/src/app/[locale]/locations/components/LocationChildrenGrid.tsx +++ b/src/app/[locale]/locations/components/LocationChildrenGrid.tsx @@ -50,7 +50,7 @@ export default async function LocationChildrenGrid({ flag: getFlag(node), href: buildLocationHref([...parentSlugs, node.slug ?? '']), totalLabel: t('childFeedTotal', { count: getTotalFeedCount(counts) }), - chips: getFeedCountEntries(counts).map(({ dataType, count }) => ({ + counts: getFeedCountEntries(counts).map(({ dataType, count }) => ({ dataType, count, label: `${formatCount(count, locale)} ${t(`shortLabels.${dataType}`)}`, diff --git a/src/app/[locale]/locations/components/LocationChildrenList.tsx b/src/app/[locale]/locations/components/LocationChildrenList.tsx index 25b88e01..2d160ff7 100644 --- a/src/app/[locale]/locations/components/LocationChildrenList.tsx +++ b/src/app/[locale]/locations/components/LocationChildrenList.tsx @@ -12,7 +12,7 @@ import { import SearchIcon from '@mui/icons-material/Search'; import { useTranslations } from 'next-intl'; import { Link } from '../../../../i18n/navigation'; -import FeedCountChips, { type FeedCountChip } from './FeedCountChips'; +import { type FeedCountChip } from './FeedCountChips'; import { filterByName } from '../lib/location-directory'; export interface LocationChildItem { @@ -22,7 +22,8 @@ export interface LocationChildItem { flag?: string; href: string; totalLabel: string; - chips: FeedCountChip[]; + /** Plain-text counts; chips are kept for the page summary. */ + counts: FeedCountChip[]; } interface Props { @@ -64,7 +65,12 @@ export default function LocationChildrenList({ items }: Props): ReactElement { ), }, }} - sx={{ mb: 1, maxWidth: { md: 480 } }} + sx={{ + mb: 1, + maxWidth: { md: 480 }, + // White in light mode, like the cards below; the section is paper. + '& .MuiInputBase-root': { bgcolor: 'background.default' }, + }} />
    - + + {item.counts.map(({ dataType, count, label }) => ( + 0 ? 'text.secondary' : 'text.disabled'} + > + {label} + + ))} + diff --git a/src/app/[locale]/locations/components/LocationFeedsList.tsx b/src/app/[locale]/locations/components/LocationFeedsList.tsx index 257430de..7b81285f 100644 --- a/src/app/[locale]/locations/components/LocationFeedsList.tsx +++ b/src/app/[locale]/locations/components/LocationFeedsList.tsx @@ -76,6 +76,7 @@ export default function LocationFeedsList({ slugs, Math.min(Math.max(item.page ?? 1, 1), pageCount), )}#${FEEDS_SECTION_ID}`} + prefetch={false} {...item} /> )} diff --git a/src/app/[locale]/locations/lib/location-directory-data.ts b/src/app/[locale]/locations/lib/location-directory-data.ts index 4b387d3c..27838a86 100644 --- a/src/app/[locale]/locations/lib/location-directory-data.ts +++ b/src/app/[locale]/locations/lib/location-directory-data.ts @@ -22,15 +22,15 @@ import { type LocationQuery, } from './location-directory'; -/** 1 day; also flushed on demand with the existing `guest-feeds` tag. */ -export const LOCATION_DIRECTORY_REVALIDATE_SECONDS = 86400; +/** 6 hours; also flushed on demand with the existing `guest-feeds` tag. */ +export const LOCATION_DIRECTORY_REVALIDATE_SECONDS = 21600; const CACHE_TAGS = ['location-directory', 'guest-feeds']; /** * 400 (invalid hierarchy) and 404 (nothing located there) both mean the URL * does not exist. They are thrown as this error inside the cached functions, * because unstable_cache never stores a thrown error: caching the miss would - * keep a location missing for a whole day after it starts to exist (e.g. a + * keep a location missing for hours after it starts to exist (e.g. a * 404 served before the API route was deployed). */ class LocationNotFoundError extends Error {} @@ -69,13 +69,13 @@ async function nullWhenMissing( function toQuery( continent: string | undefined, - countryCode: string | undefined, + country: string | undefined, subdivision: string | undefined, municipality: string | undefined, ): LocationQuery { const query: LocationQuery = {}; if (continent != undefined) query.continent = continent; - if (countryCode != undefined) query.country_code = countryCode; + if (country != undefined) query.country = country; if (subdivision != undefined) query.subdivision = subdivision; if (municipality != undefined) query.municipality = municipality; return query; @@ -86,7 +86,7 @@ function toQuery( const cachedDirectory = unstable_cache( async ( continent?: string, - countryCode?: string, + country?: string, subdivision?: string, municipality?: string, ): Promise => { @@ -94,7 +94,7 @@ const cachedDirectory = unstable_cache( return await requireData( async () => await getLocationDirectory( - toQuery(continent, countryCode, subdivision, municipality), + toQuery(continent, country, subdivision, municipality), accessToken, ), ); @@ -107,7 +107,7 @@ const cachedDirectoryFeeds = unstable_cache( async ( offset: number, continent?: string, - countryCode?: string, + country?: string, subdivision?: string, municipality?: string, ): Promise => { @@ -116,7 +116,7 @@ const cachedDirectoryFeeds = unstable_cache( async () => await getLocationDirectoryFeeds( { - ...toQuery(continent, countryCode, subdivision, municipality), + ...toQuery(continent, country, subdivision, municipality), limit: LOCATION_FEEDS_PAGE_SIZE, offset, }, @@ -136,18 +136,13 @@ const cachedDirectoryFeeds = unstable_cache( export const fetchLocationDirectory = cache( async ( continent?: string, - countryCode?: string, + country?: string, subdivision?: string, municipality?: string, ): Promise => await nullWhenMissing( async () => - await cachedDirectory( - continent, - countryCode, - subdivision, - municipality, - ), + await cachedDirectory(continent, country, subdivision, municipality), ), ); @@ -156,7 +151,7 @@ export const fetchLocationDirectoryFeeds = cache( async ( page: number, continent?: string, - countryCode?: string, + country?: string, subdivision?: string, municipality?: string, ): Promise => @@ -165,7 +160,7 @@ export const fetchLocationDirectoryFeeds = cache( await cachedDirectoryFeeds( getPageOffset(page), continent, - countryCode, + country, subdivision, municipality, ), diff --git a/src/app/[locale]/locations/lib/location-directory.spec.ts b/src/app/[locale]/locations/lib/location-directory.spec.ts index fcb2b989..ff0c6640 100644 --- a/src/app/[locale]/locations/lib/location-directory.spec.ts +++ b/src/app/[locale]/locations/lib/location-directory.spec.ts @@ -23,7 +23,7 @@ const breadcrumb: LocationDirectoryNode[] = [ code: 'NA', name: 'North America', }, - { level: 'country', slug: 'ca', code: 'CA', name: 'Canada' }, + { level: 'country', slug: 'canada', code: 'CA', name: 'Canada' }, { level: 'subdivision', slug: 'quebec', code: null, name: 'Québec' }, { level: 'municipality', slug: 'montreal', code: null, name: 'Montréal' }, ]; @@ -39,10 +39,10 @@ describe('parseLocationSegments', () => { continent: 'north-america', }); expect( - parseLocationSegments(['north-america', 'ca', 'quebec', 'montreal']), + parseLocationSegments(['north-america', 'canada', 'quebec', 'montreal']), ).toEqual({ continent: 'north-america', - country_code: 'ca', + country: 'canada', subdivision: 'quebec', municipality: 'montreal', }); @@ -50,7 +50,13 @@ describe('parseLocationSegments', () => { it('rejects paths deeper than a municipality', () => { expect( - parseLocationSegments(['north-america', 'ca', 'quebec', 'montreal', 'x']), + parseLocationSegments([ + 'north-america', + 'canada', + 'quebec', + 'montreal', + 'x', + ]), ).toBeNull(); }); @@ -103,8 +109,8 @@ describe('buildLocationHref', () => { it('builds the root and nested paths', () => { expect(buildLocationHref([])).toBe('/locations'); expect( - buildLocationHref(['north-america', 'ca', 'quebec', 'montreal']), - ).toBe('/locations/north-america/ca/quebec/montreal'); + buildLocationHref(['north-america', 'canada', 'quebec', 'montreal']), + ).toBe('/locations/north-america/canada/quebec/montreal'); }); it('adds ?page only after the first page', () => { @@ -119,7 +125,7 @@ describe('buildLocationHref', () => { it('round-trips with the breadcrumb slugs', () => { expect(buildLocationHref(getBreadcrumbSlugs(breadcrumb), 2)).toBe( - '/locations/north-america/ca/quebec/montreal?page=2', + '/locations/north-america/canada/quebec/montreal?page=2', ); expect(getBreadcrumbSlugs(undefined)).toEqual([]); }); diff --git a/src/app/[locale]/locations/lib/location-directory.ts b/src/app/[locale]/locations/lib/location-directory.ts index cdce6d46..c3dc11e2 100644 --- a/src/app/[locale]/locations/lib/location-directory.ts +++ b/src/app/[locale]/locations/lib/location-directory.ts @@ -22,14 +22,15 @@ export const FEEDS_SECTION_ID = 'feeds'; /** Query parameters shared by both directory endpoints. */ export interface LocationQuery { continent?: string; - country_code?: string; + /** Country slug, e.g. `canada`. */ + country?: string; subdivision?: string; municipality?: string; } const QUERY_KEYS_BY_DEPTH = [ 'continent', - 'country_code', + 'country', 'subdivision', 'municipality', ] as const; @@ -91,7 +92,7 @@ export function getPageOffset( /** * Locale-less path to a directory node, e.g. - * `/locations/north-america/ca/quebec?page=2`. Page 1 has no query string so + * `/locations/north-america/canada/quebec?page=2`. Page 1 has no query string so * that it stays the canonical URL of the node. Pass the result to the * next-intl `Link`, which adds the locale prefix. */ diff --git a/src/app/services/feeds/types.ts b/src/app/services/feeds/types.ts index 585c159b..bee0a805 100644 --- a/src/app/services/feeds/types.ts +++ b/src/app/services/feeds/types.ts @@ -366,7 +366,7 @@ export interface paths { path?: never; cookie?: never; }; - /** @description Browse the feed location directory one level at a time: continent > country > subdivision > municipality. The level is selected by the most specific parameter provided; with no parameters the response lists continents.
    Feed counts are based on the feeds' declared locations, only include feeds with a status of `active`, `inactive`, `development` or `future`, and roll up to every ancestor level (a feed located in Montréal counts toward Québec, Canada and North America). A feed is counted at most once per node, even when it declares several locations within that node.
    Each parameter requires its parent: `country_code` requires `continent`, `subdivision` requires `country_code`, and `municipality` requires `subdivision`. */ + /** @description Browse the feed location directory one level at a time: continent > country > subdivision > municipality. The level is selected by the most specific parameter provided; with no parameters the response lists continents.
    Feed counts are based on the feeds' declared locations, only include feeds with a status of `active`, `inactive`, `development` or `future`, and roll up to every ancestor level (a feed located in Montréal counts toward Québec, Canada and North America). A feed is counted at most once per node, even when it declares several locations within that node.
    Each parameter requires its parent: `country` requires `continent`, `subdivision` requires `country`, and `municipality` requires `subdivision`. */ get: operations['getLocationDirectory']; put?: never; post?: never; @@ -1375,7 +1375,7 @@ export interface components { LocationDirectoryNode: { level?: components['schemas']['LocationDirectoryLevel']; /** - * @description URL-safe identifier of the node, used as the matching request parameter. Continents use a fixed slug (`africa`, `antarctica`, `asia`, `europe`, `north-america`, `oceania`, `south-america`), countries use their lowercase ISO 3166-1 alpha-2 code, and subdivisions and municipalities use their name lowercased, without accents, with every run of non-alphanumeric characters replaced by a single hyphen (`Québec` -> `quebec`, `Saint-Jean-sur-Richelieu` -> `saint-jean-sur-richelieu`). + * @description URL-safe identifier of the node, used as the matching request parameter. Continents use a fixed slug (`africa`, `antarctica`, `asia`, `europe`, `north-america`, `oceania`, `south-america`), countries use a fixed slug of their short English name (`portugal`, `united-states`, `cote-d-ivoire`), and subdivisions and municipalities use their name lowercased, without accents, with every run of non-alphanumeric characters replaced by a single hyphen (`Québec` -> `quebec`, `Saint-Jean-sur-Richelieu` -> `saint-jean-sur-richelieu`). * @example quebec */ slug?: string; @@ -1920,9 +1920,9 @@ export interface components { has_seal_query_param: boolean; /** @description Continent slug (`africa`, `antarctica`, `asia`, `europe`, `north-america`, `oceania`, `south-america`). */ location_directory_continent: string; - /** @description ISO 3166-1 alpha-2 country code. Case-insensitive. Requires `continent`. */ - location_directory_country_code: string; - /** @description Subdivision slug as returned by `/v1/locations/directory`. Requires `country_code`. */ + /** @description Country slug as returned by `/v1/locations/directory`, e.g. `portugal`. Case-insensitive. Requires `continent`. */ + location_directory_country: string; + /** @description Subdivision slug as returned by `/v1/locations/directory`. Requires `country`. */ location_directory_subdivision: string; /** @description Municipality slug as returned by `/v1/locations/directory`. Requires `subdivision`. */ location_directory_municipality: string; @@ -2670,9 +2670,9 @@ export interface operations { query?: { /** @description Continent slug (`africa`, `antarctica`, `asia`, `europe`, `north-america`, `oceania`, `south-america`). */ continent?: components['parameters']['location_directory_continent']; - /** @description ISO 3166-1 alpha-2 country code. Case-insensitive. Requires `continent`. */ - country_code?: components['parameters']['location_directory_country_code']; - /** @description Subdivision slug as returned by `/v1/locations/directory`. Requires `country_code`. */ + /** @description Country slug as returned by `/v1/locations/directory`, e.g. `portugal`. Case-insensitive. Requires `continent`. */ + country?: components['parameters']['location_directory_country']; + /** @description Subdivision slug as returned by `/v1/locations/directory`. Requires `country`. */ subdivision?: components['parameters']['location_directory_subdivision']; /** @description Municipality slug as returned by `/v1/locations/directory`. Requires `subdivision`. */ municipality?: components['parameters']['location_directory_municipality']; @@ -2713,9 +2713,9 @@ export interface operations { query?: { /** @description Continent slug (`africa`, `antarctica`, `asia`, `europe`, `north-america`, `oceania`, `south-america`). */ continent?: components['parameters']['location_directory_continent']; - /** @description ISO 3166-1 alpha-2 country code. Case-insensitive. Requires `continent`. */ - country_code?: components['parameters']['location_directory_country_code']; - /** @description Subdivision slug as returned by `/v1/locations/directory`. Requires `country_code`. */ + /** @description Country slug as returned by `/v1/locations/directory`, e.g. `portugal`. Case-insensitive. Requires `continent`. */ + country?: components['parameters']['location_directory_country']; + /** @description Subdivision slug as returned by `/v1/locations/directory`. Requires `country`. */ subdivision?: components['parameters']['location_directory_subdivision']; /** @description Municipality slug as returned by `/v1/locations/directory`. Requires `subdivision`. */ municipality?: components['parameters']['location_directory_municipality']; diff --git a/src/mocks/locationDirectory.spec.ts b/src/mocks/locationDirectory.spec.ts index 00907ef7..6ccc6bb5 100644 --- a/src/mocks/locationDirectory.spec.ts +++ b/src/mocks/locationDirectory.spec.ts @@ -31,7 +31,7 @@ describe('location directory mocks', () => { it('resolves the Montréal breadcrumb and rolls counts up', () => { const query = readDirectoryQuery( new URLSearchParams( - 'continent=north-america&country_code=ca&subdivision=quebec&municipality=montreal', + 'continent=north-america&country=canada&subdivision=quebec&municipality=montreal', ), ); const result = getMockDirectory(query); @@ -51,9 +51,9 @@ describe('location directory mocks', () => { }); it('returns 400 on a broken hierarchy and 404 when nothing is there', () => { - expect(getMockDirectory({ country_code: 'ca' }).status).toBe(400); + expect(getMockDirectory({ country: 'canada' }).status).toBe(400); expect( - getMockDirectory({ continent: 'europe', country_code: 'ca' }).status, + getMockDirectory({ continent: 'europe', country: 'canada' }).status, ).toBe(400); expect(getMockDirectory({ continent: 'antarctica' }).status).toBe(404); }); @@ -61,7 +61,7 @@ describe('location directory mocks', () => { it('paginates feeds, most relevant to the node first', () => { const query = { continent: 'north-america', - country_code: 'ca', + country: 'canada', subdivision: 'quebec', }; const first = getMockDirectoryFeeds(query, 20, 0); diff --git a/src/mocks/locationDirectory.ts b/src/mocks/locationDirectory.ts index 3d0f58fc..4a662629 100644 --- a/src/mocks/locationDirectory.ts +++ b/src/mocks/locationDirectory.ts @@ -44,6 +44,13 @@ export function slugify(name: string): string { .replace(/^-+|-+$/g, ''); } +/** ISO code of a country slug (`united-states` -> `US`), like the API. */ +function countryCodeOf(slug: string): string | undefined { + return Object.keys(COUNTRIES).find( + (code) => slugify(COUNTRIES[code].name) === slug.toLowerCase(), + ); +} + type Place = [countryCode: string, subdivision?: string, municipality?: string]; type FeedSeed = [ dataType: Feed['data_type'], @@ -259,7 +266,8 @@ export const MOCK_FEEDS: Feed[] = FEED_SEEDS.map(toFeed); export interface DirectoryQuery { continent?: string; - country_code?: string; + /** Country slug, e.g. `canada`. */ + country?: string; subdivision?: string; municipality?: string; } @@ -279,8 +287,8 @@ function matches(location: FeedLocation, query: DirectoryQuery): boolean { if (query.continent != undefined && country.continent !== query.continent) return false; if ( - query.country_code != undefined && - location.country_code?.toLowerCase() !== query.country_code.toLowerCase() + query.country != undefined && + slugify(country.name) !== query.country.toLowerCase() ) return false; if ( @@ -314,21 +322,20 @@ function countFeeds(feeds: Feed[]): FeedCounts { function levelOf(query: DirectoryQuery): DirectoryLevel { if (query.municipality != undefined) return 'municipality'; if (query.subdivision != undefined) return 'subdivision'; - if (query.country_code != undefined) return 'country'; + if (query.country != undefined) return 'country'; if (query.continent != undefined) return 'continent'; return 'root'; } /** 400 when a parameter is missing its parent or the country is elsewhere. */ function isValidHierarchy(query: DirectoryQuery): boolean { - if (query.country_code != undefined && query.continent == undefined) - return false; - if (query.subdivision != undefined && query.country_code == undefined) + if (query.country != undefined && query.continent == undefined) return false; + if (query.subdivision != undefined && query.country == undefined) return false; if (query.municipality != undefined && query.subdivision == undefined) return false; - if (query.country_code != undefined) { - const country = COUNTRIES[query.country_code.toUpperCase()]; + if (query.country != undefined) { + const country = COUNTRIES[countryCodeOf(query.country) ?? '']; if (country != undefined && country.continent !== query.continent) return false; } @@ -365,11 +372,10 @@ function nodeFor( : makeNode(level, slug, continent.code, continent.name, query); } case 'country': { - const code = (query.country_code ?? '').toUpperCase(); + const code = countryCodeOf(query.country ?? ''); + if (code == undefined) return undefined; const country = COUNTRIES[code]; - return country == undefined - ? undefined - : makeNode(level, code.toLowerCase(), code, country.name, query); + return makeNode(level, slugify(country.name), code, country.name, query); } case 'subdivision': return makeNode( @@ -419,7 +425,10 @@ function childrenOf(query: DirectoryQuery): DirectoryNode[] { if (level === 'root') { childQuery = { continent: COUNTRIES[countryCode].continent }; } else if (level === 'continent') { - childQuery = { ...query, country_code: countryCode.toLowerCase() }; + childQuery = { + ...query, + country: slugify(COUNTRIES[countryCode].name), + }; } else if (level === 'country' && location.subdivision_name != null) { name = location.subdivision_name; childQuery = { ...query, subdivision: slugify(name) }; @@ -444,8 +453,8 @@ function breadcrumbOf(query: DirectoryQuery): DirectoryNode[] { const all = feedsIn(query); const steps: DirectoryQuery[] = []; if (query.continent != undefined) steps.push({ continent: query.continent }); - if (query.country_code != undefined) - steps.push({ ...steps[0], country_code: query.country_code }); + if (query.country != undefined) + steps.push({ ...steps[0], country: query.country }); if (query.subdivision != undefined) steps.push({ ...steps[1], subdivision: query.subdivision }); if (query.municipality != undefined) @@ -474,7 +483,7 @@ export function readDirectoryQuery(params: URLSearchParams): DirectoryQuery { }; return { continent: read('continent'), - country_code: read('country_code'), + country: read('country'), subdivision: read('subdivision'), municipality: read('municipality'), }; From aa7ef320891ffacda67072e091c5510281e3d607 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Tue, 3 Nov 2026 08:59:55 -0500 Subject: [PATCH 05/11] feat: add loading states and bold counts to location directory cards - Clicking a child location shows a pending bar on its card (useLinkStatus, delayed 150ms to avoid flashing on fast navigations). - loading.tsx renders a skeleton of the location page while it streams. - Feed counts on child cards show their numbers in bold. Co-Authored-By: Claude Opus 5.5 --- messages/en.json | 3 +- messages/fr.json | 3 +- .../locations/[[...location]]/loading.tsx | 99 +++++++++++++++++++ .../components/LinkPendingIndicator.tsx | 42 ++++++++ .../components/LocationChildrenGrid.tsx | 8 +- .../components/LocationChildrenList.tsx | 61 ++++++++---- 6 files changed, 195 insertions(+), 21 deletions(-) create mode 100644 src/app/[locale]/locations/[[...location]]/loading.tsx create mode 100644 src/app/[locale]/locations/components/LinkPendingIndicator.tsx diff --git a/messages/en.json b/messages/en.json index b3116032..357520b9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1283,9 +1283,10 @@ "subdivision": "Regions", "municipality": "Municipalities" }, - "childFeedTotal": "{count, plural, one {# feed} other {# feeds}}", + "childFeedTotal": "{count, plural, one {# feed} other {# feeds}}", "childrenSearchPlaceholder": "Search locations", "childrenSearchAria": "Filter the locations below by name", + "childLoadingAria": "Loading {name}", "childrenSearchResults": "{count, plural, =0 {No matching locations} one {# matching location} other {# matching locations}}", "childrenSearchEmpty": "No location matches “{query}”.", "feedsHeading": "Feeds in {place}", diff --git a/messages/fr.json b/messages/fr.json index bd6e12fe..b19d46ad 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1283,9 +1283,10 @@ "subdivision": "Régions", "municipality": "Municipalités" }, - "childFeedTotal": "{count, plural, one {# flux} other {# flux}}", + "childFeedTotal": "{count, plural, one {# flux} other {# flux}}", "childrenSearchPlaceholder": "Rechercher un lieu", "childrenSearchAria": "Filtrer les lieux ci-dessous par nom", + "childLoadingAria": "Chargement de {name}", "childrenSearchResults": "{count, plural, =0 {Aucun lieu correspondant} one {# lieu correspondant} other {# lieux correspondants}}", "childrenSearchEmpty": "Aucun lieu ne correspond à « {query} ».", "feedsHeading": "Flux dans : {place}", diff --git a/src/app/[locale]/locations/[[...location]]/loading.tsx b/src/app/[locale]/locations/[[...location]]/loading.tsx new file mode 100644 index 00000000..4f26a04e --- /dev/null +++ b/src/app/[locale]/locations/[[...location]]/loading.tsx @@ -0,0 +1,99 @@ +import { type ReactElement } from 'react'; +import { Box, Container, Skeleton } from '@mui/material'; +import SectionContainer from '../../../components/SectionContainer'; + +const CARD_COUNT = 8; +const ROW_COUNT = 8; + +/** + * Shown while a location page streams in, e.g. after clicking a child + * location; same layout as page.tsx so nothing jumps when it resolves. + * ref: https://nextjs.org/docs/app/api-reference/file-conventions/loading + */ +export default function Loading(): ReactElement { + return ( + + + {/* Back button and breadcrumb */} + + {/* Title and intro */} + + + {/* Feed count chips */} + + {[150, 140, 100].map((width) => ( + + ))} + + + {/* Child locations */} + + + {Array.from({ length: CARD_COUNT }, (_, index) => ( + + ))} + + + {/* Feeds table */} + + {Array.from({ length: ROW_COUNT }, (_, index) => ( + + ))} + + + ); +} diff --git a/src/app/[locale]/locations/components/LinkPendingIndicator.tsx b/src/app/[locale]/locations/components/LinkPendingIndicator.tsx new file mode 100644 index 00000000..f1df17e4 --- /dev/null +++ b/src/app/[locale]/locations/components/LinkPendingIndicator.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { type ReactElement } from 'react'; +import { LinearProgress } from '@mui/material'; +import { keyframes } from '@mui/material/styles'; +// useLinkStatus is not re-exported by i18n/navigation; it reads the status of +// the nearest enclosing Link, which next-intl's Link renders under the hood. +import { useLinkStatus } from 'next/link'; + +// Fast navigations finish before the bar shows, so it never flashes. +const appearAfterDelay = keyframes` + to { opacity: 1; } +`; + +interface Props { + ariaLabel: string; +} + +/** + * Thin bar along the bottom of the enclosing Link while its navigation is + * pending. Must render inside a Link; the Link needs `position: relative`. + */ +export default function LinkPendingIndicator({ + ariaLabel, +}: Props): ReactElement | null { + const { pending } = useLinkStatus(); + if (!pending) return null; + return ( + + ); +} diff --git a/src/app/[locale]/locations/components/LocationChildrenGrid.tsx b/src/app/[locale]/locations/components/LocationChildrenGrid.tsx index 04ee1d69..493ed546 100644 --- a/src/app/[locale]/locations/components/LocationChildrenGrid.tsx +++ b/src/app/[locale]/locations/components/LocationChildrenGrid.tsx @@ -49,11 +49,15 @@ export default async function LocationChildrenGrid({ name, flag: getFlag(node), href: buildLocationHref([...parentSlugs, node.slug ?? '']), - totalLabel: t('childFeedTotal', { count: getTotalFeedCount(counts) }), + totalLabel: t.rich('childFeedTotal', { + count: getTotalFeedCount(counts), + b: (chunks) => {chunks}, + }), counts: getFeedCountEntries(counts).map(({ dataType, count }) => ({ dataType, count, - label: `${formatCount(count, locale)} ${t(`shortLabels.${dataType}`)}`, + formattedCount: formatCount(count, locale), + typeLabel: t(`shortLabels.${dataType}`), })), })); diff --git a/src/app/[locale]/locations/components/LocationChildrenList.tsx b/src/app/[locale]/locations/components/LocationChildrenList.tsx index 2d160ff7..ee2f03f5 100644 --- a/src/app/[locale]/locations/components/LocationChildrenList.tsx +++ b/src/app/[locale]/locations/components/LocationChildrenList.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useState, type ReactElement } from 'react'; +import { useMemo, useState, type ReactElement, type ReactNode } from 'react'; import { Box, Card, @@ -12,8 +12,20 @@ import { import SearchIcon from '@mui/icons-material/Search'; import { useTranslations } from 'next-intl'; import { Link } from '../../../../i18n/navigation'; -import { type FeedCountChip } from './FeedCountChips'; -import { filterByName } from '../lib/location-directory'; +import LinkPendingIndicator from './LinkPendingIndicator'; +import { + filterByName, + type FeedCountDataType, +} from '../lib/location-directory'; + +export interface LocationChildCount { + dataType: FeedCountDataType; + count: number; + /** Locale-formatted, rendered bold. */ + formattedCount: string; + /** e.g. "GTFS-RT". */ + typeLabel: string; +} export interface LocationChildItem { slug: string; @@ -21,11 +33,17 @@ export interface LocationChildItem { /** Emoji flag, countries only. */ flag?: string; href: string; - totalLabel: string; + /** e.g. "8 feeds", with the number in a . */ + totalLabel: ReactNode; /** Plain-text counts; chips are kept for the page summary. */ - counts: FeedCountChip[]; + counts: LocationChildCount[]; } +// Bold, primary-colored numbers stand out from their secondary-colored labels. +const countNumberSx = { + '& strong': { fontWeight: 700, color: 'text.primary' }, +} as const; + interface Props { /** Already sorted, most feeds first. */ items: LocationChildItem[]; @@ -149,7 +167,7 @@ export default function LocationChildrenList({ items }: Props): ReactElement { component='span' variant='body2' color='text.secondary' - sx={{ whiteSpace: 'nowrap' }} + sx={{ whiteSpace: 'nowrap', ...countNumberSx }} > {item.totalLabel} @@ -162,18 +180,27 @@ export default function LocationChildrenList({ items }: Props): ReactElement { rowGap: 0.5, }} > - {item.counts.map(({ dataType, count, label }) => ( - 0 ? 'text.secondary' : 'text.disabled'} - > - {label} - - ))} + {item.counts.map( + ({ dataType, count, formattedCount, typeLabel }) => ( + 0 ? 'text.secondary' : 'text.disabled' + } + // Zero counts stay fully disabled. + sx={count > 0 ? countNumberSx : undefined} + > + {formattedCount} {typeLabel} + + ), + )} + From 194f056cad2666a0eb7b0070e56278bb27e485c4 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Tue, 3 Nov 2026 09:44:25 -0500 Subject: [PATCH 06/11] feat: list location directory feeds through the search endpoint The feeds of a location node come from /v1/search with the node's location parameters, the directory statuses and sort=location, replacing the removed /v1/locations/directory/feeds endpoint. The root page has no location, so it lists the newest feeds first. API types regenerated and the search mock serves directory feeds for location queries. Co-Authored-By: Claude Opus 5.5 --- .../lib/location-directory-data.spec.ts | 39 +++++++- .../locations/lib/location-directory-data.ts | 27 ++++-- src/app/services/feeds/index.ts | 40 ++------ src/app/services/feeds/types.ts | 93 ++++++------------- src/mocks/handlers.ts | 29 +++--- src/mocks/locationDirectory.ts | 7 +- 6 files changed, 109 insertions(+), 126 deletions(-) diff --git a/src/app/[locale]/locations/lib/location-directory-data.spec.ts b/src/app/[locale]/locations/lib/location-directory-data.spec.ts index 9c9b0bd3..037f36f0 100644 --- a/src/app/[locale]/locations/lib/location-directory-data.spec.ts +++ b/src/app/[locale]/locations/lib/location-directory-data.spec.ts @@ -33,7 +33,7 @@ jest.mock('next/cache', () => ({ })); const mockGetLocationDirectory = jest.fn(); -const mockGetLocationDirectoryFeeds = jest.fn(); +const mockSearchFeeds = jest.fn(); jest.mock('../../../services/feeds', () => ({ ApiError: class ApiError extends Error { @@ -45,8 +45,7 @@ jest.mock('../../../services/feeds', () => ({ }, getLocationDirectory: (...args: unknown[]) => mockGetLocationDirectory(...args), - getLocationDirectoryFeeds: (...args: unknown[]) => - mockGetLocationDirectoryFeeds(...args), + searchFeeds: (...args: unknown[]) => mockSearchFeeds(...args), })); jest.mock('../../../utils/auth-server', () => ({ @@ -63,7 +62,7 @@ const directory = { describe('location directory data', () => { beforeEach(() => { mockGetLocationDirectory.mockReset(); - mockGetLocationDirectoryFeeds.mockReset(); + mockSearchFeeds.mockReset(); }); it('resolves a 404 to null without caching it', async () => { @@ -78,7 +77,7 @@ describe('location directory data', () => { it('resolves a 400 on the feeds page to null without caching it', async () => { const feeds = { total: 0, results: [] }; - mockGetLocationDirectoryFeeds + mockSearchFeeds .mockRejectedValueOnce(new ApiError('Bad Request', 400)) .mockResolvedValueOnce(feeds); @@ -88,6 +87,36 @@ describe('location directory data', () => { ); }); + it('lists the feeds of a node with search, most relevant first', async () => { + mockSearchFeeds.mockResolvedValue({ total: 0, results: [] }); + + await fetchLocationDirectoryFeeds(2, 'europe', 'portugal'); + expect(mockSearchFeeds).toHaveBeenCalledWith( + { + query: { + continent: 'europe', + country: 'portugal', + status: ['active', 'inactive', 'development', 'future'], + sort: 'location', + limit: 20, + offset: 20, + }, + }, + 'guest-token', + ); + }); + + it('lists the newest feeds at the root, which has no location', async () => { + mockSearchFeeds.mockResolvedValue({ total: 0, results: [] }); + + await fetchLocationDirectoryFeeds(1); + const [{ query }] = mockSearchFeeds.mock.calls[0] as [ + { query: Record }, + ]; + expect(query.sort).toBeUndefined(); + expect(query.continent).toBeUndefined(); + }); + it('caches a found location', async () => { mockGetLocationDirectory.mockResolvedValue(directory); diff --git a/src/app/[locale]/locations/lib/location-directory-data.ts b/src/app/[locale]/locations/lib/location-directory-data.ts index 27838a86..831792ef 100644 --- a/src/app/[locale]/locations/lib/location-directory-data.ts +++ b/src/app/[locale]/locations/lib/location-directory-data.ts @@ -12,9 +12,9 @@ import { getGuestGcipIdToken } from '../../../utils/auth-server'; import { ApiError, getLocationDirectory, - getLocationDirectoryFeeds, - type LocationDirectoryFeedsResponse, + searchFeeds, type LocationDirectoryResponse, + type SearchFeedsResponse, } from '../../../services/feeds'; import { getPageOffset, @@ -26,6 +26,11 @@ import { export const LOCATION_DIRECTORY_REVALIDATE_SECONDS = 21600; const CACHE_TAGS = ['location-directory', 'guest-feeds']; +/** The statuses the directory counts, so a node lists as many feeds. */ +const DIRECTORY_FEED_STATUSES: Array< + 'active' | 'inactive' | 'development' | 'future' +> = ['active', 'inactive', 'development', 'future']; + /** * 400 (invalid hierarchy) and 404 (nothing located there) both mean the URL * does not exist. They are thrown as this error inside the cached functions, @@ -110,15 +115,21 @@ const cachedDirectoryFeeds = unstable_cache( country?: string, subdivision?: string, municipality?: string, - ): Promise => { + ): Promise => { const accessToken = await getGuestGcipIdToken(); return await requireData( async () => - await getLocationDirectoryFeeds( + await searchFeeds( { - ...toQuery(continent, country, subdivision, municipality), - limit: LOCATION_FEEDS_PAGE_SIZE, - offset, + query: { + ...toQuery(continent, country, subdivision, municipality), + status: DIRECTORY_FEED_STATUSES, + // Most relevant location first; the API rejects it without a + // location, so the root lists the newest feeds first. + sort: continent != undefined ? 'location' : undefined, + limit: LOCATION_FEEDS_PAGE_SIZE, + offset, + }, }, accessToken, ), @@ -154,7 +165,7 @@ export const fetchLocationDirectoryFeeds = cache( country?: string, subdivision?: string, municipality?: string, - ): Promise => + ): Promise => await nullWhenMissing( async () => await cachedDirectoryFeeds( diff --git a/src/app/services/feeds/index.ts b/src/app/services/feeds/index.ts index ab8ff846..edd343d5 100644 --- a/src/app/services/feeds/index.ts +++ b/src/app/services/feeds/index.ts @@ -348,14 +348,18 @@ export const getMetadata = async (): Promise< }); }; +export type SearchFeedsResponse = + paths['/v1/search']['get']['responses'][200]['content']['application/json']; + +/** + * Also lists the feeds of a location directory node, with its location + * parameters and `sort: 'location'`. + */ export const searchFeeds = async ( params: AllFeedsParams, accessToken: string, userContextJwt?: string, -): Promise< - | paths['/v1/search']['get']['responses'][200]['content']['application/json'] - | undefined -> => { +): Promise => { const authMiddleware = generateAuthMiddlewareWithToken( accessToken, userContextJwt, @@ -370,16 +374,9 @@ export type LocationDirectoryParams = NonNullable< paths['/v1/locations/directory']['get']['parameters']['query'] >; -export type LocationDirectoryFeedsParams = NonNullable< - paths['/v1/locations/directory/feeds']['get']['parameters']['query'] ->; - export type LocationDirectoryResponse = paths['/v1/locations/directory']['get']['responses'][200]['content']['application/json']; -export type LocationDirectoryFeedsResponse = - paths['/v1/locations/directory/feeds']['get']['responses'][200]['content']['application/json']; - /** * One level of the feed location directory: the node's breadcrumb, feed * counts and children. Throws an `ApiError` with status 400 (invalid @@ -402,27 +399,6 @@ export const getLocationDirectory = async ( }); }; -/** - * A page of the feeds located within a location directory node, already - * sorted by relevance to that node. - */ -export const getLocationDirectoryFeeds = async ( - params: LocationDirectoryFeedsParams, - accessToken: string, - userContextJwt?: string, -): Promise => { - const authMiddleware = generateAuthMiddlewareWithToken( - accessToken, - userContextJwt, - ); - return await withAuthMiddleware(authMiddleware, async () => { - const response = await client.GET('/v1/locations/directory/feeds', { - params: { query: params }, - }); - return response.data; - }); -}; - export const getLicense = async ( id: string, accessToken: string, diff --git a/src/app/services/feeds/types.ts b/src/app/services/feeds/types.ts index bee0a805..113ed5bd 100644 --- a/src/app/services/feeds/types.ts +++ b/src/app/services/feeds/types.ts @@ -331,6 +331,14 @@ export interface paths { * Query: New York Transit *
    * Search Executed: 'new' & york & 'transit' + *

    + * Location filters: + *
    + * - `continent`, `country`, `subdivision` and `municipality` restrict the results to feeds with a declared + * location within that node of `/v1/locations/directory`, using the same slugs, parent requirements and + * validation. Unknown slugs return no feed. + * - To list the same feeds as a directory node's counts, also pass `status=active,inactive,development,future`. + * - `sort=location` orders the results by relevance to the requested location instead of by creation date. *
    */ get: operations['searchFeeds']; @@ -366,7 +374,7 @@ export interface paths { path?: never; cookie?: never; }; - /** @description Browse the feed location directory one level at a time: continent > country > subdivision > municipality. The level is selected by the most specific parameter provided; with no parameters the response lists continents.
    Feed counts are based on the feeds' declared locations, only include feeds with a status of `active`, `inactive`, `development` or `future`, and roll up to every ancestor level (a feed located in Montréal counts toward Québec, Canada and North America). A feed is counted at most once per node, even when it declares several locations within that node.
    Each parameter requires its parent: `country` requires `continent`, `subdivision` requires `country`, and `municipality` requires `subdivision`. */ + /** @description Browse the feed location directory one level at a time: continent > country > subdivision > municipality. The level is selected by the most specific parameter provided; with no parameters the response lists continents.
    Feed counts are based on the feeds' declared locations, only include feeds with a status of `active`, `inactive`, `development` or `future`, and roll up to every ancestor level (a feed located in Montréal counts toward Québec, Canada and North America). A feed is counted at most once per node, even when it declares several locations within that node.
    Each parameter requires its parent: `country` requires `continent`, `subdivision` requires `country`, and `municipality` requires `subdivision`.
    To list the feeds of a node, call `/v1/search` with the same parameters, `status=active,inactive,development,future` and `sort=location`. */ get: operations['getLocationDirectory']; put?: never; post?: never; @@ -376,23 +384,6 @@ export interface paths { patch?: never; trace?: never; }; - '/v1/locations/directory/feeds': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** @description List the feeds located within a location directory node, using the same location parameters, status rules and validation as `/v1/locations/directory`.
    Feeds are sorted by relevance to the requested node: feeds whose most specific matching declared location is closest to the requested level come first (in the Québec node, a feed located in "Canada, Québec" ranks above one located in "Canada, Québec, Montréal"). Ties are broken by provider, feed name and feed id. */ - get: operations['getLocationDirectoryFeeds']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; '/v1/licenses': { parameters: { query?: never; @@ -1399,11 +1390,6 @@ export interface components { /** @description The nodes one level below the requested node that contain at least one feed, sorted by name. Empty at the `municipality` level. */ children?: components['schemas']['LocationDirectoryNode'][]; }; - LocationDirectoryFeedsResponse: { - /** @description The total number of feeds within the node regardless of limit and offset. */ - total?: number; - results?: components['schemas']['SearchFeedItemResult'][]; - }; LocationSearchResponse: { /** @description The total number of matching locations regardless of limit and offset. */ total?: number; @@ -1927,8 +1913,6 @@ export interface components { /** @description Municipality slug as returned by `/v1/locations/directory`. Requires `subdivision`. */ location_directory_municipality: string; /** @description The number of items to be returned. */ - limit_query_param_location_directory_feeds_endpoint: number; - /** @description The number of items to be returned. */ limit_query_param_locations_endpoint: number; /** @description The number of items to be returned. */ limit_query_param_feeds_endpoint: number; @@ -1948,6 +1932,8 @@ export interface components { offset: number; /** @description General search query to match against transit provider, location, and feed name. */ search_text_query_param: string; + /** @description Sort order of the results. `created_at` (default): newest feeds first, then by relevance to `search_query` when one is given. `location`: by relevance to the requested location, which requires `continent`. Feeds whose most specific matching declared location is closest to the requested level come first (when filtering on Québec, a feed located in "Canada, Québec" ranks above one located in "Canada, Québec, Montréal"); ties are broken by provider, feed name and feed id. */ + search_sort: 'created_at' | 'location'; /** @description Comma separated list of GBFS versions to filter by. */ version_query_param: string; /** @description Comma separated list of data types to filter by. Valid values are gtfs, gtfs_rt and gbfs. */ @@ -2598,6 +2584,16 @@ export interface operations { license_is_spdx?: components['parameters']['license_is_spdx']; /** @description Comma separated list of tags to filter feeds by their license tags. */ license_tags?: components['parameters']['license_tags']; + /** @description Continent slug (`africa`, `antarctica`, `asia`, `europe`, `north-america`, `oceania`, `south-america`). */ + continent?: components['parameters']['location_directory_continent']; + /** @description Country slug as returned by `/v1/locations/directory`, e.g. `portugal`. Case-insensitive. Requires `continent`. */ + country?: components['parameters']['location_directory_country']; + /** @description Subdivision slug as returned by `/v1/locations/directory`. Requires `country`. */ + subdivision?: components['parameters']['location_directory_subdivision']; + /** @description Municipality slug as returned by `/v1/locations/directory`. Requires `subdivision`. */ + municipality?: components['parameters']['location_directory_municipality']; + /** @description Sort order of the results. `created_at` (default): newest feeds first, then by relevance to `search_query` when one is given. `location`: by relevance to the requested location, which requires `continent`. Feeds whose most specific matching declared location is closest to the requested level come first (when filtering on Québec, a feed located in "Canada, Québec" ranks above one located in "Canada, Québec, Montréal"); ties are broken by provider, feed name and feed id. */ + sort?: components['parameters']['search_sort']; }; header?: never; path?: never; @@ -2618,6 +2614,13 @@ export interface operations { }; }; }; + /** @description A location parameter was provided without its parent, the country does not belong to the continent, or `sort=location` was requested without a location. */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Filtering by Seal of Reliability status is not available to this caller. */ 403: { headers: { @@ -2708,46 +2711,6 @@ export interface operations { }; }; }; - getLocationDirectoryFeeds: { - parameters: { - query?: { - /** @description Continent slug (`africa`, `antarctica`, `asia`, `europe`, `north-america`, `oceania`, `south-america`). */ - continent?: components['parameters']['location_directory_continent']; - /** @description Country slug as returned by `/v1/locations/directory`, e.g. `portugal`. Case-insensitive. Requires `continent`. */ - country?: components['parameters']['location_directory_country']; - /** @description Subdivision slug as returned by `/v1/locations/directory`. Requires `country`. */ - subdivision?: components['parameters']['location_directory_subdivision']; - /** @description Municipality slug as returned by `/v1/locations/directory`. Requires `subdivision`. */ - municipality?: components['parameters']['location_directory_municipality']; - /** @description The number of items to be returned. */ - limit?: components['parameters']['limit_query_param_location_directory_feeds_endpoint']; - /** @description Offset of the first item to return. */ - offset?: components['parameters']['offset']; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description The page of feeds located within the requested node. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['LocationDirectoryFeedsResponse']; - }; - }; - /** @description A parameter was provided without its parent, or the country does not belong to the continent. */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; getLicenses: { parameters: { query?: { diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 8adffae9..da4a4ccf 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -14,26 +14,27 @@ import { } from './locationDirectory'; export const handlers = [ - // Location directory: feeds of a node (paginated, relevance-sorted) - http.get(`*/v1/locations/directory/feeds`, ({ request }) => { - const params = new URL(request.url).searchParams; - const limit = Number(params.get('limit') ?? '20'); - const offset = Number(params.get('offset') ?? '0'); - const { status, body } = getMockDirectoryFeeds( - readDirectoryQuery(params), - Number.isFinite(limit) ? limit : 20, - Number.isFinite(offset) ? offset : 0, - ); - return HttpResponse.json(body, { status }); - }), // Location directory: breadcrumb, counts and children of a node http.get(`*/v1/locations/directory`, ({ request }) => { const params = new URL(request.url).searchParams; const { status, body } = getMockDirectory(readDirectoryQuery(params)); return HttpResponse.json(body, { status }); }), - // Mock search endpoint: return a list including test-516 and mdb-2947 - http.get(`*/v1/search`, () => { + // Mock search endpoint. With location parameters (location directory + // pages): the feeds of that node, paginated and relevance-sorted. + // Otherwise: a list including test-516 and mdb-2947. + http.get(`*/v1/search`, ({ request }) => { + const params = new URL(request.url).searchParams; + if (params.has('continent') || params.get('sort') === 'location') { + const limit = Number(params.get('limit') ?? '20'); + const offset = Number(params.get('offset') ?? '0'); + const { status, body } = getMockDirectoryFeeds( + readDirectoryQuery(params), + Number.isFinite(limit) ? limit : 20, + Number.isFinite(offset) ? offset : 0, + ); + return HttpResponse.json(body, { status }); + } return HttpResponse.json({ total: 2, results: [ diff --git a/src/mocks/locationDirectory.ts b/src/mocks/locationDirectory.ts index 4a662629..56d6601f 100644 --- a/src/mocks/locationDirectory.ts +++ b/src/mocks/locationDirectory.ts @@ -1,7 +1,7 @@ /** * Mock data and logic for the location directory endpoints: * GET /v1/locations/directory - * GET /v1/locations/directory/feeds + * GET /v1/search with location parameters (the feeds of a node) * * The directory tree, the rolled-up counts, the breadcrumb and the children * are all derived from MOCK_FEEDS' declared locations, so the mock behaves @@ -16,7 +16,10 @@ type DirectoryNode = components['schemas']['LocationDirectoryNode']; type DirectoryLevel = components['schemas']['LocationDirectoryLevel']; type FeedCounts = components['schemas']['LocationDirectoryFeedCounts']; type DirectoryResponse = components['schemas']['LocationDirectoryResponse']; -type FeedsResponse = components['schemas']['LocationDirectoryFeedsResponse']; +interface FeedsResponse { + total: number; + results: Feed[]; +} const CONTINENTS: Record = { africa: { code: 'AF', name: 'Africa' }, From d707a35a94aafa31d257cc1225e4f7a88b61fbea Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 4 Nov 2026 12:44:43 -0500 Subject: [PATCH 07/11] feat: browse locations from the geopolygon hierarchy - Pages use /v1/locations (continents, countries of a continent, a location by path, its children by parent_location_id) and /v1/search with continent or location_id, replacing /v1/locations/directory. - /locations/{continent}/{path...} works at any depth; segments are the API slugs, and the breadcrumb comes from path_names and path_slugs. - Children are fetched page by page on the server (100 per call). - Mocks cover variable depth and suffixed slugs; level-specific wording is now level-agnostic. Co-Authored-By: Claude Opus 5.5 --- messages/en.json | 11 +- messages/fr.json | 11 +- .../locations/[[...location]]/page.tsx | 191 ++-- .../locations/components/FeedCountSummary.tsx | 4 +- .../components/LocationChildrenGrid.tsx | 78 +- .../components/LocationChildrenList.tsx | 5 +- .../components/LocationFeedsList.tsx | 10 +- .../lib/location-directory-data.spec.ts | 305 +++++-- .../locations/lib/location-directory-data.ts | 242 +++-- .../locations/lib/location-directory.spec.ts | 311 +++++-- .../locations/lib/location-directory.ts | 249 ++++-- src/app/services/feeds/index.ts | 26 +- src/app/services/feeds/types.ts | 207 ++--- src/mocks/handlers.ts | 26 +- src/mocks/locationDirectory.spec.ts | 160 +++- src/mocks/locationDirectory.ts | 842 +++++++++--------- 16 files changed, 1608 insertions(+), 1070 deletions(-) diff --git a/messages/en.json b/messages/en.json index 357520b9..832e30ef 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1269,7 +1269,7 @@ "locations": { "root": "Locations", "title": "Feeds by location", - "rootIntro": "Browse open GTFS Schedule, GTFS Realtime and GBFS feeds by continent, country, region and municipality.", + "rootIntro": "Browse open GTFS Schedule, GTFS Realtime and GBFS feeds by location, from continents down to cities.", "nodeIntro": "{total, plural, =0 {No feeds are} one {# feed is} other {# feeds are}} located in {place}.", "feedCountsAria": "Feed counts by data type", "shortLabels": { @@ -1278,10 +1278,9 @@ "gbfs": "GBFS" }, "childrenHeading": { - "continent": "Continents", - "country": "Countries", - "subdivision": "Regions", - "municipality": "Municipalities" + "continents": "Continents", + "countries": "Countries", + "places": "Places" }, "childFeedTotal": "{count, plural, one {# feed} other {# feeds}}", "childrenSearchPlaceholder": "Search locations", @@ -1296,7 +1295,7 @@ "paginationAria": "Feed list pages", "meta": { "rootTitle": "Transit Feeds by Location | Mobility Database", - "rootDescription": "Browse open GTFS Schedule, GTFS Realtime and GBFS transit and mobility feeds by continent, country, region and municipality.", + "rootDescription": "Browse open GTFS Schedule, GTFS Realtime and GBFS transit and mobility feeds by location, from continents down to cities.", "nodeTitle": "{place} Transit Feeds | Mobility Database", "nodeTitlePaged": "{place} Transit Feeds, Page {page} | Mobility Database", "nodeDescription": "Browse {total} open transit and mobility feeds located in {place}: {gtfs} GTFS Schedule, {gtfs_rt} GTFS Realtime and {gbfs} GBFS." diff --git a/messages/fr.json b/messages/fr.json index b19d46ad..28a7ebe6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1269,7 +1269,7 @@ "locations": { "root": "Lieux", "title": "Flux par lieu", - "rootIntro": "Parcourez les flux ouverts GTFS Schedule, GTFS Realtime et GBFS par continent, pays, région et municipalité.", + "rootIntro": "Parcourez les flux ouverts GTFS Schedule, GTFS Realtime et GBFS par lieu, des continents jusqu’aux villes.", "nodeIntro": "{total, plural, =0 {Aucun flux n’est situé} one {# flux est situé} other {# flux sont situés}} dans : {place}.", "feedCountsAria": "Nombre de flux par type de données", "shortLabels": { @@ -1278,10 +1278,9 @@ "gbfs": "GBFS" }, "childrenHeading": { - "continent": "Continents", - "country": "Pays", - "subdivision": "Régions", - "municipality": "Municipalités" + "continents": "Continents", + "countries": "Pays", + "places": "Lieux" }, "childFeedTotal": "{count, plural, one {# flux} other {# flux}}", "childrenSearchPlaceholder": "Rechercher un lieu", @@ -1296,7 +1295,7 @@ "paginationAria": "Pages de la liste des flux", "meta": { "rootTitle": "Flux de transport par lieu | Mobility Database", - "rootDescription": "Parcourez les flux ouverts de transport et de mobilité GTFS Schedule, GTFS Realtime et GBFS par continent, pays, région et municipalité.", + "rootDescription": "Parcourez les flux ouverts de transport et de mobilité GTFS Schedule, GTFS Realtime et GBFS par lieu, des continents jusqu’aux villes.", "nodeTitle": "Flux de transport : {place} | Mobility Database", "nodeTitlePaged": "Flux de transport : {place}, page {page} | Mobility Database", "nodeDescription": "Parcourez {total} flux ouverts de transport et de mobilité situés dans : {place}. {gtfs} GTFS Schedule, {gtfs_rt} GTFS Realtime et {gbfs} GBFS." diff --git a/src/app/[locale]/locations/[[...location]]/page.tsx b/src/app/[locale]/locations/[[...location]]/page.tsx index 97d6c089..4e3b356a 100644 --- a/src/app/[locale]/locations/[[...location]]/page.tsx +++ b/src/app/[locale]/locations/[[...location]]/page.tsx @@ -11,20 +11,21 @@ import FeedCountSummary from '../components/FeedCountSummary'; import LocationChildrenGrid from '../components/LocationChildrenGrid'; import LocationFeedsList from '../components/LocationFeedsList'; import { + buildBreadcrumbSteps, buildLocationHref, buildPlaceName, FEEDS_SECTION_ID, - getBreadcrumbSlugs, - getChildLevel, + getLocationName, + getLocationSegments, getPageCount, getTotalFeedCount, - type LocationQuery, + type LocationPath, parseLocationSegments, parsePageParam, } from '../lib/location-directory'; import { - fetchLocationDirectory, - fetchLocationDirectoryFeeds, + fetchLocationPage, + type LocationPageData, } from '../lib/location-directory-data'; const SITE_URL = 'https://mobilitydatabase.org'; @@ -35,87 +36,79 @@ interface PageProps { } interface ParsedRequest { - query: LocationQuery; + locale: string; + location: LocationPath; page: number; } +/** The request, or null when the URL or the page number is malformed. */ async function parseRequest({ params, searchParams, -}: PageProps): Promise<{ locale: string } & (ParsedRequest | { query: null })> { - const [{ locale, location }, { page: rawPage }] = await Promise.all([ - params, - searchParams, - ]); - const query = parseLocationSegments(location); - const page = parsePageParam(rawPage); - if (query === null || page === null) return { locale, query: null }; - return { locale, query, page }; -} - -function fetchDirectory( - query: LocationQuery, -): ReturnType { - return fetchLocationDirectory( - query.continent, - query.country, - query.subdivision, - query.municipality, +}: PageProps): Promise { + const [{ locale, location: segments }, { page: rawPage }] = await Promise.all( + [params, searchParams], ); + const location = parseLocationSegments(segments); + const page = parsePageParam(rawPage); + if (location === null || page === null) return null; + return { locale, location, page }; } -function fetchFeedsPage( - query: LocationQuery, - page: number, -): ReturnType { - return fetchLocationDirectoryFeeds( +/** + * The page data, or null when the location does not exist or the page is + * past the last one. Deduped by React cache() between generateMetadata and + * the page. + */ +async function fetchPageData({ + location, + page, +}: ParsedRequest): Promise { + const data = await fetchLocationPage( page, - query.continent, - query.country, - query.subdivision, - query.municipality, + location.continent, + location.path.join('/'), ); + if (data === null || page > getPageCount(data.feeds.total ?? 0)) { + return null; + } + return data; } export async function generateMetadata(props: PageProps): Promise { const request = await parseRequest(props); - if (request.query === null) return {}; - const { query, page } = request; - // Same calls as the page: deduped by React cache() within the request. - const [directory, feeds, t] = await Promise.all([ - fetchDirectory(query), - fetchFeedsPage(query, page), + if (request === null) return {}; + const [data, t] = await Promise.all([ + fetchPageData(request), getTranslations({ locale: request.locale, namespace: 'locations.meta' }), ]); - if ( - directory === null || - feeds === null || - page > getPageCount(feeds.total ?? 0) - ) { - return {}; - } - - const place = buildPlaceName(directory.breadcrumb); - const counts = directory.feed_counts; - const isRoot = place === ''; - const title = isRoot - ? t('rootTitle') - : page > 1 - ? t('nodeTitlePaged', { place, page }) - : t('nodeTitle', { place }); - const description = isRoot - ? t('rootDescription') - : t('nodeDescription', { - place, - total: getTotalFeedCount(counts), - gtfs: counts?.gtfs ?? 0, - gtfs_rt: counts?.gtfs_rt ?? 0, - gbfs: counts?.gbfs ?? 0, - }); - const url = `${SITE_URL}${buildLocationHref( - getBreadcrumbSlugs(directory.breadcrumb), - page, - )}`; + if (data === null) return {}; + + const { location } = data; + const { page } = request; + const place = buildPlaceName(location); + const counts = location?.feed_counts; + const title = + location === null + ? t('rootTitle') + : page > 1 + ? t('nodeTitlePaged', { place, page }) + : t('nodeTitle', { place }); + const description = + location === null + ? t('rootDescription') + : t('nodeDescription', { + place, + total: getTotalFeedCount(counts), + gtfs: counts?.gtfs ?? 0, + gtfs_rt: counts?.gtfs_rt ?? 0, + gbfs: counts?.gbfs ?? 0, + }); + const segments = + location === null || request.location.continent === undefined + ? [] + : getLocationSegments(request.location.continent, location); + const url = `${SITE_URL}${buildLocationHref(segments, page)}`; return { title, @@ -135,45 +128,46 @@ export default async function LocationsPage( props: PageProps, ): Promise { const request = await parseRequest(props); - if (request.query === null) notFound(); - const { locale, query, page } = request; + if (request === null) notFound(); + const { locale, page } = request; setRequestLocale(locale); - // Directory node and feed page are independent: fetch them in parallel. - const [directory, feeds, t, tCommon] = await Promise.all([ - fetchDirectory(query), - fetchFeedsPage(query, page), + const [data, t, tCommon] = await Promise.all([ + fetchPageData(request), getTranslations('locations'), getTranslations('common'), ]); - if (directory === null || feeds === null) notFound(); + if (data === null) notFound(); + const { location, children, feeds } = data; + const continent = request.location.continent ?? ''; + const isRoot = location === null; const total = feeds.total ?? 0; const pageCount = getPageCount(total); - if (page > pageCount) notFound(); - - const breadcrumb = directory.breadcrumb ?? []; - const slugs = getBreadcrumbSlugs(breadcrumb); - const place = buildPlaceName(breadcrumb); - const isRoot = breadcrumb.length === 0; - const children = directory.children ?? []; - const childLevel = getChildLevel(directory.level); + const segments = isRoot ? [] : getLocationSegments(continent, location); + const place = buildPlaceName(location); + const steps = buildBreadcrumbSteps(continent, location); const crumbs: Crumb[] = [ { label: tCommon('feeds'), href: '/feeds' }, { label: t('root'), href: isRoot ? undefined : buildLocationHref([]) }, - ...breadcrumb.map((node, index) => ({ - label: node.name ?? node.slug ?? '', + ...steps.map((step, index) => ({ + label: step.label, href: - index === breadcrumb.length - 1 + index === steps.length - 1 ? undefined - : buildLocationHref(slugs.slice(0, index + 1)), + : buildLocationHref(step.segments), })), ]; // Without history to pop, back goes one level up the directory. const backFallbackHref = isRoot ? '/feeds' - : buildLocationHref(slugs.slice(0, -1)); + : buildLocationHref(segments.slice(0, -1)); + const childrenHeading = isRoot + ? t('childrenHeading.continents') + : location.location_type === 'continent' + ? t('childrenHeading.countries') + : t('childrenHeading.places'); return ( - {isRoot ? t('title') : breadcrumb[breadcrumb.length - 1]?.name} + {isRoot ? t('title') : getLocationName(location)} - + {/* No catalog-wide counts by data type at the root. */} + {isRoot ? null : ( + + )} - {childLevel !== null && children.length > 0 ? ( + {children.length > 0 ? ( - {t(`childrenHeading.${childLevel}`)}{' '} + {childrenHeading}{' '} ({children.length}) @@ -248,7 +245,7 @@ export default async function LocationsPage( total={total} page={page} pageCount={pageCount} - slugs={slugs} + segments={segments} /> diff --git a/src/app/[locale]/locations/components/FeedCountSummary.tsx b/src/app/[locale]/locations/components/FeedCountSummary.tsx index 5ce5463b..55b90eab 100644 --- a/src/app/[locale]/locations/components/FeedCountSummary.tsx +++ b/src/app/[locale]/locations/components/FeedCountSummary.tsx @@ -5,11 +5,11 @@ import { formatCount, getFeedCountEntries, type FeedCountDataType, - type LocationDirectoryFeedCounts, + type LocationFeedCounts, } from '../lib/location-directory'; interface Props { - counts: LocationDirectoryFeedCounts | undefined; + counts: LocationFeedCounts | undefined; locale: string; } diff --git a/src/app/[locale]/locations/components/LocationChildrenGrid.tsx b/src/app/[locale]/locations/components/LocationChildrenGrid.tsx index 493ed546..13f110f2 100644 --- a/src/app/[locale]/locations/components/LocationChildrenGrid.tsx +++ b/src/app/[locale]/locations/components/LocationChildrenGrid.tsx @@ -8,58 +8,72 @@ import { buildLocationHref, formatCount, getFeedCountEntries, + getLocationName, + getLocationSegments, getTotalFeedCount, sortByFeedCount, - type LocationDirectoryNode, + type LocationResult, } from '../lib/location-directory'; interface Props { - nodes: LocationDirectoryNode[]; - /** Slugs of the current node; each child links one level deeper. */ - parentSlugs: string[]; + locations: LocationResult[]; + /** + * Continent slug of the current page; empty at the root, where the + * children are the continents themselves. + */ + continent: string; locale: string; } -function getFlag(node: LocationDirectoryNode): string | undefined { - if (node.level !== 'country' || node.code == undefined) return undefined; - const flag = getEmojiFlag(node.code.toUpperCase() as TCountryCode); +function getFlag(location: LocationResult): string | undefined { + if ( + location.location_type !== 'country' || + location.country_code == undefined + ) + return undefined; + const flag = getEmojiFlag( + location.country_code.toUpperCase() as TCountryCode, + ); return flag !== '' ? flag : undefined; } /** - * Child nodes, most feeds first. Everything that needs the server (flags, - * translations, number formatting) is resolved here so the client list only - * filters plain data. + * Child locations, most feeds first. Everything that needs the server + * (flags, translations, number formatting, links) is resolved here so the + * client list only filters plain data. */ export default async function LocationChildrenGrid({ - nodes, - parentSlugs, + locations, + continent, locale, }: Props): Promise { const t = await getTranslations('locations'); const items: LocationChildItem[] = sortByFeedCount( - nodes.map((node) => ({ - node, - name: node.name ?? node.slug ?? '', - counts: node.feed_counts, + locations.map((location) => ({ + location, + name: getLocationName(location), + counts: location.feed_counts, })), - ).map(({ node, name, counts }) => ({ - slug: node.slug ?? '', - name, - flag: getFlag(node), - href: buildLocationHref([...parentSlugs, node.slug ?? '']), - totalLabel: t.rich('childFeedTotal', { - count: getTotalFeedCount(counts), - b: (chunks) => {chunks}, - }), - counts: getFeedCountEntries(counts).map(({ dataType, count }) => ({ - dataType, - count, - formattedCount: formatCount(count, locale), - typeLabel: t(`shortLabels.${dataType}`), - })), - })); + ).map(({ location, name, counts }) => { + const href = buildLocationHref(getLocationSegments(continent, location)); + return { + key: href, + name, + flag: getFlag(location), + href, + totalLabel: t.rich('childFeedTotal', { + count: getTotalFeedCount(counts), + b: (chunks) => {chunks}, + }), + counts: getFeedCountEntries(counts).map(({ dataType, count }) => ({ + dataType, + count, + formattedCount: formatCount(count, locale), + typeLabel: t(`shortLabels.${dataType}`), + })), + }; + }); return ; } diff --git a/src/app/[locale]/locations/components/LocationChildrenList.tsx b/src/app/[locale]/locations/components/LocationChildrenList.tsx index ee2f03f5..51b3fadd 100644 --- a/src/app/[locale]/locations/components/LocationChildrenList.tsx +++ b/src/app/[locale]/locations/components/LocationChildrenList.tsx @@ -28,7 +28,8 @@ export interface LocationChildCount { } export interface LocationChildItem { - slug: string; + /** Unique among siblings (the href). */ + key: string; name: string; /** Emoji flag, countries only. */ flag?: string; @@ -129,7 +130,7 @@ export default function LocationChildrenList({ items }: Props): ReactElement { }} > {visibleItems.map((item) => ( -
  • +
  • ({})); @@ -15,24 +12,25 @@ jest.mock('react', () => ({ cache: (fn: unknown) => fn, })); -// Behaves like unstable_cache: a resolved value is stored per arguments, a -// thrown error is not. +// Behaves like unstable_cache: a resolved value is stored per key parts and +// arguments, a thrown error is not. Tests reset the store between them. +const mockCacheStore = new Map(); jest.mock('next/cache', () => ({ unstable_cache: ( fn: (...args: A) => Promise, + keyParts: string[], ): ((...args: A) => Promise) => { - const store = new Map(); return async (...args: A): Promise => { - const key = JSON.stringify(args); - if (store.has(key)) return store.get(key) as R; + const key = JSON.stringify([keyParts, args]); + if (mockCacheStore.has(key)) return mockCacheStore.get(key) as R; const value = await fn(...args); - store.set(key, value); + mockCacheStore.set(key, value); return value; }; }, })); -const mockGetLocationDirectory = jest.fn(); +const mockGetLocations = jest.fn(); const mockSearchFeeds = jest.fn(); jest.mock('../../../services/feeds', () => ({ @@ -43,8 +41,7 @@ jest.mock('../../../services/feeds', () => ({ this.status = status; } }, - getLocationDirectory: (...args: unknown[]) => - mockGetLocationDirectory(...args), + getLocations: (...args: unknown[]) => mockGetLocations(...args), searchFeeds: (...args: unknown[]) => mockSearchFeeds(...args), })); @@ -52,86 +49,260 @@ jest.mock('../../../utils/auth-server', () => ({ getGuestGcipIdToken: async () => 'guest-token', })); -const directory = { - level: 'continent', - breadcrumb: [], - feed_counts: { gtfs: 1, gtfs_rt: 0, gbfs: 0, total: 1 }, - children: [], +const STATUSES = ['active', 'inactive', 'development', 'future']; + +const northAmerica = { + location_id: null, + location_type: 'continent', + name: 'North America', + slug: 'north-america', + continent_slug: 'north-america', + path_slugs: [], + feed_counts: { total: 30 }, +}; +const antarctica = { + ...northAmerica, + name: 'Antarctica', + slug: 'antarctica', + continent_slug: 'antarctica', + feed_counts: { total: 0 }, +}; +const montreal = { + location_id: 175905, + location_type: 'municipality', + name: 'Montréal', + slug: 'montreal', + continent_slug: 'north-america', + path_slugs: ['canada', 'quebec', 'montreal'], + feed_counts: { total: 22 }, }; +const feeds = { total: 22, results: [] }; + +/** getLocations answers by query shape. */ +function answerLocations( + byQuery: (query: Record) => unknown, +): void { + mockGetLocations.mockImplementation(async (query: Record) => + byQuery(query), + ); +} + +function locationQueries(): Array> { + return mockGetLocations.mock.calls.map( + ([query]) => query as Record, + ); +} + +function searchQueries(): Array> { + return mockSearchFeeds.mock.calls.map( + ([params]) => (params as { query: Record }).query, + ); +} -describe('location directory data', () => { +describe('fetchLocationPage', () => { beforeEach(() => { - mockGetLocationDirectory.mockReset(); + mockCacheStore.clear(); + mockGetLocations.mockReset(); mockSearchFeeds.mockReset(); + mockSearchFeeds.mockResolvedValue(feeds); }); - it('resolves a 404 to null without caching it', async () => { - mockGetLocationDirectory - .mockRejectedValueOnce(new ApiError('Not Found', 404)) - .mockResolvedValueOnce(directory); + it('lists the continents with feeds and the newest feeds at the root', async () => { + answerLocations(() => ({ total: 2, results: [northAmerica, antarctica] })); - await expect(fetchLocationDirectory('europe')).resolves.toBeNull(); - await expect(fetchLocationDirectory('europe')).resolves.toEqual(directory); - expect(mockGetLocationDirectory).toHaveBeenCalledTimes(2); - }); + const data = await fetchLocationPage(2); - it('resolves a 400 on the feeds page to null without caching it', async () => { - const feeds = { total: 0, results: [] }; - mockSearchFeeds - .mockRejectedValueOnce(new ApiError('Bad Request', 400)) - .mockResolvedValueOnce(feeds); + expect(data).toEqual({ location: null, children: [northAmerica], feeds }); + expect(locationQueries()).toEqual([ + { + location_type: 'continent', + include_feed_counts: true, + limit: 100, + offset: 0, + }, + ]); + expect(mockGetLocations.mock.calls[0][1]).toBe('guest-token'); + expect(searchQueries()).toEqual([ + { status: STATUSES, limit: 20, offset: 20 }, + ]); + }); - await expect(fetchLocationDirectoryFeeds(1, 'asia')).resolves.toBeNull(); - await expect(fetchLocationDirectoryFeeds(1, 'asia')).resolves.toEqual( - feeds, + it('lists the countries and feeds of a continent', async () => { + const canada = { ...montreal, location_type: 'country', slug: 'canada' }; + answerLocations((query) => + query.location_type === 'continent' + ? { total: 1, results: [northAmerica] } + : { total: 1, results: [canada] }, ); + + const data = await fetchLocationPage(1, 'north-america', ''); + + expect(data).toEqual({ location: northAmerica, children: [canada], feeds }); + expect(locationQueries()[1]).toEqual({ + continent: 'north-america', + location_type: 'country', + has_feeds: true, + include_feed_counts: true, + sort: 'feed_count', + limit: 100, + offset: 0, + }); + expect(searchQueries()).toEqual([ + { + continent: 'north-america', + sort: 'location', + status: STATUSES, + limit: 20, + offset: 0, + }, + ]); }); - it('lists the feeds of a node with search, most relevant first', async () => { - mockSearchFeeds.mockResolvedValue({ total: 0, results: [] }); + it('is null for an unknown continent, without listing anything', async () => { + answerLocations(() => ({ total: 1, results: [northAmerica] })); + + await expect(fetchLocationPage(1, 'atlantis', '')).resolves.toBeNull(); + expect(mockGetLocations).toHaveBeenCalledTimes(1); + expect(mockSearchFeeds).not.toHaveBeenCalled(); + }); + + it('resolves a deeper location by path, then its children and feeds', async () => { + const child = { ...montreal, location_id: 1, slug: 'ville-marie' }; + answerLocations((query) => + query.path !== undefined + ? { total: 1, results: [montreal] } + : { total: 1, results: [child] }, + ); + + const data = await fetchLocationPage( + 3, + 'north-america', + 'canada/quebec/montreal', + ); - await fetchLocationDirectoryFeeds(2, 'europe', 'portugal'); - expect(mockSearchFeeds).toHaveBeenCalledWith( + expect(data).toEqual({ location: montreal, children: [child], feeds }); + expect(locationQueries()).toEqual([ { - query: { - continent: 'europe', - country: 'portugal', - status: ['active', 'inactive', 'development', 'future'], - sort: 'location', - limit: 20, - offset: 20, - }, + continent: 'north-america', + path: 'canada/quebec/montreal', + include_feed_counts: true, + limit: 1, }, - 'guest-token', + { + parent_location_id: 175905, + has_feeds: true, + include_feed_counts: true, + sort: 'feed_count', + limit: 100, + offset: 0, + }, + ]); + expect(searchQueries()).toEqual([ + { + location_id: 175905, + sort: 'location', + status: STATUSES, + limit: 20, + offset: 40, + }, + ]); + }); + + it('pages through every child location', async () => { + const children = Array.from({ length: 230 }, (_, index) => ({ + ...montreal, + location_id: index + 1, + slug: `child-${index + 1}`, + })); + answerLocations((query) => { + if (query.path !== undefined) return { total: 1, results: [montreal] }; + const offset = query.offset as number; + const limit = query.limit as number; + return { total: 230, results: children.slice(offset, offset + limit) }; + }); + + const data = await fetchLocationPage( + 1, + 'north-america', + 'canada/quebec/montreal', + ); + + expect(data?.children).toEqual(children); + expect( + locationQueries() + .slice(1) + .map(({ offset, limit }) => [offset, limit]), + ).toEqual([ + [0, 100], + [100, 100], + [200, 100], + ]); + }); + + it('is null when no location has the path, and never caches the miss', async () => { + answerLocations(() => ({ total: 0, results: [] })); + await expect( + fetchLocationPage(1, 'north-america', 'canada/quebec/montreal'), + ).resolves.toBeNull(); + expect(mockSearchFeeds).not.toHaveBeenCalled(); + + answerLocations((query) => + query.path !== undefined + ? { total: 1, results: [montreal] } + : { total: 0, results: [] }, ); + await expect( + fetchLocationPage(1, 'north-america', 'canada/quebec/montreal'), + ).resolves.toMatchObject({ location: montreal }); }); - it('lists the newest feeds at the root, which has no location', async () => { - mockSearchFeeds.mockResolvedValue({ total: 0, results: [] }); + it('is null when the result is not at the requested continent or path', async () => { + answerLocations(() => ({ total: 1, results: [montreal] })); - await fetchLocationDirectoryFeeds(1); - const [{ query }] = mockSearchFeeds.mock.calls[0] as [ - { query: Record }, - ]; - expect(query.sort).toBeUndefined(); - expect(query.continent).toBeUndefined(); + // The API accepts continent codes too; only slugs are valid URLs. + await expect( + fetchLocationPage(1, 'na', 'canada/quebec/montreal'), + ).resolves.toBeNull(); + await expect( + fetchLocationPage(1, 'north-america', 'canada/quebec'), + ).resolves.toBeNull(); + }); + + it('resolves a 400 or 404 to null without caching it', async () => { + mockGetLocations + .mockRejectedValueOnce(new ApiError('Bad Request', 400)) + .mockResolvedValue({ total: 1, results: [northAmerica] }); + + await expect(fetchLocationPage(1)).resolves.toBeNull(); + await expect(fetchLocationPage(1)).resolves.toMatchObject({ + children: [northAmerica], + }); + + mockSearchFeeds.mockReset(); + mockSearchFeeds + .mockRejectedValueOnce(new ApiError('Not Found', 404)) + .mockResolvedValueOnce(feeds); + await expect(fetchLocationPage(2)).resolves.toBeNull(); + await expect(fetchLocationPage(2)).resolves.toMatchObject({ feeds }); }); it('caches a found location', async () => { - mockGetLocationDirectory.mockResolvedValue(directory); + answerLocations((query) => + query.path !== undefined + ? { total: 1, results: [montreal] } + : { total: 0, results: [] }, + ); - await fetchLocationDirectory('oceania'); - await fetchLocationDirectory('oceania'); - expect(mockGetLocationDirectory).toHaveBeenCalledTimes(1); + await fetchLocationPage(1, 'north-america', 'canada/quebec/montreal'); + await fetchLocationPage(2, 'north-america', 'canada/quebec/montreal'); + // Location and children once; one feed page each. + expect(mockGetLocations).toHaveBeenCalledTimes(2); + expect(mockSearchFeeds).toHaveBeenCalledTimes(2); }); it('rethrows other API errors', async () => { - mockGetLocationDirectory.mockRejectedValueOnce( - new ApiError('Server Error', 500), - ); + mockGetLocations.mockRejectedValueOnce(new ApiError('Server Error', 500)); - await expect(fetchLocationDirectory('africa')).rejects.toThrow( - 'Server Error', - ); + await expect(fetchLocationPage(1)).rejects.toThrow('Server Error'); }); }); diff --git a/src/app/[locale]/locations/lib/location-directory-data.ts b/src/app/[locale]/locations/lib/location-directory-data.ts index 831792ef..4711ce50 100644 --- a/src/app/[locale]/locations/lib/location-directory-data.ts +++ b/src/app/[locale]/locations/lib/location-directory-data.ts @@ -11,32 +11,39 @@ import { unstable_cache } from 'next/cache'; import { getGuestGcipIdToken } from '../../../utils/auth-server'; import { ApiError, - getLocationDirectory, + getLocations, searchFeeds, - type LocationDirectoryResponse, + type LocationsParams, type SearchFeedsResponse, } from '../../../services/feeds'; import { + fetchAllPages, + getContinentSlug, getPageOffset, + getTotalFeedCount, + isLocationAtPath, LOCATION_FEEDS_PAGE_SIZE, - type LocationQuery, + type LocationResult, } from './location-directory'; /** 6 hours; also flushed on demand with the existing `guest-feeds` tag. */ export const LOCATION_DIRECTORY_REVALIDATE_SECONDS = 21600; const CACHE_TAGS = ['location-directory', 'guest-feeds']; +const CACHE_OPTIONS = { + tags: CACHE_TAGS, + revalidate: LOCATION_DIRECTORY_REVALIDATE_SECONDS, +}; -/** The statuses the directory counts, so a node lists as many feeds. */ +/** The statuses `feed_counts` counts, so a location lists as many feeds. */ const DIRECTORY_FEED_STATUSES: Array< 'active' | 'inactive' | 'development' | 'future' > = ['active', 'inactive', 'development', 'future']; /** - * 400 (invalid hierarchy) and 404 (nothing located there) both mean the URL - * does not exist. They are thrown as this error inside the cached functions, - * because unstable_cache never stores a thrown error: caching the miss would - * keep a location missing for hours after it starts to exist (e.g. a - * 404 served before the API route was deployed). + * A URL that does not exist: a 400/404 from the API, or no location at the + * requested path. Thrown inside the cached functions because unstable_cache + * never stores a thrown error: caching the miss would keep a location missing + * for hours after it starts to exist. */ class LocationNotFoundError extends Error {} @@ -46,7 +53,7 @@ async function requireData( try { const data = await request(); if (data == undefined) { - throw new Error('Empty response from the location directory API'); + throw new Error('Empty response from the locations API'); } return data; } catch (error) { @@ -72,61 +79,114 @@ async function nullWhenMissing( } } -function toQuery( - continent: string | undefined, - country: string | undefined, - subdivision: string | undefined, - municipality: string | undefined, -): LocationQuery { - const query: LocationQuery = {}; - if (continent != undefined) query.continent = continent; - if (country != undefined) query.country = country; - if (subdivision != undefined) query.subdivision = subdivision; - if (municipality != undefined) query.municipality = municipality; - return query; +/** Every page of `GET /v1/locations` for the query (100 per call). */ +async function getAllLocations( + query: LocationsParams, + accessToken: string, +): Promise { + return await fetchAllPages( + async (offset, limit) => + await requireData( + async () => + await getLocations({ ...query, limit, offset }, accessToken), + ), + ); } // unstable_cache keys on the key parts plus the call arguments, so the // arguments are kept primitive. -const cachedDirectory = unstable_cache( - async ( - continent?: string, - country?: string, - subdivision?: string, - municipality?: string, - ): Promise => { + +const cachedContinents = unstable_cache( + async (): Promise => { const accessToken = await getGuestGcipIdToken(); - return await requireData( + return await getAllLocations( + { location_type: 'continent', include_feed_counts: true }, + accessToken, + ); + }, + ['location-directory-continents'], + CACHE_OPTIONS, +); + +/** The location at `continent/path`; throws when there is none. */ +const cachedLocation = unstable_cache( + async (continent: string, path: string): Promise => { + const accessToken = await getGuestGcipIdToken(); + const response = await requireData( async () => - await getLocationDirectory( - toQuery(continent, country, subdivision, municipality), + await getLocations( + { continent, path, include_feed_counts: true, limit: 1 }, accessToken, ), ); + const location = response.results?.[0]; + if ( + location == undefined || + location.location_id == undefined || + !isLocationAtPath(location, continent, path.split('/')) + ) { + throw new LocationNotFoundError(`No location at ${continent}/${path}`); + } + return location; }, - ['location-directory'], - { tags: CACHE_TAGS, revalidate: LOCATION_DIRECTORY_REVALIDATE_SECONDS }, + ['location-directory-location'], + CACHE_OPTIONS, ); -const cachedDirectoryFeeds = unstable_cache( +const CHILDREN_QUERY = { + has_feeds: true, + include_feed_counts: true, + sort: 'feed_count', +} as const satisfies LocationsParams; + +const cachedCountries = unstable_cache( + async (continent: string): Promise => { + const accessToken = await getGuestGcipIdToken(); + return await getAllLocations( + { ...CHILDREN_QUERY, continent, location_type: 'country' }, + accessToken, + ); + }, + ['location-directory-countries'], + CACHE_OPTIONS, +); + +const cachedSubLocations = unstable_cache( + async (parentLocationId: number): Promise => { + const accessToken = await getGuestGcipIdToken(); + return await getAllLocations( + { ...CHILDREN_QUERY, parent_location_id: parentLocationId }, + accessToken, + ); + }, + ['location-directory-sub-locations'], + CACHE_OPTIONS, +); + +/** + * One page of feeds: newest first at the root (no location), otherwise most + * relevant to the continent or location first. + */ +const cachedFeeds = unstable_cache( async ( offset: number, - continent?: string, - country?: string, - subdivision?: string, - municipality?: string, + continent: string | null, + locationId: number | null, ): Promise => { const accessToken = await getGuestGcipIdToken(); + const location = + locationId !== null + ? { location_id: locationId, sort: 'location' as const } + : continent !== null + ? { continent, sort: 'location' as const } + : {}; return await requireData( async () => await searchFeeds( { query: { - ...toQuery(continent, country, subdivision, municipality), + ...location, status: DIRECTORY_FEED_STATUSES, - // Most relevant location first; the API rejects it without a - // location, so the root lists the newest feeds first. - sort: continent != undefined ? 'location' : undefined, limit: LOCATION_FEEDS_PAGE_SIZE, offset, }, @@ -136,44 +196,78 @@ const cachedDirectoryFeeds = unstable_cache( ); }, ['location-directory-feeds'], - { tags: CACHE_TAGS, revalidate: LOCATION_DIRECTORY_REVALIDATE_SECONDS }, + CACHE_OPTIONS, ); +export interface LocationPageData { + /** The current location; null at the root. */ + location: LocationResult | null; + /** Continents at the root, otherwise the locations with feeds one level down. */ + children: LocationResult[]; + /** The requested page of feeds located there. */ + feeds: SearchFeedsResponse; +} + +async function loadLocationPage( + page: number, + continent: string | undefined, + path: string, +): Promise { + const offset = getPageOffset(page); + + if (continent === undefined) { + const [continents, feeds] = await Promise.all([ + cachedContinents(), + cachedFeeds(offset, null, null), + ]); + // Children deeper down are fetched with `has_feeds`; continents are not, + // so that an empty continent still resolves. + const children = continents.filter( + (continent) => getTotalFeedCount(continent.feed_counts) > 0, + ); + return { location: null, children, feeds }; + } + + if (path === '') { + // Resolve the continent before listing its countries and feeds, so an + // unknown slug is a 404 and never adds entries to the data cache. + const continents = await cachedContinents(); + const location = continents.find( + (candidate) => getContinentSlug(candidate) === continent, + ); + if (location == undefined) { + throw new LocationNotFoundError(`Unknown continent ${continent}`); + } + const [children, feeds] = await Promise.all([ + cachedCountries(continent), + cachedFeeds(offset, continent, null), + ]); + return { location, children, feeds }; + } + + const location = await cachedLocation(continent, path); + const locationId = location.location_id ?? 0; + const [children, feeds] = await Promise.all([ + cachedSubLocations(locationId), + cachedFeeds(offset, null, locationId), + ]); + return { location, children, feeds }; +} + /** - * Directory node (breadcrumb, counts, children), or null when the location - * does not exist. React cache() dedupes the generateMetadata + page calls - * within one request; it compares arguments by identity, hence primitives. + * Everything a directory page shows, or null when the location does not + * exist. `path` is the location's path slugs joined with `/` (empty at the + * root and on a continent page). React cache() dedupes the generateMetadata + * and page calls within one request; it compares arguments by identity, + * hence primitives. */ -export const fetchLocationDirectory = cache( - async ( - continent?: string, - country?: string, - subdivision?: string, - municipality?: string, - ): Promise => - await nullWhenMissing( - async () => - await cachedDirectory(continent, country, subdivision, municipality), - ), -); - -/** One page (1-based) of the feeds located in a node, or null if missing. */ -export const fetchLocationDirectoryFeeds = cache( +export const fetchLocationPage = cache( async ( page: number, continent?: string, - country?: string, - subdivision?: string, - municipality?: string, - ): Promise => + path: string = '', + ): Promise => await nullWhenMissing( - async () => - await cachedDirectoryFeeds( - getPageOffset(page), - continent, - country, - subdivision, - municipality, - ), + async () => await loadLocationPage(page, continent, path), ), ); diff --git a/src/app/[locale]/locations/lib/location-directory.spec.ts b/src/app/[locale]/locations/lib/location-directory.spec.ts index ff0c6640..063996d6 100644 --- a/src/app/[locale]/locations/lib/location-directory.spec.ts +++ b/src/app/[locale]/locations/lib/location-directory.spec.ts @@ -1,67 +1,113 @@ import { + buildBreadcrumbSteps, buildLocationHref, buildPlaceName, + fetchAllPages, filterByName, formatCount, - getBreadcrumbSlugs, - getChildLevel, + getContinentSlug, getFeedCountEntries, + getLocationName, + getLocationSegments, getPageCount, getPageOffset, getTotalFeedCount, + isLocationAtPath, + type LocationResult, normalizeLocationName, - type LocationDirectoryNode, parseLocationSegments, parsePageParam, sortByFeedCount, } from './location-directory'; -const breadcrumb: LocationDirectoryNode[] = [ - { - level: 'continent', - slug: 'north-america', - code: 'NA', - name: 'North America', - }, - { level: 'country', slug: 'canada', code: 'CA', name: 'Canada' }, - { level: 'subdivision', slug: 'quebec', code: null, name: 'Québec' }, - { level: 'municipality', slug: 'montreal', code: null, name: 'Montréal' }, -]; +const northAmerica: LocationResult = { + location_id: null, + parent_location_id: null, + location_type: 'continent', + name: 'North America', + slug: 'north-america', + continent_slug: 'north-america', + continent_name: 'North America', + path_slugs: [], + path_names: [], +}; + +// Variable depth: Montréal sits under an intermediate region. +const montreal: LocationResult = { + location_id: 175905, + parent_location_id: 161950, + location_type: 'municipality', + name: 'Montréal', + slug: 'montreal', + continent_slug: 'north-america', + continent_name: 'North America', + path_slugs: ['canada', 'quebec', 'montreal-region', 'montreal'], + path_names: ['Canada', 'Québec', 'Montréal (region)', 'Montréal'], +}; + +const canada: LocationResult = { + location_id: 1, + parent_location_id: null, + location_type: 'country', + name: 'Canada', + slug: 'canada', + continent_slug: 'north-america', + continent_name: 'North America', + path_slugs: ['canada'], + path_names: ['Canada'], +}; describe('parseLocationSegments', () => { - it('returns an empty query at the root', () => { - expect(parseLocationSegments(undefined)).toEqual({}); - expect(parseLocationSegments([])).toEqual({}); + it('returns an empty path at the root', () => { + expect(parseLocationSegments(undefined)).toEqual({ path: [] }); + expect(parseLocationSegments([])).toEqual({ path: [] }); }); - it('maps each segment to its API parameter, untouched', () => { + it('splits the continent from the path slugs, at any depth', () => { expect(parseLocationSegments(['north-america'])).toEqual({ continent: 'north-america', + path: [], }); - expect( - parseLocationSegments(['north-america', 'canada', 'quebec', 'montreal']), - ).toEqual({ + expect(parseLocationSegments(['north-america', 'canada'])).toEqual({ + continent: 'north-america', + path: ['canada'], + }); + const deep = ['canada', 'quebec', 'montreal-region', 'montreal', 'a', 'b']; + expect(parseLocationSegments(['north-america', ...deep])).toEqual({ continent: 'north-america', - country: 'canada', - subdivision: 'quebec', - municipality: 'montreal', + path: deep, }); }); - it('rejects paths deeper than a municipality', () => { + it('accepts collision suffixes and non-Latin lowercase slugs', () => { expect( parseLocationSegments([ 'north-america', - 'canada', - 'quebec', - 'montreal', - 'x', - ]), - ).toBeNull(); + 'united-states', + 'ohio', + 'washington-township-123456', + ])?.path, + ).toEqual(['united-states', 'ohio', 'washington-township-123456']); + expect(parseLocationSegments(['asia', '東京'])?.path).toEqual(['東京']); }); - it('rejects empty segments', () => { - expect(parseLocationSegments(['north-america', ' '])).toBeNull(); + it('decodes percent-encoded segments', () => { + expect( + parseLocationSegments(['asia', encodeURIComponent('東京')])?.path, + ).toEqual(['東京']); + }); + + it.each([ + [['north-america', '']], + [['north-america', ' ']], + [['North-America']], + [['north-america', 'Canada']], + [['north-america', 'can ada']], + [['north-america', 'can%20ada']], + [['north-america', 'canada.json']], + [['north-america', '%E0%A4%A']], + ])('rejects malformed segments %p', (segments) => { + expect(parseLocationSegments(segments)).toBeNull(); }); }); @@ -122,12 +168,176 @@ describe('buildLocationHref', () => { it('encodes unsafe characters in segments', () => { expect(buildLocationHref(['a b', 'c/d'])).toBe('/locations/a%20b/c%2Fd'); }); +}); + +describe('location segments and names', () => { + it('links a location through its path slugs under the continent', () => { + expect(getLocationSegments('north-america', montreal)).toEqual([ + 'north-america', + 'canada', + 'quebec', + 'montreal-region', + 'montreal', + ]); + expect( + buildLocationHref(getLocationSegments('north-america', canada)), + ).toBe('/locations/north-america/canada'); + }); + + it('links a continent through its own slug', () => { + expect(getLocationSegments('', northAmerica)).toEqual(['north-america']); + expect(getContinentSlug({ ...northAmerica, continent_slug: null })).toBe( + 'north-america', + ); + }); + + it('round-trips with URL parsing', () => { + const href = buildLocationHref( + getLocationSegments('north-america', montreal), + 2, + ); + expect(href).toBe( + '/locations/north-america/canada/quebec/montreal-region/montreal?page=2', + ); + const segments = href.split('?')[0].split('/').slice(2); + const parsed = parseLocationSegments(segments); + expect(parsed).toEqual({ + continent: 'north-america', + path: montreal.path_slugs, + }); + expect( + isLocationAtPath(montreal, 'north-america', parsed?.path ?? []), + ).toBe(true); + }); + + it('checks the result is the requested location', () => { + expect(isLocationAtPath(canada, 'north-america', ['canada'])).toBe(true); + // The API also accepts continent codes: only the slug is canonical. + expect(isLocationAtPath(canada, 'na', ['canada'])).toBe(false); + expect(isLocationAtPath(canada, 'north-america', ['mexico'])).toBe(false); + }); + + it('names a location, falling back to its slug', () => { + expect(getLocationName(montreal)).toBe('Montréal'); + expect(getLocationName({ ...montreal, name: null })).toBe('montreal'); + expect(getLocationName({ ...northAmerica, name: null })).toBe( + 'North America', + ); + }); +}); + +describe('buildBreadcrumbSteps', () => { + it('is empty at the root', () => { + expect(buildBreadcrumbSteps('', null)).toEqual([]); + }); + + it('has a single step on a continent', () => { + expect(buildBreadcrumbSteps('north-america', northAmerica)).toEqual([ + { label: 'North America', segments: ['north-america'] }, + ]); + }); + + it('adds one step per path name, linked by the path slug prefix', () => { + expect(buildBreadcrumbSteps('north-america', montreal)).toEqual([ + { label: 'North America', segments: ['north-america'] }, + { label: 'Canada', segments: ['north-america', 'canada'] }, + { label: 'Québec', segments: ['north-america', 'canada', 'quebec'] }, + { + label: 'Montréal (region)', + segments: ['north-america', 'canada', 'quebec', 'montreal-region'], + }, + { + label: 'Montréal', + segments: [ + 'north-america', + 'canada', + 'quebec', + 'montreal-region', + 'montreal', + ], + }, + ]); + }); + + it('falls back to the continent slug and ignores unaligned names', () => { + const steps = buildBreadcrumbSteps('north-america', { + ...canada, + continent_name: null, + path_names: ['Canada', 'Extra'], + }); + expect(steps.map((step) => step.label)).toEqual([ + 'north-america', + 'Canada', + ]); + }); +}); + +describe('buildPlaceName', () => { + it('is empty at the root', () => { + expect(buildPlaceName(null)).toBe(''); + }); + + it('names a continent on its own', () => { + expect(buildPlaceName(northAmerica)).toBe('North America'); + }); + + it('lists the most specific place first, without the continent', () => { + expect(buildPlaceName(canada)).toBe('Canada'); + expect(buildPlaceName(montreal)).toBe( + 'Montréal, Montréal (region), Québec, Canada', + ); + }); +}); + +describe('fetchAllPages', () => { + const pageOf = ( + total: number, + offset: number, + limit: number, + ): { total: number; results: number[] } => ({ + total, + results: Array.from( + { length: Math.max(0, Math.min(limit, total - offset)) }, + (_, index) => offset + index, + ), + }); + + it('stops after the first page when it holds everything', async () => { + const fetchPage = jest.fn(async (offset: number, limit: number) => + pageOf(42, offset, limit), + ); + await expect(fetchAllPages(fetchPage)).resolves.toHaveLength(42); + expect(fetchPage).toHaveBeenCalledTimes(1); + expect(fetchPage).toHaveBeenCalledWith(0, 100); + }); - it('round-trips with the breadcrumb slugs', () => { - expect(buildLocationHref(getBreadcrumbSlugs(breadcrumb), 2)).toBe( - '/locations/north-america/canada/quebec/montreal?page=2', + it('fetches the remaining pages in order', async () => { + const fetchPage = jest.fn(async (offset: number, limit: number) => + pageOf(250, offset, limit), ); - expect(getBreadcrumbSlugs(undefined)).toEqual([]); + const results = await fetchAllPages(fetchPage); + expect(results).toEqual(Array.from({ length: 250 }, (_, index) => index)); + expect(fetchPage.mock.calls).toEqual([ + [0, 100], + [100, 100], + [200, 100], + ]); + }); + + it('follows a server that returns fewer results than asked', async () => { + const fetchPage = jest.fn(async (offset: number) => pageOf(25, offset, 10)); + await expect(fetchAllPages(fetchPage)).resolves.toHaveLength(25); + expect(fetchPage.mock.calls.map(([offset]) => offset)).toEqual([0, 10, 20]); + }); + + it('handles empty and total-less responses', async () => { + await expect( + fetchAllPages(async () => ({ total: 0, results: [] })), + ).resolves.toEqual([]); + await expect(fetchAllPages(async () => ({}))).resolves.toEqual([]); + await expect( + fetchAllPages(async () => ({ results: [1, 2] })), + ).resolves.toEqual([1, 2]); }); }); @@ -159,33 +369,6 @@ describe('feed counts', () => { }); }); -describe('getChildLevel', () => { - it('walks one level down and stops at municipalities', () => { - expect(getChildLevel('root')).toBe('continent'); - expect(getChildLevel(undefined)).toBe('continent'); - expect(getChildLevel('continent')).toBe('country'); - expect(getChildLevel('country')).toBe('subdivision'); - expect(getChildLevel('subdivision')).toBe('municipality'); - expect(getChildLevel('municipality')).toBeNull(); - }); -}); - -describe('buildPlaceName', () => { - it('is empty at the root', () => { - expect(buildPlaceName([])).toBe(''); - expect(buildPlaceName(undefined)).toBe(''); - }); - - it('names a continent on its own', () => { - expect(buildPlaceName(breadcrumb.slice(0, 1))).toBe('North America'); - }); - - it('lists the most specific place first, without the continent', () => { - expect(buildPlaceName(breadcrumb.slice(0, 2))).toBe('Canada'); - expect(buildPlaceName(breadcrumb)).toBe('Montréal, Québec, Canada'); - }); -}); - describe('normalizeLocationName', () => { it('drops accents and case', () => { expect(normalizeLocationName(' Québec ')).toBe('quebec'); diff --git a/src/app/[locale]/locations/lib/location-directory.ts b/src/app/[locale]/locations/lib/location-directory.ts index c3dc11e2..d9af3aba 100644 --- a/src/app/[locale]/locations/lib/location-directory.ts +++ b/src/app/[locale]/locations/lib/location-directory.ts @@ -1,59 +1,62 @@ /** * Pure helpers for the /locations directory: URL segment and page parsing, - * URL building and count formatting. Safe to import from server and client - * components; holds no data fetching. + * URL and breadcrumb building, paging and count formatting. Safe to import + * from server and client components; holds no data fetching. */ import type { components } from '../../../services/feeds/types'; -export type LocationDirectoryNode = - components['schemas']['LocationDirectoryNode']; -export type LocationDirectoryLevel = - components['schemas']['LocationDirectoryLevel']; -export type LocationDirectoryFeedCounts = - components['schemas']['LocationDirectoryFeedCounts']; +export type LocationResult = components['schemas']['LocationSearchResult']; +export type LocationFeedCounts = components['schemas']['LocationFeedCounts']; export const LOCATIONS_BASE_PATH = '/locations'; export const LOCATION_FEEDS_PAGE_SIZE = 20; -/** continent > country > subdivision > municipality */ -export const MAX_LOCATION_DEPTH = 4; +/** Maximum `limit` of `GET /v1/locations`. */ +export const LOCATIONS_API_PAGE_SIZE = 100; /** Anchor of the feed list; pagination links jump back to it. */ export const FEEDS_SECTION_ID = 'feeds'; -/** Query parameters shared by both directory endpoints. */ -export interface LocationQuery { +/** + * A directory location as addressed by its URL: the continent slug, then the + * `path_slugs` of the location from the country down. Both are empty at the + * root; `path` is empty on a continent page. + */ +export interface LocationPath { continent?: string; - /** Country slug, e.g. `canada`. */ - country?: string; - subdivision?: string; - municipality?: string; + path: string[]; } -const QUERY_KEYS_BY_DEPTH = [ - 'continent', - 'country', - 'subdivision', - 'municipality', -] as const; +// API slugs: lowercase letters (any script), digits and hyphens, e.g. +// `north-america`, `montreal`, `washington-township-123456`. +const SLUG_PATTERN = /^[\p{Ll}\p{Lo}\p{M}\p{Nd}-]+$/u; + +function decodeSegment(segment: string): string | null { + if (!segment.includes('%')) return segment; + try { + return decodeURIComponent(segment); + } catch { + return null; + } +} /** - * Maps the optional catch-all segments to the directory query. Segments are - * passed through untouched: API slugs are exactly the URL segments. + * Maps the optional catch-all segments to a location path: the first segment + * is the continent, the others are the location's path slugs, at any depth. * - * @returns the query (empty at the root), or null when the path is deeper - * than a municipality or contains an empty segment. + * @returns the path (empty at the root), or null when a segment is not a + * valid slug (empty, uppercase, spaces, punctuation other than `-`). */ export function parseLocationSegments( segments: readonly string[] | undefined, -): LocationQuery | null { - if (segments == undefined) return {}; - if (segments.length > MAX_LOCATION_DEPTH) return null; - const query: LocationQuery = {}; - for (let depth = 0; depth < segments.length; depth++) { - const segment = segments[depth].trim(); - if (segment === '') return null; - query[QUERY_KEYS_BY_DEPTH[depth]] = segment; +): LocationPath | null { + if (segments == undefined || segments.length === 0) return { path: [] }; + const slugs: string[] = []; + for (const segment of segments) { + const slug = decodeSegment(segment); + if (slug === null || !SLUG_PATTERN.test(slug)) return null; + slugs.push(slug); } - return query; + const [continent, ...path] = slugs; + return { continent, path }; } const PAGE_PATTERN = /^[1-9]\d*$/; @@ -91,26 +94,141 @@ export function getPageOffset( } /** - * Locale-less path to a directory node, e.g. - * `/locations/north-america/canada/quebec?page=2`. Page 1 has no query string so - * that it stays the canonical URL of the node. Pass the result to the + * Locale-less path to a directory location, e.g. + * `/locations/north-america/canada/quebec?page=2`. Page 1 has no query string + * so that it stays the canonical URL of the location. Pass the result to the * next-intl `Link`, which adds the locale prefix. */ export function buildLocationHref( - slugs: readonly string[], + segments: readonly string[], page: number = 1, ): string { - const path = [LOCATIONS_BASE_PATH, ...slugs.map(encodeURIComponent)].join( + const path = [LOCATIONS_BASE_PATH, ...segments.map(encodeURIComponent)].join( '/', ); return page > 1 ? `${path}?page=${page}` : path; } -/** Slugs of every breadcrumb node, from the continent down. */ -export function getBreadcrumbSlugs( - breadcrumb: readonly LocationDirectoryNode[] | undefined, +/** URL slug of a continent row; continents have no geopolygon path. */ +export function getContinentSlug(continent: LocationResult): string { + return continent.continent_slug ?? continent.slug ?? ''; +} + +/** Display name of a location, falling back to its slug. */ +export function getLocationName(location: LocationResult): string { + if (location.location_type === 'continent') { + return ( + location.name ?? location.continent_name ?? getContinentSlug(location) + ); + } + return location.name ?? location.slug ?? ''; +} + +/** + * URL segments of a location under the continent of the current page: + * `[continent, ...path_slugs]`. A continent row links to its own slug. + */ +export function getLocationSegments( + continentSlug: string, + location: LocationResult, ): string[] { - return (breadcrumb ?? []).map((node) => node.slug ?? ''); + if (location.location_type === 'continent') { + return [getContinentSlug(location)]; + } + return [continentSlug, ...(location.path_slugs ?? [])]; +} + +export interface BreadcrumbStep { + label: string; + /** URL segments of this step, continent first. */ + segments: string[]; +} + +/** + * The continent, then one step per `path_names` entry, each linked through + * the matching prefix of `path_slugs`. Empty at the root. + */ +export function buildBreadcrumbSteps( + continentSlug: string, + location: LocationResult | null, +): BreadcrumbStep[] { + if (location === null) return []; + const continentStep: BreadcrumbStep = { + label: + location.continent_name ?? + (location.location_type === 'continent' + ? getLocationName(location) + : continentSlug), + segments: [continentSlug], + }; + if (location.location_type === 'continent') return [continentStep]; + const names = location.path_names ?? []; + const slugs = location.path_slugs ?? []; + const depth = Math.min(names.length, slugs.length); + const steps = [continentStep]; + for (let index = 0; index < depth; index++) { + steps.push({ + label: names[index], + segments: [continentSlug, ...slugs.slice(0, index + 1)], + }); + } + return steps; +} + +/** + * Human-readable place for titles, most specific first: + * `Montréal, Québec, Canada`. A continent is named on its own; empty at the + * root. + */ +export function buildPlaceName(location: LocationResult | null): string { + if (location === null) return ''; + if (location.location_type === 'continent') return getLocationName(location); + const names = location.path_names ?? []; + if (names.length === 0) return getLocationName(location); + return [...names].reverse().join(', '); +} + +/** True when the API result is the location the URL asked for. */ +export function isLocationAtPath( + location: LocationResult, + continentSlug: string, + path: readonly string[], +): boolean { + return ( + location.continent_slug === continentSlug && + (location.path_slugs ?? []).join('/') === path.join('/') + ); +} + +export interface ResultsPage { + total?: number; + results?: T[]; +} + +/** + * Fetches every result of an offset-paginated endpoint: the first page tells + * the total and the effective page size, the remaining pages are then + * fetched in parallel. + */ +export async function fetchAllPages( + fetchPage: (offset: number, limit: number) => Promise>, + pageSize: number = LOCATIONS_API_PAGE_SIZE, +): Promise { + const first = await fetchPage(0, pageSize); + const results = [...(first.results ?? [])]; + const total = first.total ?? results.length; + // A server that caps `limit` lower than asked still pages consistently. + const step = results.length; + if (step === 0 || step >= total) return results; + const offsets: number[] = []; + for (let offset = step; offset < total; offset += step) { + offsets.push(offset); + } + const rest = await Promise.all( + offsets.map(async (offset) => await fetchPage(offset, pageSize)), + ); + for (const page of rest) results.push(...(page.results ?? [])); + return results; } export const FEED_COUNT_DATA_TYPES = ['gtfs', 'gtfs_rt', 'gbfs'] as const; @@ -123,7 +241,7 @@ export interface FeedCountEntry { /** GTFS, GTFS-RT and GBFS counts in display order; missing counts are 0. */ export function getFeedCountEntries( - counts: LocationDirectoryFeedCounts | undefined, + counts: LocationFeedCounts | undefined, ): FeedCountEntry[] { return FEED_COUNT_DATA_TYPES.map((dataType) => ({ dataType, @@ -131,9 +249,9 @@ export function getFeedCountEntries( })); } -/** Total distinct feeds of a node, falling back to the sum by data type. */ +/** Total distinct feeds of a location, falling back to the sum by type. */ export function getTotalFeedCount( - counts: LocationDirectoryFeedCounts | undefined, + counts: LocationFeedCounts | undefined, ): number { if (counts?.total != undefined) return counts.total; return getFeedCountEntries(counts).reduce( @@ -153,7 +271,7 @@ export function normalizeLocationName(name: string): string { /** Most feeds first; ties by name, ignoring accents and case. */ export function sortByFeedCount< - T extends { name: string; counts: LocationDirectoryFeedCounts | undefined }, + T extends { name: string; counts: LocationFeedCounts | undefined }, >(items: readonly T[]): T[] { return [...items].sort( (a, b) => @@ -182,38 +300,3 @@ export function formatCount(value: number | undefined, locale: string): string { value ?? 0, ); } - -const CHILD_LEVEL: Record< - LocationDirectoryLevel, - LocationDirectoryLevel | null -> = { - root: 'continent', - continent: 'country', - country: 'subdivision', - subdivision: 'municipality', - municipality: null, -}; - -/** The level of a node's children, or null for municipalities. */ -export function getChildLevel( - level: LocationDirectoryLevel | undefined, -): LocationDirectoryLevel | null { - return CHILD_LEVEL[level ?? 'root']; -} - -/** - * Human-readable place for titles, most specific first: - * `Montréal, Québec, Canada`. The continent is dropped once a country is - * known; a continent on its own is returned as is. Empty at the root. - */ -export function buildPlaceName( - breadcrumb: readonly LocationDirectoryNode[] | undefined, -): string { - const names = (breadcrumb ?? []) - .filter( - (node, index, all) => !(node.level === 'continent' && all.length > 1), - ) - .map((node) => node.name ?? node.slug ?? '') - .filter((name) => name !== ''); - return names.reverse().join(', '); -} diff --git a/src/app/services/feeds/index.ts b/src/app/services/feeds/index.ts index edd343d5..3a9644b8 100644 --- a/src/app/services/feeds/index.ts +++ b/src/app/services/feeds/index.ts @@ -352,8 +352,8 @@ export type SearchFeedsResponse = paths['/v1/search']['get']['responses'][200]['content']['application/json']; /** - * Also lists the feeds of a location directory node, with its location - * parameters and `sort: 'location'`. + * Also lists the feeds of a location directory page, with `continent` or + * `location_id` and `sort: 'location'`. */ export const searchFeeds = async ( params: AllFeedsParams, @@ -370,29 +370,29 @@ export const searchFeeds = async ( }); }; -export type LocationDirectoryParams = NonNullable< - paths['/v1/locations/directory']['get']['parameters']['query'] +export type LocationsParams = NonNullable< + paths['/v1/locations']['get']['parameters']['query'] >; -export type LocationDirectoryResponse = - paths['/v1/locations/directory']['get']['responses'][200]['content']['application/json']; +export type LocationsResponse = + paths['/v1/locations']['get']['responses'][200]['content']['application/json']; /** - * One level of the feed location directory: the node's breadcrumb, feed - * counts and children. Throws an `ApiError` with status 400 (invalid - * hierarchy) or 404 (no feed located there). + * Searches locations (continents, countries, subdivisions, municipalities). + * Returns at most 100 results per call: page with `limit` and `offset`. + * Throws an `ApiError` with status 400 on an unsupported `sort`. */ -export const getLocationDirectory = async ( - params: LocationDirectoryParams, +export const getLocations = async ( + params: LocationsParams, accessToken: string, userContextJwt?: string, -): Promise => { +): Promise => { const authMiddleware = generateAuthMiddlewareWithToken( accessToken, userContextJwt, ); return await withAuthMiddleware(authMiddleware, async () => { - const response = await client.GET('/v1/locations/directory', { + const response = await client.GET('/v1/locations', { params: { query: params }, }); return response.data; diff --git a/src/app/services/feeds/types.ts b/src/app/services/feeds/types.ts index 113ed5bd..c95abba7 100644 --- a/src/app/services/feeds/types.ts +++ b/src/app/services/feeds/types.ts @@ -334,11 +334,12 @@ export interface paths { *

    * Location filters: *
    - * - `continent`, `country`, `subdivision` and `municipality` restrict the results to feeds with a declared - * location within that node of `/v1/locations/directory`, using the same slugs, parent requirements and - * validation. Unknown slugs return no feed. - * - To list the same feeds as a directory node's counts, also pass `status=active,inactive,development,future`. - * - `sort=location` orders the results by relevance to the requested location instead of by creation date. + * - `continent` restricts the results to feeds with stops in that continent, and `location_id` to feeds with + * stops in that location of `/v1/locations`, sub-areas included. Feeds are matched through their + * reverse-geolocated stops, so a feed that was never geolocated does not match. Unknown values return no feed. + * - To list the same feeds as a location's `feed_counts`, also pass `status=active,inactive,development,future`. + * - `sort=location` orders the results by the share of each feed's stops inside the requested location instead + * of by creation date. *
    */ get: operations['searchFeeds']; @@ -357,7 +358,7 @@ export interface paths { path?: never; cookie?: never; }; - /** @description Search locations (countries, subdivisions and municipalities). Results can be filtered by a free-text query and narrowed to a specific country, subdivision or location type. Matches are ordered from the broadest area to the most specific, and by relevance within each level. */ + /** @description Search locations (continents, countries, subdivisions and municipalities). Results can be filtered by a free-text query and narrowed to a specific continent, country, subdivision, parent location, path or location type. Matches are ordered from the broadest area to the most specific, and by relevance within each level, unless `sort=feed_count` is given.
    Feed counts (`include_feed_counts`, `has_feeds`, `sort=feed_count`) count distinct feeds with a status of `active`, `inactive`, `development` or `future` that have reverse-geolocated stops in the location, sub-areas included. Work-in-progress feeds are counted only for callers allowed to see them, as in `/v1/search`. To list a location's feeds, call `/v1/search` with its `location_id` (or `continent` for a continent), `status=active,inactive,development,future` and `sort=location`.
    Continents come from a fixed classification and are not geopolygons. They are only returned with `location_type=continent`; their `location_id`, `parent_location_id` and ISO fields are null, `slug` and `continent_slug` both hold the continent slug, and `path_names` and `path_slugs` are empty. */ get: operations['getLocations']; put?: never; post?: never; @@ -367,23 +368,6 @@ export interface paths { patch?: never; trace?: never; }; - '/v1/locations/directory': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** @description Browse the feed location directory one level at a time: continent > country > subdivision > municipality. The level is selected by the most specific parameter provided; with no parameters the response lists continents.
    Feed counts are based on the feeds' declared locations, only include feeds with a status of `active`, `inactive`, `development` or `future`, and roll up to every ancestor level (a feed located in Montréal counts toward Québec, Canada and North America). A feed is counted at most once per node, even when it declares several locations within that node.
    Each parameter requires its parent: `country` requires `continent`, `subdivision` requires `country`, and `municipality` requires `subdivision`.
    To list the feeds of a node, call `/v1/search` with the same parameters, `status=active,inactive,development,future` and `sort=location`. */ - get: operations['getLocationDirectory']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; '/v1/licenses': { parameters: { query?: never; @@ -1333,24 +1317,8 @@ export interface components { */ municipality?: string; }; - /** - * @description A level of the location directory: - * * `root` - the whole world; its children are continents. - * * `continent` - a continent, derived from the ISO 3166-1 country code. - * * `country` - a country, identified by its ISO 3166-1 alpha-2 code. - * * `subdivision` - a first-level subdivision as declared on the feed (e.g. a state or province). - * * `municipality` - a municipality as declared on the feed. - * @example subdivision - * @enum {string} - */ - LocationDirectoryLevel: - | 'root' - | 'continent' - | 'country' - | 'subdivision' - | 'municipality'; - /** @description Number of distinct feeds per data type within a directory node. */ - LocationDirectoryFeedCounts: { + /** @description Number of distinct feeds per data type with stops in a location, sub-areas included. */ + LocationFeedCounts: { /** @example 42 */ gtfs?: number; /** @example 18 */ @@ -1363,33 +1331,6 @@ export interface components { */ total?: number; }; - LocationDirectoryNode: { - level?: components['schemas']['LocationDirectoryLevel']; - /** - * @description URL-safe identifier of the node, used as the matching request parameter. Continents use a fixed slug (`africa`, `antarctica`, `asia`, `europe`, `north-america`, `oceania`, `south-america`), countries use a fixed slug of their short English name (`portugal`, `united-states`, `cote-d-ivoire`), and subdivisions and municipalities use their name lowercased, without accents, with every run of non-alphanumeric characters replaced by a single hyphen (`Québec` -> `quebec`, `Saint-Jean-sur-Richelieu` -> `saint-jean-sur-richelieu`). - * @example quebec - */ - slug?: string; - /** - * @description The continent code (`AF`, `AN`, `AS`, `EU`, `NA`, `OC`, `SA`) or the ISO 3166-1 alpha-2 country code. Null for subdivisions and municipalities. - * @example null - */ - code?: string | null; - /** - * @description Display name of the node in English. - * @example Québec - */ - name?: string; - feed_counts?: components['schemas']['LocationDirectoryFeedCounts']; - }; - LocationDirectoryResponse: { - level?: components['schemas']['LocationDirectoryLevel']; - /** @description The path from the continent down to the requested node, inclusive. Empty at the `root` level. */ - breadcrumb?: components['schemas']['LocationDirectoryNode'][]; - feed_counts?: components['schemas']['LocationDirectoryFeedCounts']; - /** @description The nodes one level below the requested node that contain at least one feed, sorted by name. Empty at the `municipality` level. */ - children?: components['schemas']['LocationDirectoryNode'][]; - }; LocationSearchResponse: { /** @description The total number of matching locations regardless of limit and offset. */ total?: number; @@ -1398,12 +1339,12 @@ export interface components { }; LocationSearchResult: { /** - * @description Stable location identifier. + * @description Stable location identifier. Null for continents. * @example 175905 */ - location_id?: number; + location_id?: number | null; /** - * @description Stable identifier of the nearest containing location. + * @description Stable identifier of the nearest containing location. Null for continents and countries. * @example 161950 */ parent_location_id?: number | null; @@ -1418,11 +1359,42 @@ export interface components { */ alt_name?: string | null; /** - * @description The type of location: `country` (has an ISO 3166-1 code), `subdivision` (has an ISO 3166-2 code) or `municipality` (a locality below the subdivision level). + * @description The type of location: `continent`, `country` (has an ISO 3166-1 code), `subdivision` (has an ISO 3166-2 code) or `municipality` (a locality below the subdivision level). * @example municipality * @enum {string} */ - location_type?: 'country' | 'subdivision' | 'municipality'; + location_type?: 'continent' | 'country' | 'subdivision' | 'municipality'; + /** + * @description Code of the continent that contains this location (`AF`, `AN`, `AS`, `EU`, `NA`, `OC`, `SA`). Null when the country has no continent in the classification. + * @example NA + */ + continent_code?: string | null; + /** + * @description URL-safe identifier of the continent, as accepted by the `continent` parameter. + * @example north-america + */ + continent_slug?: string | null; + /** + * @description English name of the continent. + * @example North America + */ + continent_name?: string | null; + /** + * @description URL-safe identifier of the location among its siblings: its name lowercased, without accents, with every run of non-alphanumeric characters replaced by a single hyphen (`Montréal` -> `montreal`). When siblings under the same parent would share a slug, each of them gets `-` appended. A name without any ASCII letter or digit uses its `location_id` as the slug. + * @example montreal + */ + slug?: string | null; + /** + * @description The slugs of the locations in `path_names`, in the same order; joined with `/` they form the `path` parameter. Empty for continents. + * @example [ + * "canada", + * "quebec", + * "montreal-region", + * "montreal" + * ] + */ + path_slugs?: string[]; + feed_counts?: components['schemas']['LocationFeedCounts']; /** * @description The name of the country that contains this location. * @example Canada @@ -1904,14 +1876,12 @@ export interface components { is_official_query_param: boolean; /** @description If true, only return feeds that currently hold the Seal of Reliability; if false, only feeds without it. Viewing a feed's seal is public, but filtering the catalogue by it is granted per user and requires the `isSealFilterEnabled` feature flag - other callers receive a 403. To request access or learn more, contact us at api@mobilitydata.org. */ has_seal_query_param: boolean; - /** @description Continent slug (`africa`, `antarctica`, `asia`, `europe`, `north-america`, `oceania`, `south-america`). */ - location_directory_continent: string; - /** @description Country slug as returned by `/v1/locations/directory`, e.g. `portugal`. Case-insensitive. Requires `continent`. */ - location_directory_country: string; - /** @description Subdivision slug as returned by `/v1/locations/directory`. Requires `country`. */ - location_directory_subdivision: string; - /** @description Municipality slug as returned by `/v1/locations/directory`. Requires `subdivision`. */ - location_directory_municipality: string; + /** @description Continent slug (`africa`, `antarctica`, `asia`, `europe`, `north-america`, `oceania`, `south-america`) or code (`AF`, `AN`, `AS`, `EU`, `NA`, `OC`, `SA`). Case-insensitive. */ + location_continent: string; + /** @description A `location_id` from `/v1/locations`: only feeds with reverse-geolocated stops in that location, sub-areas included. */ + location_id_query_param: number; + /** @description Sort order of the results. `admin_level` (default): from the broadest area to the most specific, then by relevance to `search_query` when one is given, then by display name. `feed_count`: most feeds first, then by display name. */ + locations_sort: 'admin_level' | 'feed_count'; /** @description The number of items to be returned. */ limit_query_param_locations_endpoint: number; /** @description The number of items to be returned. */ @@ -1932,7 +1902,7 @@ export interface components { offset: number; /** @description General search query to match against transit provider, location, and feed name. */ search_text_query_param: string; - /** @description Sort order of the results. `created_at` (default): newest feeds first, then by relevance to `search_query` when one is given. `location`: by relevance to the requested location, which requires `continent`. Feeds whose most specific matching declared location is closest to the requested level come first (when filtering on Québec, a feed located in "Canada, Québec" ranks above one located in "Canada, Québec, Montréal"); ties are broken by provider, feed name and feed id. */ + /** @description Sort order of the results. `created_at` (default): newest feeds first, then by relevance to `search_query` when one is given. `location`: by relevance to the requested location, which requires `continent` or `location_id`. Feeds with the largest share of their reverse-geolocated stops inside the location come first (on Montréal, a city bus feed with all its stops in Montréal ranks above a regional train with a fifth of them there); ties are broken by provider, feed name and feed id. */ search_sort: 'created_at' | 'location'; /** @description Comma separated list of GBFS versions to filter by. */ version_query_param: string; @@ -2584,15 +2554,11 @@ export interface operations { license_is_spdx?: components['parameters']['license_is_spdx']; /** @description Comma separated list of tags to filter feeds by their license tags. */ license_tags?: components['parameters']['license_tags']; - /** @description Continent slug (`africa`, `antarctica`, `asia`, `europe`, `north-america`, `oceania`, `south-america`). */ - continent?: components['parameters']['location_directory_continent']; - /** @description Country slug as returned by `/v1/locations/directory`, e.g. `portugal`. Case-insensitive. Requires `continent`. */ - country?: components['parameters']['location_directory_country']; - /** @description Subdivision slug as returned by `/v1/locations/directory`. Requires `country`. */ - subdivision?: components['parameters']['location_directory_subdivision']; - /** @description Municipality slug as returned by `/v1/locations/directory`. Requires `subdivision`. */ - municipality?: components['parameters']['location_directory_municipality']; - /** @description Sort order of the results. `created_at` (default): newest feeds first, then by relevance to `search_query` when one is given. `location`: by relevance to the requested location, which requires `continent`. Feeds whose most specific matching declared location is closest to the requested level come first (when filtering on Québec, a feed located in "Canada, Québec" ranks above one located in "Canada, Québec, Montréal"); ties are broken by provider, feed name and feed id. */ + /** @description Continent slug (`africa`, `antarctica`, `asia`, `europe`, `north-america`, `oceania`, `south-america`) or code (`AF`, `AN`, `AS`, `EU`, `NA`, `OC`, `SA`). Case-insensitive. */ + continent?: components['parameters']['location_continent']; + /** @description A `location_id` from `/v1/locations`: only feeds with reverse-geolocated stops in that location, sub-areas included. */ + location_id?: components['parameters']['location_id_query_param']; + /** @description Sort order of the results. `created_at` (default): newest feeds first, then by relevance to `search_query` when one is given. `location`: by relevance to the requested location, which requires `continent` or `location_id`. Feeds with the largest share of their reverse-geolocated stops inside the location come first (on Montréal, a city bus feed with all its stops in Montréal ranks above a regional train with a fifth of them there); ties are broken by provider, feed name and feed id. */ sort?: components['parameters']['search_sort']; }; header?: never; @@ -2614,7 +2580,7 @@ export interface operations { }; }; }; - /** @description A location parameter was provided without its parent, the country does not belong to the continent, or `sort=location` was requested without a location. */ + /** @description `sort=location` was requested without `continent` or `location_id`, or `sort` has an unsupported value. */ 400: { headers: { [name: string]: unknown; @@ -2645,11 +2611,28 @@ export interface operations { subdivision_code?: string; /** * @description Filter by the type of location: + * * `continent` - a continent, from a fixed country-to-continent classification. * * `country` - a sovereign country, identified by an ISO 3166-1 code. * * `subdivision` - a first-level subdivision (e.g. state or province), identified by an ISO 3166-2 code. * * `municipality` - a locality below the subdivision level (e.g. a city or town). */ - location_type?: 'country' | 'subdivision' | 'municipality'; + location_type?: + | 'continent' + | 'country' + | 'subdivision' + | 'municipality'; + /** @description Continent slug (`africa`, `antarctica`, `asia`, `europe`, `north-america`, `oceania`, `south-america`) or code (`AF`, `AN`, `AS`, `EU`, `NA`, `OC`, `SA`). Case-insensitive. */ + continent?: components['parameters']['location_continent']; + /** @description Limit results to the direct children of this location (the next level down). */ + parent_location_id?: number; + /** @description Return the location at this path: its `path_slugs` joined with `/`, from the country down, e.g. `canada/quebec/montreal`. Case-insensitive. Returns no result when no location has that path. */ + path?: string; + /** @description When true, only return locations with at least one feed (see the feed count rules above). */ + has_feeds?: boolean; + /** @description When true, include `feed_counts` in each result. */ + include_feed_counts?: boolean; + /** @description Sort order of the results. `admin_level` (default): from the broadest area to the most specific, then by relevance to `search_query` when one is given, then by display name. `feed_count`: most feeds first, then by display name. */ + sort?: components['parameters']['locations_sort']; }; header?: never; path?: never; @@ -2666,49 +2649,13 @@ export interface operations { 'application/json': components['schemas']['LocationSearchResponse']; }; }; - }; - }; - getLocationDirectory: { - parameters: { - query?: { - /** @description Continent slug (`africa`, `antarctica`, `asia`, `europe`, `north-america`, `oceania`, `south-america`). */ - continent?: components['parameters']['location_directory_continent']; - /** @description Country slug as returned by `/v1/locations/directory`, e.g. `portugal`. Case-insensitive. Requires `continent`. */ - country?: components['parameters']['location_directory_country']; - /** @description Subdivision slug as returned by `/v1/locations/directory`. Requires `country`. */ - subdivision?: components['parameters']['location_directory_subdivision']; - /** @description Municipality slug as returned by `/v1/locations/directory`. Requires `subdivision`. */ - municipality?: components['parameters']['location_directory_municipality']; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description The requested directory node, its breadcrumb and its children. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['LocationDirectoryResponse']; - }; - }; - /** @description A parameter was provided without its parent, or the country does not belong to the continent. */ + /** @description `sort` has an unsupported value. */ 400: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description No feed is located at the requested location. */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; }; }; getLicenses: { diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index da4a4ccf..9225ba1d 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -8,31 +8,25 @@ import feed2947 from '../../cypress/fixtures/feed_mdb-2947.json'; import gtfsFeed2947 from '../../cypress/fixtures/gtfs_feed_mdb-2947.json'; import datasets2947 from '../../cypress/fixtures/feed_datasets_mdb-2947.json'; import { - getMockDirectory, - getMockDirectoryFeeds, - readDirectoryQuery, + getMockLocationFeeds, + getMockLocations, + isLocationSearch, } from './locationDirectory'; export const handlers = [ - // Location directory: breadcrumb, counts and children of a node - http.get(`*/v1/locations/directory`, ({ request }) => { + // Locations: continents, children by parent, path lookup, feed counts + http.get(`*/v1/locations`, ({ request }) => { const params = new URL(request.url).searchParams; - const { status, body } = getMockDirectory(readDirectoryQuery(params)); + const { status, body } = getMockLocations(params); return HttpResponse.json(body, { status }); }), - // Mock search endpoint. With location parameters (location directory - // pages): the feeds of that node, paginated and relevance-sorted. + // Mock search endpoint. With `continent` or `location_id` (location + // directory pages): the feeds located there, paginated and sorted. // Otherwise: a list including test-516 and mdb-2947. http.get(`*/v1/search`, ({ request }) => { const params = new URL(request.url).searchParams; - if (params.has('continent') || params.get('sort') === 'location') { - const limit = Number(params.get('limit') ?? '20'); - const offset = Number(params.get('offset') ?? '0'); - const { status, body } = getMockDirectoryFeeds( - readDirectoryQuery(params), - Number.isFinite(limit) ? limit : 20, - Number.isFinite(offset) ? offset : 0, - ); + if (isLocationSearch(params)) { + const { status, body } = getMockLocationFeeds(params); return HttpResponse.json(body, { status }); } return HttpResponse.json({ diff --git a/src/mocks/locationDirectory.spec.ts b/src/mocks/locationDirectory.spec.ts index 6ccc6bb5..4f632e67 100644 --- a/src/mocks/locationDirectory.spec.ts +++ b/src/mocks/locationDirectory.spec.ts @@ -1,10 +1,37 @@ import { - getMockDirectory, - getMockDirectoryFeeds, - readDirectoryQuery, + getMockLocationFeeds, + getMockLocations, + isLocationSearch, slugify, } from './locationDirectory'; +type Locations = ReturnType; +type Feeds = ReturnType; + +function locations( + query: string, +): NonNullable['body']> { + const result = getMockLocations(new URLSearchParams(query)); + if (result.status !== 200) throw new Error(`expected 200 for ${query}`); + return result.body; +} + +function feeds(query: string): Extract['body'] { + const result = getMockLocationFeeds(new URLSearchParams(query)); + if (result.status !== 200) throw new Error(`expected 200 for ${query}`); + return result.body; +} + +function at( + path: string, +): NonNullable['results']>[number] { + const [location] = + locations( + `continent=north-america&path=${path}&include_feed_counts=true&limit=1`, + ).results ?? []; + return location; +} + describe('location directory mocks', () => { it('slugifies names like the API', () => { expect(slugify('Québec')).toBe('quebec'); @@ -14,64 +41,101 @@ describe('location directory mocks', () => { expect(slugify('Île-de-France')).toBe('ile-de-france'); }); - it('lists continents at the root', () => { - const result = getMockDirectory({}); - expect(result.status).toBe(200); - if (result.status !== 200) return; - expect(result.body.level).toBe('root'); - expect(result.body.breadcrumb).toEqual([]); - expect(result.body.children?.map((node) => node.slug)).toEqual([ + it('lists continents with counts, empty ones included', () => { + const { results } = locations( + 'location_type=continent&include_feed_counts=true', + ); + expect(results?.map((row) => row.continent_slug)).toEqual([ 'africa', + 'antarctica', 'europe', 'north-america', 'oceania', ]); + const antarctica = results?.find((row) => row.slug === 'antarctica'); + expect(antarctica?.location_id).toBeNull(); + expect(antarctica?.path_slugs).toEqual([]); + expect(antarctica?.feed_counts?.total).toBe(0); }); - it('resolves the Montréal breadcrumb and rolls counts up', () => { - const query = readDirectoryQuery( - new URLSearchParams( - 'continent=north-america&country=canada&subdivision=quebec&municipality=montreal', - ), + it('lists the countries of a continent with feeds, most feeds first', () => { + const { results } = locations( + 'continent=NA&location_type=country&has_feeds=true&include_feed_counts=true&sort=feed_count', ); - const result = getMockDirectory(query); - expect(result.status).toBe(200); - if (result.status !== 200) return; - expect(result.body.breadcrumb?.map((node) => node.name)).toEqual([ - 'North America', - 'Canada', - 'Québec', - 'Montréal', + expect(results?.map((row) => row.path_slugs)).toEqual([ + ['canada'], + ['united-states'], ]); - expect(result.body.children).toEqual([]); - const montreal = result.body.feed_counts?.total ?? 0; - const quebec = result.body.breadcrumb?.[2].feed_counts?.total ?? 0; - expect(montreal).toBeGreaterThan(20); - expect(quebec).toBeGreaterThan(montreal); + expect(results?.[0].feed_counts?.total).toBeGreaterThan( + results?.[1].feed_counts?.total ?? 0, + ); }); - it('returns 400 on a broken hierarchy and 404 when nothing is there', () => { - expect(getMockDirectory({ country: 'canada' }).status).toBe(400); + it('resolves a path and rolls counts up', () => { + const montreal = at('canada/quebec/montreal'); + expect(montreal.path_names).toEqual(['Canada', 'Québec', 'Montréal']); + expect(montreal.feed_counts?.total).toBe(22); + expect(at('canada/quebec').feed_counts?.total).toBeGreaterThan(22); + expect(at('canada/quebec/nowhere')).toBeUndefined(); + expect(locations('continent=europe&path=canada').results).toEqual([]); + }); + + it('has a variable depth and collision-suffixed slugs', () => { + const losAngeles = at( + 'united-states/california/los-angeles-county/los-angeles', + ); + expect(losAngeles.path_names).toHaveLength(4); expect( - getMockDirectory({ continent: 'europe', country: 'canada' }).status, - ).toBe(400); - expect(getMockDirectory({ continent: 'antarctica' }).status).toBe(404); + at('united-states/california/san-francisco').path_slugs, + ).toHaveLength(3); + const ohio = at('united-states/ohio'); + const { results } = locations( + `parent_location_id=${ohio.location_id}&has_feeds=true`, + ); + expect(results?.map((row) => row.slug)).toEqual([ + 'washington-township-123456', + 'washington-township-123457', + ]); + }); + + it('pages children and rejects unsupported parameters', () => { + const quebec = at('canada/quebec'); + const all = locations(`parent_location_id=${quebec.location_id}`); + const second = locations( + `parent_location_id=${quebec.location_id}&limit=2&offset=2`, + ); + expect(second.total).toBe(all.total); + expect(second.results).toEqual(all.results?.slice(2, 4)); + expect(all.results?.[0].feed_counts).toBeUndefined(); + expect(getMockLocations(new URLSearchParams('sort=name')).status).toBe(400); + expect(getMockLocations(new URLSearchParams('limit=101')).status).toBe(400); }); - it('paginates feeds, most relevant to the node first', () => { - const query = { - continent: 'north-america', - country: 'canada', - subdivision: 'quebec', - }; - const first = getMockDirectoryFeeds(query, 20, 0); - const second = getMockDirectoryFeeds(query, 20, 20); - if (first.status !== 200 || second.status !== 200) { - throw new Error('expected 200'); - } - expect(first.body.results).toHaveLength(20); - expect(second.body.results?.length).toBe((first.body.total ?? 0) - 20); - // Feeds declared at the Québec level outrank municipality-level ones. - expect(first.body.results?.[0].locations?.[0].municipality).toBeUndefined(); + it('routes location searches only', () => { + expect(isLocationSearch(new URLSearchParams('location_id=1'))).toBe(true); + expect(isLocationSearch(new URLSearchParams('continent=europe'))).toBe( + true, + ); + expect(isLocationSearch(new URLSearchParams('status=active'))).toBe(false); + }); + + it('paginates the feeds of a location, most relevant first', () => { + const montreal = at('canada/quebec/montreal'); + const query = `location_id=${montreal.location_id}&sort=location&limit=20`; + const first = feeds(query); + const second = feeds(`${query}&offset=20`); + expect(first.total).toBe(22); + expect(first.results).toHaveLength(20); + expect(second.results).toHaveLength(2); + // The commuter train also serves Laval and Longueuil: least relevant. + expect(second.results[1].feed_name).toBe('Commuter trains'); + }); + + it('lists the feeds of a continent and rejects sort=location alone', () => { + expect(feeds('continent=europe&sort=location').total).toBe(3); + expect(feeds('continent=asia').total).toBe(0); + expect( + getMockLocationFeeds(new URLSearchParams('sort=location')).status, + ).toBe(400); }); }); diff --git a/src/mocks/locationDirectory.ts b/src/mocks/locationDirectory.ts index 56d6601f..04ec71e4 100644 --- a/src/mocks/locationDirectory.ts +++ b/src/mocks/locationDirectory.ts @@ -1,68 +1,165 @@ /** * Mock data and logic for the location directory endpoints: - * GET /v1/locations/directory - * GET /v1/search with location parameters (the feeds of a node) + * GET /v1/locations (continents and geopolygon locations) + * GET /v1/search with `continent` / `location_id` (the feeds of a location) * - * The directory tree, the rolled-up counts, the breadcrumb and the children - * are all derived from MOCK_FEEDS' declared locations, so the mock behaves - * like the API: a feed counts once per node, 400 on a broken hierarchy, 404 - * when nothing is located there, feeds sorted by relevance to the node. - * Feed ids, URLs and names are fictional mock data. + * Locations form a tree of variable depth (Los Angeles sits under a county, + * San Francisco directly under California). Feed counts, `has_feeds` and the + * `sort=location` relevance are derived from MOCK_FEEDS' stop locations, so + * the mock behaves like the API: a feed counts once per location, sub-areas + * included. Location ids, feed ids, URLs and names are fictional mock data. */ import type { components } from '../app/services/feeds/types'; type Feed = components['schemas']['SearchFeedItemResult']; -type DirectoryNode = components['schemas']['LocationDirectoryNode']; -type DirectoryLevel = components['schemas']['LocationDirectoryLevel']; -type FeedCounts = components['schemas']['LocationDirectoryFeedCounts']; -type DirectoryResponse = components['schemas']['LocationDirectoryResponse']; +type LocationResult = components['schemas']['LocationSearchResult']; +type FeedCounts = components['schemas']['LocationFeedCounts']; +type LocationsResponse = components['schemas']['LocationSearchResponse']; interface FeedsResponse { total: number; results: Feed[]; } -const CONTINENTS: Record = { - africa: { code: 'AF', name: 'Africa' }, - europe: { code: 'EU', name: 'Europe' }, - 'north-america': { code: 'NA', name: 'North America' }, - oceania: { code: 'OC', name: 'Oceania' }, -}; - -const COUNTRIES: Record = { - AU: { name: 'Australia', continent: 'oceania' }, - CA: { name: 'Canada', continent: 'north-america' }, - DE: { name: 'Germany', continent: 'europe' }, - FR: { name: 'France', continent: 'europe' }, - US: { name: 'United States', continent: 'north-america' }, - ZA: { name: 'South Africa', continent: 'africa' }, -}; +interface Continent { + slug: string; + code: string; + name: string; +} + +const CONTINENTS: Continent[] = [ + { slug: 'africa', code: 'AF', name: 'Africa' }, + { slug: 'antarctica', code: 'AN', name: 'Antarctica' }, + { slug: 'europe', code: 'EU', name: 'Europe' }, + { slug: 'north-america', code: 'NA', name: 'North America' }, + { slug: 'oceania', code: 'OC', name: 'Oceania' }, +]; + +type PlaceType = 'country' | 'subdivision' | 'municipality'; + +interface Place { + id: number; + parent: number | null; + type: PlaceType; + name: string; + /** ISO 3166-1 (countries) or ISO 3166-2 (subdivisions) code. */ + code?: string; + /** Countries only. */ + continent?: string; +} + +const country = ( + id: number, + name: string, + code: string, + continent: string, +): Place => ({ id, parent: null, type: 'country', name, code, continent }); +const subdivision = ( + id: number, + parent: number, + name: string, + code: string, +): Place => ({ id, parent, type: 'subdivision', name, code }); +const municipality = (id: number, parent: number, name: string): Place => ({ + id, + parent, + type: 'municipality', + name, +}); + +export const MOCK_PLACES: Place[] = [ + country(100, 'Canada', 'CA', 'north-america'), + subdivision(110, 100, 'Québec', 'CA-QC'), + municipality(111, 110, 'Montréal'), + municipality(112, 110, 'Laval'), + municipality(113, 110, 'Longueuil'), + municipality(114, 110, 'Québec'), + subdivision(120, 100, 'Ontario', 'CA-ON'), + municipality(121, 120, 'Toronto'), + subdivision(130, 100, 'British Columbia', 'CA-BC'), + municipality(131, 130, 'Vancouver'), + country(200, 'United States', 'US', 'north-america'), + subdivision(210, 200, 'California', 'US-CA'), + // Extra intermediate level: county > city. + municipality(211, 210, 'Los Angeles County'), + municipality(212, 211, 'Los Angeles'), + municipality(213, 211, 'Pasadena'), + municipality(214, 210, 'San Francisco'), + subdivision(220, 200, 'New York', 'US-NY'), + municipality(221, 220, 'New York'), + subdivision(230, 200, 'Ohio', 'US-OH'), + // Siblings sharing a name: both slugs get `-`. + municipality(123456, 230, 'Washington Township'), + municipality(123457, 230, 'Washington Township'), + country(300, 'France', 'FR', 'europe'), + subdivision(310, 300, 'Île-de-France', 'FR-IDF'), + municipality(311, 310, 'Paris'), + country(400, 'Germany', 'DE', 'europe'), + subdivision(410, 400, 'Berlin', 'DE-BE'), + municipality(411, 410, 'Berlin'), + country(500, 'Australia', 'AU', 'oceania'), + subdivision(510, 500, 'New South Wales', 'AU-NSW'), + municipality(511, 510, 'Sydney'), + country(600, 'South Africa', 'ZA', 'africa'), + subdivision(610, 600, 'Western Cape', 'ZA-WC'), + municipality(611, 610, 'Cape Town'), +]; + +const PLACES_BY_ID = new Map(MOCK_PLACES.map((place) => [place.id, place])); /** Same slug rule as the API: `Québec` -> `quebec`. */ export function slugify(name: string): string { return name .normalize('NFD') - .replace(/[̀-ͯ]/g, '') + .replace(/\p{Diacritic}/gu, '') .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); + .replace(/[^a-z0-9]+/g, '-'); } -/** ISO code of a country slug (`united-states` -> `US`), like the API. */ -function countryCodeOf(slug: string): string | undefined { - return Object.keys(COUNTRIES).find( - (code) => slugify(COUNTRIES[code].name) === slug.toLowerCase(), +/** Slug among siblings, with `-` when a sibling shares it. */ +function slugOf(place: Place): string { + const slug = slugify(place.name); + const collides = MOCK_PLACES.some( + (other) => + other.id !== place.id && + other.parent === place.parent && + slugify(other.name) === slug, + ); + return collides ? `${slug}-${place.id}` : slug; +} + +/** The place and its ancestors, from the country down. */ +function chainOf(place: Place): Place[] { + const chain: Place[] = []; + let current: Place | undefined = place; + while (current != undefined) { + chain.unshift(current); + current = + current.parent === null ? undefined : PLACES_BY_ID.get(current.parent); + } + return chain; +} + +function continentOf(place: Place): Continent | undefined { + const slug = chainOf(place)[0].continent; + return CONTINENTS.find((continent) => continent.slug === slug); +} + +function isWithin(placeId: number, ancestorId: number): boolean { + const place = PLACES_BY_ID.get(placeId); + return ( + place != undefined && chainOf(place).some(({ id }) => id === ancestorId) ); } -type Place = [countryCode: string, subdivision?: string, municipality?: string]; type FeedSeed = [ dataType: Feed['data_type'], provider: string, feedName: string, - places: Place[], + /** One entry per group of stops, all of equal weight. */ + stopPlaces: number[], ]; -const MONTREAL: Place = ['CA', 'Québec', 'Montréal']; +const MONTREAL = 111; const EXO_SECTORS = [ 'Chambly-Richelieu-Carignan', 'Haut-Saint-Laurent', @@ -80,21 +177,16 @@ const EXO_SECTORS = [ const FEED_SEEDS: FeedSeed[] = [ // Canada-wide - ['gtfs', 'Example Intercity Rail', 'National rail network', [['CA']]], - // Québec (subdivision level only) - ['gtfs', 'Example Coach Lines', 'Intercity coaches', [['CA', 'Québec']]], - ['gtfs_rt', 'Example Coach Lines', 'Trip updates', [['CA', 'Québec']]], - // Montréal: more than one page of feeds + ['gtfs', 'Example Intercity Rail', 'National rail network', [110, 120, 130]], + // Québec, stops not geolocated to a municipality + ['gtfs', 'Example Coach Lines', 'Intercity coaches', [110]], + ['gtfs_rt', 'Example Coach Lines', 'Trip updates', [110]], + // Montréal: 22 feeds, more than one page ['gtfs', 'Société de transport de Montréal', 'Bus and metro', [MONTREAL]], ['gtfs_rt', 'Société de transport de Montréal', 'Trip updates', [MONTREAL]], ['gtfs_rt', 'Société de transport de Montréal', 'Alerts', [MONTREAL]], ['gtfs', 'Réseau express métropolitain', 'REM light metro', [MONTREAL]], - [ - 'gtfs', - 'exo', - 'Commuter trains', - [MONTREAL, ['CA', 'Québec', 'Laval'], ['CA', 'Québec', 'Longueuil']], - ], + ['gtfs', 'exo', 'Commuter trains', [MONTREAL, 112, 113]], ['gtfs_rt', 'exo', 'Commuter trains - trip updates', [MONTREAL]], ...EXO_SECTORS.map( (sector): FeedSeed => ['gtfs', 'exo', `Buses - ${sector}`, [MONTREAL]], @@ -104,441 +196,341 @@ const FEED_SEEDS: FeedSeed[] = [ ['gbfs', 'Example Car Share', 'Montréal car share', [MONTREAL]], ['gtfs_rt', 'exo', 'Commuter trains - vehicle positions', [MONTREAL]], // Elsewhere in Québec - [ - 'gtfs', - 'Société de transport de Laval', - 'Buses', - [['CA', 'Québec', 'Laval']], - ], - [ - 'gtfs', - 'Réseau de transport de Longueuil', - 'Buses', - [['CA', 'Québec', 'Longueuil']], - ], - [ - 'gtfs', - 'Réseau de transport de la Capitale', - 'Buses', - [['CA', 'Québec', 'Québec']], - ], - [ - 'gtfs_rt', - 'Réseau de transport de la Capitale', - 'Vehicle positions', - [['CA', 'Québec', 'Québec']], - ], - // Ontario - [ - 'gtfs', - 'Example Regional Transit', - 'Regional rail and buses', - [['CA', 'Ontario']], - ], - [ - 'gtfs', - 'Toronto Transit Commission', - 'Surface and subway', - [['CA', 'Ontario', 'Toronto']], - ], - [ - 'gtfs_rt', - 'Toronto Transit Commission', - 'Trip updates', - [['CA', 'Ontario', 'Toronto']], - ], - ['gbfs', 'Bike Share Toronto', 'Bike share', [['CA', 'Ontario', 'Toronto']]], - // British Columbia - [ - 'gtfs', - 'Example Coast Transit', - 'Buses and SkyTrain', - [['CA', 'British Columbia', 'Vancouver']], - ], + ['gtfs', 'Société de transport de Laval', 'Buses', [112]], + ['gtfs', 'Réseau de transport de Longueuil', 'Buses', [113]], + ['gtfs', 'Réseau de transport de la Capitale', 'Buses', [114]], + ['gtfs_rt', 'Réseau de transport de la Capitale', 'Vehicle positions', [114]], + // Ontario and British Columbia + ['gtfs', 'Example Regional Transit', 'Regional rail and buses', [120, 121]], + ['gtfs', 'Toronto Transit Commission', 'Surface and subway', [121]], + ['gtfs_rt', 'Toronto Transit Commission', 'Trip updates', [121]], + ['gbfs', 'Bike Share Toronto', 'Bike share', [121]], + ['gtfs', 'Example Coast Transit', 'Buses and SkyTrain', [131]], // United States - [ - 'gtfs', - 'Example Metro Los Angeles', - 'Bus', - [['US', 'California', 'Los Angeles']], - ], - [ - 'gtfs', - 'Example Bay Area Rail', - 'Rapid transit', - [ - ['US', 'California', 'San Francisco'], - ['US', 'California', 'Oakland'], - ], - ], - [ - 'gtfs_rt', - 'Example Bay Area Rail', - 'Trip updates', - [['US', 'California', 'San Francisco']], - ], - [ - 'gbfs', - 'Example Bikes SF', - 'Bike share', - [['US', 'California', 'San Francisco']], - ], - [ - 'gtfs', - 'Example Metropolitan Transit', - 'Subway', - [['US', 'New York', 'New York']], - ], - [ - 'gtfs_rt', - 'Example Metropolitan Transit', - 'Subway trip updates', - [['US', 'New York', 'New York']], - ], + ['gtfs', 'Example Metro Los Angeles', 'Bus', [212, 213]], + ['gtfs_rt', 'Example Metro Los Angeles', 'Trip updates', [212]], + ['gtfs', 'Example County Transit', 'County buses', [211]], + ['gtfs', 'Example Bay Area Rail', 'Rapid transit', [214]], + ['gbfs', 'Example Bikes SF', 'Bike share', [214]], + ['gtfs', 'Example Metropolitan Transit', 'Subway', [221]], + ['gtfs_rt', 'Example Metropolitan Transit', 'Subway trip updates', [221]], + ['gtfs', 'Example Township Shuttle', 'Shuttle', [123456]], + ['gtfs', 'Example Township Dial-a-Ride', 'Dial-a-ride', [123457]], // Europe - [ - 'gtfs', - 'Example Île-de-France Mobilités', - 'Regional network', - [['FR', 'Île-de-France', 'Paris']], - ], - [ - 'gbfs', - 'Example Vélo Paris', - 'Bike share', - [['FR', 'Île-de-France', 'Paris']], - ], - [ - 'gtfs', - 'Example Verkehrsverbund Berlin', - 'Regional network', - [['DE', 'Berlin', 'Berlin']], - ], + ['gtfs', 'Example Île-de-France Mobilités', 'Regional network', [311]], + ['gbfs', 'Example Vélo Paris', 'Bike share', [311]], + ['gtfs', 'Example Verkehrsverbund Berlin', 'Regional network', [411]], // Oceania - [ - 'gtfs', - 'Example Transport NSW', - 'Sydney network', - [['AU', 'New South Wales', 'Sydney']], - ], - [ - 'gtfs_rt', - 'Example Transport NSW', - 'Trip updates', - [['AU', 'New South Wales', 'Sydney']], - ], + ['gtfs', 'Example Transport NSW', 'Sydney network', [511]], + ['gtfs_rt', 'Example Transport NSW', 'Trip updates', [511]], // Africa - [ - 'gtfs', - 'Example Cape Town Bus', - 'Bus rapid transit', - [['ZA', 'Western Cape', 'Cape Town']], - ], + ['gtfs', 'Example Cape Town Bus', 'Bus rapid transit', [611]], ]; -function toFeed( - [dataType, provider, feedName, places]: FeedSeed, +interface MockFeed { + feed: Feed; + stopPlaces: number[]; +} + +function declaredLocation( + placeId: number, +): NonNullable[number] { + const chain = chainOf(PLACES_BY_ID.get(placeId) as Place); + const municipalities = chain.filter(({ type }) => type === 'municipality'); + return { + country_code: chain[0].code, + country: chain[0].name, + subdivision_name: chain.find(({ type }) => type === 'subdivision')?.name, + municipality: municipalities[municipalities.length - 1]?.name, + }; +} + +function toMockFeed( + [dataType, provider, feedName, stopPlaces]: FeedSeed, index: number, -): Feed { +): MockFeed { const id = `mock-loc-${String(index + 1).padStart(3, '0')}`; return { - id, - data_type: dataType, - status: 'active', - official: index % 3 === 0, - seasonal: false, - provider, - feed_name: feedName, - external_ids: [{ external_id: id, source: 'mdb' }], - source_info: { - producer_url: `https://example.com/${id}/${dataType}`, - authentication_type: 0, + stopPlaces, + feed: { + id, + data_type: dataType, + status: 'active', + official: index % 3 === 0, + seasonal: false, + provider, + feed_name: feedName, + external_ids: [{ external_id: id, source: 'mdb' }], + source_info: { + producer_url: `https://example.com/${id}/${dataType}`, + authentication_type: 0, + }, + locations: stopPlaces.map(declaredLocation), + entity_types: dataType === 'gtfs_rt' ? ['tu', 'vp'] : undefined, + versions: dataType === 'gbfs' ? ['2.3'] : undefined, }, - locations: places.map(([countryCode, subdivision, municipality]) => ({ - country_code: countryCode, - country: COUNTRIES[countryCode].name, - subdivision_name: subdivision, - municipality, - })), - entity_types: dataType === 'gtfs_rt' ? ['tu', 'vp'] : undefined, - versions: dataType === 'gbfs' ? ['2.3'] : undefined, }; } -export const MOCK_FEEDS: Feed[] = FEED_SEEDS.map(toFeed); +const MOCK_FEEDS: MockFeed[] = FEED_SEEDS.map(toMockFeed); -export interface DirectoryQuery { +/** Where to look for feeds: a continent slug and/or a location id. */ +interface FeedScope { continent?: string; - /** Country slug, e.g. `canada`. */ - country?: string; - subdivision?: string; - municipality?: string; + locationId?: number; } -type FeedLocation = NonNullable[number]; - -/** Depth of a declared location: 1 country, 2 subdivision, 3 municipality. */ -function locationDepth(location: FeedLocation): number { - if (location.municipality != undefined) return 3; - if (location.subdivision_name != undefined) return 2; - return 1; -} - -function matches(location: FeedLocation, query: DirectoryQuery): boolean { - const country = COUNTRIES[location.country_code ?? '']; - if (country == undefined) return false; - if (query.continent != undefined && country.continent !== query.continent) - return false; - if ( - query.country != undefined && - slugify(country.name) !== query.country.toLowerCase() - ) - return false; - if ( - query.subdivision != undefined && - slugify(location.subdivision_name ?? '') !== query.subdivision - ) - return false; - if ( - query.municipality != undefined && - slugify(location.municipality ?? '') !== query.municipality - ) - return false; - return true; +/** Share of a feed's stop groups inside the scope, 0 when outside. */ +function shareIn({ stopPlaces }: MockFeed, scope: FeedScope): number { + const inside = stopPlaces.filter((placeId) => { + const place = PLACES_BY_ID.get(placeId) as Place; + if ( + scope.continent != undefined && + continentOf(place)?.slug !== scope.continent + ) { + return false; + } + return scope.locationId == undefined || isWithin(placeId, scope.locationId); + }); + return inside.length / stopPlaces.length; } -function feedsIn(query: DirectoryQuery): Feed[] { - return MOCK_FEEDS.filter((feed) => - (feed.locations ?? []).some((location) => matches(location, query)), - ); +function feedsIn(scope: FeedScope): MockFeed[] { + return MOCK_FEEDS.filter((feed) => shareIn(feed, scope) > 0); } -function countFeeds(feeds: Feed[]): FeedCounts { +function countFeeds(feeds: MockFeed[]): FeedCounts { + const count = (dataType: Feed['data_type']): number => + feeds.filter(({ feed }) => feed.data_type === dataType).length; return { - gtfs: feeds.filter((feed) => feed.data_type === 'gtfs').length, - gtfs_rt: feeds.filter((feed) => feed.data_type === 'gtfs_rt').length, - gbfs: feeds.filter((feed) => feed.data_type === 'gbfs').length, + gtfs: count('gtfs'), + gtfs_rt: count('gtfs_rt'), + gbfs: count('gbfs'), total: feeds.length, }; } -function levelOf(query: DirectoryQuery): DirectoryLevel { - if (query.municipality != undefined) return 'municipality'; - if (query.subdivision != undefined) return 'subdivision'; - if (query.country != undefined) return 'country'; - if (query.continent != undefined) return 'continent'; - return 'root'; +interface Row { + result: LocationResult; + scope: FeedScope; + depth: number; } -/** 400 when a parameter is missing its parent or the country is elsewhere. */ -function isValidHierarchy(query: DirectoryQuery): boolean { - if (query.country != undefined && query.continent == undefined) return false; - if (query.subdivision != undefined && query.country == undefined) - return false; - if (query.municipality != undefined && query.subdivision == undefined) - return false; - if (query.country != undefined) { - const country = COUNTRIES[countryCodeOf(query.country) ?? '']; - if (country != undefined && country.continent !== query.continent) - return false; - } - return true; +function continentRow(continent: Continent): Row { + return { + depth: 0, + scope: { continent: continent.slug }, + result: { + location_id: null, + parent_location_id: null, + name: continent.name, + alt_name: null, + location_type: 'continent', + continent_code: continent.code, + continent_slug: continent.slug, + continent_name: continent.name, + slug: continent.slug, + path_slugs: [], + path_names: [], + country_name: null, + country_code: null, + subdivision_name: null, + subdivision_code: null, + display_name: continent.name, + }, + }; } -function makeNode( - level: DirectoryLevel, - slug: string, - code: string | null, - name: string, - query: DirectoryQuery, -): DirectoryNode { +function placeRow(place: Place): Row { + const chain = chainOf(place); + const continent = continentOf(place); + const placeSubdivision = chain.find(({ type }) => type === 'subdivision'); return { - level, - slug, - code, - name, - feed_counts: countFeeds(feedsIn(query)), + depth: chain.length, + scope: { locationId: place.id }, + result: { + location_id: place.id, + parent_location_id: place.parent, + name: place.name, + alt_name: null, + location_type: place.type, + continent_code: continent?.code ?? null, + continent_slug: continent?.slug ?? null, + continent_name: continent?.name ?? null, + slug: slugOf(place), + path_slugs: chain.map(slugOf), + path_names: chain.map(({ name }) => name), + country_name: chain[0].name, + country_code: chain[0].code ?? null, + subdivision_name: placeSubdivision?.name ?? null, + subdivision_code: placeSubdivision?.code ?? null, + display_name: chain.map(({ name }) => name).join(', '), + }, }; } -function nodeFor( - query: DirectoryQuery, - displayName?: string, -): DirectoryNode | undefined { - const level = levelOf(query); - switch (level) { - case 'continent': { - const slug = query.continent ?? ''; - const continent = CONTINENTS[slug]; - return continent == undefined - ? undefined - : makeNode(level, slug, continent.code, continent.name, query); - } - case 'country': { - const code = countryCodeOf(query.country ?? ''); - if (code == undefined) return undefined; - const country = COUNTRIES[code]; - return makeNode(level, slugify(country.name), code, country.name, query); - } - case 'subdivision': - return makeNode( - level, - query.subdivision ?? '', - null, - displayName ?? query.subdivision ?? '', - query, - ); - case 'municipality': - return makeNode( - level, - query.municipality ?? '', - null, - displayName ?? query.municipality ?? '', - query, - ); - default: - return undefined; - } -} +const ROWS: Row[] = [ + ...CONTINENTS.map(continentRow), + ...MOCK_PLACES.map(placeRow), +]; -/** Original (accented) names of a subdivision/municipality, by slug. */ -function displayNameOf( - feeds: Feed[], - field: 'subdivision_name' | 'municipality', - slug: string, -): string | undefined { - for (const feed of feeds) { - for (const location of feed.locations ?? []) { - const value = location[field]; - if (value != undefined && slugify(value) === slug) return value; - } - } - return undefined; +export type MockResult = + | { status: 200; body: T } + | { status: 400; body: { detail: string } }; + +const badRequest = (detail: string): MockResult => ({ + status: 400, + body: { detail }, +}); + +function readPaging( + params: URLSearchParams, + maxLimit: number, +): { limit: number; offset: number } | null { + const limit = Number(params.get('limit') ?? String(maxLimit)); + const offset = Number(params.get('offset') ?? '0'); + if (!Number.isInteger(limit) || limit < 0 || limit > maxLimit) return null; + if (!Number.isInteger(offset) || offset < 0) return null; + return { limit, offset }; } -function childrenOf(query: DirectoryQuery): DirectoryNode[] { - const level = levelOf(query); - const children = new Map(); - for (const feed of feedsIn(query)) { - for (const location of feed.locations ?? []) { - if (!matches(location, query)) continue; - const countryCode = location.country_code ?? ''; - let childQuery: DirectoryQuery | undefined; - let name: string | undefined; - if (level === 'root') { - childQuery = { continent: COUNTRIES[countryCode].continent }; - } else if (level === 'continent') { - childQuery = { - ...query, - country: slugify(COUNTRIES[countryCode].name), - }; - } else if (level === 'country' && location.subdivision_name != null) { - name = location.subdivision_name; - childQuery = { ...query, subdivision: slugify(name) }; - } else if (level === 'subdivision' && location.municipality != null) { - name = location.municipality; - childQuery = { ...query, municipality: slugify(name) }; - } - if (childQuery == undefined) continue; - const key = JSON.stringify(childQuery); - if (!children.has(key)) { - const node = nodeFor(childQuery, name); - if (node != undefined) children.set(key, node); - } - } - } - return [...children.values()].sort((a, b) => - (a.name ?? '').localeCompare(b.name ?? ''), +function readContinent(params: URLSearchParams): string | undefined { + const value = params.get('continent')?.toLowerCase(); + if (value == undefined || value === '') return undefined; + // Slug or code, case-insensitive; unknown values match nothing. + return ( + CONTINENTS.find(({ slug, code }) => + [slug, code.toLowerCase()].includes(value), + )?.slug ?? `unknown:${value}` ); } -function breadcrumbOf(query: DirectoryQuery): DirectoryNode[] { - const all = feedsIn(query); - const steps: DirectoryQuery[] = []; - if (query.continent != undefined) steps.push({ continent: query.continent }); - if (query.country != undefined) - steps.push({ ...steps[0], country: query.country }); - if (query.subdivision != undefined) - steps.push({ ...steps[1], subdivision: query.subdivision }); - if (query.municipality != undefined) - steps.push({ ...steps[2], municipality: query.municipality }); - return steps.flatMap((step) => { - const level = levelOf(step); - const name = - level === 'subdivision' - ? displayNameOf(all, 'subdivision_name', step.subdivision ?? '') - : level === 'municipality' - ? displayNameOf(all, 'municipality', step.municipality ?? '') - : undefined; - const node = nodeFor(step, name); - return node == undefined ? [] : [node]; - }); -} +const normalize = (text: string): string => + text + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .toLowerCase(); -export type MockResult = - | { status: 200; body: T } - | { status: 400 | 404; body: { detail: string } }; +/** GET /v1/locations */ +export function getMockLocations( + params: URLSearchParams, +): MockResult { + const sort = params.get('sort') ?? 'admin_level'; + if (sort !== 'admin_level' && sort !== 'feed_count') { + return badRequest('Unsupported sort.'); + } + const paging = readPaging(params, 100); + if (paging === null) return badRequest('Invalid limit or offset.'); -export function readDirectoryQuery(params: URLSearchParams): DirectoryQuery { - const read = (key: string): string | undefined => { - const value = params.get(key); - return value == null || value === '' ? undefined : value; - }; - return { - continent: read('continent'), - country: read('country'), - subdivision: read('subdivision'), - municipality: read('municipality'), - }; -} + const locationType = params.get('location_type'); + const continent = readContinent(params); + const parentId = params.get('parent_location_id'); + const path = params.get('path')?.toLowerCase(); + const countryCode = params.get('country_code')?.toUpperCase(); + const subdivisionCode = params.get('subdivision_code')?.toUpperCase(); + const words = normalize(params.get('search_query') ?? '') + .split(/\s+/) + .filter((word) => word !== ''); + const hasFeeds = params.get('has_feeds') === 'true'; + const includeCounts = params.get('include_feed_counts') === 'true'; + + const rows = ROWS.map((row) => ({ + row, + counts: countFeeds(feedsIn(row.scope)), + })).filter(({ row: { result }, counts }) => { + if (locationType != null && result.location_type !== locationType) + return false; + if (continent != undefined && result.continent_slug !== continent) + return false; + if (parentId != null && String(result.parent_location_id) !== parentId) + return false; + if (path != undefined && (result.path_slugs ?? []).join('/') !== path) + return false; + if (countryCode != undefined && result.country_code !== countryCode) + return false; + if ( + subdivisionCode != undefined && + result.subdivision_code !== subdivisionCode + ) + return false; + const haystack = normalize(result.display_name ?? ''); + if (!words.every((word) => haystack.includes(word))) return false; + return !hasFeeds || (counts.total ?? 0) > 0; + }); + + rows.sort( + (a, b) => + (sort === 'feed_count' + ? (b.counts.total ?? 0) - (a.counts.total ?? 0) + : a.row.depth - b.row.depth) || + (a.row.result.display_name ?? '').localeCompare( + b.row.result.display_name ?? '', + ), + ); -export function getMockDirectory( - query: DirectoryQuery, -): MockResult { - if (!isValidHierarchy(query)) { - return { status: 400, body: { detail: 'Invalid location hierarchy.' } }; - } - const feeds = feedsIn(query); - if (feeds.length === 0) { - return { status: 404, body: { detail: 'No feed located there.' } }; - } return { status: 200, body: { - level: levelOf(query), - breadcrumb: breadcrumbOf(query), - feed_counts: countFeeds(feeds), - children: childrenOf(query), + total: rows.length, + results: rows + .slice(paging.offset, paging.offset + paging.limit) + .map(({ row, counts }) => + includeCounts ? { ...row.result, feed_counts: counts } : row.result, + ), }, }; } -export function getMockDirectoryFeeds( - query: DirectoryQuery, - limit: number, - offset: number, +/** True when a /v1/search request targets a location. */ +export function isLocationSearch(params: URLSearchParams): boolean { + return ( + params.has('continent') || + params.has('location_id') || + params.get('sort') === 'location' + ); +} + +/** GET /v1/search with `continent` and/or `location_id`. */ +export function getMockLocationFeeds( + params: URLSearchParams, ): MockResult { - if (!isValidHierarchy(query)) { - return { status: 400, body: { detail: 'Invalid location hierarchy.' } }; + const sort = params.get('sort') ?? 'created_at'; + const continent = readContinent(params); + const rawLocationId = params.get('location_id'); + const locationId = rawLocationId == null ? undefined : Number(rawLocationId); + if (locationId != undefined && !Number.isInteger(locationId)) { + return badRequest('Invalid location_id.'); } - const relevance = (feed: Feed): number => - Math.max( - ...(feed.locations ?? []) - .filter((location) => matches(location, query)) - .map(locationDepth), - ); - const sorted = feedsIn(query).sort( - (a, b) => - relevance(a) - relevance(b) || - (a.provider ?? '').localeCompare(b.provider ?? '') || - (a.feed_name ?? '').localeCompare(b.feed_name ?? '') || - a.id.localeCompare(b.id), + if (sort !== 'created_at' && sort !== 'location') { + return badRequest('Unsupported sort.'); + } + if ( + sort === 'location' && + continent == undefined && + locationId == undefined + ) { + return badRequest('sort=location requires continent or location_id.'); + } + const paging = readPaging(params, 3500); + if (paging === null) return badRequest('Invalid limit or offset.'); + + const scope: FeedScope = { continent, locationId }; + const sorted = feedsIn(scope).sort((a, b) => + sort === 'location' + ? shareIn(b, scope) - shareIn(a, scope) || + (a.feed.provider ?? '').localeCompare(b.feed.provider ?? '') || + (a.feed.feed_name ?? '').localeCompare(b.feed.feed_name ?? '') || + a.feed.id.localeCompare(b.feed.id) + : // Newest first: the mock ids grow with the creation order. + b.feed.id.localeCompare(a.feed.id), ); return { status: 200, body: { total: sorted.length, - results: sorted.slice(offset, offset + limit), + results: sorted + .slice(paging.offset, paging.offset + paging.limit) + .map(({ feed }) => feed), }, }; } From fe94bc210bddfc0099dcc12321f7d12934d68a83 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 5 Nov 2026 07:29:17 -0500 Subject: [PATCH 08/11] chore: regenerate API types for realtime feed locations The location filters and feed counts now locate GTFS Realtime feeds through the schedule feeds they reference; only the descriptions change. Co-Authored-By: Claude Opus 5.5 --- src/app/services/feeds/types.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/app/services/feeds/types.ts b/src/app/services/feeds/types.ts index c95abba7..8293329f 100644 --- a/src/app/services/feeds/types.ts +++ b/src/app/services/feeds/types.ts @@ -336,7 +336,8 @@ export interface paths { *
    * - `continent` restricts the results to feeds with stops in that continent, and `location_id` to feeds with * stops in that location of `/v1/locations`, sub-areas included. Feeds are matched through their - * reverse-geolocated stops, so a feed that was never geolocated does not match. Unknown values return no feed. + * reverse-geolocated stops, so a feed that was never geolocated does not match. A GTFS Realtime feed that was + * never geolocated matches the locations of the schedule feeds it references. Unknown values return no feed. * - To list the same feeds as a location's `feed_counts`, also pass `status=active,inactive,development,future`. * - `sort=location` orders the results by the share of each feed's stops inside the requested location instead * of by creation date. @@ -358,7 +359,7 @@ export interface paths { path?: never; cookie?: never; }; - /** @description Search locations (continents, countries, subdivisions and municipalities). Results can be filtered by a free-text query and narrowed to a specific continent, country, subdivision, parent location, path or location type. Matches are ordered from the broadest area to the most specific, and by relevance within each level, unless `sort=feed_count` is given.
    Feed counts (`include_feed_counts`, `has_feeds`, `sort=feed_count`) count distinct feeds with a status of `active`, `inactive`, `development` or `future` that have reverse-geolocated stops in the location, sub-areas included. Work-in-progress feeds are counted only for callers allowed to see them, as in `/v1/search`. To list a location's feeds, call `/v1/search` with its `location_id` (or `continent` for a continent), `status=active,inactive,development,future` and `sort=location`.
    Continents come from a fixed classification and are not geopolygons. They are only returned with `location_type=continent`; their `location_id`, `parent_location_id` and ISO fields are null, `slug` and `continent_slug` both hold the continent slug, and `path_names` and `path_slugs` are empty. */ + /** @description Search locations (continents, countries, subdivisions and municipalities). Results can be filtered by a free-text query and narrowed to a specific continent, country, subdivision, parent location, path or location type. Matches are ordered from the broadest area to the most specific, and by relevance within each level, unless `sort=feed_count` is given.
    Feed counts (`include_feed_counts`, `has_feeds`, `sort=feed_count`) count distinct feeds with a status of `active`, `inactive`, `development` or `future` that have reverse-geolocated stops in the location, sub-areas included. A GTFS Realtime feed that was never geolocated is counted in the locations of the schedule feeds it references. Work-in-progress feeds are counted only for callers allowed to see them, as in `/v1/search`. To list a location's feeds, call `/v1/search` with its `location_id` (or `continent` for a continent), `status=active,inactive,development,future` and `sort=location`.
    Continents come from a fixed classification and are not geopolygons. They are only returned with `location_type=continent`; their `location_id`, `parent_location_id` and ISO fields are null, `slug` and `continent_slug` both hold the continent slug, and `path_names` and `path_slugs` are empty. */ get: operations['getLocations']; put?: never; post?: never; @@ -1878,7 +1879,7 @@ export interface components { has_seal_query_param: boolean; /** @description Continent slug (`africa`, `antarctica`, `asia`, `europe`, `north-america`, `oceania`, `south-america`) or code (`AF`, `AN`, `AS`, `EU`, `NA`, `OC`, `SA`). Case-insensitive. */ location_continent: string; - /** @description A `location_id` from `/v1/locations`: only feeds with reverse-geolocated stops in that location, sub-areas included. */ + /** @description A `location_id` from `/v1/locations`: only feeds with reverse-geolocated stops in that location, sub-areas included. A GTFS Realtime feed that was never geolocated takes the locations of the schedule feeds it references. */ location_id_query_param: number; /** @description Sort order of the results. `admin_level` (default): from the broadest area to the most specific, then by relevance to `search_query` when one is given, then by display name. `feed_count`: most feeds first, then by display name. */ locations_sort: 'admin_level' | 'feed_count'; @@ -2556,7 +2557,7 @@ export interface operations { license_tags?: components['parameters']['license_tags']; /** @description Continent slug (`africa`, `antarctica`, `asia`, `europe`, `north-america`, `oceania`, `south-america`) or code (`AF`, `AN`, `AS`, `EU`, `NA`, `OC`, `SA`). Case-insensitive. */ continent?: components['parameters']['location_continent']; - /** @description A `location_id` from `/v1/locations`: only feeds with reverse-geolocated stops in that location, sub-areas included. */ + /** @description A `location_id` from `/v1/locations`: only feeds with reverse-geolocated stops in that location, sub-areas included. A GTFS Realtime feed that was never geolocated takes the locations of the schedule feeds it references. */ location_id?: components['parameters']['location_id_query_param']; /** @description Sort order of the results. `created_at` (default): newest feeds first, then by relevance to `search_query` when one is given. `location`: by relevance to the requested location, which requires `continent` or `location_id`. Feeds with the largest share of their reverse-geolocated stops inside the location come first (on Montréal, a city bus feed with all its stops in Montréal ranks above a regional train with a fifth of them there); ties are broken by provider, feed name and feed id. */ sort?: components['parameters']['search_sort']; From c52c870699bf7b350b0d0909e412860526de44e0 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 5 Nov 2026 07:37:04 -0500 Subject: [PATCH 09/11] ci: bump cypress-io/github-action to v7 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/preview-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/preview-pr.yml b/.github/workflows/preview-pr.yml index 6d80a398..8bb402cc 100644 --- a/.github/workflows/preview-pr.yml +++ b/.github/workflows/preview-pr.yml @@ -86,7 +86,7 @@ jobs: VERCEL_PROJECT_ID: ${{ env.VERCEL_PROJECT_ID }} - name: Cypress test - uses: cypress-io/github-action@v6 + uses: cypress-io/github-action@v7 with: start: yarn e2e:setup command: yarn e2e:run From 5e00f83d7705b587acddd11d5cd7116e4a6dc6d1 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 5 Nov 2026 08:51:34 -0500 Subject: [PATCH 10/11] chore: regenerate API types for the locations path description Co-Authored-By: Claude Opus 5.5 --- src/app/services/feeds/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/services/feeds/types.ts b/src/app/services/feeds/types.ts index 8293329f..57d9ebdc 100644 --- a/src/app/services/feeds/types.ts +++ b/src/app/services/feeds/types.ts @@ -2626,7 +2626,7 @@ export interface operations { continent?: components['parameters']['location_continent']; /** @description Limit results to the direct children of this location (the next level down). */ parent_location_id?: number; - /** @description Return the location at this path: its `path_slugs` joined with `/`, from the country down, e.g. `canada/quebec/montreal`. Case-insensitive. Returns no result when no location has that path. */ + /** @description Return the location at this path: its `path_slugs` joined with `/`, from the country down, e.g. `canada/quebec/montreal`. Case-insensitive; leading and trailing slashes are ignored. Returns no result when no location has that path. */ path?: string; /** @description When true, only return locations with at least one feed (see the feed count rules above). */ has_feeds?: boolean; From d5cc04819e8d8f48f4acc1974f07955e4a07477b Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Fri, 6 Nov 2026 08:38:01 -0500 Subject: [PATCH 11/11] feat: build directory pages from the location lookup endpoint Every directory page now resolves its URL with /v1/locations/lookup?path=...&include=feed_counts,children and lists its feeds with /v1/search?location_path=..., both sent at the same time, so a page waits for one round trip. Continents are ordinary locations with negative ids, and a location's path is its URL after /locations/, so the continent special cases are gone. Empty feed pages are never cached, so an unknown URL leaves nothing in the data cache. Co-Authored-By: Claude Opus 5.5 --- .../locations/[[...location]]/page.tsx | 38 +- .../components/LocationChildrenGrid.tsx | 8 +- .../lib/location-directory-data.spec.ts | 294 ++++++------ .../locations/lib/location-directory-data.ts | 226 +++++---- .../locations/lib/location-directory.spec.ts | 196 ++++---- .../locations/lib/location-directory.ts | 126 ++--- src/app/services/feeds/index.ts | 90 +++- src/app/services/feeds/types.ts | 301 ++++++++++-- src/mocks/handlers.ts | 27 +- src/mocks/locationDirectory.spec.ts | 225 ++++++--- src/mocks/locationDirectory.ts | 429 +++++++++++------- 11 files changed, 1210 insertions(+), 750 deletions(-) diff --git a/src/app/[locale]/locations/[[...location]]/page.tsx b/src/app/[locale]/locations/[[...location]]/page.tsx index 4e3b356a..f7b3fd63 100644 --- a/src/app/[locale]/locations/[[...location]]/page.tsx +++ b/src/app/[locale]/locations/[[...location]]/page.tsx @@ -16,10 +16,8 @@ import { buildPlaceName, FEEDS_SECTION_ID, getLocationName, - getLocationSegments, getPageCount, getTotalFeedCount, - type LocationPath, parseLocationSegments, parsePageParam, } from '../lib/location-directory'; @@ -37,7 +35,8 @@ interface PageProps { interface ParsedRequest { locale: string; - location: LocationPath; + /** URL segments: the location's API path slugs, continent first. */ + segments: string[]; page: number; } @@ -49,10 +48,10 @@ async function parseRequest({ const [{ locale, location: segments }, { page: rawPage }] = await Promise.all( [params, searchParams], ); - const location = parseLocationSegments(segments); + const slugs = parseLocationSegments(segments); const page = parsePageParam(rawPage); - if (location === null || page === null) return null; - return { locale, location, page }; + if (slugs === null || page === null) return null; + return { locale, segments: slugs, page }; } /** @@ -61,14 +60,10 @@ async function parseRequest({ * the page. */ async function fetchPageData({ - location, + segments, page, }: ParsedRequest): Promise { - const data = await fetchLocationPage( - page, - location.continent, - location.path.join('/'), - ); + const data = await fetchLocationPage(page, segments.join('/')); if (data === null || page > getPageCount(data.feeds.total ?? 0)) { return null; } @@ -104,11 +99,8 @@ export async function generateMetadata(props: PageProps): Promise { gtfs_rt: counts?.gtfs_rt ?? 0, gbfs: counts?.gbfs ?? 0, }); - const segments = - location === null || request.location.continent === undefined - ? [] - : getLocationSegments(request.location.continent, location); - const url = `${SITE_URL}${buildLocationHref(segments, page)}`; + // The URL is the location's API path. + const url = `${SITE_URL}${buildLocationHref(request.segments, page)}`; return { title, @@ -129,7 +121,7 @@ export default async function LocationsPage( ): Promise { const request = await parseRequest(props); if (request === null) notFound(); - const { locale, page } = request; + const { locale, page, segments } = request; setRequestLocale(locale); const [data, t, tCommon] = await Promise.all([ @@ -140,13 +132,11 @@ export default async function LocationsPage( if (data === null) notFound(); const { location, children, feeds } = data; - const continent = request.location.continent ?? ''; const isRoot = location === null; const total = feeds.total ?? 0; const pageCount = getPageCount(total); - const segments = isRoot ? [] : getLocationSegments(continent, location); const place = buildPlaceName(location); - const steps = buildBreadcrumbSteps(continent, location); + const steps = buildBreadcrumbSteps(location); const crumbs: Crumb[] = [ { label: tCommon('feeds'), href: '/feeds' }, @@ -218,11 +208,7 @@ export default async function LocationsPage( ({children.length}) - + ) : null} diff --git a/src/app/[locale]/locations/components/LocationChildrenGrid.tsx b/src/app/[locale]/locations/components/LocationChildrenGrid.tsx index 13f110f2..e5eeb472 100644 --- a/src/app/[locale]/locations/components/LocationChildrenGrid.tsx +++ b/src/app/[locale]/locations/components/LocationChildrenGrid.tsx @@ -17,11 +17,6 @@ import { interface Props { locations: LocationResult[]; - /** - * Continent slug of the current page; empty at the root, where the - * children are the continents themselves. - */ - continent: string; locale: string; } @@ -44,7 +39,6 @@ function getFlag(location: LocationResult): string | undefined { */ export default async function LocationChildrenGrid({ locations, - continent, locale, }: Props): Promise { const t = await getTranslations('locations'); @@ -56,7 +50,7 @@ export default async function LocationChildrenGrid({ counts: location.feed_counts, })), ).map(({ location, name, counts }) => { - const href = buildLocationHref(getLocationSegments(continent, location)); + const href = buildLocationHref(getLocationSegments(location)); return { key: href, name, diff --git a/src/app/[locale]/locations/lib/location-directory-data.spec.ts b/src/app/[locale]/locations/lib/location-directory-data.spec.ts index c95cbc80..671863be 100644 --- a/src/app/[locale]/locations/lib/location-directory-data.spec.ts +++ b/src/app/[locale]/locations/lib/location-directory-data.spec.ts @@ -31,6 +31,8 @@ jest.mock('next/cache', () => ({ })); const mockGetLocations = jest.fn(); +const mockGetLocationByPath = jest.fn(); +const mockGetLocationChildren = jest.fn(); const mockSearchFeeds = jest.fn(); jest.mock('../../../services/feeds', () => ({ @@ -42,6 +44,8 @@ jest.mock('../../../services/feeds', () => ({ } }, getLocations: (...args: unknown[]) => mockGetLocations(...args), + getLocationByPath: (...args: unknown[]) => mockGetLocationByPath(...args), + getLocationChildren: (...args: unknown[]) => mockGetLocationChildren(...args), searchFeeds: (...args: unknown[]) => mockSearchFeeds(...args), })); @@ -50,47 +54,45 @@ jest.mock('../../../utils/auth-server', () => ({ })); const STATUSES = ['active', 'inactive', 'development', 'future']; +const MONTREAL_PATH = 'north-america/canada/quebec/montreal'; const northAmerica = { - location_id: null, + location_id: -5, location_type: 'continent', name: 'North America', slug: 'north-america', - continent_slug: 'north-america', - path_slugs: [], + path: 'north-america', + path_slugs: ['north-america'], feed_counts: { total: 30 }, }; -const antarctica = { - ...northAmerica, - name: 'Antarctica', - slug: 'antarctica', - continent_slug: 'antarctica', - feed_counts: { total: 0 }, -}; const montreal = { location_id: 175905, location_type: 'municipality', name: 'Montréal', slug: 'montreal', - continent_slug: 'north-america', - path_slugs: ['canada', 'quebec', 'montreal'], + path: MONTREAL_PATH, + path_slugs: ['north-america', 'canada', 'quebec', 'montreal'], feed_counts: { total: 22 }, }; -const feeds = { total: 22, results: [] }; - -/** getLocations answers by query shape. */ -function answerLocations( - byQuery: (query: Record) => unknown, -): void { - mockGetLocations.mockImplementation(async (query: Record) => - byQuery(query), - ); +const feeds = { total: 22, results: [{ id: 'mdb-1' }] }; +const emptyFeeds = { total: 0, results: [] }; + +function child(index: number): typeof montreal { + return { + ...montreal, + location_id: index + 1, + slug: `child-${index + 1}`, + path: `${MONTREAL_PATH}/child-${index + 1}`, + }; } -function locationQueries(): Array> { - return mockGetLocations.mock.calls.map( - ([query]) => query as Record, - ); +/** The lookup answer: the location and the first page of its children. */ +function detail( + location: Record, + children: unknown[], + total: number = children.length, +): Record { + return { ...location, children: { total, results: children } }; } function searchQueries(): Array> { @@ -103,169 +105,164 @@ describe('fetchLocationPage', () => { beforeEach(() => { mockCacheStore.clear(); mockGetLocations.mockReset(); + mockGetLocationByPath.mockReset(); + mockGetLocationChildren.mockReset(); mockSearchFeeds.mockReset(); mockSearchFeeds.mockResolvedValue(feeds); }); it('lists the continents with feeds and the newest feeds at the root', async () => { - answerLocations(() => ({ total: 2, results: [northAmerica, antarctica] })); + mockGetLocations.mockResolvedValue({ total: 1, results: [northAmerica] }); const data = await fetchLocationPage(2); expect(data).toEqual({ location: null, children: [northAmerica], feeds }); - expect(locationQueries()).toEqual([ - { - location_type: 'continent', - include_feed_counts: true, - limit: 100, - offset: 0, - }, + expect(mockGetLocations.mock.calls).toEqual([ + [ + { + location_type: 'continent', + has_feeds: true, + include: ['feed_counts'], + sort: 'feed_count', + limit: 100, + offset: 0, + }, + 'guest-token', + ], ]); - expect(mockGetLocations.mock.calls[0][1]).toBe('guest-token'); expect(searchQueries()).toEqual([ { status: STATUSES, limit: 20, offset: 20 }, ]); + expect(mockGetLocationByPath).not.toHaveBeenCalled(); }); - it('lists the countries and feeds of a continent', async () => { + it('looks a continent up by path, like any other location', async () => { const canada = { ...montreal, location_type: 'country', slug: 'canada' }; - answerLocations((query) => - query.location_type === 'continent' - ? { total: 1, results: [northAmerica] } - : { total: 1, results: [canada] }, - ); + mockGetLocationByPath.mockResolvedValue(detail(northAmerica, [canada])); - const data = await fetchLocationPage(1, 'north-america', ''); + const data = await fetchLocationPage(1, 'north-america'); expect(data).toEqual({ location: northAmerica, children: [canada], feeds }); - expect(locationQueries()[1]).toEqual({ - continent: 'north-america', - location_type: 'country', - has_feeds: true, - include_feed_counts: true, - sort: 'feed_count', - limit: 100, - offset: 0, - }); expect(searchQueries()).toEqual([ { - continent: 'north-america', + location_path: 'north-america', sort: 'location', status: STATUSES, limit: 20, offset: 0, }, ]); + expect(mockGetLocations).not.toHaveBeenCalled(); }); - it('is null for an unknown continent, without listing anything', async () => { - answerLocations(() => ({ total: 1, results: [northAmerica] })); - - await expect(fetchLocationPage(1, 'atlantis', '')).resolves.toBeNull(); - expect(mockGetLocations).toHaveBeenCalledTimes(1); - expect(mockSearchFeeds).not.toHaveBeenCalled(); - }); - - it('resolves a deeper location by path, then its children and feeds', async () => { - const child = { ...montreal, location_id: 1, slug: 'ville-marie' }; - answerLocations((query) => - query.path !== undefined - ? { total: 1, results: [montreal] } - : { total: 1, results: [child] }, + it('fetches the location, its children and its feeds in parallel', async () => { + let resolveLookup: (value: unknown) => void = () => {}; + mockGetLocationByPath.mockReturnValue( + new Promise((resolve) => { + resolveLookup = resolve; + }), ); - const data = await fetchLocationPage( - 3, - 'north-america', - 'canada/quebec/montreal', - ); - - expect(data).toEqual({ location: montreal, children: [child], feeds }); - expect(locationQueries()).toEqual([ - { - continent: 'north-america', - path: 'canada/quebec/montreal', - include_feed_counts: true, - limit: 1, - }, - { - parent_location_id: 175905, - has_feeds: true, - include_feed_counts: true, - sort: 'feed_count', - limit: 100, - offset: 0, - }, - ]); + const pending = fetchLocationPage(3, MONTREAL_PATH); + // The feeds are requested before the lookup answers. + await new Promise((resolve) => setTimeout(resolve, 0)); expect(searchQueries()).toEqual([ { - location_id: 175905, + location_path: MONTREAL_PATH, sort: 'location', status: STATUSES, limit: 20, offset: 40, }, ]); - }); + resolveLookup(detail(montreal, [child(0)])); - it('pages through every child location', async () => { - const children = Array.from({ length: 230 }, (_, index) => ({ - ...montreal, - location_id: index + 1, - slug: `child-${index + 1}`, - })); - answerLocations((query) => { - if (query.path !== undefined) return { total: 1, results: [montreal] }; - const offset = query.offset as number; - const limit = query.limit as number; - return { total: 230, results: children.slice(offset, offset + limit) }; + await expect(pending).resolves.toEqual({ + location: montreal, + children: [child(0)], + feeds, }); + expect(mockGetLocationByPath.mock.calls).toEqual([ + [ + { + path: MONTREAL_PATH, + include: ['feed_counts', 'children'], + has_feeds: true, + sort: 'feed_count', + limit: 100, + }, + 'guest-token', + ], + ]); + expect(mockGetLocationChildren).not.toHaveBeenCalled(); + }); - const data = await fetchLocationPage( - 1, - 'north-america', - 'canada/quebec/montreal', + it('pages through the children beyond the first 100', async () => { + const children = Array.from({ length: 230 }, (_, index) => child(index)); + mockGetLocationByPath.mockResolvedValue( + detail(montreal, children.slice(0, 100), 230), + ); + mockGetLocationChildren.mockImplementation( + async (_id: number, query: { offset: number; limit: number }) => ({ + total: 230, + results: children.slice(query.offset, query.offset + query.limit), + }), ); + const data = await fetchLocationPage(1, MONTREAL_PATH); + expect(data?.children).toEqual(children); - expect( - locationQueries() - .slice(1) - .map(({ offset, limit }) => [offset, limit]), - ).toEqual([ - [0, 100], - [100, 100], - [200, 100], + expect(mockGetLocationChildren.mock.calls).toEqual([ + [ + 175905, + { + has_feeds: true, + include: ['feed_counts'], + sort: 'feed_count', + limit: 100, + offset: 100, + }, + 'guest-token', + ], + [ + 175905, + { + has_feeds: true, + include: ['feed_counts'], + sort: 'feed_count', + limit: 100, + offset: 200, + }, + 'guest-token', + ], ]); }); - it('is null when no location has the path, and never caches the miss', async () => { - answerLocations(() => ({ total: 0, results: [] })); - await expect( - fetchLocationPage(1, 'north-america', 'canada/quebec/montreal'), - ).resolves.toBeNull(); - expect(mockSearchFeeds).not.toHaveBeenCalled(); - - answerLocations((query) => - query.path !== undefined - ? { total: 1, results: [montreal] } - : { total: 0, results: [] }, - ); - await expect( - fetchLocationPage(1, 'north-america', 'canada/quebec/montreal'), - ).resolves.toMatchObject({ location: montreal }); + it('is null when the lookup is a 404, and never caches the miss', async () => { + mockSearchFeeds.mockResolvedValue(emptyFeeds); + mockGetLocationByPath.mockRejectedValueOnce(new ApiError('Not Found', 404)); + await expect(fetchLocationPage(1, MONTREAL_PATH)).resolves.toBeNull(); + // Nothing cached for the unknown path, not even its empty feed page. + expect(mockCacheStore.size).toBe(0); + + mockSearchFeeds.mockResolvedValue(feeds); + mockGetLocationByPath.mockResolvedValue(detail(montreal, [])); + await expect(fetchLocationPage(1, MONTREAL_PATH)).resolves.toMatchObject({ + location: montreal, + feeds, + }); }); - it('is null when the result is not at the requested continent or path', async () => { - answerLocations(() => ({ total: 1, results: [montreal] })); + it('never caches an empty feed page', async () => { + mockGetLocationByPath.mockResolvedValue(detail(montreal, [])); + mockSearchFeeds.mockResolvedValue(emptyFeeds); - // The API accepts continent codes too; only slugs are valid URLs. - await expect( - fetchLocationPage(1, 'na', 'canada/quebec/montreal'), - ).resolves.toBeNull(); - await expect( - fetchLocationPage(1, 'north-america', 'canada/quebec'), - ).resolves.toBeNull(); + await expect(fetchLocationPage(9, MONTREAL_PATH)).resolves.toMatchObject({ + feeds: emptyFeeds, + }); + await fetchLocationPage(9, MONTREAL_PATH); + expect(mockSearchFeeds).toHaveBeenCalledTimes(2); + expect(mockGetLocationByPath).toHaveBeenCalledTimes(1); }); it('resolves a 400 or 404 to null without caching it', async () => { @@ -287,22 +284,25 @@ describe('fetchLocationPage', () => { }); it('caches a found location', async () => { - answerLocations((query) => - query.path !== undefined - ? { total: 1, results: [montreal] } - : { total: 0, results: [] }, - ); + mockGetLocationByPath.mockResolvedValue(detail(montreal, [])); - await fetchLocationPage(1, 'north-america', 'canada/quebec/montreal'); - await fetchLocationPage(2, 'north-america', 'canada/quebec/montreal'); - // Location and children once; one feed page each. - expect(mockGetLocations).toHaveBeenCalledTimes(2); + await fetchLocationPage(1, MONTREAL_PATH); + await fetchLocationPage(2, MONTREAL_PATH); + await fetchLocationPage(2, MONTREAL_PATH); + // The location once; one call per feed page. + expect(mockGetLocationByPath).toHaveBeenCalledTimes(1); expect(mockSearchFeeds).toHaveBeenCalledTimes(2); }); it('rethrows other API errors', async () => { mockGetLocations.mockRejectedValueOnce(new ApiError('Server Error', 500)); - await expect(fetchLocationPage(1)).rejects.toThrow('Server Error'); + + mockGetLocationByPath.mockRejectedValueOnce( + new ApiError('Server Error', 500), + ); + await expect(fetchLocationPage(1, MONTREAL_PATH)).rejects.toThrow( + 'Server Error', + ); }); }); diff --git a/src/app/[locale]/locations/lib/location-directory-data.ts b/src/app/[locale]/locations/lib/location-directory-data.ts index 4711ce50..09ca4544 100644 --- a/src/app/[locale]/locations/lib/location-directory-data.ts +++ b/src/app/[locale]/locations/lib/location-directory-data.ts @@ -11,18 +11,19 @@ import { unstable_cache } from 'next/cache'; import { getGuestGcipIdToken } from '../../../utils/auth-server'; import { ApiError, + getLocationByPath, + getLocationChildren, getLocations, searchFeeds, - type LocationsParams, + type LocationChildrenParams, type SearchFeedsResponse, } from '../../../services/feeds'; import { fetchAllPages, - getContinentSlug, + fetchRemainingPages, getPageOffset, - getTotalFeedCount, - isLocationAtPath, LOCATION_FEEDS_PAGE_SIZE, + LOCATIONS_API_PAGE_SIZE, type LocationResult, } from './location-directory'; @@ -40,10 +41,10 @@ const DIRECTORY_FEED_STATUSES: Array< > = ['active', 'inactive', 'development', 'future']; /** - * A URL that does not exist: a 400/404 from the API, or no location at the - * requested path. Thrown inside the cached functions because unstable_cache - * never stores a thrown error: caching the miss would keep a location missing - * for hours after it starts to exist. + * A URL that does not exist: a 400/404 from the API (an unknown path is a + * 404 from the lookup). Thrown inside the cached functions because + * unstable_cache never stores a thrown error: caching the miss would keep a + * location missing for hours after it starts to exist. */ class LocationNotFoundError extends Error {} @@ -79,108 +80,105 @@ async function nullWhenMissing( } } -/** Every page of `GET /v1/locations` for the query (100 per call). */ -async function getAllLocations( - query: LocationsParams, - accessToken: string, -): Promise { - return await fetchAllPages( - async (offset, limit) => - await requireData( - async () => - await getLocations({ ...query, limit, offset }, accessToken), - ), - ); +/** Children with feeds, most feeds first, with their counts. */ +function childrenQuery(): Required< + Pick +> { + return { has_feeds: true, include: ['feed_counts'], sort: 'feed_count' }; } // unstable_cache keys on the key parts plus the call arguments, so the // arguments are kept primitive. +/** The continents with feeds, most feeds first (every page). */ const cachedContinents = unstable_cache( async (): Promise => { const accessToken = await getGuestGcipIdToken(); - return await getAllLocations( - { location_type: 'continent', include_feed_counts: true }, - accessToken, + return await fetchAllPages( + async (offset, limit) => + await requireData( + async () => + await getLocations( + { ...childrenQuery(), location_type: 'continent', limit, offset }, + accessToken, + ), + ), ); }, ['location-directory-continents'], CACHE_OPTIONS, ); -/** The location at `continent/path`; throws when there is none. */ +interface LocationWithChildren { + location: LocationResult; + /** Every direct child with feeds, most feeds first. */ + children: LocationResult[]; +} + +/** + * The location at `path` and its children with feeds: one lookup holds the + * location and the first 100 children, the remaining children pages are + * then fetched in parallel. Throws when no location has that path. + */ const cachedLocation = unstable_cache( - async (continent: string, path: string): Promise => { + async (path: string): Promise => { const accessToken = await getGuestGcipIdToken(); - const response = await requireData( + const { children: firstPage, ...location } = await requireData( async () => - await getLocations( - { continent, path, include_feed_counts: true, limit: 1 }, + await getLocationByPath( + { + ...childrenQuery(), + path, + include: ['feed_counts', 'children'], + limit: LOCATIONS_API_PAGE_SIZE, + }, accessToken, ), ); - const location = response.results?.[0]; - if ( - location == undefined || - location.location_id == undefined || - !isLocationAtPath(location, continent, path.split('/')) - ) { - throw new LocationNotFoundError(`No location at ${continent}/${path}`); - } - return location; + const locationId = location.location_id; + const children = await fetchRemainingPages( + firstPage ?? {}, + async (offset, limit) => + await requireData( + async () => + await getLocationChildren( + locationId, + { ...childrenQuery(), limit, offset }, + accessToken, + ), + ), + ); + return { location, children }; }, ['location-directory-location'], CACHE_OPTIONS, ); -const CHILDREN_QUERY = { - has_feeds: true, - include_feed_counts: true, - sort: 'feed_count', -} as const satisfies LocationsParams; - -const cachedCountries = unstable_cache( - async (continent: string): Promise => { - const accessToken = await getGuestGcipIdToken(); - return await getAllLocations( - { ...CHILDREN_QUERY, continent, location_type: 'country' }, - accessToken, - ); - }, - ['location-directory-countries'], - CACHE_OPTIONS, -); +/** + * Carries a feed page without results out of unstable_cache without storing + * it: an unknown path, a page past the last one or a location without feeds. + * The feeds are fetched in parallel with the location, so caching empty + * pages would let any URL add entries to the data cache. + */ +class UncachedFeedsPage extends Error { + readonly feeds: SearchFeedsResponse; -const cachedSubLocations = unstable_cache( - async (parentLocationId: number): Promise => { - const accessToken = await getGuestGcipIdToken(); - return await getAllLocations( - { ...CHILDREN_QUERY, parent_location_id: parentLocationId }, - accessToken, - ); - }, - ['location-directory-sub-locations'], - CACHE_OPTIONS, -); + constructor(feeds: SearchFeedsResponse) { + super('Empty feed page'); + this.feeds = feeds; + } +} /** - * One page of feeds: newest first at the root (no location), otherwise most - * relevant to the continent or location first. + * One page of feeds: newest first at the root (`path` null), otherwise most + * relevant to the location at `path` first. */ const cachedFeeds = unstable_cache( - async ( - offset: number, - continent: string | null, - locationId: number | null, - ): Promise => { + async (offset: number, path: string | null): Promise => { const accessToken = await getGuestGcipIdToken(); const location = - locationId !== null - ? { location_id: locationId, sort: 'location' as const } - : continent !== null - ? { continent, sort: 'location' as const } - : {}; - return await requireData( + path !== null ? { location_path: path, sort: 'location' as const } : {}; + const feeds = await requireData( async () => await searchFeeds( { @@ -194,11 +192,25 @@ const cachedFeeds = unstable_cache( accessToken, ), ); + if ((feeds.results ?? []).length === 0) throw new UncachedFeedsPage(feeds); + return feeds; }, ['location-directory-feeds'], CACHE_OPTIONS, ); +async function fetchFeeds( + offset: number, + path: string | null, +): Promise { + try { + return await cachedFeeds(offset, path); + } catch (error) { + if (error instanceof UncachedFeedsPage) return error.feeds; + throw error; + } +} + export interface LocationPageData { /** The current location; null at the root. */ location: LocationResult | null; @@ -208,66 +220,38 @@ export interface LocationPageData { feeds: SearchFeedsResponse; } +/** + * The location (with its children) and the feed page are fetched in + * parallel from the path alone, in one round trip. + */ async function loadLocationPage( page: number, - continent: string | undefined, path: string, ): Promise { const offset = getPageOffset(page); - if (continent === undefined) { + if (path === '') { const [continents, feeds] = await Promise.all([ cachedContinents(), - cachedFeeds(offset, null, null), - ]); - // Children deeper down are fetched with `has_feeds`; continents are not, - // so that an empty continent still resolves. - const children = continents.filter( - (continent) => getTotalFeedCount(continent.feed_counts) > 0, - ); - return { location: null, children, feeds }; - } - - if (path === '') { - // Resolve the continent before listing its countries and feeds, so an - // unknown slug is a 404 and never adds entries to the data cache. - const continents = await cachedContinents(); - const location = continents.find( - (candidate) => getContinentSlug(candidate) === continent, - ); - if (location == undefined) { - throw new LocationNotFoundError(`Unknown continent ${continent}`); - } - const [children, feeds] = await Promise.all([ - cachedCountries(continent), - cachedFeeds(offset, continent, null), + fetchFeeds(offset, null), ]); - return { location, children, feeds }; + return { location: null, children: continents, feeds }; } - const location = await cachedLocation(continent, path); - const locationId = location.location_id ?? 0; - const [children, feeds] = await Promise.all([ - cachedSubLocations(locationId), - cachedFeeds(offset, null, locationId), + const [{ location, children }, feeds] = await Promise.all([ + cachedLocation(path), + fetchFeeds(offset, path), ]); return { location, children, feeds }; } /** * Everything a directory page shows, or null when the location does not - * exist. `path` is the location's path slugs joined with `/` (empty at the - * root and on a continent page). React cache() dedupes the generateMetadata - * and page calls within one request; it compares arguments by identity, - * hence primitives. + * exist. `path` is the location's API path, continent first (empty at the + * root). React cache() dedupes the generateMetadata and page calls within + * one request; it compares arguments by identity, hence primitives. */ export const fetchLocationPage = cache( - async ( - page: number, - continent?: string, - path: string = '', - ): Promise => - await nullWhenMissing( - async () => await loadLocationPage(page, continent, path), - ), + async (page: number, path: string = ''): Promise => + await nullWhenMissing(async () => await loadLocationPage(page, path)), ); diff --git a/src/app/[locale]/locations/lib/location-directory.spec.ts b/src/app/[locale]/locations/lib/location-directory.spec.ts index 063996d6..99c03998 100644 --- a/src/app/[locale]/locations/lib/location-directory.spec.ts +++ b/src/app/[locale]/locations/lib/location-directory.spec.ts @@ -3,16 +3,15 @@ import { buildLocationHref, buildPlaceName, fetchAllPages, + fetchRemainingPages, filterByName, formatCount, - getContinentSlug, getFeedCountEntries, getLocationName, getLocationSegments, getPageCount, getPageOffset, getTotalFeedCount, - isLocationAtPath, type LocationResult, normalizeLocationName, parseLocationSegments, @@ -21,15 +20,15 @@ import { } from './location-directory'; const northAmerica: LocationResult = { - location_id: null, + location_id: -5, parent_location_id: null, location_type: 'continent', + continent_code: 'NA', name: 'North America', slug: 'north-america', - continent_slug: 'north-america', - continent_name: 'North America', - path_slugs: [], - path_names: [], + path: 'north-america', + path_slugs: ['north-america'], + path_names: ['North America'], }; // Variable depth: Montréal sits under an intermediate region. @@ -37,46 +36,69 @@ const montreal: LocationResult = { location_id: 175905, parent_location_id: 161950, location_type: 'municipality', + continent_code: 'NA', name: 'Montréal', slug: 'montreal', - continent_slug: 'north-america', - continent_name: 'North America', - path_slugs: ['canada', 'quebec', 'montreal-region', 'montreal'], - path_names: ['Canada', 'Québec', 'Montréal (region)', 'Montréal'], + path: 'north-america/canada/quebec/montreal-region/montreal', + path_slugs: [ + 'north-america', + 'canada', + 'quebec', + 'montreal-region', + 'montreal', + ], + path_names: [ + 'North America', + 'Canada', + 'Québec', + 'Montréal (region)', + 'Montréal', + ], }; const canada: LocationResult = { location_id: 1, - parent_location_id: null, + parent_location_id: -5, location_type: 'country', + continent_code: 'NA', name: 'Canada', slug: 'canada', - continent_slug: 'north-america', - continent_name: 'North America', - path_slugs: ['canada'], - path_names: ['Canada'], + path: 'north-america/canada', + path_slugs: ['north-america', 'canada'], + path_names: ['North America', 'Canada'], +}; + +// A country without continent in the classification. +const clipperton: LocationResult = { + location_id: 2, + parent_location_id: null, + location_type: 'country', + continent_code: null, + name: 'Clipperton', + slug: 'clipperton', + path: 'clipperton', + path_slugs: ['clipperton'], + path_names: ['Clipperton'], }; describe('parseLocationSegments', () => { - it('returns an empty path at the root', () => { - expect(parseLocationSegments(undefined)).toEqual({ path: [] }); - expect(parseLocationSegments([])).toEqual({ path: [] }); + it('returns no slugs at the root', () => { + expect(parseLocationSegments(undefined)).toEqual([]); + expect(parseLocationSegments([])).toEqual([]); }); - it('splits the continent from the path slugs, at any depth', () => { - expect(parseLocationSegments(['north-america'])).toEqual({ - continent: 'north-america', - path: [], - }); - expect(parseLocationSegments(['north-america', 'canada'])).toEqual({ - continent: 'north-america', - path: ['canada'], - }); - const deep = ['canada', 'quebec', 'montreal-region', 'montreal', 'a', 'b']; - expect(parseLocationSegments(['north-america', ...deep])).toEqual({ - continent: 'north-america', - path: deep, - }); + it('keeps every slug, continent first, at any depth', () => { + expect(parseLocationSegments(['north-america'])).toEqual(['north-america']); + const deep = [ + 'north-america', + 'canada', + 'quebec', + 'montreal-region', + 'montreal', + 'a', + ]; + expect(parseLocationSegments(deep)).toEqual(deep); + expect(parseLocationSegments(['clipperton'])).toEqual(['clipperton']); }); it('accepts collision suffixes and non-Latin lowercase slugs', () => { @@ -86,15 +108,20 @@ describe('parseLocationSegments', () => { 'united-states', 'ohio', 'washington-township-123456', - ])?.path, - ).toEqual(['united-states', 'ohio', 'washington-township-123456']); - expect(parseLocationSegments(['asia', '東京'])?.path).toEqual(['東京']); + ]), + ).toEqual([ + 'north-america', + 'united-states', + 'ohio', + 'washington-township-123456', + ]); + expect(parseLocationSegments(['asia', '東京'])).toEqual(['asia', '東京']); }); it('decodes percent-encoded segments', () => { - expect( - parseLocationSegments(['asia', encodeURIComponent('東京')])?.path, - ).toEqual(['東京']); + expect(parseLocationSegments(['asia', encodeURIComponent('東京')])).toEqual( + ['asia', '東京'], + ); }); it.each([ @@ -171,74 +198,54 @@ describe('buildLocationHref', () => { }); describe('location segments and names', () => { - it('links a location through its path slugs under the continent', () => { - expect(getLocationSegments('north-america', montreal)).toEqual([ + it('links a location through its API path', () => { + expect(getLocationSegments(montreal)).toEqual([ 'north-america', 'canada', 'quebec', 'montreal-region', 'montreal', ]); - expect( - buildLocationHref(getLocationSegments('north-america', canada)), - ).toBe('/locations/north-america/canada'); - }); - - it('links a continent through its own slug', () => { - expect(getLocationSegments('', northAmerica)).toEqual(['north-america']); - expect(getContinentSlug({ ...northAmerica, continent_slug: null })).toBe( - 'north-america', + expect(buildLocationHref(getLocationSegments(canada))).toBe( + '/locations/north-america/canada', + ); + expect(buildLocationHref(getLocationSegments(northAmerica))).toBe( + '/locations/north-america', + ); + expect(buildLocationHref(getLocationSegments(clipperton))).toBe( + '/locations/clipperton', ); }); it('round-trips with URL parsing', () => { - const href = buildLocationHref( - getLocationSegments('north-america', montreal), - 2, - ); + const href = buildLocationHref(getLocationSegments(montreal), 2); expect(href).toBe( '/locations/north-america/canada/quebec/montreal-region/montreal?page=2', ); const segments = href.split('?')[0].split('/').slice(2); - const parsed = parseLocationSegments(segments); - expect(parsed).toEqual({ - continent: 'north-america', - path: montreal.path_slugs, - }); - expect( - isLocationAtPath(montreal, 'north-america', parsed?.path ?? []), - ).toBe(true); - }); - - it('checks the result is the requested location', () => { - expect(isLocationAtPath(canada, 'north-america', ['canada'])).toBe(true); - // The API also accepts continent codes: only the slug is canonical. - expect(isLocationAtPath(canada, 'na', ['canada'])).toBe(false); - expect(isLocationAtPath(canada, 'north-america', ['mexico'])).toBe(false); + expect(parseLocationSegments(segments)?.join('/')).toBe(montreal.path); }); it('names a location, falling back to its slug', () => { expect(getLocationName(montreal)).toBe('Montréal'); expect(getLocationName({ ...montreal, name: null })).toBe('montreal'); - expect(getLocationName({ ...northAmerica, name: null })).toBe( - 'North America', - ); + expect(getLocationName(northAmerica)).toBe('North America'); }); }); describe('buildBreadcrumbSteps', () => { it('is empty at the root', () => { - expect(buildBreadcrumbSteps('', null)).toEqual([]); + expect(buildBreadcrumbSteps(null)).toEqual([]); }); it('has a single step on a continent', () => { - expect(buildBreadcrumbSteps('north-america', northAmerica)).toEqual([ + expect(buildBreadcrumbSteps(northAmerica)).toEqual([ { label: 'North America', segments: ['north-america'] }, ]); }); it('adds one step per path name, linked by the path slug prefix', () => { - expect(buildBreadcrumbSteps('north-america', montreal)).toEqual([ + expect(buildBreadcrumbSteps(montreal)).toEqual([ { label: 'North America', segments: ['north-america'] }, { label: 'Canada', segments: ['north-america', 'canada'] }, { label: 'Québec', segments: ['north-america', 'canada', 'quebec'] }, @@ -259,14 +266,19 @@ describe('buildBreadcrumbSteps', () => { ]); }); - it('falls back to the continent slug and ignores unaligned names', () => { - const steps = buildBreadcrumbSteps('north-america', { + it('starts at the country without continent', () => { + expect(buildBreadcrumbSteps(clipperton)).toEqual([ + { label: 'Clipperton', segments: ['clipperton'] }, + ]); + }); + + it('ignores unaligned names', () => { + const steps = buildBreadcrumbSteps({ ...canada, - continent_name: null, - path_names: ['Canada', 'Extra'], + path_names: ['North America', 'Canada', 'Extra'], }); expect(steps.map((step) => step.label)).toEqual([ - 'north-america', + 'North America', 'Canada', ]); }); @@ -287,6 +299,14 @@ describe('buildPlaceName', () => { 'Montréal, Montréal (region), Québec, Canada', ); }); + + it('keeps the country of a location without continent', () => { + expect(buildPlaceName(clipperton)).toBe('Clipperton'); + }); + + it('falls back to the name without path names', () => { + expect(buildPlaceName({ ...canada, path_names: [] })).toBe('Canada'); + }); }); describe('fetchAllPages', () => { @@ -330,6 +350,22 @@ describe('fetchAllPages', () => { expect(fetchPage.mock.calls.map(([offset]) => offset)).toEqual([0, 10, 20]); }); + it('completes a first page fetched elsewhere', async () => { + const fetchPage = jest.fn(async (offset: number, limit: number) => + pageOf(230, offset, limit), + ); + const results = await fetchRemainingPages(pageOf(230, 0, 100), fetchPage); + expect(results).toEqual(Array.from({ length: 230 }, (_, index) => index)); + expect(fetchPage.mock.calls).toEqual([ + [100, 100], + [200, 100], + ]); + await expect( + fetchRemainingPages(pageOf(42, 0, 100), fetchPage), + ).resolves.toHaveLength(42); + expect(fetchPage).toHaveBeenCalledTimes(2); + }); + it('handles empty and total-less responses', async () => { await expect( fetchAllPages(async () => ({ total: 0, results: [] })), diff --git a/src/app/[locale]/locations/lib/location-directory.ts b/src/app/[locale]/locations/lib/location-directory.ts index d9af3aba..7572b6c5 100644 --- a/src/app/[locale]/locations/lib/location-directory.ts +++ b/src/app/[locale]/locations/lib/location-directory.ts @@ -10,21 +10,11 @@ export type LocationFeedCounts = components['schemas']['LocationFeedCounts']; export const LOCATIONS_BASE_PATH = '/locations'; export const LOCATION_FEEDS_PAGE_SIZE = 20; -/** Maximum `limit` of `GET /v1/locations`. */ +/** Maximum `limit` of the `/v1/locations` endpoints. */ export const LOCATIONS_API_PAGE_SIZE = 100; /** Anchor of the feed list; pagination links jump back to it. */ export const FEEDS_SECTION_ID = 'feeds'; -/** - * A directory location as addressed by its URL: the continent slug, then the - * `path_slugs` of the location from the country down. Both are empty at the - * root; `path` is empty on a continent page. - */ -export interface LocationPath { - continent?: string; - path: string[]; -} - // API slugs: lowercase letters (any script), digits and hyphens, e.g. // `north-america`, `montreal`, `washington-township-123456`. const SLUG_PATTERN = /^[\p{Ll}\p{Lo}\p{M}\p{Nd}-]+$/u; @@ -39,24 +29,24 @@ function decodeSegment(segment: string): string | null { } /** - * Maps the optional catch-all segments to a location path: the first segment - * is the continent, the others are the location's path slugs, at any depth. + * Maps the optional catch-all segments to the slugs of a location's API + * `path`, continent first, at any depth: `/locations/` is the + * location's `path`. * - * @returns the path (empty at the root), or null when a segment is not a + * @returns the slugs (empty at the root), or null when a segment is not a * valid slug (empty, uppercase, spaces, punctuation other than `-`). */ export function parseLocationSegments( segments: readonly string[] | undefined, -): LocationPath | null { - if (segments == undefined || segments.length === 0) return { path: [] }; +): string[] | null { + if (segments == undefined) return []; const slugs: string[] = []; for (const segment of segments) { const slug = decodeSegment(segment); if (slug === null || !SLUG_PATTERN.test(slug)) return null; slugs.push(slug); } - const [continent, ...path] = slugs; - return { continent, path }; + return slugs; } const PAGE_PATTERN = /^[1-9]\d*$/; @@ -109,33 +99,18 @@ export function buildLocationHref( return page > 1 ? `${path}?page=${page}` : path; } -/** URL slug of a continent row; continents have no geopolygon path. */ -export function getContinentSlug(continent: LocationResult): string { - return continent.continent_slug ?? continent.slug ?? ''; -} - /** Display name of a location, falling back to its slug. */ export function getLocationName(location: LocationResult): string { - if (location.location_type === 'continent') { - return ( - location.name ?? location.continent_name ?? getContinentSlug(location) - ); - } return location.name ?? location.slug ?? ''; } /** - * URL segments of a location under the continent of the current page: - * `[continent, ...path_slugs]`. A continent row links to its own slug. + * URL segments of a location: its `path_slugs`, continent first + * (`['north-america', 'canada', 'quebec']`), so that its directory URL is + * `/locations/`. */ -export function getLocationSegments( - continentSlug: string, - location: LocationResult, -): string[] { - if (location.location_type === 'continent') { - return [getContinentSlug(location)]; - } - return [continentSlug, ...(location.path_slugs ?? [])]; +export function getLocationSegments(location: LocationResult): string[] { + return location.path_slugs; } export interface BreadcrumbStep { @@ -145,59 +120,40 @@ export interface BreadcrumbStep { } /** - * The continent, then one step per `path_names` entry, each linked through - * the matching prefix of `path_slugs`. Empty at the root. + * One step per `path_names` entry, continent first, each linked through the + * matching prefix of `path_slugs`. Empty at the root. */ export function buildBreadcrumbSteps( - continentSlug: string, location: LocationResult | null, ): BreadcrumbStep[] { if (location === null) return []; - const continentStep: BreadcrumbStep = { - label: - location.continent_name ?? - (location.location_type === 'continent' - ? getLocationName(location) - : continentSlug), - segments: [continentSlug], - }; - if (location.location_type === 'continent') return [continentStep]; - const names = location.path_names ?? []; - const slugs = location.path_slugs ?? []; + const names = location.path_names; + const slugs = location.path_slugs; const depth = Math.min(names.length, slugs.length); - const steps = [continentStep]; + const steps: BreadcrumbStep[] = []; for (let index = 0; index < depth; index++) { steps.push({ label: names[index], - segments: [continentSlug, ...slugs.slice(0, index + 1)], + segments: slugs.slice(0, index + 1), }); } return steps; } /** - * Human-readable place for titles, most specific first: - * `Montréal, Québec, Canada`. A continent is named on its own; empty at the - * root. + * Human-readable place for titles, most specific first and without the + * continent: `Montréal, Québec, Canada`. A continent is named on its own; + * empty at the root. */ export function buildPlaceName(location: LocationResult | null): string { if (location === null) return ''; if (location.location_type === 'continent') return getLocationName(location); - const names = location.path_names ?? []; - if (names.length === 0) return getLocationName(location); - return [...names].reverse().join(', '); -} - -/** True when the API result is the location the URL asked for. */ -export function isLocationAtPath( - location: LocationResult, - continentSlug: string, - path: readonly string[], -): boolean { - return ( - location.continent_slug === continentSlug && - (location.path_slugs ?? []).join('/') === path.join('/') - ); + const names = location.path_names; + // `path_names` starts with the continent, when the country has one. + const placeNames = + location.continent_code != undefined ? names.slice(1) : names; + if (placeNames.length === 0) return getLocationName(location); + return [...placeNames].reverse().join(', '); } export interface ResultsPage { @@ -205,16 +161,21 @@ export interface ResultsPage { results?: T[]; } +export type PageFetcher = ( + offset: number, + limit: number, +) => Promise>; + /** - * Fetches every result of an offset-paginated endpoint: the first page tells - * the total and the effective page size, the remaining pages are then - * fetched in parallel. + * Completes the first page of an offset-paginated endpoint: it tells the + * total and the effective page size, the remaining pages are then fetched + * in parallel. */ -export async function fetchAllPages( - fetchPage: (offset: number, limit: number) => Promise>, +export async function fetchRemainingPages( + first: ResultsPage, + fetchPage: PageFetcher, pageSize: number = LOCATIONS_API_PAGE_SIZE, ): Promise { - const first = await fetchPage(0, pageSize); const results = [...(first.results ?? [])]; const total = first.total ?? results.length; // A server that caps `limit` lower than asked still pages consistently. @@ -231,6 +192,15 @@ export async function fetchAllPages( return results; } +/** Every result of an offset-paginated endpoint; see fetchRemainingPages. */ +export async function fetchAllPages( + fetchPage: PageFetcher, + pageSize: number = LOCATIONS_API_PAGE_SIZE, +): Promise { + const first = await fetchPage(0, pageSize); + return await fetchRemainingPages(first, fetchPage, pageSize); +} + export const FEED_COUNT_DATA_TYPES = ['gtfs', 'gtfs_rt', 'gbfs'] as const; export type FeedCountDataType = (typeof FEED_COUNT_DATA_TYPES)[number]; diff --git a/src/app/services/feeds/index.ts b/src/app/services/feeds/index.ts index 3a9644b8..4dc6dc23 100644 --- a/src/app/services/feeds/index.ts +++ b/src/app/services/feeds/index.ts @@ -1,5 +1,5 @@ import createClient, { type Middleware } from 'openapi-fetch'; -import type { paths } from './types'; +import type { components, paths } from './types'; import { type AllFeedsParams, type AllFeedType } from './utils'; import { type GtfsRoute } from '../../types'; import { getFeedFilesBaseUrl } from '../../utils/config'; @@ -352,8 +352,8 @@ export type SearchFeedsResponse = paths['/v1/search']['get']['responses'][200]['content']['application/json']; /** - * Also lists the feeds of a location directory page, with `continent` or - * `location_id` and `sort: 'location'`. + * Also lists the feeds of a location directory page, with `location_path` + * (or `location_id`) and `sort: 'location'`. */ export const searchFeeds = async ( params: AllFeedsParams, @@ -380,7 +380,7 @@ export type LocationsResponse = /** * Searches locations (continents, countries, subdivisions, municipalities). * Returns at most 100 results per call: page with `limit` and `offset`. - * Throws an `ApiError` with status 400 on an unsupported `sort`. + * Throws an `ApiError` with status 400 on an unsupported `sort` or `include`. */ export const getLocations = async ( params: LocationsParams, @@ -399,6 +399,88 @@ export const getLocations = async ( }); }; +export type LocationDetail = components['schemas']['LocationDetail']; + +export type LocationLookupParams = + paths['/v1/locations/lookup']['get']['parameters']['query']; + +/** + * One location from its `path` (e.g. `north-america/canada/quebec`), with + * the first page of its children when `include` has `children`; `has_feeds`, + * `sort`, `limit` and `offset` then apply to the children. + * Throws an `ApiError` with status 404 when no location has that path. + */ +export const getLocationByPath = async ( + params: LocationLookupParams, + accessToken: string, + userContextJwt?: string, +): Promise => { + const authMiddleware = generateAuthMiddlewareWithToken( + accessToken, + userContextJwt, + ); + return await withAuthMiddleware(authMiddleware, async () => { + const response = await client.GET('/v1/locations/lookup', { + params: { query: params }, + }); + return response.data; + }); +}; + +export type LocationParams = NonNullable< + paths['/v1/locations/{location_id}']['get']['parameters']['query'] +>; + +/** + * One location from its `location_id` (negative for a continent), same + * response as `getLocationByPath`. + * Throws an `ApiError` with status 404 when no location has that id. + */ +export const getLocation = async ( + locationId: number, + params: LocationParams, + accessToken: string, + userContextJwt?: string, +): Promise => { + const authMiddleware = generateAuthMiddlewareWithToken( + accessToken, + userContextJwt, + ); + return await withAuthMiddleware(authMiddleware, async () => { + const response = await client.GET('/v1/locations/{location_id}', { + params: { path: { location_id: locationId }, query: params }, + }); + return response.data; + }); +}; + +export type LocationChildrenParams = NonNullable< + paths['/v1/locations/{location_id}/children']['get']['parameters']['query'] +>; + +/** + * The direct children of a location, at most 100 per call: page with + * `limit` and `offset`. + * Throws an `ApiError` with status 404 when no location has that id. + */ +export const getLocationChildren = async ( + locationId: number, + params: LocationChildrenParams, + accessToken: string, + userContextJwt?: string, +): Promise => { + const authMiddleware = generateAuthMiddlewareWithToken( + accessToken, + userContextJwt, + ); + return await withAuthMiddleware(authMiddleware, async () => { + const response = await client.GET('/v1/locations/{location_id}/children', { + params: { path: { location_id: locationId }, query: params }, + }); + return response.data; + }); +}; + export const getLicense = async ( id: string, accessToken: string, diff --git a/src/app/services/feeds/types.ts b/src/app/services/feeds/types.ts index 57d9ebdc..83f7a47d 100644 --- a/src/app/services/feeds/types.ts +++ b/src/app/services/feeds/types.ts @@ -334,10 +334,11 @@ export interface paths { *

    * Location filters: *
    - * - `continent` restricts the results to feeds with stops in that continent, and `location_id` to feeds with - * stops in that location of `/v1/locations`, sub-areas included. Feeds are matched through their - * reverse-geolocated stops, so a feed that was never geolocated does not match. A GTFS Realtime feed that was - * never geolocated matches the locations of the schedule feeds it references. Unknown values return no feed. + * - `location_id` (any location of `/v1/locations`, continents included) or `location_path` (its `path`) + * restricts the results to feeds with stops in that location, sub-areas included. Feeds are matched through + * their reverse-geolocated stops, so a feed that was never geolocated does not match. A GTFS Realtime feed that + * was never geolocated matches the locations of the schedule feeds it references. Unknown values return no + * feed. Pass one of the two, not both. * - To list the same feeds as a location's `feed_counts`, also pass `status=active,inactive,development,future`. * - `sort=location` orders the results by the share of each feed's stops inside the requested location instead * of by creation date. @@ -359,7 +360,7 @@ export interface paths { path?: never; cookie?: never; }; - /** @description Search locations (continents, countries, subdivisions and municipalities). Results can be filtered by a free-text query and narrowed to a specific continent, country, subdivision, parent location, path or location type. Matches are ordered from the broadest area to the most specific, and by relevance within each level, unless `sort=feed_count` is given.
    Feed counts (`include_feed_counts`, `has_feeds`, `sort=feed_count`) count distinct feeds with a status of `active`, `inactive`, `development` or `future` that have reverse-geolocated stops in the location, sub-areas included. A GTFS Realtime feed that was never geolocated is counted in the locations of the schedule feeds it references. Work-in-progress feeds are counted only for callers allowed to see them, as in `/v1/search`. To list a location's feeds, call `/v1/search` with its `location_id` (or `continent` for a continent), `status=active,inactive,development,future` and `sort=location`.
    Continents come from a fixed classification and are not geopolygons. They are only returned with `location_type=continent`; their `location_id`, `parent_location_id` and ISO fields are null, `slug` and `continent_slug` both hold the continent slug, and `path_names` and `path_slugs` are empty. */ + /** @description Search and filter locations: continents, countries, subdivisions and municipalities. Results can be filtered by a free-text query, a continent, a country, a subdivision or a location type. Matches are ordered from the broadest area to the most specific, and by relevance within each level, unless `sort=feed_count` is given.
    To get one location, use `/v1/locations/{location_id}` or, from its path, `/v1/locations/lookup`; to list a location's direct children, use `/v1/locations/{location_id}/children` or `include=children` on those two endpoints. To list a location's feeds, call `/v1/search` with its `location_id` (or its `path` as `location_path`), `status=active,inactive,development,future` and `sort=location`.
    Every location has a `location_id` and a `path`. Countries, subdivisions and municipalities are OpenStreetMap administrative areas and their `location_id` is the OSM id. Continents come from a fixed country-to-continent classification; their `location_id` is negative (`-1` Africa, `-2` Antarctica, `-3` Asia, `-4` Europe, `-5` North America, `-6` Oceania, `-7` South America) and they are the parent of their countries.
    Feed counts (`include=feed_counts`, `has_feeds`, `sort=feed_count`) count distinct feeds with a status of `active`, `inactive`, `development` or `future` that have reverse-geolocated stops in the location, sub-areas included. A GTFS Realtime feed that was never geolocated is counted in the locations of the schedule feeds it references. Work-in-progress feeds are counted only for callers allowed to see them, as in `/v1/search`. */ get: operations['getLocations']; put?: never; post?: never; @@ -369,6 +370,57 @@ export interface paths { patch?: never; trace?: never; }; + '/v1/locations/lookup': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Get one location from its `path`: the slugs of the location and of every area containing it, from the continent down, joined with `/` (e.g. `north-america/canada/quebec`). A continent's path is its slug (`north-america`); a country without continent in the classification starts at the country. This is the same location as `/v1/locations/{location_id}`, addressed by path instead of id.
    With `include=children`, the response also holds the first page of the location's direct children; `has_feeds`, `sort`, `limit` and `offset` then apply to the children. Paths follow OpenStreetMap names and can change when a place is renamed; store the `location_id` rather than the path. */ + get: operations['getLocationByPath']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v1/locations/{location_id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Get one location by its `location_id`. With `include=children`, the response also holds the first page of the location's direct children; `has_feeds`, `sort`, `limit` and `offset` then apply to the children. */ + get: operations['getLocation']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v1/locations/{location_id}/children': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description List the direct children of a location, one level down: the countries of a continent, the subdivisions of a country, and so on. Depth varies by country, so the children of a subdivision can be regions or municipalities. */ + get: operations['getLocationChildren']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/v1/licenses': { parameters: { query?: never; @@ -1334,18 +1386,18 @@ export interface components { }; LocationSearchResponse: { /** @description The total number of matching locations regardless of limit and offset. */ - total?: number; + total: number; /** @description The page of matching locations, ordered from the broadest area to the most specific and by relevance within each level. */ - results?: components['schemas']['LocationSearchResult'][]; + results: components['schemas']['LocationSearchResult'][]; }; LocationSearchResult: { /** - * @description Stable location identifier. Null for continents. + * @description Stable location identifier: the OpenStreetMap id of the area, or a negative id for a continent (`-1` to `-7`). * @example 175905 */ - location_id?: number | null; + location_id: number; /** - * @description Stable identifier of the nearest containing location. Null for continents and countries. + * @description Identifier of the nearest containing location; for a country, its continent. Null for continents and for countries without continent in the classification. * @example 161950 */ parent_location_id?: number | null; @@ -1364,37 +1416,33 @@ export interface components { * @example municipality * @enum {string} */ - location_type?: 'continent' | 'country' | 'subdivision' | 'municipality'; + location_type: 'continent' | 'country' | 'subdivision' | 'municipality'; /** * @description Code of the continent that contains this location (`AF`, `AN`, `AS`, `EU`, `NA`, `OC`, `SA`). Null when the country has no continent in the classification. * @example NA */ continent_code?: string | null; /** - * @description URL-safe identifier of the continent, as accepted by the `continent` parameter. - * @example north-america - */ - continent_slug?: string | null; - /** - * @description English name of the continent. - * @example North America + * @description URL-safe identifier of the location among its siblings: its name lowercased, without accents, with every run of non-alphanumeric characters replaced by a single hyphen (`Montréal` -> `montreal`). When siblings under the same parent would share a slug, each of them gets `-` appended. A name without any ASCII letter or digit uses its `location_id` as the slug. A continent's slug is fixed (`north-america`). + * @example montreal */ - continent_name?: string | null; + slug: string; /** - * @description URL-safe identifier of the location among its siblings: its name lowercased, without accents, with every run of non-alphanumeric characters replaced by a single hyphen (`Montréal` -> `montreal`). When siblings under the same parent would share a slug, each of them gets `-` appended. A name without any ASCII letter or digit uses its `location_id` as the slug. - * @example montreal + * @description The location's `path_slugs` joined with `/`, from the continent down, as accepted by `/v1/locations/lookup` and by the `location_path` parameter of `/v1/search`. Paths follow OpenStreetMap names and can change when a place is renamed; store the `location_id` instead. + * @example north-america/canada/quebec/montreal-region/montreal */ - slug?: string | null; + path: string; /** - * @description The slugs of the locations in `path_names`, in the same order; joined with `/` they form the `path` parameter. Empty for continents. + * @description The slugs of the locations in `path_names`, in the same order. * @example [ + * "north-america", * "canada", * "quebec", * "montreal-region", * "montreal" * ] */ - path_slugs?: string[]; + path_slugs: string[]; feed_counts?: components['schemas']['LocationFeedCounts']; /** * @description The name of the country that contains this location. @@ -1417,21 +1465,25 @@ export interface components { */ subdivision_code?: string | null; /** - * @description The ordered list of location names from the broadest containing area down to this location. + * @description The ordered list of location names from the broadest containing area, the continent, down to this location. A country without continent in the classification starts at the country. * @example [ + * "North America", * "Canada", * "Quebec", * "Montréal (region)", * "Montréal" * ] */ - path_names?: string[]; + path_names: string[]; /** - * @description A human-readable representation of the full location hierarchy, joined from the broadest area to this location. + * @description A human-readable representation of the location hierarchy, joined from the country (or, for a continent, the continent itself) down to this location. * @example Canada, Quebec, Montréal (region), Montréal */ display_name?: string | null; }; + LocationDetail: components['schemas']['LocationSearchResult'] & { + children?: components['schemas']['LocationSearchResponse']; + }; BasicDataset: { /** * @description Unique identifier used as a key for the datasets table. @@ -1877,10 +1929,24 @@ export interface components { is_official_query_param: boolean; /** @description If true, only return feeds that currently hold the Seal of Reliability; if false, only feeds without it. Viewing a feed's seal is public, but filtering the catalogue by it is granted per user and requires the `isSealFilterEnabled` feature flag - other callers receive a 403. To request access or learn more, contact us at api@mobilitydata.org. */ has_seal_query_param: boolean; - /** @description Continent slug (`africa`, `antarctica`, `asia`, `europe`, `north-america`, `oceania`, `south-america`) or code (`AF`, `AN`, `AS`, `EU`, `NA`, `OC`, `SA`). Case-insensitive. */ + /** @description Limit results to locations in this continent, the continent included, given as its slug (`africa`, `antarctica`, `asia`, `europe`, `north-america`, `oceania`, `south-america`) or code (`AF`, `AN`, `AS`, `EU`, `NA`, `OC`, `SA`). Case-insensitive. */ location_continent: string; - /** @description A `location_id` from `/v1/locations`: only feeds with reverse-geolocated stops in that location, sub-areas included. A GTFS Realtime feed that was never geolocated takes the locations of the schedule feeds it references. */ + /** @description A `location_id` from `/v1/locations`, continents included: only feeds with reverse-geolocated stops in that location, sub-areas included. A GTFS Realtime feed that was never geolocated takes the locations of the schedule feeds it references. */ location_id_query_param: number; + /** @description A location's `path` (e.g. `north-america/canada/quebec`), as an alternative to `location_id`: only feeds with reverse-geolocated stops in that location, sub-areas included. Case-insensitive; leading and trailing slashes are ignored. */ + location_path_query_param: string; + /** @description The location's `location_id`; negative for a continent. */ + location_id_path_param: number; + /** @description When true, only return locations with at least one feed (see the feed count rules of `/v1/locations`). */ + has_feeds_query_param: boolean; + /** @description With `include=children`, only return the children with at least one feed. */ + children_has_feeds_query_param: boolean; + /** @description Optional data to add to each location: `feed_counts`, the location's feed counts per data type. */ + locations_include: 'feed_counts'[]; + /** @description Optional data to add, comma-separated: `feed_counts`, the feed counts of the location (and of its children when they are included); `children`, the first page of the location's direct children. */ + location_include: ('feed_counts' | 'children')[]; + /** @description With `include=children`, the sort order of the children. `admin_level` (default): from the broadest area to the most specific, then by display name. `feed_count`: most feeds first, then by display name. */ + children_sort: 'admin_level' | 'feed_count'; /** @description Sort order of the results. `admin_level` (default): from the broadest area to the most specific, then by relevance to `search_query` when one is given, then by display name. `feed_count`: most feeds first, then by display name. */ locations_sort: 'admin_level' | 'feed_count'; /** @description The number of items to be returned. */ @@ -1903,7 +1969,7 @@ export interface components { offset: number; /** @description General search query to match against transit provider, location, and feed name. */ search_text_query_param: string; - /** @description Sort order of the results. `created_at` (default): newest feeds first, then by relevance to `search_query` when one is given. `location`: by relevance to the requested location, which requires `continent` or `location_id`. Feeds with the largest share of their reverse-geolocated stops inside the location come first (on Montréal, a city bus feed with all its stops in Montréal ranks above a regional train with a fifth of them there); ties are broken by provider, feed name and feed id. */ + /** @description Sort order of the results. `created_at` (default): newest feeds first, then by relevance to `search_query` when one is given. `location`: by relevance to the requested location, which requires `location_id` or `location_path`. Feeds with the largest share of their reverse-geolocated stops inside the location come first (on Montréal, a city bus feed with all its stops in Montréal ranks above a regional train with a fifth of them there); ties are broken by provider, feed name and feed id. */ search_sort: 'created_at' | 'location'; /** @description Comma separated list of GBFS versions to filter by. */ version_query_param: string; @@ -2555,11 +2621,11 @@ export interface operations { license_is_spdx?: components['parameters']['license_is_spdx']; /** @description Comma separated list of tags to filter feeds by their license tags. */ license_tags?: components['parameters']['license_tags']; - /** @description Continent slug (`africa`, `antarctica`, `asia`, `europe`, `north-america`, `oceania`, `south-america`) or code (`AF`, `AN`, `AS`, `EU`, `NA`, `OC`, `SA`). Case-insensitive. */ - continent?: components['parameters']['location_continent']; - /** @description A `location_id` from `/v1/locations`: only feeds with reverse-geolocated stops in that location, sub-areas included. A GTFS Realtime feed that was never geolocated takes the locations of the schedule feeds it references. */ + /** @description A `location_id` from `/v1/locations`, continents included: only feeds with reverse-geolocated stops in that location, sub-areas included. A GTFS Realtime feed that was never geolocated takes the locations of the schedule feeds it references. */ location_id?: components['parameters']['location_id_query_param']; - /** @description Sort order of the results. `created_at` (default): newest feeds first, then by relevance to `search_query` when one is given. `location`: by relevance to the requested location, which requires `continent` or `location_id`. Feeds with the largest share of their reverse-geolocated stops inside the location come first (on Montréal, a city bus feed with all its stops in Montréal ranks above a regional train with a fifth of them there); ties are broken by provider, feed name and feed id. */ + /** @description A location's `path` (e.g. `north-america/canada/quebec`), as an alternative to `location_id`: only feeds with reverse-geolocated stops in that location, sub-areas included. Case-insensitive; leading and trailing slashes are ignored. */ + location_path?: components['parameters']['location_path_query_param']; + /** @description Sort order of the results. `created_at` (default): newest feeds first, then by relevance to `search_query` when one is given. `location`: by relevance to the requested location, which requires `location_id` or `location_path`. Feeds with the largest share of their reverse-geolocated stops inside the location come first (on Montréal, a city bus feed with all its stops in Montréal ranks above a regional train with a fifth of them there); ties are broken by provider, feed name and feed id. */ sort?: components['parameters']['search_sort']; }; header?: never; @@ -2581,7 +2647,7 @@ export interface operations { }; }; }; - /** @description `sort=location` was requested without `continent` or `location_id`, or `sort` has an unsupported value. */ + /** @description `sort=location` was requested without `location_id` or `location_path`, both `location_id` and `location_path` were given, or `sort` has an unsupported value. */ 400: { headers: { [name: string]: unknown; @@ -2606,9 +2672,11 @@ export interface operations { offset?: components['parameters']['offset']; /** @description Free-text search matched against the location name, alternate name and its full hierarchy (e.g. "Canada, Quebec, Montréal"). Matching is accent-insensitive and supports typeahead-style prefix matching, so "mon" matches "Montréal". When several words are provided, all of them must match. */ search_query?: string; - /** @description Limit results to locations contained within this country, given as its ISO 3166-1 alpha-2 code. Case-insensitive. */ + /** @description Limit results to locations in this continent, the continent included, given as its slug (`africa`, `antarctica`, `asia`, `europe`, `north-america`, `oceania`, `south-america`) or code (`AF`, `AN`, `AS`, `EU`, `NA`, `OC`, `SA`). Case-insensitive. */ + continent?: components['parameters']['location_continent']; + /** @description Limit results to locations contained within this country, the country included, given as its ISO 3166-1 alpha-2 code. Case-insensitive. */ country_code?: string; - /** @description Limit results to locations contained within this subdivision, given as its ISO 3166-2 code. Case-insensitive. */ + /** @description Limit results to locations contained within this subdivision, the subdivision included, given as its ISO 3166-2 code. Case-insensitive. */ subdivision_code?: string; /** * @description Filter by the type of location: @@ -2622,16 +2690,10 @@ export interface operations { | 'country' | 'subdivision' | 'municipality'; - /** @description Continent slug (`africa`, `antarctica`, `asia`, `europe`, `north-america`, `oceania`, `south-america`) or code (`AF`, `AN`, `AS`, `EU`, `NA`, `OC`, `SA`). Case-insensitive. */ - continent?: components['parameters']['location_continent']; - /** @description Limit results to the direct children of this location (the next level down). */ - parent_location_id?: number; - /** @description Return the location at this path: its `path_slugs` joined with `/`, from the country down, e.g. `canada/quebec/montreal`. Case-insensitive; leading and trailing slashes are ignored. Returns no result when no location has that path. */ - path?: string; - /** @description When true, only return locations with at least one feed (see the feed count rules above). */ - has_feeds?: boolean; - /** @description When true, include `feed_counts` in each result. */ - include_feed_counts?: boolean; + /** @description When true, only return locations with at least one feed (see the feed count rules of `/v1/locations`). */ + has_feeds?: components['parameters']['has_feeds_query_param']; + /** @description Optional data to add to each location: `feed_counts`, the location's feed counts per data type. */ + include?: components['parameters']['locations_include']; /** @description Sort order of the results. `admin_level` (default): from the broadest area to the most specific, then by relevance to `search_query` when one is given, then by display name. `feed_count`: most feeds first, then by display name. */ sort?: components['parameters']['locations_sort']; }; @@ -2650,13 +2712,156 @@ export interface operations { 'application/json': components['schemas']['LocationSearchResponse']; }; }; - /** @description `sort` has an unsupported value. */ + /** @description `sort` or `include` has an unsupported value. */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getLocationByPath: { + parameters: { + query: { + /** @description The location's `path`. Case-insensitive; leading and trailing slashes are ignored. */ + path: string; + /** @description Optional data to add, comma-separated: `feed_counts`, the feed counts of the location (and of its children when they are included); `children`, the first page of the location's direct children. */ + include?: components['parameters']['location_include']; + /** @description With `include=children`, only return the children with at least one feed. */ + has_feeds?: components['parameters']['children_has_feeds_query_param']; + /** @description With `include=children`, the sort order of the children. `admin_level` (default): from the broadest area to the most specific, then by display name. `feed_count`: most feeds first, then by display name. */ + sort?: components['parameters']['children_sort']; + /** @description The number of items to be returned. */ + limit?: components['parameters']['limit_query_param_locations_endpoint']; + /** @description Offset of the first item to return. */ + offset?: components['parameters']['offset']; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The location at that path. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['LocationDetail']; + }; + }; + /** @description `path` is empty, or `sort` or `include` has an unsupported value. */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description No location has that path. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getLocation: { + parameters: { + query?: { + /** @description Optional data to add, comma-separated: `feed_counts`, the feed counts of the location (and of its children when they are included); `children`, the first page of the location's direct children. */ + include?: components['parameters']['location_include']; + /** @description With `include=children`, only return the children with at least one feed. */ + has_feeds?: components['parameters']['children_has_feeds_query_param']; + /** @description With `include=children`, the sort order of the children. `admin_level` (default): from the broadest area to the most specific, then by display name. `feed_count`: most feeds first, then by display name. */ + sort?: components['parameters']['children_sort']; + /** @description The number of items to be returned. */ + limit?: components['parameters']['limit_query_param_locations_endpoint']; + /** @description Offset of the first item to return. */ + offset?: components['parameters']['offset']; + }; + header?: never; + path: { + /** @description The location's `location_id`; negative for a continent. */ + location_id: components['parameters']['location_id_path_param']; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The location. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['LocationDetail']; + }; + }; + /** @description `sort` or `include` has an unsupported value. */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description No location has that id. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getLocationChildren: { + parameters: { + query?: { + /** @description The number of items to be returned. */ + limit?: components['parameters']['limit_query_param_locations_endpoint']; + /** @description Offset of the first item to return. */ + offset?: components['parameters']['offset']; + /** @description When true, only return locations with at least one feed (see the feed count rules of `/v1/locations`). */ + has_feeds?: components['parameters']['has_feeds_query_param']; + /** @description Optional data to add to each location: `feed_counts`, the location's feed counts per data type. */ + include?: components['parameters']['locations_include']; + /** @description Sort order of the results. `admin_level` (default): from the broadest area to the most specific, then by relevance to `search_query` when one is given, then by display name. `feed_count`: most feeds first, then by display name. */ + sort?: components['parameters']['locations_sort']; + }; + header?: never; + path: { + /** @description The location's `location_id`; negative for a continent. */ + location_id: components['parameters']['location_id_path_param']; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The children of the location. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['LocationSearchResponse']; + }; + }; + /** @description `sort` or `include` has an unsupported value. */ 400: { headers: { [name: string]: unknown; }; content?: never; }; + /** @description No location has that id. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; }; getLicenses: { diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 9225ba1d..260753b9 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -8,19 +8,42 @@ import feed2947 from '../../cypress/fixtures/feed_mdb-2947.json'; import gtfsFeed2947 from '../../cypress/fixtures/gtfs_feed_mdb-2947.json'; import datasets2947 from '../../cypress/fixtures/feed_datasets_mdb-2947.json'; import { + getMockLocation, + getMockLocationByPath, + getMockLocationChildren, getMockLocationFeeds, getMockLocations, isLocationSearch, } from './locationDirectory'; export const handlers = [ - // Locations: continents, children by parent, path lookup, feed counts + // Locations: list and search, with feed counts http.get(`*/v1/locations`, ({ request }) => { const params = new URL(request.url).searchParams; const { status, body } = getMockLocations(params); return HttpResponse.json(body, { status }); }), - // Mock search endpoint. With `continent` or `location_id` (location + // One location by path, with its first page of children; before the + // `{location_id}` route so that `lookup` is not read as an id. + http.get(`*/v1/locations/lookup`, ({ request }) => { + const params = new URL(request.url).searchParams; + const { status, body } = getMockLocationByPath(params); + return HttpResponse.json(body, { status }); + }), + http.get(`*/v1/locations/:locationId/children`, ({ request, params }) => { + const query = new URL(request.url).searchParams; + const { status, body } = getMockLocationChildren( + String(params.locationId), + query, + ); + return HttpResponse.json(body, { status }); + }), + http.get(`*/v1/locations/:locationId`, ({ request, params }) => { + const query = new URL(request.url).searchParams; + const { status, body } = getMockLocation(String(params.locationId), query); + return HttpResponse.json(body, { status }); + }), + // Mock search endpoint. With `location_id` or `location_path` (location // directory pages): the feeds located there, paginated and sorted. // Otherwise: a list including test-516 and mdb-2947. http.get(`*/v1/search`, ({ request }) => { diff --git a/src/mocks/locationDirectory.spec.ts b/src/mocks/locationDirectory.spec.ts index 4f632e67..72d9b2b2 100644 --- a/src/mocks/locationDirectory.spec.ts +++ b/src/mocks/locationDirectory.spec.ts @@ -1,4 +1,7 @@ import { + getMockLocation, + getMockLocationByPath, + getMockLocationChildren, getMockLocationFeeds, getMockLocations, isLocationSearch, @@ -6,30 +9,38 @@ import { } from './locationDirectory'; type Locations = ReturnType; +type Detail = ReturnType; type Feeds = ReturnType; -function locations( - query: string, -): NonNullable['body']> { - const result = getMockLocations(new URLSearchParams(query)); - if (result.status !== 200) throw new Error(`expected 200 for ${query}`); - return result.body; +function ok(result: { status: number; body: unknown }, label: string): T { + if (result.status !== 200) throw new Error(`expected 200 for ${label}`); + return result.body as T; } -function feeds(query: string): Extract['body'] { - const result = getMockLocationFeeds(new URLSearchParams(query)); - if (result.status !== 200) throw new Error(`expected 200 for ${query}`); - return result.body; +type LocationsBody = Extract['body']; +type DetailBody = Extract['body']; +type FeedsBody = Extract['body']; + +function locations(query: string): LocationsBody { + return ok(getMockLocations(new URLSearchParams(query)), query); +} + +function lookup(path: string, query: string = ''): DetailBody { + return ok( + getMockLocationByPath(new URLSearchParams(`path=${path}&${query}`)), + path, + ); +} + +function children(id: number | undefined, query: string = ''): LocationsBody { + return ok( + getMockLocationChildren(String(id), new URLSearchParams(query)), + String(id), + ); } -function at( - path: string, -): NonNullable['results']>[number] { - const [location] = - locations( - `continent=north-america&path=${path}&include_feed_counts=true&limit=1`, - ).results ?? []; - return location; +function feeds(query: string): FeedsBody { + return ok(getMockLocationFeeds(new URLSearchParams(query)), query); } describe('location directory mocks', () => { @@ -41,87 +52,148 @@ describe('location directory mocks', () => { expect(slugify('Île-de-France')).toBe('ile-de-france'); }); - it('lists continents with counts, empty ones included', () => { + it('lists the continents with feeds, most feeds first', () => { + const all = locations('location_type=continent&include=feed_counts'); + expect(all.total).toBe(7); + const antarctica = all.results?.find((row) => row.slug === 'antarctica'); + expect(antarctica).toMatchObject({ + location_id: -2, + parent_location_id: null, + path: 'antarctica', + path_slugs: ['antarctica'], + path_names: ['Antarctica'], + feed_counts: { total: 0 }, + }); + const { results } = locations( - 'location_type=continent&include_feed_counts=true', + 'location_type=continent&has_feeds=true&include=feed_counts&sort=feed_count', ); - expect(results?.map((row) => row.continent_slug)).toEqual([ - 'africa', - 'antarctica', - 'europe', - 'north-america', - 'oceania', - ]); - const antarctica = results?.find((row) => row.slug === 'antarctica'); - expect(antarctica?.location_id).toBeNull(); - expect(antarctica?.path_slugs).toEqual([]); - expect(antarctica?.feed_counts?.total).toBe(0); + expect(results?.map((row) => row.location_id)).toEqual([-5, -4, -6, -1]); + expect(results?.[0].path).toBe('north-america'); }); - it('lists the countries of a continent with feeds, most feeds first', () => { - const { results } = locations( - 'continent=NA&location_type=country&has_feeds=true&include_feed_counts=true&sort=feed_count', + it('starts every path at the continent', () => { + const quebec = lookup('north-america/canada/quebec'); + expect(quebec).toMatchObject({ + location_id: 110, + parent_location_id: 100, + continent_code: 'NA', + path: 'north-america/canada/quebec', + path_slugs: ['north-america', 'canada', 'quebec'], + path_names: ['North America', 'Canada', 'Québec'], + display_name: 'Canada, Québec', + }); + expect(lookup('north-america/canada').parent_location_id).toBe(-5); + expect(lookup('clipperton')).toMatchObject({ + parent_location_id: null, + continent_code: null, + path_slugs: ['clipperton'], + }); + }); + + it('looks a path up with its children and rolls counts up', () => { + const montreal = lookup( + 'north-america/canada/quebec/montreal', + 'include=feed_counts', + ); + expect(montreal.feed_counts?.total).toBe(22); + expect(montreal.children).toBeUndefined(); + + const quebec = lookup( + '/North-America/canada/quebec/', + 'include=feed_counts,children&has_feeds=true&sort=feed_count&limit=2', ); - expect(results?.map((row) => row.path_slugs)).toEqual([ - ['canada'], - ['united-states'], + expect(quebec.feed_counts?.total).toBeGreaterThan(22); + expect(quebec.children?.total).toBe(4); + // Laval and Québec tie on 2 feeds: by display name. + expect(quebec.children?.results?.map((row) => row.slug)).toEqual([ + 'montreal', + 'laval', ]); - expect(results?.[0].feed_counts?.total).toBeGreaterThan( - results?.[1].feed_counts?.total ?? 0, + expect(quebec.children?.results?.[0].feed_counts?.total).toBe(22); + }); + + it('answers unknown paths and ids with a 404', () => { + const notFound = (params: string): number => + getMockLocationByPath(new URLSearchParams(params)).status; + expect(notFound('path=north-america/canada/nowhere')).toBe(404); + // Paths start at the continent. + expect(notFound('path=canada')).toBe(404); + expect(notFound('path=')).toBe(400); + expect(notFound('path=europe&include=everything')).toBe(400); + expect(getMockLocation('42', new URLSearchParams()).status).toBe(404); + expect(getMockLocationChildren('42', new URLSearchParams()).status).toBe( + 404, ); }); - it('resolves a path and rolls counts up', () => { - const montreal = at('canada/quebec/montreal'); - expect(montreal.path_names).toEqual(['Canada', 'Québec', 'Montréal']); - expect(montreal.feed_counts?.total).toBe(22); - expect(at('canada/quebec').feed_counts?.total).toBeGreaterThan(22); - expect(at('canada/quebec/nowhere')).toBeUndefined(); - expect(locations('continent=europe&path=canada').results).toEqual([]); + it('gets a location by id, continents included', () => { + const europe = ok( + getMockLocation('-4', new URLSearchParams('include=children')), + 'europe', + ); + expect(europe.path).toBe('europe'); + expect(europe.children?.results?.map((row) => row.slug)).toEqual([ + 'france', + 'germany', + ]); }); it('has a variable depth and collision-suffixed slugs', () => { - const losAngeles = at( - 'united-states/california/los-angeles-county/los-angeles', - ); - expect(losAngeles.path_names).toHaveLength(4); expect( - at('united-states/california/san-francisco').path_slugs, - ).toHaveLength(3); - const ohio = at('united-states/ohio'); - const { results } = locations( - `parent_location_id=${ohio.location_id}&has_feeds=true`, - ); - expect(results?.map((row) => row.slug)).toEqual([ - 'washington-township-123456', - 'washington-township-123457', + lookup( + 'north-america/united-states/california/los-angeles-county/los-angeles', + ).path_names, + ).toHaveLength(5); + expect( + lookup('north-america/united-states/california/san-francisco').path_slugs, + ).toHaveLength(4); + const ohio = lookup('north-america/united-states/ohio'); + expect( + children(ohio.location_id, 'has_feeds=true').results?.map( + (row) => row.path, + ), + ).toEqual([ + 'north-america/united-states/ohio/washington-township-123456', + 'north-america/united-states/ohio/washington-township-123457', ]); }); it('pages children and rejects unsupported parameters', () => { - const quebec = at('canada/quebec'); - const all = locations(`parent_location_id=${quebec.location_id}`); - const second = locations( - `parent_location_id=${quebec.location_id}&limit=2&offset=2`, - ); + const quebec = lookup('north-america/canada/quebec'); + const all = children(quebec.location_id); + const second = children(quebec.location_id, 'limit=2&offset=2'); expect(second.total).toBe(all.total); expect(second.results).toEqual(all.results?.slice(2, 4)); expect(all.results?.[0].feed_counts).toBeUndefined(); expect(getMockLocations(new URLSearchParams('sort=name')).status).toBe(400); expect(getMockLocations(new URLSearchParams('limit=101')).status).toBe(400); + expect( + getMockLocations(new URLSearchParams('include=children')).status, + ).toBe(400); + }); + + it('filters the list by continent, slug or code', () => { + const bySlug = locations('continent=europe&location_type=country'); + const byCode = locations('continent=EU&location_type=country'); + expect(bySlug.results?.map((row) => row.slug)).toEqual([ + 'france', + 'germany', + ]); + expect(byCode).toEqual(bySlug); }); it('routes location searches only', () => { expect(isLocationSearch(new URLSearchParams('location_id=1'))).toBe(true); - expect(isLocationSearch(new URLSearchParams('continent=europe'))).toBe( + expect(isLocationSearch(new URLSearchParams('location_path=europe'))).toBe( true, ); expect(isLocationSearch(new URLSearchParams('status=active'))).toBe(false); }); it('paginates the feeds of a location, most relevant first', () => { - const montreal = at('canada/quebec/montreal'); - const query = `location_id=${montreal.location_id}&sort=location&limit=20`; + const query = + 'location_path=north-america/canada/quebec/montreal&sort=location&limit=20'; const first = feeds(query); const second = feeds(`${query}&offset=20`); expect(first.total).toBe(22); @@ -129,13 +201,20 @@ describe('location directory mocks', () => { expect(second.results).toHaveLength(2); // The commuter train also serves Laval and Longueuil: least relevant. expect(second.results[1].feed_name).toBe('Commuter trains'); + expect(feeds('location_id=111&sort=location&limit=20')).toEqual(first); }); - it('lists the feeds of a continent and rejects sort=location alone', () => { - expect(feeds('continent=europe&sort=location').total).toBe(3); - expect(feeds('continent=asia').total).toBe(0); - expect( - getMockLocationFeeds(new URLSearchParams('sort=location')).status, - ).toBe(400); + it('lists the feeds of a continent by id or path', () => { + expect(feeds('location_path=europe&sort=location').total).toBe(3); + expect(feeds('location_id=-4&sort=location').total).toBe(3); + expect(feeds('location_path=asia').total).toBe(0); + expect(feeds('location_path=atlantis').total).toBe(0); + }); + + it('rejects sort=location alone and both location parameters', () => { + const status = (query: string): number => + getMockLocationFeeds(new URLSearchParams(query)).status; + expect(status('sort=location')).toBe(400); + expect(status('location_id=-4&location_path=europe')).toBe(400); }); }); diff --git a/src/mocks/locationDirectory.ts b/src/mocks/locationDirectory.ts index 04ec71e4..2474e71d 100644 --- a/src/mocks/locationDirectory.ts +++ b/src/mocks/locationDirectory.ts @@ -1,13 +1,17 @@ /** * Mock data and logic for the location directory endpoints: - * GET /v1/locations (continents and geopolygon locations) - * GET /v1/search with `continent` / `location_id` (the feeds of a location) + * GET /v1/locations (list and search) + * GET /v1/locations/lookup (one location by path, with its children) + * GET /v1/locations/{location_id} and /v1/locations/{location_id}/children + * GET /v1/search with `location_id` / `location_path` (a location's feeds) * - * Locations form a tree of variable depth (Los Angeles sits under a county, - * San Francisco directly under California). Feed counts, `has_feeds` and the - * `sort=location` relevance are derived from MOCK_FEEDS' stop locations, so - * the mock behaves like the API: a feed counts once per location, sub-areas - * included. Location ids, feed ids, URLs and names are fictional mock data. + * Locations form a tree of variable depth under the continents (Los Angeles + * sits under a county, San Francisco directly under California). Continents + * have negative ids and parent their countries; a country without continent + * starts its own path. Feed counts, `has_feeds` and the `sort=location` + * relevance are derived from MOCK_FEEDS' stop locations, so the mock behaves + * like the API: a feed counts once per location, sub-areas included. + * Location ids, feed ids, URLs and names are fictional mock data. */ import type { components } from '../app/services/feeds/types'; @@ -15,44 +19,45 @@ type Feed = components['schemas']['SearchFeedItemResult']; type LocationResult = components['schemas']['LocationSearchResult']; type FeedCounts = components['schemas']['LocationFeedCounts']; type LocationsResponse = components['schemas']['LocationSearchResponse']; +type LocationDetail = components['schemas']['LocationDetail']; interface FeedsResponse { total: number; results: Feed[]; } -interface Continent { - slug: string; - code: string; - name: string; -} - -const CONTINENTS: Continent[] = [ - { slug: 'africa', code: 'AF', name: 'Africa' }, - { slug: 'antarctica', code: 'AN', name: 'Antarctica' }, - { slug: 'europe', code: 'EU', name: 'Europe' }, - { slug: 'north-america', code: 'NA', name: 'North America' }, - { slug: 'oceania', code: 'OC', name: 'Oceania' }, -]; - -type PlaceType = 'country' | 'subdivision' | 'municipality'; +type PlaceType = 'continent' | 'country' | 'subdivision' | 'municipality'; interface Place { id: number; parent: number | null; type: PlaceType; name: string; - /** ISO 3166-1 (countries) or ISO 3166-2 (subdivisions) code. */ + /** + * Continent code, ISO 3166-1 (countries) or ISO 3166-2 (subdivisions) + * code. + */ code?: string; - /** Countries only. */ - continent?: string; } +/** Fixed ids, as in the API; Asia and South America have no feeds here. */ +const continent = (id: number, name: string, code: string): Place => ({ + id, + parent: null, + type: 'continent', + name, + code, +}); +const AFRICA = -1; +const EUROPE = -4; +const NORTH_AMERICA = -5; +const OCEANIA = -6; + const country = ( id: number, name: string, code: string, - continent: string, -): Place => ({ id, parent: null, type: 'country', name, code, continent }); + continentId: number | null, +): Place => ({ id, parent: continentId, type: 'country', name, code }); const subdivision = ( id: number, parent: number, @@ -67,7 +72,14 @@ const municipality = (id: number, parent: number, name: string): Place => ({ }); export const MOCK_PLACES: Place[] = [ - country(100, 'Canada', 'CA', 'north-america'), + continent(AFRICA, 'Africa', 'AF'), + continent(-2, 'Antarctica', 'AN'), + continent(-3, 'Asia', 'AS'), + continent(EUROPE, 'Europe', 'EU'), + continent(NORTH_AMERICA, 'North America', 'NA'), + continent(OCEANIA, 'Oceania', 'OC'), + continent(-7, 'South America', 'SA'), + country(100, 'Canada', 'CA', NORTH_AMERICA), subdivision(110, 100, 'Québec', 'CA-QC'), municipality(111, 110, 'Montréal'), municipality(112, 110, 'Laval'), @@ -77,7 +89,7 @@ export const MOCK_PLACES: Place[] = [ municipality(121, 120, 'Toronto'), subdivision(130, 100, 'British Columbia', 'CA-BC'), municipality(131, 130, 'Vancouver'), - country(200, 'United States', 'US', 'north-america'), + country(200, 'United States', 'US', NORTH_AMERICA), subdivision(210, 200, 'California', 'US-CA'), // Extra intermediate level: county > city. municipality(211, 210, 'Los Angeles County'), @@ -90,18 +102,20 @@ export const MOCK_PLACES: Place[] = [ // Siblings sharing a name: both slugs get `-`. municipality(123456, 230, 'Washington Township'), municipality(123457, 230, 'Washington Township'), - country(300, 'France', 'FR', 'europe'), + country(300, 'France', 'FR', EUROPE), subdivision(310, 300, 'Île-de-France', 'FR-IDF'), municipality(311, 310, 'Paris'), - country(400, 'Germany', 'DE', 'europe'), + country(400, 'Germany', 'DE', EUROPE), subdivision(410, 400, 'Berlin', 'DE-BE'), municipality(411, 410, 'Berlin'), - country(500, 'Australia', 'AU', 'oceania'), + country(500, 'Australia', 'AU', OCEANIA), subdivision(510, 500, 'New South Wales', 'AU-NSW'), municipality(511, 510, 'Sydney'), - country(600, 'South Africa', 'ZA', 'africa'), + country(600, 'South Africa', 'ZA', AFRICA), subdivision(610, 600, 'Western Cape', 'ZA-WC'), municipality(611, 610, 'Cape Town'), + // No continent in the classification: its path starts at the country. + country(700, 'Clipperton', 'CP', null), ]; const PLACES_BY_ID = new Map(MOCK_PLACES.map((place) => [place.id, place])); @@ -127,7 +141,7 @@ function slugOf(place: Place): string { return collides ? `${slug}-${place.id}` : slug; } -/** The place and its ancestors, from the country down. */ +/** The place and its ancestors, from the continent down. */ function chainOf(place: Place): Place[] { const chain: Place[] = []; let current: Place | undefined = place; @@ -139,9 +153,8 @@ function chainOf(place: Place): Place[] { return chain; } -function continentOf(place: Place): Continent | undefined { - const slug = chainOf(place)[0].continent; - return CONTINENTS.find((continent) => continent.slug === slug); +function ancestorOfType(place: Place, type: PlaceType): Place | undefined { + return chainOf(place).find((ancestor) => ancestor.type === type); } function isWithin(placeId: number, ancestorId: number): boolean { @@ -225,6 +238,8 @@ const FEED_SEEDS: FeedSeed[] = [ ['gtfs_rt', 'Example Transport NSW', 'Trip updates', [511]], // Africa ['gtfs', 'Example Cape Town Bus', 'Bus rapid transit', [611]], + // No continent + ['gtfs', 'Example Island Boats', 'Supply boats', [700]], ]; interface MockFeed { @@ -235,12 +250,14 @@ interface MockFeed { function declaredLocation( placeId: number, ): NonNullable[number] { - const chain = chainOf(PLACES_BY_ID.get(placeId) as Place); + const place = PLACES_BY_ID.get(placeId) as Place; + const chain = chainOf(place); + const placeCountry = ancestorOfType(place, 'country'); const municipalities = chain.filter(({ type }) => type === 'municipality'); return { - country_code: chain[0].code, - country: chain[0].name, - subdivision_name: chain.find(({ type }) => type === 'subdivision')?.name, + country_code: placeCountry?.code, + country: placeCountry?.name, + subdivision_name: ancestorOfType(place, 'subdivision')?.name, municipality: municipalities[municipalities.length - 1]?.name, }; } @@ -274,29 +291,14 @@ function toMockFeed( const MOCK_FEEDS: MockFeed[] = FEED_SEEDS.map(toMockFeed); -/** Where to look for feeds: a continent slug and/or a location id. */ -interface FeedScope { - continent?: string; - locationId?: number; -} - -/** Share of a feed's stop groups inside the scope, 0 when outside. */ -function shareIn({ stopPlaces }: MockFeed, scope: FeedScope): number { - const inside = stopPlaces.filter((placeId) => { - const place = PLACES_BY_ID.get(placeId) as Place; - if ( - scope.continent != undefined && - continentOf(place)?.slug !== scope.continent - ) { - return false; - } - return scope.locationId == undefined || isWithin(placeId, scope.locationId); - }); +/** Share of a feed's stop groups inside the location, 0 when outside. */ +function shareIn({ stopPlaces }: MockFeed, locationId: number): number { + const inside = stopPlaces.filter((placeId) => isWithin(placeId, locationId)); return inside.length / stopPlaces.length; } -function feedsIn(scope: FeedScope): MockFeed[] { - return MOCK_FEEDS.filter((feed) => shareIn(feed, scope) > 0); +function feedsIn(locationId: number): MockFeed[] { + return MOCK_FEEDS.filter((feed) => shareIn(feed, locationId) > 0); } function countFeeds(feeds: MockFeed[]): FeedCounts { @@ -312,77 +314,63 @@ function countFeeds(feeds: MockFeed[]): FeedCounts { interface Row { result: LocationResult; - scope: FeedScope; depth: number; + counts: FeedCounts; } -function continentRow(continent: Continent): Row { - return { - depth: 0, - scope: { continent: continent.slug }, - result: { - location_id: null, - parent_location_id: null, - name: continent.name, - alt_name: null, - location_type: 'continent', - continent_code: continent.code, - continent_slug: continent.slug, - continent_name: continent.name, - slug: continent.slug, - path_slugs: [], - path_names: [], - country_name: null, - country_code: null, - subdivision_name: null, - subdivision_code: null, - display_name: continent.name, - }, - }; -} - -function placeRow(place: Place): Row { +function toRow(place: Place): Row { const chain = chainOf(place); - const continent = continentOf(place); - const placeSubdivision = chain.find(({ type }) => type === 'subdivision'); + const placeContinent = ancestorOfType(place, 'continent'); + const placeCountry = ancestorOfType(place, 'country'); + const placeSubdivision = ancestorOfType(place, 'subdivision'); + const pathSlugs = chain.map(slugOf); + // From the country down, or the continent itself. + const displayChain = + place.type === 'continent' + ? chain + : chain.filter(({ type }) => type !== 'continent'); return { depth: chain.length, - scope: { locationId: place.id }, + counts: countFeeds(feedsIn(place.id)), result: { location_id: place.id, parent_location_id: place.parent, name: place.name, alt_name: null, location_type: place.type, - continent_code: continent?.code ?? null, - continent_slug: continent?.slug ?? null, - continent_name: continent?.name ?? null, + continent_code: placeContinent?.code ?? null, slug: slugOf(place), - path_slugs: chain.map(slugOf), + path: pathSlugs.join('/'), + path_slugs: pathSlugs, path_names: chain.map(({ name }) => name), - country_name: chain[0].name, - country_code: chain[0].code ?? null, + country_name: placeCountry?.name ?? null, + country_code: placeCountry?.code ?? null, subdivision_name: placeSubdivision?.name ?? null, subdivision_code: placeSubdivision?.code ?? null, - display_name: chain.map(({ name }) => name).join(', '), + display_name: displayChain.map(({ name }) => name).join(', '), }, }; } -const ROWS: Row[] = [ - ...CONTINENTS.map(continentRow), - ...MOCK_PLACES.map(placeRow), -]; +const ROWS: Row[] = MOCK_PLACES.map(toRow); +const ROWS_BY_ID = new Map( + ROWS.map((row) => [row.result.location_id as number, row]), +); export type MockResult = | { status: 200; body: T } - | { status: 400; body: { detail: string } }; + | { status: 400 | 404; body: { detail: string } }; const badRequest = (detail: string): MockResult => ({ status: 400, body: { detail }, }); +const notFound = (detail: string): MockResult => ({ + status: 404, + body: { detail }, +}); + function readPaging( params: URLSearchParams, maxLimit: number, @@ -394,14 +382,35 @@ function readPaging( return { limit, offset }; } -function readContinent(params: URLSearchParams): string | undefined { +/** The comma-separated `include` values, or null when one is unsupported. */ +function readInclude( + params: URLSearchParams, + allowed: readonly string[], +): Set | null { + const values = (params.get('include') ?? '') + .split(',') + .filter((value) => value !== ''); + if (values.some((value) => !allowed.includes(value))) return null; + return new Set(values); +} + +function readSort( + params: URLSearchParams, +): 'admin_level' | 'feed_count' | null { + const sort = params.get('sort') ?? 'admin_level'; + return sort === 'admin_level' || sort === 'feed_count' ? sort : null; +} + +function readContinent(params: URLSearchParams): number | undefined { const value = params.get('continent')?.toLowerCase(); if (value == undefined || value === '') return undefined; // Slug or code, case-insensitive; unknown values match nothing. return ( - CONTINENTS.find(({ slug, code }) => - [slug, code.toLowerCase()].includes(value), - )?.slug ?? `unknown:${value}` + ROWS.find( + ({ result }) => + result.location_type === 'continent' && + [result.slug, result.continent_code?.toLowerCase()].includes(value), + )?.result.location_id ?? Number.NaN ); } @@ -411,40 +420,70 @@ const normalize = (text: string): string => .replace(/\p{Diacritic}/gu, '') .toLowerCase(); -/** GET /v1/locations */ -export function getMockLocations( +function sortRows(rows: Row[], sort: 'admin_level' | 'feed_count'): Row[] { + return [...rows].sort( + (a, b) => + (sort === 'feed_count' + ? (b.counts.total ?? 0) - (a.counts.total ?? 0) + : a.depth - b.depth) || + (a.result.display_name ?? '').localeCompare(b.result.display_name ?? ''), + ); +} + +/** + * Filters, sorts and pages rows like the list endpoints; `include` adds the + * feed counts. + */ +function listRows( + rows: Row[], params: URLSearchParams, + include: Set, ): MockResult { - const sort = params.get('sort') ?? 'admin_level'; - if (sort !== 'admin_level' && sort !== 'feed_count') { - return badRequest('Unsupported sort.'); - } + const sort = readSort(params); + if (sort === null) return badRequest('Unsupported sort.'); const paging = readPaging(params, 100); if (paging === null) return badRequest('Invalid limit or offset.'); + const hasFeeds = params.get('has_feeds') === 'true'; + const matching = sortRows( + rows.filter(({ counts }) => !hasFeeds || (counts.total ?? 0) > 0), + sort, + ); + return { + status: 200, + body: { + total: matching.length, + results: matching + .slice(paging.offset, paging.offset + paging.limit) + .map(({ result, counts }) => + include.has('feed_counts') + ? { ...result, feed_counts: counts } + : result, + ), + }, + }; +} +/** GET /v1/locations */ +export function getMockLocations( + params: URLSearchParams, +): MockResult { + const include = readInclude(params, ['feed_counts']); + if (include === null) return badRequest('Unsupported include.'); const locationType = params.get('location_type'); - const continent = readContinent(params); - const parentId = params.get('parent_location_id'); - const path = params.get('path')?.toLowerCase(); + const continentId = readContinent(params); const countryCode = params.get('country_code')?.toUpperCase(); const subdivisionCode = params.get('subdivision_code')?.toUpperCase(); const words = normalize(params.get('search_query') ?? '') .split(/\s+/) .filter((word) => word !== ''); - const hasFeeds = params.get('has_feeds') === 'true'; - const includeCounts = params.get('include_feed_counts') === 'true'; - const rows = ROWS.map((row) => ({ - row, - counts: countFeeds(feedsIn(row.scope)), - })).filter(({ row: { result }, counts }) => { + const rows = ROWS.filter(({ result }) => { if (locationType != null && result.location_type !== locationType) return false; - if (continent != undefined && result.continent_slug !== continent) - return false; - if (parentId != null && String(result.parent_location_id) !== parentId) - return false; - if (path != undefined && (result.path_slugs ?? []).join('/') !== path) + if ( + continentId != undefined && + !isWithin(result.location_id as number, continentId) + ) return false; if (countryCode != undefined && result.country_code !== countryCode) return false; @@ -454,70 +493,132 @@ export function getMockLocations( ) return false; const haystack = normalize(result.display_name ?? ''); - if (!words.every((word) => haystack.includes(word))) return false; - return !hasFeeds || (counts.total ?? 0) > 0; + return words.every((word) => haystack.includes(word)); }); + return listRows(rows, params, include); +} - rows.sort( - (a, b) => - (sort === 'feed_count' - ? (b.counts.total ?? 0) - (a.counts.total ?? 0) - : a.row.depth - b.row.depth) || - (a.row.result.display_name ?? '').localeCompare( - b.row.result.display_name ?? '', - ), +function childRows(locationId: number): Row[] { + return ROWS.filter(({ result }) => result.parent_location_id === locationId); +} + +/** The location of `row`, with its children when `include` has them. */ +function detailOf( + row: Row, + params: URLSearchParams, +): MockResult { + const include = readInclude(params, ['feed_counts', 'children']); + if (include === null) return badRequest('Unsupported include.'); + const location: LocationDetail = include.has('feed_counts') + ? { ...row.result, feed_counts: row.counts } + : { ...row.result }; + if (!include.has('children')) return { status: 200, body: location }; + const children = listRows( + childRows(row.result.location_id as number), + params, + include, ); + if (children.status !== 200) return children; + return { status: 200, body: { ...location, children: children.body } }; +} - return { - status: 200, - body: { - total: rows.length, - results: rows - .slice(paging.offset, paging.offset + paging.limit) - .map(({ row, counts }) => - includeCounts ? { ...row.result, feed_counts: counts } : row.result, - ), - }, - }; +function normalizePath(path: string): string { + return path.toLowerCase().replace(/^\/+|\/+$/g, ''); +} + +/** GET /v1/locations/lookup */ +export function getMockLocationByPath( + params: URLSearchParams, +): MockResult { + const path = normalizePath(params.get('path') ?? ''); + if (path === '') return badRequest('Missing path.'); + const row = ROWS.find(({ result }) => result.path === path); + if (row == undefined) return notFound(`No location at ${path}.`); + return detailOf(row, params); +} + +function readLocationId(raw: string): number | undefined { + const id = Number(raw); + return Number.isInteger(id) ? id : undefined; +} + +/** GET /v1/locations/{location_id} */ +export function getMockLocation( + rawId: string, + params: URLSearchParams, +): MockResult { + const id = readLocationId(rawId); + if (id === undefined) return badRequest('Invalid location_id.'); + const row = ROWS_BY_ID.get(id); + if (row == undefined) return notFound(`No location ${id}.`); + return detailOf(row, params); +} + +/** GET /v1/locations/{location_id}/children */ +export function getMockLocationChildren( + rawId: string, + params: URLSearchParams, +): MockResult { + const id = readLocationId(rawId); + if (id === undefined) return badRequest('Invalid location_id.'); + if (!ROWS_BY_ID.has(id)) return notFound(`No location ${id}.`); + const include = readInclude(params, ['feed_counts']); + if (include === null) return badRequest('Unsupported include.'); + return listRows(childRows(id), params, include); } /** True when a /v1/search request targets a location. */ export function isLocationSearch(params: URLSearchParams): boolean { return ( - params.has('continent') || params.has('location_id') || + params.has('location_path') || params.get('sort') === 'location' ); } -/** GET /v1/search with `continent` and/or `location_id`. */ +/** GET /v1/search with `location_id` or `location_path`. */ export function getMockLocationFeeds( params: URLSearchParams, ): MockResult { const sort = params.get('sort') ?? 'created_at'; - const continent = readContinent(params); const rawLocationId = params.get('location_id'); - const locationId = rawLocationId == null ? undefined : Number(rawLocationId); - if (locationId != undefined && !Number.isInteger(locationId)) { - return badRequest('Invalid location_id.'); + const rawPath = params.get('location_path'); + if (rawLocationId != null && rawPath != null) { + return badRequest('Pass location_id or location_path, not both.'); + } + let locationId: number | undefined; + if (rawLocationId != null) { + locationId = readLocationId(rawLocationId); + if (locationId === undefined) return badRequest('Invalid location_id.'); } if (sort !== 'created_at' && sort !== 'location') { return badRequest('Unsupported sort.'); } - if ( - sort === 'location' && - continent == undefined && - locationId == undefined - ) { - return badRequest('sort=location requires continent or location_id.'); + if (sort === 'location' && rawLocationId == null && rawPath == null) { + return badRequest('sort=location requires location_id or location_path.'); } const paging = readPaging(params, 3500); if (paging === null) return badRequest('Invalid limit or offset.'); - const scope: FeedScope = { continent, locationId }; - const sorted = feedsIn(scope).sort((a, b) => - sort === 'location' - ? shareIn(b, scope) - shareIn(a, scope) || + if (rawPath != null) { + const path = normalizePath(rawPath); + locationId = ROWS.find(({ result }) => result.path === path)?.result + .location_id; + // Unknown values return no feed. + if (locationId == undefined) { + return { status: 200, body: { total: 0, results: [] } }; + } + } + + const scoped = + locationId == undefined + ? MOCK_FEEDS + : ROWS_BY_ID.has(locationId) + ? feedsIn(locationId) + : []; + const sorted = [...scoped].sort((a, b) => + sort === 'location' && locationId != undefined + ? shareIn(b, locationId) - shareIn(a, locationId) || (a.feed.provider ?? '').localeCompare(b.feed.provider ?? '') || (a.feed.feed_name ?? '').localeCompare(b.feed.feed_name ?? '') || a.feed.id.localeCompare(b.feed.id)