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 && ( + + )} + + ; + 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 !== '' ? ( + {consumer.name} + ) : ( + + ); + })} + + + + {CONSUMER_TYPES.map((type) => ( + { + setSelectedType(selectedType === type ? null : type); + }} + /> + ))} + + + + + + + + {/* 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 ? ( + + ) : null} + {feature.category.toLowerCase() !== + 'base' && ( + + )} + + + + {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 { - { - setValidatorAnchorEl(null); + setToolsAnchorEl(null); }} disableScrollLock disableRestoreFocus + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }} sx={{ pointerEvents: 'none' }} slotProps={{ paper: { onMouseEnter: () => { - clearTimeout(validatorCloseTimer.current); + clearTimeout(toolsCloseTimer.current); }, - onMouseLeave: handleValidatorClose, + onMouseLeave: handleToolsClose, sx: { pointerEvents: 'auto' }, }, }} > - - {tCommon('gtfsValidator')} - - - - {tCommon('gtfsRtValidator')} - - - {config.gbfsValidator ? ( - { - setValidatorAnchorEl(null); - handleNavigation('/gbfs-validator'); - }} - > - {tCommon('gbfsValidator')} - - ) : ( - - {tCommon('gbfsValidator')} - - - )} - + + {/* 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) => ( - { - setAccountAnchorEl(null); - handleNavigation(item.target); - }} - > - {item.title} - - )), - - GBFS - , - ...gbfsMetricsNavItems.map((item) => ( - { - setAccountAnchorEl(null); - handleNavigation(item.target); - }} - > - {item.title} - - )), - ]} ) : ( 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')} + + + + {config.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; +}