Skip to content

Commit c66185b

Browse files
Merge pull request #128 from MobilityData/feat/gtfs-feature-tracker
feat: gtfs feature tracker + updated header / footer section
2 parents 1b47ae8 + b473ac3 commit c66185b

18 files changed

Lines changed: 1594 additions & 167 deletions

File tree

messages/en.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@
9999
"gbfsValidator": "GBFS Validator",
100100
"gtfsValidator": "GTFS Validator",
101101
"gtfsRtValidator": "GTFS RT Validator",
102+
"tools": "Tools",
103+
"analytics": "Analytics",
104+
"metrics": "Metrics",
105+
"account": "Account",
106+
"gtfsFeatureTracker": "GTFS Feature Tracker",
102107
"start": "Start",
103108
"end": "End",
104109
"showLess": "Show less",
@@ -138,6 +143,8 @@
138143
"resultsFor": "{startResult}-{endResult} of {totalResults} results",
139144
"deprecated": "Deprecated",
140145
"searchPlaceholder": "Transit provider, feed name, or location",
146+
"featureTrackerBanner": "See which trip planners use these features",
147+
"featureTrackerBannerSingle": "See which trip planners use {feature}",
141148
"noResults": "We're sorry, we found no search results for ''{activeSearch}''.",
142149
"searchSuggestions": "Search suggestions: ",
143150
"searchTips": {
@@ -539,14 +546,15 @@
539546
"copyright": "© {year} MobilityDatabase. All rights reserved.",
540547
"columns": {
541548
"platform": "Platform",
542-
"validators": "Validators",
549+
"tools": "Tools",
543550
"company": "Company",
544551
"legal": "Legal"
545552
},
546553
"links": {
547554
"feeds": "Feeds",
548555
"addFeed": "Add a Feed",
549556
"apiDocs": "API Docs",
557+
"gtfsFeatureTracker": "GTFS Feature Tracker",
550558
"gtfsValidator": "GTFS Validator",
551559
"gtfsRtValidator": "GTFS-RT Validator",
552560
"gbfsValidator": "GBFS Validator",

messages/fr.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@
9999
"gbfsValidator": "GBFS Validator",
100100
"gtfsValidator": "GTFS Validator",
101101
"gtfsRtValidator": "GTFS RT Validator",
102+
"tools": "Outils",
103+
"analytics": "Analyses",
104+
"metrics": "Métriques",
105+
"account": "Compte",
106+
"gtfsFeatureTracker": "Suivi des fonctionnalités GTFS",
102107
"start": "Start",
103108
"end": "End",
104109
"showLess": "Show less",
@@ -138,6 +143,8 @@
138143
"resultsFor": "{startResult}-{endResult} of {totalResults} results",
139144
"deprecated": "Deprecated",
140145
"searchPlaceholder": "Transit provider, feed name, or location",
146+
"featureTrackerBanner": "Voir quels planificateurs d’itinéraires utilisent ces fonctionnalités",
147+
"featureTrackerBannerSingle": "Voir quels planificateurs d’itinéraires utilisent {feature}",
141148
"noResults": "We're sorry, we found no search results for ''{activeSearch}''.",
142149
"searchSuggestions": "Search suggestions: ",
143150
"searchTips": {
@@ -539,14 +546,15 @@
539546
"copyright": "© {year} MobilityDatabase. Tous droits réservés.",
540547
"columns": {
541548
"platform": "Plateforme",
542-
"validators": "Validateurs",
549+
"tools": "Outils",
543550
"company": "Entreprise",
544551
"legal": "Légal"
545552
},
546553
"links": {
547554
"feeds": "Flux",
548555
"addFeed": "Ajouter un flux",
549556
"apiDocs": "Docs API",
557+
"gtfsFeatureTracker": "Suivi des fonctionnalités GTFS",
550558
"gtfsValidator": "Validateur GTFS",
551559
"gtfsRtValidator": "Validateur GTFS-RT",
552560
"gbfsValidator": "Validateur GBFS",
53.4 KB
Loading
32.6 KB
Loading
3.41 KB
Loading
5.87 KB
Loading
5.92 KB
Loading

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

Lines changed: 44 additions & 9 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 {
@@ -39,6 +39,7 @@ import {
3939
deriveFilterFlags,
4040
buildSearchUrl,
4141
} from '../lib/useFeedsSearch';
42+
import { toFeatureAnchor } from '../../../utils/featureAnchor';
4243

4344
export default function FeedsScreen(): React.ReactElement {
4445
const theme = useTheme();
@@ -68,6 +69,16 @@ export default function FeedsScreen(): React.ReactElement {
6869
areGBFSFiltersEnabled,
6970
} = deriveFilterFlags(selectedFeedTypes);
7071

72+
const featureTrackerHref =
73+
selectedFeatures.length === 1
74+
? `/gtfs-feature-tracker#${toFeatureAnchor(selectedFeatures[0])}`
75+
: '/gtfs-feature-tracker';
76+
77+
const featureTrackerLabel =
78+
selectedFeatures.length === 1
79+
? t('featureTrackerBannerSingle', { feature: selectedFeatures[0] })
80+
: t('featureTrackerBanner');
81+
7182
// SWR-powered data fetching - keyed off URL params
7283
const { feedsData, isLoading, isValidating, isError, searchLimit } =
7384
useFeedsSearch(searchParams);
@@ -179,7 +190,6 @@ export default function FeedsScreen(): React.ReactElement {
179190
position: 'relative',
180191
}}
181192
>
182-
<CssBaseline />
183193
<Box
184194
sx={{
185195
display: 'flex',
@@ -544,13 +554,38 @@ export default function FeedsScreen(): React.ReactElement {
544554
alignItems: 'self-end',
545555
}}
546556
>
547-
<Typography
548-
variant='subtitle2'
549-
sx={{ fontWeight: 'bold' }}
550-
gutterBottom
557+
<Box
558+
sx={{
559+
display: 'flex',
560+
alignItems: 'end',
561+
gap: 2,
562+
mr: 1,
563+
flexWrap: 'wrap',
564+
}}
551565
>
552-
{getSearchResultNumbers()}
553-
</Typography>
566+
<Typography
567+
variant='subtitle2'
568+
sx={{ fontWeight: 'bold', mb: 0 }}
569+
gutterBottom
570+
>
571+
{getSearchResultNumbers()}
572+
</Typography>
573+
{selectedFeatures.length > 0 &&
574+
areFeatureFiltersEnabled && (
575+
<Button
576+
component={NextLink}
577+
href={featureTrackerHref}
578+
variant='outlined'
579+
size='small'
580+
target='_blank'
581+
color='primary'
582+
endIcon={<OpenInNew />}
583+
>
584+
{featureTrackerLabel}
585+
</Button>
586+
)}
587+
</Box>
588+
554589
<ToggleButtonGroup
555590
color='primary'
556591
value={searchView}
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)