Skip to content

Commit f1931ad

Browse files
Alessandro100Copilot
andcommitted
gtfs feature tracker
Co-authored-by: Copilot <copilot@github.com>
1 parent cd41fde commit f1931ad

8 files changed

Lines changed: 1108 additions & 5 deletions

File tree

messages/en.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@
138138
"resultsFor": "{startResult}-{endResult} of {totalResults} results",
139139
"deprecated": "Deprecated",
140140
"searchPlaceholder": "Transit provider, feed name, or location",
141+
"featureTrackerBanner": "See which trip planners use these features",
141142
"noResults": "We're sorry, we found no search results for ''{activeSearch}''.",
142143
"searchSuggestions": "Search suggestions: ",
143144
"searchTips": {
@@ -539,14 +540,15 @@
539540
"copyright": "© {year} MobilityDatabase. All rights reserved.",
540541
"columns": {
541542
"platform": "Platform",
542-
"validators": "Validators",
543+
"tools": "Tools",
543544
"company": "Company",
544545
"legal": "Legal"
545546
},
546547
"links": {
547548
"feeds": "Feeds",
548549
"addFeed": "Add a Feed",
549550
"apiDocs": "API Docs",
551+
"gtfsFeatureTracker": "GTFS Feature Tracker",
550552
"gtfsValidator": "GTFS Validator",
551553
"gtfsRtValidator": "GTFS-RT Validator",
552554
"gbfsValidator": "GBFS Validator",

messages/fr.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@
138138
"resultsFor": "{startResult}-{endResult} of {totalResults} results",
139139
"deprecated": "Deprecated",
140140
"searchPlaceholder": "Transit provider, feed name, or location",
141+
"featureTrackerBanner": "Voir quels planificateurs d’itinéraires utilisent ces fonctionnalités",
141142
"noResults": "We're sorry, we found no search results for ''{activeSearch}''.",
142143
"searchSuggestions": "Search suggestions: ",
143144
"searchTips": {
@@ -539,14 +540,15 @@
539540
"copyright": "© {year} MobilityDatabase. Tous droits réservés.",
540541
"columns": {
541542
"platform": "Plateforme",
542-
"validators": "Validateurs",
543+
"tools": "Outils",
543544
"company": "Entreprise",
544545
"legal": "Légal"
545546
},
546547
"links": {
547548
"feeds": "Flux",
548549
"addFeed": "Ajouter un flux",
549550
"apiDocs": "Docs API",
551+
"gtfsFeatureTracker": "Suivi des fonctionnalités GTFS",
550552
"gtfsValidator": "Validateur GTFS",
551553
"gtfsRtValidator": "Validateur GTFS-RT",
552554
"gbfsValidator": "Validateur GBFS",

src/app/[locale]/feeds/components/FeedsScreen.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
Button,
77
Chip,
88
Container,
9-
CssBaseline,
109
Grid,
1110
InputAdornment,
1211
LinearProgress,
@@ -19,8 +18,9 @@ import {
1918
Typography,
2019
useTheme,
2120
} from '@mui/material';
22-
import { Search } from '@mui/icons-material';
21+
import { OpenInNew, Search } from '@mui/icons-material';
2322
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
23+
import NextLink from 'next/link';
2424
import SearchTable from '../../../screens/Feeds/SearchTable';
2525
import { useTranslations } from 'next-intl';
2626
import {
@@ -179,7 +179,6 @@ export default function FeedsScreen(): React.ReactElement {
179179
position: 'relative',
180180
}}
181181
>
182-
<CssBaseline />
183182
<Box
184183
sx={{
185184
display: 'flex',
@@ -319,6 +318,19 @@ export default function FeedsScreen(): React.ReactElement {
319318

320319
<Grid size={{ xs: 12, md: 10 }}>
321320
<Box sx={chipHolderStyles}>
321+
{selectedFeatures.length > 0 && (
322+
<Button
323+
component={NextLink}
324+
href='/gtfs-feature-tracker'
325+
variant='outlined'
326+
size='small'
327+
target='_blank'
328+
color='primary'
329+
endIcon={<OpenInNew />}
330+
>
331+
{t('featureTrackerBanner')}
332+
</Button>
333+
)}
322334
{selectedFeedTypes.gtfs && (
323335
<Chip
324336
color='primary'
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/* eslint-disable */
2+
import type { ReactElement } from 'react';
3+
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
4+
import CancelIcon from '@mui/icons-material/Cancel';
5+
import InfoIcon from '@mui/icons-material/Info';
6+
import AutorenewIcon from '@mui/icons-material/Autorenew';
7+
import RemoveIcon from '@mui/icons-material/Remove';
8+
import type {
9+
Feature,
10+
Consumer,
11+
MdLinkToken,
12+
UrlToken,
13+
FileToken,
14+
FieldToken,
15+
Token,
16+
} from './types';
17+
18+
// Many of these helper functions are based on parsing freeform text from the CSV, so they include some normalization and best-effort handling of unexpected values
19+
// Once the data will come from the database with a well-defined schema, some of these parsing/normalization functions can be simplified or removed
20+
21+
export function getStatusText(raw: string): string {
22+
const n = raw.toLowerCase().trim();
23+
if (n.startsWith('yes - for every feed')) return 'Every feed';
24+
if (n.startsWith('yes - for some feeds')) return 'Some feeds';
25+
if (n.startsWith('yes')) return 'Yes';
26+
if (n.startsWith('no')) return 'No';
27+
if (n === 'integration planned') return 'Planned';
28+
if (n === 'test in progress') return 'Test in progress';
29+
if (n === 'partial integration') return 'Partial integration';
30+
if (n === 'some fields are ignored') return 'Some fields ignored';
31+
return raw || 'Unknown';
32+
}
33+
34+
export function getStatusIcon(raw: string): ReactElement {
35+
const n = raw.toLowerCase().trim();
36+
if (n.startsWith('yes'))
37+
return <CheckCircleIcon color='success' sx={{ fontSize: 20 }} />;
38+
if (n.startsWith('no'))
39+
return <CancelIcon color='error' sx={{ fontSize: 20 }} />;
40+
if (n === 'integration planned')
41+
return <InfoIcon color='info' sx={{ fontSize: 20 }} />;
42+
if (n === 'test in progress')
43+
return <AutorenewIcon color='warning' sx={{ fontSize: 20 }} />;
44+
if (n === 'partial integration')
45+
return <InfoIcon color='warning' sx={{ fontSize: 20 }} />;
46+
if (n === 'some fields are ignored')
47+
return <InfoIcon color='info' sx={{ fontSize: 20 }} />;
48+
return <RemoveIcon sx={{ fontSize: 20, color: 'text.disabled' }} />;
49+
}
50+
51+
export function isStatusSupported(raw: string): boolean {
52+
return raw.toLowerCase().trim().startsWith('yes');
53+
}
54+
55+
export function computeCategoryProgress(
56+
features: Feature[],
57+
consumers: Consumer[],
58+
): number {
59+
let supported = 0;
60+
let total = 0;
61+
for (const feature of features) {
62+
for (const consumer of consumers) {
63+
const raw = feature.support[consumer.id]?.rawStatus ?? '';
64+
if (raw) {
65+
total++;
66+
if (isStatusSupported(raw)) supported++;
67+
}
68+
}
69+
}
70+
return total > 0 ? Math.round((supported / total) * 100) : 0;
71+
}
72+
73+
export function formatDate(dateStr: string): string {
74+
if (!dateStr) return '';
75+
const d = new Date(dateStr);
76+
if (isNaN(d.getTime())) return dateStr;
77+
return d.toLocaleDateString('en-US', {
78+
year: 'numeric',
79+
month: 'short',
80+
day: 'numeric',
81+
});
82+
}
83+
84+
export function tokenizeDetail(
85+
text: string,
86+
knownFieldsSet: Set<string>,
87+
): Token[] {
88+
if (!text) return [];
89+
const tokens: Array<MdLinkToken | UrlToken | FileToken | FieldToken> = [];
90+
91+
// 1. Markdown links [label](url)
92+
const mdRe = /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g;
93+
let m: RegExpExecArray | null;
94+
while ((m = mdRe.exec(text)) !== null) {
95+
tokens.push({
96+
type: 'mdlink',
97+
label: m[1],
98+
url: m[2],
99+
start: m.index,
100+
end: m.index + m[0].length,
101+
});
102+
}
103+
104+
// 2. Bare URLs
105+
const urlRe = /https?:\/\/[^\s,)]+/g;
106+
while ((m = urlRe.exec(text)) !== null) {
107+
const overlaps = tokens.some(
108+
(t) => m!.index >= t.start && m!.index < t.end,
109+
);
110+
if (!overlaps)
111+
tokens.push({
112+
type: 'url',
113+
value: m[0],
114+
start: m.index,
115+
end: m.index + m[0].length,
116+
});
117+
}
118+
119+
// 3. .txt file names
120+
const fileRe = /\b[a-z_]+\.txt\b/g;
121+
while ((m = fileRe.exec(text)) !== null) {
122+
const overlaps = tokens.some(
123+
(t) => m!.index >= t.start && m!.index < t.end,
124+
);
125+
if (!overlaps)
126+
tokens.push({
127+
type: 'file',
128+
value: m[0],
129+
start: m.index,
130+
end: m.index + m[0].length,
131+
});
132+
}
133+
134+
// 4. Known GTFS field names
135+
const fieldRe = /\b[a-z][a-z0-9_]*[a-z0-9]\b|\b[a-z]{2,}\b/g;
136+
while ((m = fieldRe.exec(text)) !== null) {
137+
if (!knownFieldsSet.has(m[0])) continue;
138+
const overlaps = tokens.some(
139+
(t) => m!.index >= t.start && m!.index < t.end,
140+
);
141+
if (!overlaps)
142+
tokens.push({
143+
type: 'field',
144+
value: m[0],
145+
start: m.index,
146+
end: m.index + m[0].length,
147+
});
148+
}
149+
150+
tokens.sort((a, b) => a.start - b.start);
151+
152+
const segments: Token[] = [];
153+
let cursor = 0;
154+
for (const tok of tokens) {
155+
if (tok.start > cursor)
156+
segments.push({ type: 'text', value: text.slice(cursor, tok.start) });
157+
segments.push(tok);
158+
cursor = tok.end;
159+
}
160+
if (cursor < text.length)
161+
segments.push({ type: 'text', value: text.slice(cursor) });
162+
return segments;
163+
}

0 commit comments

Comments
 (0)