{
+ const [tCommon, t] = await Promise.all([
+ getTranslations('common'),
+ getTranslations('locations'),
+ ]);
+
+ return (
+
+ ({
+ dataType,
+ count,
+ label: `${formatCount(count, locale)} ${tCommon(LABEL_KEYS[dataType])}`,
+ }))}
+ />
+
+ );
+}
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
new file mode 100644
index 00000000..e5eeb472
--- /dev/null
+++ b/src/app/[locale]/locations/components/LocationChildrenGrid.tsx
@@ -0,0 +1,73 @@
+import { type ReactElement } from 'react';
+import { getTranslations } from 'next-intl/server';
+import { getEmojiFlag, type TCountryCode } from 'countries-list';
+import LocationChildrenList, {
+ type LocationChildItem,
+} from './LocationChildrenList';
+import {
+ buildLocationHref,
+ formatCount,
+ getFeedCountEntries,
+ getLocationName,
+ getLocationSegments,
+ getTotalFeedCount,
+ sortByFeedCount,
+ type LocationResult,
+} from '../lib/location-directory';
+
+interface Props {
+ locations: LocationResult[];
+ locale: string;
+}
+
+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 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({
+ locations,
+ locale,
+}: Props): Promise {
+ const t = await getTranslations('locations');
+
+ const items: LocationChildItem[] = sortByFeedCount(
+ locations.map((location) => ({
+ location,
+ name: getLocationName(location),
+ counts: location.feed_counts,
+ })),
+ ).map(({ location, name, counts }) => {
+ const href = buildLocationHref(getLocationSegments(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
new file mode 100644
index 00000000..51b3fadd
--- /dev/null
+++ b/src/app/[locale]/locations/components/LocationChildrenList.tsx
@@ -0,0 +1,218 @@
+'use client';
+
+import { useMemo, useState, type ReactElement, type ReactNode } 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 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 {
+ /** Unique among siblings (the href). */
+ key: string;
+ name: string;
+ /** Emoji flag, countries only. */
+ flag?: string;
+ href: string;
+ /** e.g. "8 feeds", with the number in a . */
+ totalLabel: ReactNode;
+ /** Plain-text counts; chips are kept for the page summary. */
+ 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[];
+}
+
+/** 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 },
+ // White in light mode, like the cards below; the section is paper.
+ '& .MuiInputBase-root': { bgcolor: 'background.default' },
+ }}
+ />
+
+ {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}
+
+
+
+ {item.counts.map(
+ ({ dataType, count, formattedCount, typeLabel }) => (
+ 0 ? 'text.secondary' : 'text.disabled'
+ }
+ // Zero counts stay fully disabled.
+ sx={count > 0 ? countNumberSx : undefined}
+ >
+ {formattedCount} {typeLabel}
+
+ ),
+ )}
+
+
+
+
+
+ ))}
+
+ ) : (
+
+ {t('childrenSearchEmpty', { query: query.trim() })}
+
+ )}
+
+ >
+ );
+}
diff --git a/src/app/[locale]/locations/components/LocationFeedsList.tsx b/src/app/[locale]/locations/components/LocationFeedsList.tsx
new file mode 100644
index 00000000..ae28dd48
--- /dev/null
+++ b/src/app/[locale]/locations/components/LocationFeedsList.tsx
@@ -0,0 +1,87 @@
+'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;
+ /** URL segments of the current location, used to build the page links. */
+ segments: string[];
+}
+
+/**
+ * The feeds of a directory location 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,
+ segments,
+}: 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.spec.ts b/src/app/[locale]/locations/lib/location-directory-data.spec.ts
new file mode 100644
index 00000000..671863be
--- /dev/null
+++ b/src/app/[locale]/locations/lib/location-directory-data.spec.ts
@@ -0,0 +1,308 @@
+/**
+ * @jest-environment node
+ */
+
+import { ApiError } from '../../../services/feeds';
+import { fetchLocationPage } 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 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) => {
+ return async (...args: A): Promise => {
+ const key = JSON.stringify([keyParts, args]);
+ if (mockCacheStore.has(key)) return mockCacheStore.get(key) as R;
+ const value = await fn(...args);
+ mockCacheStore.set(key, value);
+ return value;
+ };
+ },
+}));
+
+const mockGetLocations = jest.fn();
+const mockGetLocationByPath = jest.fn();
+const mockGetLocationChildren = jest.fn();
+const mockSearchFeeds = jest.fn();
+
+jest.mock('../../../services/feeds', () => ({
+ ApiError: class ApiError extends Error {
+ readonly status: number;
+ constructor(message: string, status: number) {
+ super(message);
+ this.status = status;
+ }
+ },
+ getLocations: (...args: unknown[]) => mockGetLocations(...args),
+ getLocationByPath: (...args: unknown[]) => mockGetLocationByPath(...args),
+ getLocationChildren: (...args: unknown[]) => mockGetLocationChildren(...args),
+ searchFeeds: (...args: unknown[]) => mockSearchFeeds(...args),
+}));
+
+jest.mock('../../../utils/auth-server', () => ({
+ getGuestGcipIdToken: async () => 'guest-token',
+}));
+
+const STATUSES = ['active', 'inactive', 'development', 'future'];
+const MONTREAL_PATH = 'north-america/canada/quebec/montreal';
+
+const northAmerica = {
+ location_id: -5,
+ location_type: 'continent',
+ name: 'North America',
+ slug: 'north-america',
+ path: 'north-america',
+ path_slugs: ['north-america'],
+ feed_counts: { total: 30 },
+};
+const montreal = {
+ location_id: 175905,
+ location_type: 'municipality',
+ name: 'Montréal',
+ slug: 'montreal',
+ path: MONTREAL_PATH,
+ path_slugs: ['north-america', 'canada', 'quebec', 'montreal'],
+ feed_counts: { total: 22 },
+};
+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}`,
+ };
+}
+
+/** 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> {
+ return mockSearchFeeds.mock.calls.map(
+ ([params]) => (params as { query: Record }).query,
+ );
+}
+
+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 () => {
+ mockGetLocations.mockResolvedValue({ total: 1, results: [northAmerica] });
+
+ const data = await fetchLocationPage(2);
+
+ expect(data).toEqual({ location: null, children: [northAmerica], feeds });
+ expect(mockGetLocations.mock.calls).toEqual([
+ [
+ {
+ location_type: 'continent',
+ has_feeds: true,
+ include: ['feed_counts'],
+ sort: 'feed_count',
+ limit: 100,
+ offset: 0,
+ },
+ 'guest-token',
+ ],
+ ]);
+ expect(searchQueries()).toEqual([
+ { status: STATUSES, limit: 20, offset: 20 },
+ ]);
+ expect(mockGetLocationByPath).not.toHaveBeenCalled();
+ });
+
+ it('looks a continent up by path, like any other location', async () => {
+ const canada = { ...montreal, location_type: 'country', slug: 'canada' };
+ mockGetLocationByPath.mockResolvedValue(detail(northAmerica, [canada]));
+
+ const data = await fetchLocationPage(1, 'north-america');
+
+ expect(data).toEqual({ location: northAmerica, children: [canada], feeds });
+ expect(searchQueries()).toEqual([
+ {
+ location_path: 'north-america',
+ sort: 'location',
+ status: STATUSES,
+ limit: 20,
+ offset: 0,
+ },
+ ]);
+ expect(mockGetLocations).not.toHaveBeenCalled();
+ });
+
+ 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 pending = fetchLocationPage(3, MONTREAL_PATH);
+ // The feeds are requested before the lookup answers.
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(searchQueries()).toEqual([
+ {
+ location_path: MONTREAL_PATH,
+ sort: 'location',
+ status: STATUSES,
+ limit: 20,
+ offset: 40,
+ },
+ ]);
+ resolveLookup(detail(montreal, [child(0)]));
+
+ 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();
+ });
+
+ 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(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 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('never caches an empty feed page', async () => {
+ mockGetLocationByPath.mockResolvedValue(detail(montreal, []));
+ mockSearchFeeds.mockResolvedValue(emptyFeeds);
+
+ 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 () => {
+ 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 () => {
+ mockGetLocationByPath.mockResolvedValue(detail(montreal, []));
+
+ 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
new file mode 100644
index 00000000..09ca4544
--- /dev/null
+++ b/src/app/[locale]/locations/lib/location-directory-data.ts
@@ -0,0 +1,257 @@
+/**
+ * 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,
+ getLocationByPath,
+ getLocationChildren,
+ getLocations,
+ searchFeeds,
+ type LocationChildrenParams,
+ type SearchFeedsResponse,
+} from '../../../services/feeds';
+import {
+ fetchAllPages,
+ fetchRemainingPages,
+ getPageOffset,
+ LOCATION_FEEDS_PAGE_SIZE,
+ LOCATIONS_API_PAGE_SIZE,
+ 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 `feed_counts` counts, so a location lists as many feeds. */
+const DIRECTORY_FEED_STATUSES: Array<
+ 'active' | 'inactive' | 'development' | 'future'
+> = ['active', 'inactive', 'development', 'future'];
+
+/**
+ * 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 {}
+
+async function requireData(
+ request: () => Promise,
+): Promise {
+ try {
+ const data = await request();
+ if (data == undefined) {
+ throw new Error('Empty response from the locations API');
+ }
+ return data;
+ } catch (error) {
+ if (
+ error instanceof ApiError &&
+ (error.status === 400 || error.status === 404)
+ ) {
+ 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;
+ }
+}
+
+/** 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 fetchAllPages(
+ async (offset, limit) =>
+ await requireData(
+ async () =>
+ await getLocations(
+ { ...childrenQuery(), location_type: 'continent', limit, offset },
+ accessToken,
+ ),
+ ),
+ );
+ },
+ ['location-directory-continents'],
+ CACHE_OPTIONS,
+);
+
+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 (path: string): Promise => {
+ const accessToken = await getGuestGcipIdToken();
+ const { children: firstPage, ...location } = await requireData(
+ async () =>
+ await getLocationByPath(
+ {
+ ...childrenQuery(),
+ path,
+ include: ['feed_counts', 'children'],
+ limit: LOCATIONS_API_PAGE_SIZE,
+ },
+ accessToken,
+ ),
+ );
+ 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,
+);
+
+/**
+ * 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;
+
+ constructor(feeds: SearchFeedsResponse) {
+ super('Empty feed page');
+ this.feeds = feeds;
+ }
+}
+
+/**
+ * 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, path: string | null): Promise => {
+ const accessToken = await getGuestGcipIdToken();
+ const location =
+ path !== null ? { location_path: path, sort: 'location' as const } : {};
+ const feeds = await requireData(
+ async () =>
+ await searchFeeds(
+ {
+ query: {
+ ...location,
+ status: DIRECTORY_FEED_STATUSES,
+ limit: LOCATION_FEEDS_PAGE_SIZE,
+ offset,
+ },
+ },
+ 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;
+ /** Continents at the root, otherwise the locations with feeds one level down. */
+ children: LocationResult[];
+ /** The requested page of feeds located there. */
+ 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,
+ path: string,
+): Promise {
+ const offset = getPageOffset(page);
+
+ if (path === '') {
+ const [continents, feeds] = await Promise.all([
+ cachedContinents(),
+ fetchFeeds(offset, null),
+ ]);
+ return { location: null, children: continents, feeds };
+ }
+
+ 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 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, 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
new file mode 100644
index 00000000..99c03998
--- /dev/null
+++ b/src/app/[locale]/locations/lib/location-directory.spec.ts
@@ -0,0 +1,459 @@
+import {
+ buildBreadcrumbSteps,
+ buildLocationHref,
+ buildPlaceName,
+ fetchAllPages,
+ fetchRemainingPages,
+ filterByName,
+ formatCount,
+ getFeedCountEntries,
+ getLocationName,
+ getLocationSegments,
+ getPageCount,
+ getPageOffset,
+ getTotalFeedCount,
+ type LocationResult,
+ normalizeLocationName,
+ parseLocationSegments,
+ parsePageParam,
+ sortByFeedCount,
+} from './location-directory';
+
+const northAmerica: LocationResult = {
+ location_id: -5,
+ parent_location_id: null,
+ location_type: 'continent',
+ continent_code: 'NA',
+ name: 'North America',
+ slug: 'north-america',
+ path: 'north-america',
+ path_slugs: ['north-america'],
+ path_names: ['North America'],
+};
+
+// Variable depth: Montréal sits under an intermediate region.
+const montreal: LocationResult = {
+ location_id: 175905,
+ parent_location_id: 161950,
+ location_type: 'municipality',
+ continent_code: 'NA',
+ name: 'Montréal',
+ slug: 'montreal',
+ 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: -5,
+ location_type: 'country',
+ continent_code: 'NA',
+ name: 'Canada',
+ slug: '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 no slugs at the root', () => {
+ expect(parseLocationSegments(undefined)).toEqual([]);
+ expect(parseLocationSegments([])).toEqual([]);
+ });
+
+ 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', () => {
+ expect(
+ parseLocationSegments([
+ 'north-america',
+ 'united-states',
+ 'ohio',
+ 'washington-township-123456',
+ ]),
+ ).toEqual([
+ 'north-america',
+ 'united-states',
+ 'ohio',
+ 'washington-township-123456',
+ ]);
+ expect(parseLocationSegments(['asia', '東京'])).toEqual(['asia', '東京']);
+ });
+
+ it('decodes percent-encoded segments', () => {
+ expect(parseLocationSegments(['asia', encodeURIComponent('東京')])).toEqual(
+ ['asia', '東京'],
+ );
+ });
+
+ 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();
+ });
+});
+
+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', 'canada', 'quebec', 'montreal']),
+ ).toBe('/locations/north-america/canada/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');
+ });
+});
+
+describe('location segments and names', () => {
+ it('links a location through its API path', () => {
+ expect(getLocationSegments(montreal)).toEqual([
+ 'north-america',
+ 'canada',
+ 'quebec',
+ 'montreal-region',
+ 'montreal',
+ ]);
+ 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(montreal), 2);
+ expect(href).toBe(
+ '/locations/north-america/canada/quebec/montreal-region/montreal?page=2',
+ );
+ const segments = href.split('?')[0].split('/').slice(2);
+ 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)).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(northAmerica)).toEqual([
+ { label: 'North America', segments: ['north-america'] },
+ ]);
+ });
+
+ it('adds one step per path name, linked by the path slug prefix', () => {
+ expect(buildBreadcrumbSteps(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('starts at the country without continent', () => {
+ expect(buildBreadcrumbSteps(clipperton)).toEqual([
+ { label: 'Clipperton', segments: ['clipperton'] },
+ ]);
+ });
+
+ it('ignores unaligned names', () => {
+ const steps = buildBreadcrumbSteps({
+ ...canada,
+ path_names: ['North America', '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',
+ );
+ });
+
+ 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', () => {
+ 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('fetches the remaining pages in order', async () => {
+ const fetchPage = jest.fn(async (offset: number, limit: number) =>
+ pageOf(250, offset, limit),
+ );
+ 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('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: [] })),
+ ).resolves.toEqual([]);
+ await expect(fetchAllPages(async () => ({}))).resolves.toEqual([]);
+ await expect(
+ fetchAllPages(async () => ({ results: [1, 2] })),
+ ).resolves.toEqual([1, 2]);
+ });
+});
+
+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('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
new file mode 100644
index 00000000..7572b6c5
--- /dev/null
+++ b/src/app/[locale]/locations/lib/location-directory.ts
@@ -0,0 +1,272 @@
+/**
+ * Pure helpers for the /locations directory: URL segment and page parsing,
+ * 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 LocationResult = components['schemas']['LocationSearchResult'];
+export type LocationFeedCounts = components['schemas']['LocationFeedCounts'];
+
+export const LOCATIONS_BASE_PATH = '/locations';
+export const LOCATION_FEEDS_PAGE_SIZE = 20;
+/** 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';
+
+// 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 slugs of a location's API
+ * `path`, continent first, at any depth: `/locations/` is the
+ * location's `path`.
+ *
+ * @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,
+): 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);
+ }
+ return slugs;
+}
+
+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 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(
+ segments: readonly string[],
+ page: number = 1,
+): string {
+ const path = [LOCATIONS_BASE_PATH, ...segments.map(encodeURIComponent)].join(
+ '/',
+ );
+ return page > 1 ? `${path}?page=${page}` : path;
+}
+
+/** Display name of a location, falling back to its slug. */
+export function getLocationName(location: LocationResult): string {
+ return location.name ?? location.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(location: LocationResult): string[] {
+ return location.path_slugs;
+}
+
+export interface BreadcrumbStep {
+ label: string;
+ /** URL segments of this step, continent first. */
+ segments: string[];
+}
+
+/**
+ * One step per `path_names` entry, continent first, each linked through the
+ * matching prefix of `path_slugs`. Empty at the root.
+ */
+export function buildBreadcrumbSteps(
+ location: LocationResult | null,
+): BreadcrumbStep[] {
+ if (location === null) return [];
+ const names = location.path_names;
+ const slugs = location.path_slugs;
+ const depth = Math.min(names.length, slugs.length);
+ const steps: BreadcrumbStep[] = [];
+ for (let index = 0; index < depth; index++) {
+ steps.push({
+ label: names[index],
+ segments: slugs.slice(0, index + 1),
+ });
+ }
+ return steps;
+}
+
+/**
+ * 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;
+ // `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 {
+ total?: number;
+ results?: T[];
+}
+
+export type PageFetcher = (
+ offset: number,
+ limit: number,
+) => Promise>;
+
+/**
+ * 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 fetchRemainingPages(
+ first: ResultsPage,
+ fetchPage: PageFetcher,
+ pageSize: number = LOCATIONS_API_PAGE_SIZE,
+): Promise {
+ 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;
+}
+
+/** 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];
+
+export interface FeedCountEntry {
+ dataType: FeedCountDataType;
+ count: number;
+}
+
+/** GTFS, GTFS-RT and GBFS counts in display order; missing counts are 0. */
+export function getFeedCountEntries(
+ counts: LocationFeedCounts | undefined,
+): FeedCountEntry[] {
+ return FEED_COUNT_DATA_TYPES.map((dataType) => ({
+ dataType,
+ count: counts?.[dataType] ?? 0,
+ }));
+}
+
+/** Total distinct feeds of a location, falling back to the sum by type. */
+export function getTotalFeedCount(
+ counts: LocationFeedCounts | undefined,
+): number {
+ if (counts?.total != undefined) return counts.total;
+ return getFeedCountEntries(counts).reduce(
+ (sum, entry) => sum + entry.count,
+ 0,
+ );
+}
+
+/** 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: LocationFeedCounts | 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(
+ value ?? 0,
+ );
+}
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 (
+
+ }
+ onKeyDown={(e: React.KeyboardEvent) => {
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ setOpenedByKeyboard(true);
+ setAnchorEl(e.currentTarget);
+ }
+ }}
+ sx={(theme) => ({
+ ...animatedButtonStyling(theme),
+ color: theme.vars.palette.text.primary,
+ })}
+ className={isActive ? 'active short' : ''}
+ >
+ {title}
+
+
+
+ );
+}
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) => (
- : null}
- // className={activeTab.includes('/' + item.target) ? 'active' : ''}
- >
- {item.title}
-
- ))}
+ {navigationItems.map((item) =>
+ item.target === 'feeds' ? (
+
+ }
+ aria-controls='feeds-content'
+ id='feeds-header'
+ data-cy='drawer-feeds'
+ >
+
+ {item.title}
+
+
+
+ {feedsMenuItems.map((subItem) => (
+
+ ))}
+
+
+ ) : (
+ : null}
+ // className={activeTab.includes('/' + item.target) ? 'active' : ''}
+ >
+ {item.title}
+
+ ),
+ )}
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..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';
@@ -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;
},
@@ -333,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 page, with `location_path`
+ * (or `location_id`) 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,
@@ -351,6 +370,117 @@ export const searchFeeds = async (
});
};
+export type LocationsParams = NonNullable<
+ paths['/v1/locations']['get']['parameters']['query']
+>;
+
+export type LocationsResponse =
+ paths['/v1/locations']['get']['responses'][200]['content']['application/json'];
+
+/**
+ * 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` or `include`.
+ */
+export const getLocations = async (
+ params: LocationsParams,
+ accessToken: string,
+ userContextJwt?: string,
+): Promise => {
+ const authMiddleware = generateAuthMiddlewareWithToken(
+ accessToken,
+ userContextJwt,
+ );
+ return await withAuthMiddleware(authMiddleware, async () => {
+ const response = await client.GET('/v1/locations', {
+ params: { query: params },
+ });
+ return response.data;
+ });
+};
+
+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 c3be5015..83f7a47d 100644
--- a/src/app/services/feeds/types.ts
+++ b/src/app/services/feeds/types.ts
@@ -331,6 +331,17 @@ export interface paths {
* Query: New York Transit
*
* Search Executed: 'new' & york & 'transit'
+ *
+ * Location filters:
+ *
+ * - `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.
*
*/
get: operations['searchFeeds'];
@@ -349,7 +360,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 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;
@@ -359,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;
@@ -1308,20 +1370,34 @@ export interface components {
*/
municipality?: string;
};
+ /** @description Number of distinct feeds per data type with stops in a location, sub-areas included. */
+ LocationFeedCounts: {
+ /** @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;
+ };
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.
+ * @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;
+ location_id: number;
/**
- * @description Stable identifier of the nearest containing location.
+ * @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;
@@ -1336,11 +1412,38 @@ 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 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
+ */
+ slug: string;
+ /**
+ * @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
+ */
+ path: string;
+ /**
+ * @description The slugs of the locations in `path_names`, in the same order.
+ * @example [
+ * "north-america",
+ * "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
@@ -1362,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.
@@ -1822,6 +1929,26 @@ 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 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`, 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. */
limit_query_param_locations_endpoint: number;
/** @description The number of items to be returned. */
@@ -1842,6 +1969,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 `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;
/** @description Comma separated list of data types to filter by. Valid values are gtfs, gtfs_rt and gbfs. */
@@ -2492,6 +2621,12 @@ 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 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 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;
path?: never;
@@ -2512,6 +2647,13 @@ export interface operations {
};
};
};
+ /** @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;
+ };
+ content?: never;
+ };
/** @description Filtering by Seal of Reliability status is not available to this caller. */
403: {
headers: {
@@ -2530,17 +2672,30 @@ 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:
+ * * `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 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?: never;
@@ -2557,6 +2712,156 @@ export interface operations {
'application/json': components['schemas']['LocationSearchResponse'];
};
};
+ /** @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 0f826fe3..260753b9 100644
--- a/src/mocks/handlers.ts
+++ b/src/mocks/handlers.ts
@@ -7,10 +7,51 @@ 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 {
+ getMockLocation,
+ getMockLocationByPath,
+ getMockLocationChildren,
+ getMockLocationFeeds,
+ getMockLocations,
+ isLocationSearch,
+} from './locationDirectory';
export const handlers = [
- // Mock search endpoint: return a list including test-516 and mdb-2947
- http.get(`*/v1/search`, () => {
+ // 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 });
+ }),
+ // 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 }) => {
+ const params = new URL(request.url).searchParams;
+ if (isLocationSearch(params)) {
+ const { status, body } = getMockLocationFeeds(params);
+ return HttpResponse.json(body, { status });
+ }
return HttpResponse.json({
total: 2,
results: [
diff --git a/src/mocks/locationDirectory.spec.ts b/src/mocks/locationDirectory.spec.ts
new file mode 100644
index 00000000..72d9b2b2
--- /dev/null
+++ b/src/mocks/locationDirectory.spec.ts
@@ -0,0 +1,220 @@
+import {
+ getMockLocation,
+ getMockLocationByPath,
+ getMockLocationChildren,
+ getMockLocationFeeds,
+ getMockLocations,
+ isLocationSearch,
+ slugify,
+} from './locationDirectory';
+
+type Locations = ReturnType;
+type Detail = ReturnType;
+type Feeds = ReturnType;
+
+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;
+}
+
+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 feeds(query: string): FeedsBody {
+ return ok(getMockLocationFeeds(new URLSearchParams(query)), query);
+}
+
+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 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&has_feeds=true&include=feed_counts&sort=feed_count',
+ );
+ expect(results?.map((row) => row.location_id)).toEqual([-5, -4, -6, -1]);
+ expect(results?.[0].path).toBe('north-america');
+ });
+
+ 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(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(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('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', () => {
+ expect(
+ 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 = 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('location_path=europe'))).toBe(
+ true,
+ );
+ expect(isLocationSearch(new URLSearchParams('status=active'))).toBe(false);
+ });
+
+ it('paginates the feeds of a location, most relevant first', () => {
+ 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);
+ 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');
+ expect(feeds('location_id=111&sort=location&limit=20')).toEqual(first);
+ });
+
+ 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
new file mode 100644
index 00000000..2474e71d
--- /dev/null
+++ b/src/mocks/locationDirectory.ts
@@ -0,0 +1,637 @@
+/**
+ * Mock data and logic for the location directory endpoints:
+ * 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 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';
+
+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[];
+}
+
+type PlaceType = 'continent' | 'country' | 'subdivision' | 'municipality';
+
+interface Place {
+ id: number;
+ parent: number | null;
+ type: PlaceType;
+ name: string;
+ /**
+ * Continent code, ISO 3166-1 (countries) or ISO 3166-2 (subdivisions)
+ * code.
+ */
+ code?: 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,
+ continentId: number | null,
+): Place => ({ id, parent: continentId, type: 'country', name, code });
+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[] = [
+ 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'),
+ 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'),
+ // 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]));
+
+/** Same slug rule as the API: `Québec` -> `quebec`. */
+export function slugify(name: string): string {
+ return name
+ .normalize('NFD')
+ .replace(/\p{Diacritic}/gu, '')
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-');
+}
+
+/** 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 continent 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 ancestorOfType(place: Place, type: PlaceType): Place | undefined {
+ return chainOf(place).find((ancestor) => ancestor.type === type);
+}
+
+function isWithin(placeId: number, ancestorId: number): boolean {
+ const place = PLACES_BY_ID.get(placeId);
+ return (
+ place != undefined && chainOf(place).some(({ id }) => id === ancestorId)
+ );
+}
+
+type FeedSeed = [
+ dataType: Feed['data_type'],
+ provider: string,
+ feedName: string,
+ /** One entry per group of stops, all of equal weight. */
+ stopPlaces: number[],
+];
+
+const MONTREAL = 111;
+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', [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, 112, 113]],
+ ['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', [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', [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', [311]],
+ ['gbfs', 'Example Vélo Paris', 'Bike share', [311]],
+ ['gtfs', 'Example Verkehrsverbund Berlin', 'Regional network', [411]],
+ // Oceania
+ ['gtfs', 'Example Transport NSW', 'Sydney network', [511]],
+ ['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 {
+ feed: Feed;
+ stopPlaces: number[];
+}
+
+function declaredLocation(
+ placeId: number,
+): NonNullable[number] {
+ 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: placeCountry?.code,
+ country: placeCountry?.name,
+ subdivision_name: ancestorOfType(place, 'subdivision')?.name,
+ municipality: municipalities[municipalities.length - 1]?.name,
+ };
+}
+
+function toMockFeed(
+ [dataType, provider, feedName, stopPlaces]: FeedSeed,
+ index: number,
+): MockFeed {
+ const id = `mock-loc-${String(index + 1).padStart(3, '0')}`;
+ return {
+ 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,
+ },
+ };
+}
+
+const MOCK_FEEDS: MockFeed[] = FEED_SEEDS.map(toMockFeed);
+
+/** 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(locationId: number): MockFeed[] {
+ return MOCK_FEEDS.filter((feed) => shareIn(feed, locationId) > 0);
+}
+
+function countFeeds(feeds: MockFeed[]): FeedCounts {
+ const count = (dataType: Feed['data_type']): number =>
+ feeds.filter(({ feed }) => feed.data_type === dataType).length;
+ return {
+ gtfs: count('gtfs'),
+ gtfs_rt: count('gtfs_rt'),
+ gbfs: count('gbfs'),
+ total: feeds.length,
+ };
+}
+
+interface Row {
+ result: LocationResult;
+ depth: number;
+ counts: FeedCounts;
+}
+
+function toRow(place: Place): Row {
+ const chain = chainOf(place);
+ 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,
+ 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: placeContinent?.code ?? null,
+ slug: slugOf(place),
+ path: pathSlugs.join('/'),
+ path_slugs: pathSlugs,
+ path_names: chain.map(({ name }) => name),
+ country_name: placeCountry?.name ?? null,
+ country_code: placeCountry?.code ?? null,
+ subdivision_name: placeSubdivision?.name ?? null,
+ subdivision_code: placeSubdivision?.code ?? null,
+ display_name: displayChain.map(({ name }) => name).join(', '),
+ },
+ };
+}
+
+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 | 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,
+): { 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 };
+}
+
+/** 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 (
+ ROWS.find(
+ ({ result }) =>
+ result.location_type === 'continent' &&
+ [result.slug, result.continent_code?.toLowerCase()].includes(value),
+ )?.result.location_id ?? Number.NaN
+ );
+}
+
+const normalize = (text: string): string =>
+ text
+ .normalize('NFD')
+ .replace(/\p{Diacritic}/gu, '')
+ .toLowerCase();
+
+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 = 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 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 rows = ROWS.filter(({ result }) => {
+ if (locationType != null && result.location_type !== locationType)
+ return false;
+ if (
+ continentId != undefined &&
+ !isWithin(result.location_id as number, continentId)
+ )
+ 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 ?? '');
+ return words.every((word) => haystack.includes(word));
+ });
+ return listRows(rows, params, include);
+}
+
+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 } };
+}
+
+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('location_id') ||
+ params.has('location_path') ||
+ params.get('sort') === 'location'
+ );
+}
+
+/** GET /v1/search with `location_id` or `location_path`. */
+export function getMockLocationFeeds(
+ params: URLSearchParams,
+): MockResult {
+ const sort = params.get('sort') ?? 'created_at';
+ const rawLocationId = params.get('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' && 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.');
+
+ 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)
+ : // 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(paging.offset, paging.offset + paging.limit)
+ .map(({ feed }) => feed),
+ },
+ };
+}