diff --git a/messages/en.json b/messages/en.json
index 77b3d33a..3b815be7 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -99,6 +99,11 @@
"gbfsValidator": "GBFS Validator",
"gtfsValidator": "GTFS Validator",
"gtfsRtValidator": "GTFS RT Validator",
+ "tools": "Tools",
+ "analytics": "Analytics",
+ "metrics": "Metrics",
+ "account": "Account",
+ "gtfsFeatureTracker": "GTFS Feature Tracker",
"start": "Start",
"end": "End",
"showLess": "Show less",
@@ -138,6 +143,8 @@
"resultsFor": "{startResult}-{endResult} of {totalResults} results",
"deprecated": "Deprecated",
"searchPlaceholder": "Transit provider, feed name, or location",
+ "featureTrackerBanner": "See which trip planners use these features",
+ "featureTrackerBannerSingle": "See which trip planners use {feature}",
"noResults": "We're sorry, we found no search results for ''{activeSearch}''.",
"searchSuggestions": "Search suggestions: ",
"searchTips": {
@@ -539,7 +546,7 @@
"copyright": "© {year} MobilityDatabase. All rights reserved.",
"columns": {
"platform": "Platform",
- "validators": "Validators",
+ "tools": "Tools",
"company": "Company",
"legal": "Legal"
},
@@ -547,6 +554,7 @@
"feeds": "Feeds",
"addFeed": "Add a Feed",
"apiDocs": "API Docs",
+ "gtfsFeatureTracker": "GTFS Feature Tracker",
"gtfsValidator": "GTFS Validator",
"gtfsRtValidator": "GTFS-RT Validator",
"gbfsValidator": "GBFS Validator",
diff --git a/messages/fr.json b/messages/fr.json
index 0450aa9d..f24fa020 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -99,6 +99,11 @@
"gbfsValidator": "GBFS Validator",
"gtfsValidator": "GTFS Validator",
"gtfsRtValidator": "GTFS RT Validator",
+ "tools": "Outils",
+ "analytics": "Analyses",
+ "metrics": "Métriques",
+ "account": "Compte",
+ "gtfsFeatureTracker": "Suivi des fonctionnalités GTFS",
"start": "Start",
"end": "End",
"showLess": "Show less",
@@ -138,6 +143,8 @@
"resultsFor": "{startResult}-{endResult} of {totalResults} results",
"deprecated": "Deprecated",
"searchPlaceholder": "Transit provider, feed name, or location",
+ "featureTrackerBanner": "Voir quels planificateurs d’itinéraires utilisent ces fonctionnalités",
+ "featureTrackerBannerSingle": "Voir quels planificateurs d’itinéraires utilisent {feature}",
"noResults": "We're sorry, we found no search results for ''{activeSearch}''.",
"searchSuggestions": "Search suggestions: ",
"searchTips": {
@@ -539,7 +546,7 @@
"copyright": "© {year} MobilityDatabase. Tous droits réservés.",
"columns": {
"platform": "Plateforme",
- "validators": "Validateurs",
+ "tools": "Outils",
"company": "Entreprise",
"legal": "Légal"
},
@@ -547,6 +554,7 @@
"feeds": "Flux",
"addFeed": "Ajouter un flux",
"apiDocs": "Docs API",
+ "gtfsFeatureTracker": "Suivi des fonctionnalités GTFS",
"gtfsValidator": "Validateur GTFS",
"gtfsRtValidator": "Validateur GTFS-RT",
"gbfsValidator": "Validateur GBFS",
diff --git a/public/assets/tripPlannerLogos/aubin-app.png b/public/assets/tripPlannerLogos/aubin-app.png
new file mode 100644
index 00000000..7d852181
Binary files /dev/null and b/public/assets/tripPlannerLogos/aubin-app.png differ
diff --git a/public/assets/tripPlannerLogos/gmaps.png b/public/assets/tripPlannerLogos/gmaps.png
new file mode 100644
index 00000000..a66e95ea
Binary files /dev/null and b/public/assets/tripPlannerLogos/gmaps.png differ
diff --git a/public/assets/tripPlannerLogos/motis.png b/public/assets/tripPlannerLogos/motis.png
new file mode 100644
index 00000000..6fec3a42
Binary files /dev/null and b/public/assets/tripPlannerLogos/motis.png differ
diff --git a/public/assets/tripPlannerLogos/opentripplanner.png b/public/assets/tripPlannerLogos/opentripplanner.png
new file mode 100644
index 00000000..ab8d4d54
Binary files /dev/null and b/public/assets/tripPlannerLogos/opentripplanner.png differ
diff --git a/public/assets/tripPlannerLogos/transitapp.png b/public/assets/tripPlannerLogos/transitapp.png
new file mode 100644
index 00000000..8f745290
Binary files /dev/null and b/public/assets/tripPlannerLogos/transitapp.png differ
diff --git a/src/app/[locale]/feeds/components/FeedsScreen.tsx b/src/app/[locale]/feeds/components/FeedsScreen.tsx
index ead7f206..aa71c9e9 100644
--- a/src/app/[locale]/feeds/components/FeedsScreen.tsx
+++ b/src/app/[locale]/feeds/components/FeedsScreen.tsx
@@ -6,7 +6,6 @@ import {
Button,
Chip,
Container,
- CssBaseline,
Grid,
InputAdornment,
LinearProgress,
@@ -19,8 +18,9 @@ import {
Typography,
useTheme,
} from '@mui/material';
-import { Search } from '@mui/icons-material';
+import { OpenInNew, Search } from '@mui/icons-material';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
+import NextLink from 'next/link';
import SearchTable from '../../../screens/Feeds/SearchTable';
import { useTranslations } from 'next-intl';
import {
@@ -39,6 +39,7 @@ import {
deriveFilterFlags,
buildSearchUrl,
} from '../lib/useFeedsSearch';
+import { toFeatureAnchor } from '../../../utils/featureAnchor';
export default function FeedsScreen(): React.ReactElement {
const theme = useTheme();
@@ -68,6 +69,16 @@ export default function FeedsScreen(): React.ReactElement {
areGBFSFiltersEnabled,
} = deriveFilterFlags(selectedFeedTypes);
+ const featureTrackerHref =
+ selectedFeatures.length === 1
+ ? `/gtfs-feature-tracker#${toFeatureAnchor(selectedFeatures[0])}`
+ : '/gtfs-feature-tracker';
+
+ const featureTrackerLabel =
+ selectedFeatures.length === 1
+ ? t('featureTrackerBannerSingle', { feature: selectedFeatures[0] })
+ : t('featureTrackerBanner');
+
// SWR-powered data fetching - keyed off URL params
const { feedsData, isLoading, isValidating, isError, searchLimit } =
useFeedsSearch(searchParams);
@@ -179,7 +190,6 @@ export default function FeedsScreen(): React.ReactElement {
position: 'relative',
}}
>
-
-
- {getSearchResultNumbers()}
-
+
+ {getSearchResultNumbers()}
+
+ {selectedFeatures.length > 0 &&
+ areFeatureFiltersEnabled && (
+ }
+ >
+ {featureTrackerLabel}
+
+ )}
+
+
;
+ if (n.startsWith('no'))
+ return ;
+ if (n === 'integration planned')
+ return ;
+ if (n === 'test in progress')
+ return ;
+ if (n === 'partial integration')
+ return ;
+ if (n === 'some fields are ignored')
+ return ;
+ return ;
+}
+
+export function isStatusSupported(raw: string): boolean {
+ return raw.toLowerCase().trim().startsWith('yes');
+}
+
+export function computeCategoryProgress(
+ features: Feature[],
+ consumers: Consumer[],
+): number {
+ let supported = 0;
+ let total = 0;
+ for (const feature of features) {
+ for (const consumer of consumers) {
+ const raw = feature.support[consumer.id]?.rawStatus ?? '';
+ if (raw) {
+ total++;
+ if (isStatusSupported(raw)) supported++;
+ }
+ }
+ }
+ return total > 0 ? Math.round((supported / total) * 100) : 0;
+}
+
+export function formatDate(dateStr: string): string {
+ if (!dateStr) return '';
+ const d = new Date(dateStr);
+ if (isNaN(d.getTime())) return dateStr;
+ return d.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ });
+}
+
+export function tokenizeDetail(
+ text: string,
+ knownFieldsSet: Set,
+): Token[] {
+ if (!text) return [];
+ const tokens: Array = [];
+
+ // 1. Markdown links [label](url)
+ const mdRe = /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g;
+ let m: RegExpExecArray | null;
+ while ((m = mdRe.exec(text)) !== null) {
+ tokens.push({
+ type: 'mdlink',
+ label: m[1],
+ url: m[2],
+ start: m.index,
+ end: m.index + m[0].length,
+ });
+ }
+
+ // 2. Bare URLs
+ const urlRe = /https?:\/\/[^\s,)]+/g;
+ while ((m = urlRe.exec(text)) !== null) {
+ const overlaps = tokens.some(
+ (t) => m!.index >= t.start && m!.index < t.end,
+ );
+ if (!overlaps)
+ tokens.push({
+ type: 'url',
+ value: m[0],
+ start: m.index,
+ end: m.index + m[0].length,
+ });
+ }
+
+ // 3. .txt file names
+ const fileRe = /\b[a-z_]+\.txt\b/g;
+ while ((m = fileRe.exec(text)) !== null) {
+ const overlaps = tokens.some(
+ (t) => m!.index >= t.start && m!.index < t.end,
+ );
+ if (!overlaps)
+ tokens.push({
+ type: 'file',
+ value: m[0],
+ start: m.index,
+ end: m.index + m[0].length,
+ });
+ }
+
+ // 4. Known GTFS field names
+ const fieldRe = /\b[a-z][a-z0-9_]*[a-z0-9]\b|\b[a-z]{2,}\b/g;
+ while ((m = fieldRe.exec(text)) !== null) {
+ if (!knownFieldsSet.has(m[0])) continue;
+ const overlaps = tokens.some(
+ (t) => m!.index >= t.start && m!.index < t.end,
+ );
+ if (!overlaps)
+ tokens.push({
+ type: 'field',
+ value: m[0],
+ start: m.index,
+ end: m.index + m[0].length,
+ });
+ }
+
+ tokens.sort((a, b) => a.start - b.start);
+
+ const segments: Token[] = [];
+ let cursor = 0;
+ for (const tok of tokens) {
+ if (tok.start > cursor)
+ segments.push({ type: 'text', value: text.slice(cursor, tok.start) });
+ segments.push(tok);
+ cursor = tok.end;
+ }
+ if (cursor < text.length)
+ segments.push({ type: 'text', value: text.slice(cursor) });
+ return segments;
+}
diff --git a/src/app/[locale]/gtfs-feature-tracker/components/GtfsFeatureTracker.tsx b/src/app/[locale]/gtfs-feature-tracker/components/GtfsFeatureTracker.tsx
new file mode 100644
index 00000000..e3b56f5e
--- /dev/null
+++ b/src/app/[locale]/gtfs-feature-tracker/components/GtfsFeatureTracker.tsx
@@ -0,0 +1,740 @@
+'use client';
+
+import {
+ useState,
+ useMemo,
+ useEffect,
+ useRef,
+ memo,
+ type ReactElement,
+ Fragment,
+} from 'react';
+import {
+ Box,
+ Typography,
+ Chip,
+ Button,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ IconButton,
+ Collapse,
+ LinearProgress,
+ Paper,
+ Tooltip,
+ Link as MuiLink,
+ Divider,
+} from '@mui/material';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import ExpandLessIcon from '@mui/icons-material/ExpandLess';
+import OpenInNewIcon from '@mui/icons-material/OpenInNew';
+import MenuBookIcon from '@mui/icons-material/MenuBook';
+import DatasetIcon from '@mui/icons-material/Dataset';
+import FilterAltIcon from '@mui/icons-material/FilterAlt';
+import AddIcon from '@mui/icons-material/Add';
+import Image from 'next/image';
+import type { Feature, GtfsFeatureTrackerProps } from './types';
+import {
+ getStatusText,
+ getStatusIcon,
+ computeCategoryProgress,
+ formatDate,
+ tokenizeDetail,
+} from './GtfsFeatureTracker.helpers';
+import { toFeatureAnchor } from '../../../utils/featureAnchor';
+
+const CONTRIBUTE_URL = 'https://forms.gle/W3iJGgoaPDYLypZ38';
+
+const CONSUMER_TYPES = [
+ 'Journey Planner',
+ 'Open Source Journey Planner',
+ 'Specialized Journey Planner',
+] as const;
+
+const CONSUMER_LOGOS: Record = {
+ google: '/assets/tripPlannerLogos/gmaps.png',
+ transitapp: '/assets/tripPlannerLogos/transitapp.png',
+ motis: '/assets/tripPlannerLogos/motis.png',
+ opentripplanner: '/assets/tripPlannerLogos/opentripplanner.png',
+ aubin: '/assets/tripPlannerLogos/aubin-app.png',
+};
+
+// ── FeatureDetail ─────────────────────────────────────────────────────────────
+// Extracted as a memo component (rerender-memo) so each cell only re-renders
+// when its own text or the knownFields set changes
+
+const FeatureDetail = memo(function FeatureDetail({
+ text,
+ knownFieldsSet,
+}: {
+ text: string;
+ knownFieldsSet: Set;
+}): ReactElement {
+ const segments = tokenizeDetail(text, knownFieldsSet);
+ return (
+
+ {segments.map((seg, i) => {
+ if (seg.type === 'mdlink')
+ return (
+
+ {seg.label}
+
+
+ );
+ if (seg.type === 'url')
+ return (
+
+ Learn more
+
+
+ );
+ if (seg.type === 'file')
+ return (
+
+ {seg.value}
+
+ );
+ if (seg.type === 'field')
+ return (
+
+ {seg.value}
+
+ );
+ return {seg.value};
+ })}
+
+ );
+});
+
+// ── Main component ────────────────────────────────────────────────────────────
+
+export default function GtfsFeatureTracker({
+ features,
+ consumers,
+ knownFields,
+}: GtfsFeatureTrackerProps): ReactElement {
+ const [selectedType, setSelectedType] = useState(null);
+ const [highlightedFeatureAnchor, setHighlightedFeatureAnchor] = useState<
+ string | null
+ >(null);
+ const [expandedCategories, setExpandedCategories] = useState<
+ Record
+ >({});
+ const highlightTimeoutRef = useRef | null>(
+ null,
+ );
+
+ const knownFieldsSet = useMemo(() => new Set(knownFields), [knownFields]);
+
+ const filteredConsumers = useMemo(() => {
+ if (selectedType === null) return consumers;
+ return consumers.filter((c) => c.type === selectedType);
+ }, [consumers, selectedType]);
+
+ // Combined into one loop (js-combine-iterations): avoids iterating `features` twice
+ const { categories, featuresByCategory } = useMemo(() => {
+ const cats: string[] = [];
+ const map: Record = {};
+ for (const f of features) {
+ if (map[f.category] === undefined) {
+ cats.push(f.category);
+ map[f.category] = [];
+ }
+ map[f.category].push(f);
+ }
+ return { categories: cats, featuresByCategory: map };
+ }, [features]);
+
+ const toggleCategory = (category: string): void => {
+ setExpandedCategories((prev) => ({
+ ...prev,
+ [category]: !(prev[category] ?? true),
+ }));
+ };
+
+ const isCategoryExpanded = (category: string): boolean => {
+ return expandedCategories[category] ?? true;
+ };
+
+ useEffect(function highlightRowFromAnchor() {
+ const clearHighlightTimeout = (): void => {
+ if (highlightTimeoutRef.current != null) {
+ clearTimeout(highlightTimeoutRef.current);
+ highlightTimeoutRef.current = null;
+ }
+ };
+
+ const revealFromHash = (): void => {
+ const hash = window.location.hash;
+ if (hash === '') return;
+
+ const anchor = decodeURIComponent(hash.replace(/^#/, ''));
+ if (anchor === '') return;
+
+ const targetElement = document.getElementById(anchor);
+ if (targetElement == null) return;
+
+ setHighlightedFeatureAnchor(anchor);
+ targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
+
+ clearHighlightTimeout();
+ highlightTimeoutRef.current = setTimeout(() => {
+ setHighlightedFeatureAnchor((prev) => (prev === anchor ? null : prev));
+ }, 2800);
+ };
+
+ revealFromHash();
+ window.addEventListener('hashchange', revealFromHash);
+
+ return () => {
+ clearHighlightTimeout();
+ window.removeEventListener('hashchange', revealFromHash);
+ };
+ }, []);
+
+ return (
+ ({
+ maxWidth: theme.breakpoints.values.xl,
+ mx: 'auto',
+ })}
+ >
+ ({
+ maxWidth: theme.breakpoints.values.xl,
+ mx: 3,
+ mb: 3,
+ })}
+ >
+ GTFS Features Adoption Tracker
+
+
+
+ Consumers
+
+ {/* Consumer Legend */}
+
+ {filteredConsumers.map((consumer) => {
+ const consumerLogoSrc = CONSUMER_LOGOS[consumer.id.toLowerCase()];
+
+ return (
+
+ {consumerLogoSrc != null && consumerLogoSrc !== '' ? (
+
+ ) : (
+ ({
+ width: 28,
+ height: 28,
+ borderRadius: 2,
+ bgcolor: theme.palette.grey[200],
+ border: `1px solid ${theme.palette.divider}`,
+ flexShrink: 0,
+ })}
+ />
+ )}
+
+ {consumer.name}
+
+
+ );
+ })}
+
+
+
+ {CONSUMER_TYPES.map((type) => (
+ {
+ setSelectedType(selectedType === type ? null : type);
+ }}
+ />
+ ))}
+
+
+
+
+ }
+ >
+ gtfs.org
+
+
+
+ {/* Category Sections */}
+ {categories.map((category) => {
+ const categoryFeatures = featuresByCategory[category] ?? [];
+ const progress = computeCategoryProgress(
+ categoryFeatures,
+ filteredConsumers,
+ );
+ const expanded = isCategoryExpanded(category);
+
+ return (
+
+ {/* Category Header */}
+ {
+ toggleCategory(category);
+ }}
+ sx={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: 2,
+ cursor: 'pointer',
+ backgroundColor: 'background.default',
+
+ px: 3,
+ py: 1.5,
+ userSelect: 'none',
+ }}
+ >
+
+ {expanded ? : }
+
+
+ {category}
+
+ ({
+ bgcolor: `rgba(${theme.vars.palette.common.onBackgroundChannel} / 0.1)`,
+ })}
+ />
+
+
+
+
+
+ {progress}%
+
+
+
+
+
+ {/* Feature Table */}
+
+
+
+
+
+
+ Feature
+
+ {filteredConsumers.map((consumer) => {
+ const consumerLogo =
+ CONSUMER_LOGOS[consumer.id.toLowerCase()];
+
+ return (
+
+
+
+ {consumerLogo != null &&
+ consumerLogo !== '' ? (
+
+ ) : (
+
+ {consumer.name?.charAt(0).toUpperCase() ??
+ '?'}
+
+ )}
+
+ {consumer.name}
+
+
+ {consumer.lastUpdate != null ? (
+
+ {`Updated ${formatDate(consumer.lastUpdate)}`}
+
+ ) : null}
+
+
+ );
+ })}
+
+
+
+ {categoryFeatures.map((feature) => {
+ const featureAnchor = toFeatureAnchor(feature.name);
+ const isHighlighted =
+ highlightedFeatureAnchor === featureAnchor;
+
+ return (
+ ({
+ borderBottom: '1px solid',
+ borderColor: 'divider',
+ height: 72,
+ ...(isHighlighted
+ ? {
+ animation:
+ 'feature-row-highlight 2.8s cubic-bezier(0.22, 1, 0.36, 1) 1',
+ '@keyframes feature-row-highlight': {
+ '0%': {
+ backgroundColor: `rgba(${theme.vars.palette.warning.mainChannel} / 0.36)`,
+ },
+ '100%': {
+ backgroundColor: 'transparent',
+ },
+ },
+ }
+ : null),
+ '&:hover .feature-links': {
+ opacity: 1,
+ },
+ })}
+ >
+ ({
+ position: 'sticky',
+ left: 0,
+ zIndex: 10,
+ bgcolor: isHighlighted
+ ? `rgba(${theme.vars.palette.warning.mainChannel} / 0.20)`
+ : 'background.default',
+ verticalAlign: 'middle',
+ minWidth: 210,
+ })}
+ >
+
+
+ {feature.name}
+
+
+ {feature.documentationUrl != null ? (
+
+ }
+ sx={{
+ color: 'text.secondary',
+ minWidth: 0,
+ px: 0.5,
+ }}
+ >
+ Docs
+
+ ) : null}
+ {feature.category.toLowerCase() !==
+ 'base' && (
+
+ }
+ sx={{
+ color: 'text.secondary',
+ minWidth: 0,
+ px: 0.5,
+ }}
+ >
+ Feeds
+
+ )}
+
+
+
+ {filteredConsumers.map((consumer) => {
+ const support = feature.support[consumer.id] ?? {
+ rawStatus: '',
+ details: '',
+ };
+ const statusText = getStatusText(
+ support.rawStatus,
+ );
+ return (
+ ({
+ verticalAlign: 'middle',
+ backgroundColor: isHighlighted
+ ? `rgba(${theme.vars.palette.warning.mainChannel} / 0.14)`
+ : 'background.default',
+ })}
+ >
+
+
+ {getStatusIcon(support.rawStatus)}
+
+ {statusText}
+
+
+ {support.details != null &&
+ support.details !== '' ? (
+
+ ) : null}
+
+
+ );
+ })}
+
+ );
+ })}
+
+
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/app/[locale]/gtfs-feature-tracker/components/types.ts b/src/app/[locale]/gtfs-feature-tracker/components/types.ts
new file mode 100644
index 00000000..84cc6717
--- /dev/null
+++ b/src/app/[locale]/gtfs-feature-tracker/components/types.ts
@@ -0,0 +1,65 @@
+export interface TokenBase {
+ start: number;
+ end: number;
+}
+
+export interface MdLinkToken extends TokenBase {
+ type: 'mdlink';
+ label: string;
+ url: string;
+}
+
+export interface UrlToken extends TokenBase {
+ type: 'url';
+ value: string;
+}
+
+export interface FileToken extends TokenBase {
+ type: 'file';
+ value: string;
+}
+
+export interface FieldToken extends TokenBase {
+ type: 'field';
+ value: string;
+}
+
+export interface TextToken {
+ type: 'text';
+ value: string;
+}
+
+export type Token = MdLinkToken | UrlToken | FileToken | FieldToken | TextToken;
+
+export interface GtfsFeatureTrackerProps {
+ features: Feature[];
+ consumers: Consumer[];
+ knownFields: string[];
+}
+
+export interface Consumer {
+ id: string;
+ name: string;
+ type: string;
+ lastUpdate: string;
+}
+
+export interface FeatureSupport {
+ /** Raw status from CSV (e.g. "YES - for every feed", "Some fields are ignored") */
+ rawStatus: string;
+ details: string;
+}
+
+export interface Feature {
+ name: string;
+ category: string;
+ description: string;
+ documentationUrl: string | null;
+ support: Record;
+}
+
+export interface TrackerData {
+ features: Feature[];
+ consumers: Consumer[];
+ knownFields: string[];
+}
diff --git a/src/app/[locale]/gtfs-feature-tracker/lib/fetchTrackerData.ts b/src/app/[locale]/gtfs-feature-tracker/lib/fetchTrackerData.ts
new file mode 100644
index 00000000..6857fa8b
--- /dev/null
+++ b/src/app/[locale]/gtfs-feature-tracker/lib/fetchTrackerData.ts
@@ -0,0 +1,211 @@
+/* eslint-disable */
+// This file currently retrieves all data to display gtfs feature tracking information
+// In the future when this data will come from the database, this file can be removed and the data fetching logic can be moved to the page.tsx file
+
+import type {
+ Consumer,
+ Feature,
+ FeatureSupport,
+ TrackerData,
+} from '../components/types';
+
+const CSV_URL =
+ 'https://docs.google.com/spreadsheets/d/e/2PACX-1vSu9_3lyF9caXrDdlGCtO1Bg17Uhkh_L9l-REYkYVUINvrEEaVwrx1mSZ--_iKAGcJ2x8bFBzYHVU74/pub?output=csv';
+const CATEGORIES_CSV_URL =
+ 'https://docs.google.com/spreadsheets/d/e/2PACX-1vSu9_3lyF9caXrDdlGCtO1Bg17Uhkh_L9l-REYkYVUINvrEEaVwrx1mSZ--_iKAGcJ2x8bFBzYHVU74/pub?gid=1998786437&single=true&output=csv';
+const FIELDS_CSV_URL =
+ 'https://docs.google.com/spreadsheets/d/e/2PACX-1vSu9_3lyF9caXrDdlGCtO1Bg17Uhkh_L9l-REYkYVUINvrEEaVwrx1mSZ--_iKAGcJ2x8bFBzYHVU74/pub?gid=1909638109&single=true&output=csv';
+
+const GTFS_DOCS_BASE = 'https://gtfs.org/documentation/schedule/reference/';
+
+export const DOCS_URL_MAP: Record = {
+ Agency: `${GTFS_DOCS_BASE}#agencytxt`,
+ Stops: `${GTFS_DOCS_BASE}#stopstxt`,
+ Routes: `${GTFS_DOCS_BASE}#routestxt`,
+ 'Service Dates': `${GTFS_DOCS_BASE}#calendartxt`,
+ Trips: `${GTFS_DOCS_BASE}#tripstxt`,
+ 'Stop Times': `${GTFS_DOCS_BASE}#stop_timestxt`,
+ 'Feed Information': `${GTFS_DOCS_BASE}#feed_infotxt`,
+ Shapes: `${GTFS_DOCS_BASE}#shapestxt`,
+ 'Route Colors': `${GTFS_DOCS_BASE}#routestxt`,
+ 'Bike Allowed': `${GTFS_DOCS_BASE}#tripstxt`,
+ Headsigns: `${GTFS_DOCS_BASE}#tripstxt`,
+ 'Location Types': `${GTFS_DOCS_BASE}#stopstxt`,
+ Frequencies: `${GTFS_DOCS_BASE}#frequenciestxt`,
+ Transfers: `${GTFS_DOCS_BASE}#transferstxt`,
+ Translations: `${GTFS_DOCS_BASE}#translationstxt`,
+ Attributions: `${GTFS_DOCS_BASE}#attributionstxt`,
+ 'Stops Wheelchair Accessibility': `${GTFS_DOCS_BASE}#stopstxt`,
+ 'Trips Wheelchair Accessibility': `${GTFS_DOCS_BASE}#tripstxt`,
+ 'Text-to-Speech': `${GTFS_DOCS_BASE}#stopstxt`,
+ 'Fare Products': `${GTFS_DOCS_BASE}#fare_productstxt`,
+ 'Fare Media': `${GTFS_DOCS_BASE}#fare_mediatxt`,
+ 'Rider Categories': `${GTFS_DOCS_BASE}#rider_categoriestxt`,
+ 'Route-Based Fares': `${GTFS_DOCS_BASE}#fare_rulestxt`,
+ 'Time-Based Fares': `${GTFS_DOCS_BASE}#fare_leg_rulestxt`,
+ 'Zone-Based Fares': `${GTFS_DOCS_BASE}#fare_leg_rulestxt`,
+ 'Fare Transfers': `${GTFS_DOCS_BASE}#fare_transfer_rulestxt`,
+ 'Fares V1': `${GTFS_DOCS_BASE}#fare_attributestxt`,
+ 'Pathway Connections': `${GTFS_DOCS_BASE}#pathwaystxt`,
+ 'Pathway Details': `${GTFS_DOCS_BASE}#pathwaystxt`,
+ 'In-Station Traversal Time': `${GTFS_DOCS_BASE}#pathwaystxt`,
+ 'Pathway Signs': `${GTFS_DOCS_BASE}#pathwaystxt`,
+ Levels: `${GTFS_DOCS_BASE}#levelstxt`,
+ 'Continuous Stops': `${GTFS_DOCS_BASE}#stopstxt`,
+ 'Booking Rules': `${GTFS_DOCS_BASE}#booking_rulestxt`,
+ 'Predefined Routes with Deviation': `${GTFS_DOCS_BASE}#stop_timestxt`,
+ 'Zone-based Demand Responsive Services': `${GTFS_DOCS_BASE}#locationstxt`,
+ 'Fixed-Stops Demand Responsive Services': `${GTFS_DOCS_BASE}#stop_timestxt`,
+};
+
+// RFC-4180 compliant CSV parser handling quoted fields with embedded commas/newlines
+function parseCSVText(text: string): Array> {
+ const rows: string[][] = [];
+ let row: string[] = [];
+ let field = '';
+ let inQ = false;
+
+ for (let i = 0; i < text.length; i++) {
+ const c = text[i];
+ const n = text[i + 1];
+ if (inQ) {
+ if (c === '"' && n === '"') {
+ field += '"';
+ i++;
+ } else if (c === '"') {
+ inQ = false;
+ } else {
+ field += c;
+ }
+ } else {
+ if (c === '"') {
+ inQ = true;
+ } else if (c === ',') {
+ row.push(field.trim());
+ field = '';
+ } else if (c === '\r' && n === '\n') {
+ row.push(field.trim());
+ if (row.some((v) => v)) rows.push(row);
+ row = [];
+ field = '';
+ i++;
+ } else if (c === '\n' || c === '\r') {
+ row.push(field.trim());
+ if (row.some((v) => v)) rows.push(row);
+ row = [];
+ field = '';
+ } else {
+ field += c;
+ }
+ }
+ }
+ row.push(field.trim());
+ if (row.some((v) => v)) rows.push(row);
+
+ if (rows.length === 0) return [];
+ const headers = rows[0];
+ return rows.slice(1).map((r) => {
+ const obj: Record = {};
+ headers.forEach((h, idx) => {
+ obj[h] = r[idx] ?? '';
+ });
+ return obj;
+ });
+}
+
+function formatConsumerName(id: string): string {
+ const names: Record = {
+ google: 'Google',
+ transitapp: 'Transit',
+ motis: 'Motis',
+ opentripplanner: 'OpenTripPlanner',
+ aubin: 'Aubin',
+ };
+ return names[id.toLowerCase()] ?? id;
+}
+
+async function fetchCsvText(url: string): Promise {
+ const response = await fetch(url, { next: { revalidate: 3600 } });
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch tracker CSV from ${url}: ${response.status} ${response.statusText}`,
+ );
+ }
+
+ return response.text();
+}
+
+export async function fetchTrackerData(): Promise {
+ try {
+ const [featuresText, categoriesText, fieldsText] = await Promise.all([
+ fetchCsvText(CSV_URL),
+ fetchCsvText(CATEGORIES_CSV_URL),
+ fetchCsvText(FIELDS_CSV_URL),
+ ]);
+
+ // Parse known GTFS field names
+ const fieldsRows = parseCSVText(fieldsText);
+ const knownFields = Array.from(
+ new Set(fieldsRows.map((r) => r.field_name?.trim()).filter(Boolean)),
+ );
+
+ // Parse categories → consumers with types and dates
+ const categoriesRows = parseCSVText(categoriesText);
+ const consumerDates: Record = {};
+ const consumerTypes: Record = {};
+ for (const row of categoriesRows) {
+ const c = (row.consumer ?? '').trim();
+ if (!c) continue;
+ const key = c.toLowerCase().replace(/\s+/g, '');
+ consumerTypes[key] = (row.type ?? '').trim();
+ consumerDates[key] = (row.last_update ?? '').trim();
+ }
+
+ // Parse features CSV — header driven
+ const featureRows = parseCSVText(featuresText);
+ if (featureRows.length === 0) {
+ return { features: [], consumers: [], knownFields };
+ }
+
+ // Extract consumer IDs from header columns (pattern: {id}_use)
+ const headers = Object.keys(featureRows[0]);
+ const consumerIds = headers
+ .filter((h) => h.endsWith('_use'))
+ .map((h) => h.replace('_use', ''));
+
+ // Build consumer list preserving CSV column order
+ const consumers: Consumer[] = consumerIds.map((id) => {
+ const key = id.toLowerCase().replace(/\s+/g, '');
+ return {
+ id,
+ name: formatConsumerName(id),
+ type: consumerTypes[key] ?? '',
+ lastUpdate: consumerDates[key] ?? '',
+ };
+ });
+
+ const features: Feature[] = featureRows.map((row) => {
+ const support: Record = {};
+ for (const cId of consumerIds) {
+ const cLow = cId.toLowerCase();
+ support[cId] = {
+ rawStatus: (row[`${cId}_use`] ?? row[`${cLow}_use`] ?? '').trim(),
+ details: (row[`${cId}_details`] ?? row[`${cLow}_details`] ?? '').trim(),
+ };
+ }
+ return {
+ name: row.Feature ?? '',
+ category: row.Type ?? 'Other',
+ description: row.Description ?? '',
+ documentationUrl: DOCS_URL_MAP[row.Feature ?? ''] ?? null,
+ support,
+ };
+ });
+
+ return { features, consumers, knownFields };
+ } catch (error) {
+ console.error('Failed to fetch GTFS feature tracker data', error);
+ return { features: [], consumers: [], knownFields: [] };
+ }
+}
diff --git a/src/app/[locale]/gtfs-feature-tracker/page.tsx b/src/app/[locale]/gtfs-feature-tracker/page.tsx
new file mode 100644
index 00000000..b8391c8f
--- /dev/null
+++ b/src/app/[locale]/gtfs-feature-tracker/page.tsx
@@ -0,0 +1,48 @@
+import { type ReactElement } from 'react';
+import { setRequestLocale } from 'next-intl/server';
+import { type Locale, routing } from '../../../i18n/routing';
+import { type Metadata } from 'next';
+import GtfsFeatureTracker from './components/GtfsFeatureTracker';
+import { fetchTrackerData } from './lib/fetchTrackerData';
+
+export const dynamic = 'force-static';
+export const revalidate = 86400; // Revalidate every day
+
+export const metadata: Metadata = {
+ title: 'GTFS Features Adoption Tracker | MobilityDatabase',
+ description:
+ 'Track the adoption of GTFS features across major journey planners including Google, Transit, Motis, OpenTripPlanner, and more.',
+ openGraph: {
+ title: 'GTFS Features Adoption Tracker | MobilityDatabase',
+ description:
+ 'Track the adoption of GTFS features across major journey planners including Google, Transit, Motis, OpenTripPlanner, and more.',
+ url: 'https://mobilitydatabase.org/gtfs-feature-tracker',
+ siteName: 'MobilityDatabase',
+ type: 'website',
+ },
+};
+
+export function generateStaticParams(): Array<{ locale: Locale }> {
+ return routing.locales.map((locale) => ({ locale }));
+}
+
+interface PageProps {
+ params: Promise<{ locale: string }>;
+}
+
+export default async function GtfsFeatureTrackerPage({
+ params,
+}: PageProps): Promise {
+ const { locale } = await params;
+ setRequestLocale(locale);
+
+ const { features, consumers, knownFields } = await fetchTrackerData();
+
+ return (
+
+ );
+}
diff --git a/src/app/components/Footer.tsx b/src/app/components/Footer.tsx
index 4785aa5f..7f951d30 100644
--- a/src/app/components/Footer.tsx
+++ b/src/app/components/Footer.tsx
@@ -146,10 +146,9 @@ const Footer: React.FC = () => {
- {/* Validators column */}
-
+ {/* Tools column — validators + analytics */}
- {t('columns.validators')}
+ {t('columns.tools')}
{
)}
+
+ {t('links.gtfsFeatureTracker')}
+
{/* Company column */}
diff --git a/src/app/components/Header.style.ts b/src/app/components/Header.style.ts
index 7cde0766..443925a5 100644
--- a/src/app/components/Header.style.ts
+++ b/src/app/components/Header.style.ts
@@ -1,4 +1,6 @@
-import { type Theme } from '@mui/material';
+import type React from 'react';
+import { MenuItem, Typography, type Theme } from '@mui/material';
+import { styled } from '@mui/material/styles';
import { type SystemStyleObject } from '@mui/system';
import { fontFamily } from '../Theme';
@@ -47,3 +49,35 @@ export const animatedButtonStyling = (
pointerEvents: 'none',
},
});
+
+export const headerDropdownMenuHeader = (): SystemStyleObject => ({
+ px: 2,
+ pt: 1.5,
+ pb: 0.5,
+ display: 'block',
+ color: 'text.disabled',
+ lineHeight: 2,
+});
+
+export const HeaderMenuItemHeader = styled(Typography)(({ theme }) => ({
+ paddingLeft: theme.spacing(2),
+ paddingRight: theme.spacing(2),
+ paddingTop: theme.spacing(1),
+ color: theme.vars.palette.primary.main,
+ fontWeight: 700,
+ fontFamily: fontFamily.secondary,
+}));
+
+export const HeaderMenuItem = styled(MenuItem)<{
+ component?: React.ElementType;
+ href?: string;
+ target?: string;
+ rel?: string;
+}>(() => ({
+ fontFamily: fontFamily.secondary,
+ opacity: 0.8,
+ fontWeight: 500,
+ '&:hover': {
+ opacity: 1,
+ },
+})) as typeof MenuItem;
diff --git a/src/app/components/Header.tsx b/src/app/components/Header.tsx
index 6be8221e..cfb09c9d 100644
--- a/src/app/components/Header.tsx
+++ b/src/app/components/Header.tsx
@@ -9,12 +9,13 @@ import {
Divider,
Drawer,
IconButton,
- ListSubheader,
Toolbar,
Typography,
Button,
Menu,
+ MenuList,
MenuItem,
+ Popover,
Select,
Alert,
AlertTitle,
@@ -36,7 +37,12 @@ import { OpenInNew } from '@mui/icons-material';
import { useRemoteConfig } from '../context/RemoteConfigProvider';
import { fontFamily } from '../Theme';
import { defaultRemoteConfigValues } from '../interface/RemoteConfig';
-import { animatedButtonStyling } from './Header.style';
+import {
+ animatedButtonStyling,
+ headerDropdownMenuHeader,
+ HeaderMenuItem,
+ HeaderMenuItemHeader,
+} from './Header.style';
import ThemeToggle from './ThemeToggle';
import HeaderSearchBar from './HeaderSearchBar';
import { useTranslations, useLocale } from 'next-intl';
@@ -133,19 +139,20 @@ export default function DrawerAppBar(): React.ReactElement {
const container =
typeof window !== 'undefined' ? () => window.document.body : undefined;
- const [validatorAnchorEl, setValidatorAnchorEl] =
- React.useState(null);
- const validatorCloseTimer =
+ const [toolsAnchorEl, setToolsAnchorEl] = React.useState(
+ null,
+ );
+ const toolsCloseTimer =
React.useRef>(undefined);
- const handleValidatorOpen = (e: React.MouseEvent): void => {
- clearTimeout(validatorCloseTimer.current);
- setValidatorAnchorEl(e.currentTarget);
+ const handleToolsOpen = (e: React.MouseEvent): void => {
+ clearTimeout(toolsCloseTimer.current);
+ setToolsAnchorEl(e.currentTarget);
};
- const handleValidatorClose = (): void => {
- validatorCloseTimer.current = setTimeout(() => {
- setValidatorAnchorEl(null);
+ const handleToolsClose = (): void => {
+ toolsCloseTimer.current = setTimeout(() => {
+ setToolsAnchorEl(null);
}, 80);
};
@@ -167,7 +174,7 @@ export default function DrawerAppBar(): React.ReactElement {
React.useEffect(() => {
return () => {
- clearTimeout(validatorCloseTimer.current);
+ clearTimeout(toolsCloseTimer.current);
clearTimeout(accountCloseTimer.current);
};
}, []);
@@ -293,84 +300,174 @@ export default function DrawerAppBar(): React.ReactElement {
}
sx={(theme) => ({
...animatedButtonStyling(theme),
color: theme.vars.palette.text.primary,
})}
className={
- activeTab.includes('validator') ? 'active short' : ''
+ activeTab.includes('validator') ||
+ activeTab.includes('gtfs-feature-tracker')
+ ? 'active short'
+ : ''
}
>
- {tCommon('validators')}
+ {tCommon('tools')}
-
+
+ {/* Validators column */}
+
+
+ {tCommon('validators')}
+
+
+
+ {tCommon('gtfsValidator')}
+
+
+
+ {tCommon('gtfsRtValidator')}
+
+
+ {config.gbfsValidator ? (
+ {
+ setToolsAnchorEl(null);
+ handleNavigation('/gbfs-validator');
+ }}
+ >
+ {tCommon('gbfsValidator')}
+
+ ) : (
+
+ {tCommon('gbfsValidator')}
+
+
+ )}
+
+
+
+ {/* Analytics column */}
+
+
+ {tCommon('analytics')}
+
+
+
+ {tCommon('gtfsFeatureTracker')}
+
+
+
+ {/* Metrics column — admin only */}
+ {metricsOptionsEnabled ? (
+ <>
+
+
+
+ {tCommon('metricsAdminOnly')}
+
+
+
+ {tCommon('gtfs')}
+
+ {gtfsMetricsNavItems.map((item) => (
+ {
+ setToolsAnchorEl(null);
+ }}
+ >
+ {item.title}
+
+ ))}
+
+ {tCommon('gbfs')}
+
+ {gbfsMetricsNavItems.map((item) => (
+ {
+ setToolsAnchorEl(null);
+ }}
+ >
+ {item.title}
+
+ ))}
+
+
+ >
+ ) : null}
+
+
{isAuthenticated ? (
@@ -439,57 +536,6 @@ export default function DrawerAppBar(): React.ReactElement {
>
{tCommon('signOut')}
- {metricsOptionsEnabled && [
- ,
-
- {tCommon('metricsAdminOnly')}
- ,
-
- GTFS
- ,
- ...gtfsMetricsNavItems.map((item) => (
-
- )),
-
- GBFS
- ,
- ...gbfsMetricsNavItems.map((item) => (
-
- )),
- ]}
) : (
diff --git a/src/app/components/HeaderMobileDrawer.tsx b/src/app/components/HeaderMobileDrawer.tsx
index e7d4d53d..5224bf47 100644
--- a/src/app/components/HeaderMobileDrawer.tsx
+++ b/src/app/components/HeaderMobileDrawer.tsx
@@ -33,6 +33,7 @@ import { useRemoteConfig } from '../context/RemoteConfigProvider';
import { useTranslations } from 'next-intl';
import Image from 'next/image';
import ThemeToggle from './ThemeToggle';
+import { Link as LocaleLink } from '../../i18n/navigation';
const websiteTile = 'MobilityDatabase';
@@ -155,53 +156,108 @@ export default function DrawerContent({
))}
- {config.gbfsValidator && (
-
- }
- aria-controls='validators-content'
- id='validators-content'
+
+ }
+ aria-controls='tools-content'
+ id='tools-header'
+ >
+
-
- {t('validators')}
-
-
-
+ {t('tools')}
+
+
+
+ {/* Validators sub-section */}
+
+ {t('validators')}
+
+ }
+ component={Link}
+ href='https://gtfs-validator.mobilitydata.org/'
+ target='_blank'
+ rel='noopener noreferrer'
+ >
+ {t('gtfsValidator')}
+
+ }
+ component={Link}
+ href='https://github.com/MobilityData/gtfs-realtime-validator'
+ target='_blank'
+ rel='noopener noreferrer'
+ >
+ {t('gtfsRtValidator')}
+
+ {config.gbfsValidator ? (
+ ) : (
}
component={Link}
- href='https://gtfs-validator.mobilitydata.org/'
- target='_blank'
- rel='noopener noreferrer'
- >
- {t('gtfsValidator')}
-
- }
- component={Link}
- href='https://github.com/MobilityData/gtfs-realtime-validator'
+ href='https://gbfs-validator.mobilitydata.org/'
target='_blank'
rel='noopener noreferrer'
>
- {t('gtfsRtValidator')}
+ {t('gbfsValidator')}
-
-
- )}
+ )}
+
+ {/* Analytics sub-section */}
+
+ {t('analytics')}
+
+
+
+
{metricsOptionsEnabled && (
<>
@@ -214,7 +270,7 @@ export default function DrawerContent({
variant={'subtitle1'}
sx={{ fontFamily: fontFamily.secondary }}
>
- GTFS Metrics
+ {`${t('gtfs')} ${t('metrics')}`}
@@ -240,7 +296,7 @@ export default function DrawerContent({
variant={'subtitle1'}
sx={{ fontFamily: fontFamily.secondary }}
>
- GBFS Metrics
+ {`${t('gbfs')} ${t('metrics')}`}
@@ -270,7 +326,7 @@ export default function DrawerContent({
variant={'subtitle1'}
sx={{ fontFamily: fontFamily.secondary }}
>
- Account
+ {t('account')}
@@ -279,20 +335,20 @@ export default function DrawerContent({
sx={mobileNavElementStyle}
href={ACCOUNT_TARGET}
>
- Account Details
+ {t('accountDetails')}
) : (
)}
diff --git a/src/app/utils/featureAnchor.ts b/src/app/utils/featureAnchor.ts
new file mode 100644
index 00000000..ea20b895
--- /dev/null
+++ b/src/app/utils/featureAnchor.ts
@@ -0,0 +1,11 @@
+export function toFeatureAnchor(featureName: string): string {
+ const normalized = featureName
+ .trim()
+ .toLowerCase()
+ .normalize('NFKD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+
+ return normalized === '' ? 'feature' : normalized;
+}