From 45bbac87e9648078f1e32c1330c7cef257cd4e10 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 2 Sep 2026 10:35:47 -0400 Subject: [PATCH 01/21] added mcp configs --- .mcp.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .mcp.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..2c0c6b9c --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "chrome-devtools": { + "command": "npx", + "args": ["-y", "chrome-devtools-mcp@latest"] + }, + "next-devtools": { + "command": "npx", + "args": ["-y", "next-devtools-mcp@latest"] + } + } +} \ No newline at end of file From 5b7e7df2c7fe3426a8d42f149c599b70135a491e Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 2 Sep 2026 10:45:59 -0400 Subject: [PATCH 02/21] creation of template seal-of-reliability page --- .../authed/seal-of-reliability/page.tsx | 16 ++++++++++++++ .../static/seal-of-reliability/page.tsx | 18 +++++++++++++++ .../Feed/components/FeedReliabilityView.tsx | 22 +++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/seal-of-reliability/page.tsx create mode 100644 src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx create mode 100644 src/app/screens/Feed/components/FeedReliabilityView.tsx diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/seal-of-reliability/page.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/seal-of-reliability/page.tsx new file mode 100644 index 00000000..49d2a82f --- /dev/null +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/seal-of-reliability/page.tsx @@ -0,0 +1,16 @@ +import FeedReliabilityView from '../../../../../../screens/Feed/components/FeedReliabilityView'; +import { type ReactElement } from 'react'; + +interface Props { + params: Promise<{ locale: string; feedDataType: string; feedId: string }>; +} + +/** + * Force dynamic rendering for authenticated route. + * This allows cookie() and headers() access. + */ +export const dynamic = 'force-dynamic'; + +export default async function AuthedFeedReliabilityPage({}: Props): Promise { + return ; +} diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx new file mode 100644 index 00000000..3cad01be --- /dev/null +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx @@ -0,0 +1,18 @@ +import FeedReliabilityView from '../../../../../../screens/Feed/components/FeedReliabilityView'; +import { type ReactElement } from 'react'; + +interface Props { + params: Promise<{ locale: string; feedDataType: string; feedId: string }>; +} + +/** + * Seal of Reliability breakdown page (GUEST/ISR-cacheable version). + * + * IMPORTANT: This page does NOT call cookies() or headers() to remain + * ISR-compatible. User session is not available in guest route. + */ +export default async function StaticFeedReliabilityPage({ +}: Props): Promise { + + return ; +} diff --git a/src/app/screens/Feed/components/FeedReliabilityView.tsx b/src/app/screens/Feed/components/FeedReliabilityView.tsx new file mode 100644 index 00000000..91d694c7 --- /dev/null +++ b/src/app/screens/Feed/components/FeedReliabilityView.tsx @@ -0,0 +1,22 @@ +import { type ReactElement } from 'react'; +import {Container, Typography } from '@mui/material'; +import { notFound } from 'next/navigation'; +import { + type AllFeedType, + isGtfsFeedType, +} from '../../../services/feeds/utils'; + + +interface Props { + +} + +export default async function FeedReliabilityView({ +}: Props): Promise { + + return ( + + Reliability Page + + ); +} From b5b685857f60d36ab331105429990d3de986df86 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 2 Sep 2026 12:59:40 -0400 Subject: [PATCH 03/21] Seal of Reliability UI elements on feed detail page --- src/app/components/SealOfReliabilityChip.tsx | 72 ++++++ src/app/screens/Feed/FeedView.tsx | 133 ++++++++--- .../ClientQualityAnalysisButton.tsx | 35 +++ .../components/ClientQualityReportButton.tsx | 2 +- .../Feed/components/DataQualitySummary.tsx | 78 ++----- .../screens/Feed/components/FeedSummary.tsx | 53 ++++- .../Feed/components/SealQualitySummary.tsx | 211 ++++++++++++++++++ 7 files changed, 478 insertions(+), 106 deletions(-) create mode 100644 src/app/components/SealOfReliabilityChip.tsx create mode 100644 src/app/screens/Feed/components/ClientQualityAnalysisButton.tsx create mode 100644 src/app/screens/Feed/components/SealQualitySummary.tsx diff --git a/src/app/components/SealOfReliabilityChip.tsx b/src/app/components/SealOfReliabilityChip.tsx new file mode 100644 index 00000000..69bb9d26 --- /dev/null +++ b/src/app/components/SealOfReliabilityChip.tsx @@ -0,0 +1,72 @@ +'use client'; +import { Chip, Tooltip } from '@mui/material'; +import { useTranslations } from 'next-intl'; +import WorkspacePremiumIcon from '@mui/icons-material/WorkspacePremium'; +import { Link } from '../../i18n/navigation'; +import SealOfReliability, { +} from './SealOfReliability'; + +export interface SealOfReliabilityChipProps { + hasSeal: boolean | undefined; + feedId: string; + feedDataType: string; +} + +// TODO: revisit once logo is finalized +export default function SealOfReliabilityChip({ + hasSeal, + feedId, + feedDataType, +}: SealOfReliabilityChipProps): React.ReactElement | null { + const t = useTranslations('feeds'); + + if (hasSeal == undefined) { + return null; + } + + const href = `/feeds/${feedDataType}/${feedId}/seal-of-reliability`; + + if (hasSeal) { + return ( + + + } + label={t('sealOfReliabilityAlt')} + sx={{ + background: 'white', + color: 'black', + '& .MuiChip-icon': { + borderRadius: '50%', + boxShadow: '0 0 0 1.5px rgba(255,255,255,0.7)', + marginLeft: '5px', + marginRight: '-6px' + }, + }} + /> + + ); + } + + return ( + + } + label={t('noSealLabel')} + variant='outlined' + sx={{ opacity: 0.7 }} + /> + + ); +} diff --git a/src/app/screens/Feed/FeedView.tsx b/src/app/screens/Feed/FeedView.tsx index 1b93cf7c..e406283e 100644 --- a/src/app/screens/Feed/FeedView.tsx +++ b/src/app/screens/Feed/FeedView.tsx @@ -30,10 +30,12 @@ import ClientDownloadButton from './components/ClientDownloadButton'; import RevalidateCacheButton from './components/RevalidateCacheButton'; import { type components } from '../../services/feeds/types'; import ClientQualityReportButton from './components/ClientQualityReportButton'; +import ClientQualityAnalysisButton from './components/ClientQualityAnalysisButton'; import ClientSubscribeControls from './components/ClientSubscribeControls'; import { getBoundingBox } from './Feed.functions'; import dynamic from 'next/dynamic'; import { ContentBox } from '../../components/ContentBox'; +import { getUserRemoteConfigValues } from '../../../lib/remote-config.server'; const CoveredAreaMap = dynamic( async () => @@ -74,6 +76,7 @@ interface Props { relatedGtfsRtFeeds?: GTFSRTFeedType[]; totalRoutes?: number; routeTypes?: string[]; + reliability?: components['schemas']['FeedReliabilityReport']; isMobilityDatabaseAdmin?: boolean; } @@ -86,14 +89,16 @@ export default async function FeedView({ relatedGtfsRtFeeds = [], totalRoutes, routeTypes, + reliability, isMobilityDatabaseAdmin = false, }: Props): Promise { if (feed == undefined) notFound(); - const [t, tGbfs, tCommon] = await Promise.all([ + const [t, tGbfs, tCommon, config] = await Promise.all([ getTranslations('feeds'), getTranslations('gbfs'), getTranslations('common'), + getUserRemoteConfigValues(), ]); // Basic derived data @@ -201,6 +206,9 @@ export default async function FeedView({ feedStatus={feed.status} isOfficialFeed={feed.official} latestDataset={latestDataset} + feedId={feed.id ?? ''} + feedDataType={feed.data_type ?? 'gtfs'} + hasSeal={feed.reliability_seal?.has_seal} /> )} @@ -213,39 +221,6 @@ export default async function FeedView({ )} - {latestDataset?.validation_report?.validated_at != null && ( - - {`${t('qualityReportUpdated')}: ${new Date( - latestDataset.validation_report.validated_at, - ).toDateString()}`} - - )} - {isGtfsOrGtfsRtFeedType(feed) && - feed.official_updated_at != undefined && ( - - {`${t('officialFeedUpdated')}: ${new Date( - feed.official_updated_at, - ).toDateString()}`} - - )} - - {`Page generated at: ${new Date().toUTCString().replace(' GMT', ' UTC')}`} - {feed.external_ids?.some((eId) => eId.source === 'tld') === true && ( 0 && ( )} + {isGtfsFeedType(feed) && config.enableSealOfReliability && ( + + )} {latestDataset?.validation_report?.url_html != null && latestDataset.validation_report.url_html.length > 0 && ( {feed?.data_type === 'gtfs_rt' && ( @@ -432,6 +415,88 @@ export default async function FeedView({ + + {latestDataset?.validation_report?.validated_at != null && ( + + {`${t('qualityReportUpdated')}: ${new Date( + latestDataset.validation_report.validated_at, + ).toDateString()}`} + + )} + {isGtfsOrGtfsRtFeedType(feed) && + feed.official_updated_at != undefined && ( + + {`${t('officialFeedUpdated')}: ${new Date( + feed.official_updated_at, + ).toDateString()}`} + + )} + {isGtfsFeedType(feed) && feed.reliability_seal?.earned_at != null && ( + + {`${t('sealEarnedAt')}: ${new Date( + feed.reliability_seal.earned_at, + ).toDateString()}`} + + )} + {isGtfsFeedType(feed) && feed.reliability_seal?.lost_at != null && ( + + {`${t('sealLostAt')}: ${new Date( + feed.reliability_seal.lost_at, + ).toDateString()}`} + + )} + {isGtfsFeedType(feed) && + feed.reliability_seal?.evaluated_at != null && ( + + {`${t('sealEvaluatedAt')}: ${new Date( + feed.reliability_seal.evaluated_at, + ).toDateString()}`} + + )} + + {`${t('pageGeneratedAt')}: ${new Date().toUTCString().replace(' GMT', ' UTC')}`} + + {isMobilityDatabaseAdmin && ( { + sendGAEvent('event', 'view_feed_quality_analysis', { + event_category: 'engagement', + event_label: 'View Feed Quality Analysis', + }); + }; + + return ( + + ); +} diff --git a/src/app/screens/Feed/components/ClientQualityReportButton.tsx b/src/app/screens/Feed/components/ClientQualityReportButton.tsx index dd4e5e5b..78392351 100644 --- a/src/app/screens/Feed/components/ClientQualityReportButton.tsx +++ b/src/app/screens/Feed/components/ClientQualityReportButton.tsx @@ -29,7 +29,7 @@ export default function ClientQualityReportButton({ endIcon={} onClick={handleOpenFullQualityReportClick} > - {t('openFullQualityReport')} + {t('openFullValidationReport')} ); } diff --git a/src/app/screens/Feed/components/DataQualitySummary.tsx b/src/app/screens/Feed/components/DataQualitySummary.tsx index 5cb9a33a..563a21e6 100644 --- a/src/app/screens/Feed/components/DataQualitySummary.tsx +++ b/src/app/screens/Feed/components/DataQualitySummary.tsx @@ -2,10 +2,10 @@ import * as React from 'react'; import { Box, Chip } from '@mui/material'; import { CheckCircle, ReportOutlined } from '@mui/icons-material'; import { type components } from '../../../services/feeds/types'; -import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { WarningContentBox } from '../../../components/WarningContentBox'; import { FeedStatusChip } from '../../../components/FeedStatus'; import FeedVerificationChip from '../../../components/FeedVerificationChip'; +import SealOfReliabilityChip from '../../../components/SealOfReliabilityChip'; import { getTranslations } from 'next-intl/server'; import { getUserRemoteConfigValues } from '../../../../lib/remote-config.server'; @@ -13,6 +13,9 @@ export interface DataQualitySummaryProps { feedStatus: components['schemas']['Feed']['status']; isOfficialFeed: boolean | undefined; latestDataset: components['schemas']['GtfsDataset'] | undefined; + feedId: string; + feedDataType: string; + hasSeal: boolean | undefined; } // Because this is a server component, the page will not render until the data is ready, hence the async @@ -20,6 +23,9 @@ export default async function DataQualitySummary({ feedStatus, isOfficialFeed, latestDataset, + feedId, + feedDataType, + hasSeal, }: DataQualitySummaryProps): Promise { const [t, tCommon, config] = await Promise.all([ getTranslations('feeds'), @@ -36,6 +42,13 @@ export default async function DataQualitySummary({ {config.enableFeedStatusBadge && ( )} + {config.enableSealOfReliability && ( + + )} {latestDataset?.validation_report !== undefined && latestDataset.validation_report !== null && ( @@ -74,69 +87,6 @@ export default async function DataQualitySummary({ } variant='outlined' /> - - 0 ? ( - - ) : ( - - ) - } - label={ - latestDataset?.validation_report?.unique_warning_count !== - undefined && - latestDataset?.validation_report?.unique_warning_count > 0 - ? `${ - latestDataset?.validation_report?.unique_warning_count - } ${tCommon('feedback.warnings')}` - : tCommon('feedback.noWarnings') - } - color={ - latestDataset?.validation_report?.unique_warning_count !== - undefined && - latestDataset?.validation_report?.unique_warning_count > 0 - ? 'warning' - : 'success' - } - variant='outlined' - /> - - - 0 ? ( - - ) : ( - - ) - } - clickable={Boolean(latestDataset?.validation_report?.url_html)} - component='a' - href={latestDataset?.validation_report?.url_html ?? undefined} - target='_blank' - rel='noopener noreferrer nofollow' - label={ - (latestDataset?.validation_report?.unique_info_count ?? 0) > 0 - ? `${latestDataset?.validation_report?.unique_info_count} ${tCommon('feedback.infoNotices')}` - : tCommon('feedback.noInfoNotices') - } - color={ - (latestDataset?.validation_report?.unique_info_count ?? 0) > 0 - ? 'primary' - : 'success' - } - variant='outlined' - /> )} diff --git a/src/app/screens/Feed/components/FeedSummary.tsx b/src/app/screens/Feed/components/FeedSummary.tsx index f5deb513..15dc81bf 100644 --- a/src/app/screens/Feed/components/FeedSummary.tsx +++ b/src/app/screens/Feed/components/FeedSummary.tsx @@ -6,6 +6,7 @@ import LicenseDialog from './LicenseDialog'; import { getCountryLocationSummaries, getLocationName, + isGtfsFeedType, type GTFSFeedType, type GTFSRTFeedType, type GBFSFeedType, @@ -38,6 +39,7 @@ import DownloadIcon from '@mui/icons-material/Download'; import CloseIcon from '@mui/icons-material/Close'; import BusinessIcon from '@mui/icons-material/Business'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; +import WorkspacePremiumIcon from '@mui/icons-material/WorkspacePremium'; import { FeedStatusChip } from '../../../components/FeedStatus'; import { getEmojiFlag, type TCountryCode } from 'countries-list'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; @@ -46,16 +48,14 @@ import { getFeedStatusData } from '../../../utils/feedStatusConsts'; import Link from 'next/link'; import { sendGAEvent } from '@next/third-parties/google'; import { getRouteTypeTranslatedName } from '../../../constants/RouteTypes'; -import { - featureChipsStyle, - ResponsiveListItem, - StyledListItem, -} from '../Feed.styles'; +import { ResponsiveListItem, StyledListItem } from '../Feed.styles'; import { getFeatureComponentDecorators } from '../../../utils/consts'; import dynamic from 'next/dynamic'; import CopyLinkElement from './CopyLinkElement'; import { formatDateShort } from '../../../utils/date'; import ExternalIds from './ExternalIds'; +import SealQualitySummary from './SealQualitySummary'; +import SealOfReliability from '../../../components/SealOfReliability'; const Locations = dynamic( async () => await import('../../../components/Locations'), @@ -69,6 +69,8 @@ export interface FeedSummaryProps { autoDiscoveryUrl?: string; totalRoutes?: number; routeTypes?: string[]; + enableSealOfReliability?: boolean; + reliability?: components['schemas']['FeedReliabilityReport']; } export default function FeedSummary({ @@ -78,6 +80,8 @@ export default function FeedSummary({ autoDiscoveryUrl, routeTypes, totalRoutes, + enableSealOfReliability = false, + reliability, }: FeedSummaryProps): React.ReactElement { const t = useTranslations('feeds'); const tCommon = useTranslations('common'); @@ -619,6 +623,42 @@ export default function FeedSummary({ )} + {isGtfsFeedType(feed) && enableSealOfReliability && ( + + {feed.reliability_seal?.has_seal === true && ( + + + + )} + + + {t('sealOfReliabilityAlt')} + + + + + + + + + )} + {latestDataset?.validation_report?.features != undefined && latestDataset?.validation_report?.features.length > 0 && ( @@ -661,10 +701,9 @@ export default function FeedSummary({ label={feature} variant='filled' sx={{ - ...featureChipsStyle, fontWeight: 500, background: featureDecorators.color, - color: 'initial', + color: 'black', }} clickable target='_blank' diff --git a/src/app/screens/Feed/components/SealQualitySummary.tsx b/src/app/screens/Feed/components/SealQualitySummary.tsx new file mode 100644 index 00000000..543946e2 --- /dev/null +++ b/src/app/screens/Feed/components/SealQualitySummary.tsx @@ -0,0 +1,211 @@ +'use client'; + +import * as React from 'react'; +import { Box, Button, Tooltip, Typography, useTheme } from '@mui/material'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import BlockIcon from '@mui/icons-material/Block'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; +import { useTranslations } from 'next-intl'; +import { Link } from '../../../../i18n/navigation'; +import { + API_CRITERION_TO_KEY, + SEAL_CRITERION_ICONS, +} from '../../../constants/sealCriteria'; +import { type components } from '../../../services/feeds/types'; + +type ReliabilityCriterion = components['schemas']['ReliabilityCriterion']; + +type CriterionDisplayStatus = + | 'pass' + | 'atRisk' + | 'fail' + | 'notApplicable' + | 'notEvaluated'; + +// Covers all five verdicts the API can return for a criterion (pass, fail, +// unknown, not_applicable, never_evaluated) - only `fail` outside its grace +// period is a real failure; the rest are neutral, non-failing states. +function getCriterionDisplayStatus( + criterion: ReliabilityCriterion, +): CriterionDisplayStatus { + switch (criterion.status) { + case 'pass': + return 'pass'; + case 'fail': + return criterion.in_grace_period ? 'atRisk' : 'fail'; + case 'not_applicable': + return 'notApplicable'; + case 'unknown': + case 'never_evaluated': + return 'notEvaluated'; + } +} + +export interface SealQualitySummaryProps { + feedId: string; + feedDataType: string; + reliability: components['schemas']['FeedReliabilityReport'] | undefined; +} + +export default function SealQualitySummary({ + feedId, + feedDataType, + reliability, +}: SealQualitySummaryProps): React.ReactElement { + const t = useTranslations('feeds'); + const tSeal = useTranslations('sealOfReliability'); + const theme = useTheme(); + + const criteria = reliability?.criteria ?? []; + const hasSeal = reliability?.has_seal ?? false; + const anyInGracePeriod = criteria.some((c) => c.in_grace_period); + // not_applicable criteria are withdrawn from the seal entirely, so they + // shouldn't count towards "X out of Y criteria met" + const consideredCriteria = criteria.filter( + (c) => c.status !== 'not_applicable', + ); + const passedCriteriaCount = consideredCriteria.filter( + (c) => c.status === 'pass', + ).length; + + const sealStatus: 'earned' | 'gracePeriod' | 'notEarned' = !hasSeal + ? 'notEarned' + : anyInGracePeriod + ? 'gracePeriod' + : 'earned'; + + return ( + + + + + + {sealStatus === 'earned' && ( + + )} + {sealStatus === 'gracePeriod' && ( + + )} + {sealStatus === 'notEarned' && ( + + )} + + {sealStatus === 'earned' && t('sealEarnedLabel')} + {sealStatus === 'gracePeriod' && t('sealInGracePeriodLabel')} + {sealStatus === 'notEarned' && t('sealNotYetEarnedLabel')} + + + + {sealStatus === 'earned' && t('sealEarnedCaption')} + {sealStatus === 'gracePeriod' && t('sealGracePeriodCaption')} + {sealStatus === 'notEarned' && + consideredCriteria.length > 0 && + t('sealCriteriaMetCaption', { + passed: passedCriteriaCount, + total: consideredCriteria.length, + })} + + + + + + {criteria.map((criterion) => { + const key = API_CRITERION_TO_KEY[criterion.criterion]; + const CriterionIcon = SEAL_CRITERION_ICONS[key]; + const displayStatus = getCriterionDisplayStatus(criterion); + + const color = { + pass: theme.palette.success.light, + atRisk: theme.palette.warning.light, + fail: theme.palette.error.light, + notApplicable: theme.palette.grey[500], + notEvaluated: theme.palette.grey[500], + }[displayStatus]; + + const statusLabel = { + pass: t('sealCriterionPass'), + atRisk: t('sealCriterionInGracePeriod'), + fail: t('sealCriterionFail'), + notApplicable: t('sealCriterionNotApplicable'), + notEvaluated: t('sealCriterionNotEvaluated'), + }[displayStatus]; + + const graceNote = + displayStatus === 'atRisk' && + criterion.grace_period_ends_at != null + ? ` ${t('sealCriterionGracePeriodNote', { + date: new Date( + criterion.grace_period_ends_at, + ).toDateString(), + })}` + : ''; + + return ( + + + + ); + })} + + + + + + + + + + ); +} From b8458a52d42d929ace0f7f3d0c85c3fbe2522f60 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 2 Sep 2026 13:00:00 -0400 Subject: [PATCH 04/21] seal of reliability criteria --- .../SealOfReliabilityDescriptionPage.tsx | 36 +++++------ .../components/HowItIsCalculatedPage.tsx | 28 +++++---- .../seal-of-reliability/lib/content.tsx | 18 ++++++ src/app/constants/sealCriteria.ts | 61 +++++++++++++++++++ 4 files changed, 114 insertions(+), 29 deletions(-) create mode 100644 src/app/constants/sealCriteria.ts diff --git a/src/app/[locale]/seal-of-reliability/components/SealOfReliabilityDescriptionPage.tsx b/src/app/[locale]/seal-of-reliability/components/SealOfReliabilityDescriptionPage.tsx index 5ab3fa2e..cbef1c91 100644 --- a/src/app/[locale]/seal-of-reliability/components/SealOfReliabilityDescriptionPage.tsx +++ b/src/app/[locale]/seal-of-reliability/components/SealOfReliabilityDescriptionPage.tsx @@ -20,7 +20,6 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import { type ReactElement } from 'react'; import Image from 'next/image'; -import LockIcon from '@mui/icons-material/Lock'; import { getTranslations } from 'next-intl/server'; import CardSectionTitle from '../../../components/CardSectionTitle'; import SectionContainer from '../../../components/SectionContainer'; @@ -194,22 +193,25 @@ export default async function SealOfReliabilityDescriptionPage(): Promise - {criteriaEntries.map((entry) => ( - - - - {t(entry.titleKey)} - - - - {t(entry.subtitleKey)} - - - {t(entry.descriptionKey)} - - - - ))} + {criteriaEntries.map((entry) => { + const CriterionIcon = entry.icon; + return ( + + + + {t(entry.titleKey)} + + + + {t(entry.subtitleKey)} + + + {t(entry.descriptionKey)} + + + + ); + })} diff --git a/src/app/[locale]/seal-of-reliability/how-it-is-calculated/components/HowItIsCalculatedPage.tsx b/src/app/[locale]/seal-of-reliability/how-it-is-calculated/components/HowItIsCalculatedPage.tsx index 957944bb..acc132a6 100644 --- a/src/app/[locale]/seal-of-reliability/how-it-is-calculated/components/HowItIsCalculatedPage.tsx +++ b/src/app/[locale]/seal-of-reliability/how-it-is-calculated/components/HowItIsCalculatedPage.tsx @@ -16,12 +16,7 @@ import { } from '@mui/material'; import { type ReactElement } from 'react'; import { getTranslations } from 'next-intl/server'; -import VerifiedIcon from '@mui/icons-material/Verified'; -import CodeIcon from '@mui/icons-material/Code'; -import DownloadIcon from '@mui/icons-material/Download'; -import RuleIcon from '@mui/icons-material/Rule'; -import EventAvailableIcon from '@mui/icons-material/EventAvailable'; -import SyncAltIcon from '@mui/icons-material/SyncAlt'; +import { SEAL_CRITERION_ICONS } from '../../../../constants/sealCriteria'; import { fontFamily, theme } from '../../../../Theme'; import SectionContainer from '../../../../components/SectionContainer'; import CardSectionTitle from '../../../../components/CardSectionTitle'; @@ -41,6 +36,15 @@ import ScenarioCard from './ScenarioCard'; const { primary, success, divider } = theme.vars.palette; +const { + official: OfficialIcon, + stable: StableIcon, + available: AvailableIcon, + compliant: CompliantIcon, + freshRolling: FreshRollingIcon, + freshContinuous: FreshContinuousIcon, +} = SEAL_CRITERION_ICONS; + const twoColumnGrid = { display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, @@ -150,7 +154,7 @@ export default async function HowItIsCalculatedPage(): Promise { - + {t('official.title')} @@ -165,7 +169,7 @@ export default async function HowItIsCalculatedPage(): Promise { - + {t('stable.title')} @@ -177,7 +181,7 @@ export default async function HowItIsCalculatedPage(): Promise { - + {t('available.title')} @@ -193,7 +197,7 @@ export default async function HowItIsCalculatedPage(): Promise { - + {t('compliant.title')} @@ -223,7 +227,7 @@ export default async function HowItIsCalculatedPage(): Promise { - + {t('freshRolling.title')} @@ -253,7 +257,7 @@ export default async function HowItIsCalculatedPage(): Promise { - + {t('freshContinuous.title')} diff --git a/src/app/[locale]/seal-of-reliability/lib/content.tsx b/src/app/[locale]/seal-of-reliability/lib/content.tsx index 1b9b9438..69239a8d 100644 --- a/src/app/[locale]/seal-of-reliability/lib/content.tsx +++ b/src/app/[locale]/seal-of-reliability/lib/content.tsx @@ -4,6 +4,10 @@ import DirectionsBusIcon from '@mui/icons-material/DirectionsBus'; import PhoneAndroidIcon from '@mui/icons-material/PhoneAndroid'; import StorefrontIcon from '@mui/icons-material/Storefront'; import GavelIcon from '@mui/icons-material/Gavel'; +import { + type SealCriterionKey, + SEAL_CRITERION_ICONS, +} from '../../../constants/sealCriteria'; export interface HeroContent { titleKey: string; @@ -47,41 +51,55 @@ export const benefitEntries: BenefitEntry[] = [ ]; export interface CriteriaEntry { + key: SealCriterionKey; titleKey: string; subtitleKey: string; descriptionKey: string; + icon: ComponentType; } export const criteriaEntries: CriteriaEntry[] = [ { + key: 'official', titleKey: 'criteria.official.title', subtitleKey: 'criteria.official.subtitle', descriptionKey: 'criteria.official.description', + icon: SEAL_CRITERION_ICONS.official, }, { + key: 'stable', titleKey: 'criteria.stable.title', subtitleKey: 'criteria.stable.subtitle', descriptionKey: 'criteria.stable.description', + icon: SEAL_CRITERION_ICONS.stable, }, { + key: 'available', titleKey: 'criteria.available.title', subtitleKey: 'criteria.available.subtitle', descriptionKey: 'criteria.available.description', + icon: SEAL_CRITERION_ICONS.available, }, { + key: 'compliant', titleKey: 'criteria.compliant.title', subtitleKey: 'criteria.compliant.subtitle', descriptionKey: 'criteria.compliant.description', + icon: SEAL_CRITERION_ICONS.compliant, }, { + key: 'freshRolling', titleKey: 'criteria.freshRolling.title', subtitleKey: 'criteria.freshRolling.subtitle', descriptionKey: 'criteria.freshRolling.description', + icon: SEAL_CRITERION_ICONS.freshRolling, }, { + key: 'freshContinuous', titleKey: 'criteria.freshContinuous.title', subtitleKey: 'criteria.freshContinuous.subtitle', descriptionKey: 'criteria.freshContinuous.description', + icon: SEAL_CRITERION_ICONS.freshContinuous, }, ]; diff --git a/src/app/constants/sealCriteria.ts b/src/app/constants/sealCriteria.ts new file mode 100644 index 00000000..d115b66d --- /dev/null +++ b/src/app/constants/sealCriteria.ts @@ -0,0 +1,61 @@ +import { type SvgIconComponent } from '@mui/icons-material'; +import VerifiedIcon from '@mui/icons-material/Verified'; +import CodeIcon from '@mui/icons-material/Code'; +import DownloadIcon from '@mui/icons-material/Download'; +import RuleIcon from '@mui/icons-material/Rule'; +import EventAvailableIcon from '@mui/icons-material/EventAvailable'; +import SyncAltIcon from '@mui/icons-material/SyncAlt'; +import { type components } from '../services/feeds/types'; + +export type SealCriterionKey = + | 'official' + | 'stable' + | 'available' + | 'compliant' + | 'freshRolling' + | 'freshContinuous'; + +export interface SealCriterionResult { + key: SealCriterionKey; + icon: SvgIconComponent; + passed: boolean; +} + +// Per-criterion verdicts aren't exposed by the API yet (only the overall +// has_seal flag is) - hardcoded until GET /v1/gtfs_feeds/{id}/reliability +// is wired up on the frontend. +export const HARDCODED_SEAL_CRITERIA: SealCriterionResult[] = [ + { key: 'official', icon: VerifiedIcon, passed: true }, + { key: 'stable', icon: CodeIcon, passed: true }, + { key: 'available', icon: DownloadIcon, passed: true }, + { key: 'compliant', icon: RuleIcon, passed: true }, + { key: 'freshRolling', icon: EventAvailableIcon, passed: true }, + { key: 'freshContinuous', icon: SyncAltIcon, passed: false }, +]; + +export type ApiSealCriterionKey = + components['schemas']['ReliabilityCriterion']['criterion']; + +// The API's criterion enum uses snake_case names that don't line up 1:1 +// with the frontend's camelCase keys (fresh_coverage -> freshRolling). +export const API_CRITERION_TO_KEY: Record< + ApiSealCriterionKey, + SealCriterionKey +> = { + official: 'official', + stable: 'stable', + available: 'available', + compliant: 'compliant', + fresh_coverage: 'freshRolling', + fresh_continuous: 'freshContinuous', +}; + +export const SEAL_CRITERION_ICONS: Record = + { + official: VerifiedIcon, + stable: CodeIcon, + available: DownloadIcon, + compliant: RuleIcon, + freshRolling: EventAvailableIcon, + freshContinuous: SyncAltIcon, + }; From 2055b1daa112619039269870bbd89761808193d1 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 2 Sep 2026 13:26:12 -0400 Subject: [PATCH 05/21] revalidate endpoint fetch --- .../[feedDataType]/[feedId]/authed/page.tsx | 2 + .../[feedId]/lib/feed-data-shared.ts | 63 ++++++++++++++++--- .../[feedDataType]/[feedId]/static/page.tsx | 2 + src/app/services/feeds/index.ts | 20 ++++++ 4 files changed, 79 insertions(+), 8 deletions(-) diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/page.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/page.tsx index 032046d3..440899ac 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/page.tsx +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/page.tsx @@ -61,6 +61,7 @@ export default async function AuthedFeedPage({ relatedGtfsRtFeeds, totalRoutes, routeTypes, + reliability, } = feedData; return ( @@ -77,6 +78,7 @@ export default async function AuthedFeedPage({ relatedGtfsRtFeeds={relatedGtfsRtFeeds} totalRoutes={totalRoutes} routeTypes={routeTypes} + reliability={reliability} isMobilityDatabaseAdmin={isAdmin} /> diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data-shared.ts b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data-shared.ts index d245616a..87d6ef7a 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data-shared.ts +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data-shared.ts @@ -4,6 +4,7 @@ */ import 'server-only'; +import { unstable_cache } from 'next/cache'; import { getFeed, getGtfsFeed, @@ -12,6 +13,7 @@ import { getGtfsFeedDatasets, getGtfsFeedRoutes, getGtfsFeedAssociatedGtfsRtFeeds, + getGtfsFeedReliability, } from '../../../../../services/feeds'; import { type GTFSFeedType, @@ -22,6 +24,7 @@ import type { components } from '../../../../../services/feeds/types'; import type { GtfsRoute } from '../../../../../types'; type DatasetType = components['schemas']['GtfsDataset']; +type ReliabilityReport = components['schemas']['FeedReliabilityReport']; export interface FeedDataResult { feed: AllFeedType; @@ -32,6 +35,7 @@ export interface FeedDataResult { totalRoutes?: number; routeTypes?: string[]; routes?: GtfsRoute[]; + reliability?: ReliabilityReport; } /** @@ -77,6 +81,43 @@ export async function fetchDatasets( } } +/** + * Fetch the Seal of Reliability breakdown for a GTFS feed. + * + * The reliability report is tied to the feed, not the caller, so it's cached + * by feedId alone via unstable_cache, shared across all users/sessions. + * accessToken/userContextJwt are only closed over to authenticate the + * underlying call and are intentionally excluded from the cache key. + */ +export async function fetchReliabilityData( + feedId: string, + accessToken: string, + userContextJwt: string | undefined, +): Promise { + const cachedFetch = unstable_cache( + async (): Promise => { + try { + const reliability = await getGtfsFeedReliability( + feedId, + accessToken, + userContextJwt, + ); + return reliability ?? null; + } catch (e) { + return null; + } + }, + [`feed-reliability-${feedId}`], + { + tags: [`feed-${feedId}`], + revalidate: 1209600, // 14 days - public reliability data, revalidated on demand via /api/revalidate when the feed updates + }, + ); + + const reliability = await cachedFetch(); + return reliability ?? undefined; +} + /** * Fetch related feeds for GTFS-RT feeds. * Returns both GTFS and GTFS-RT related feeds. @@ -182,24 +223,29 @@ export async function fetchCompleteFeedDataImpl( throw new Error(`Feed ${feedId} not found`); } - // Fetch datasets and routes in parallel for GTFS feeds + // Fetch datasets, routes, and reliability in parallel for GTFS feeds let initialDatasets: DatasetType[] = []; let totalRoutes: number | undefined; let routeTypes: string[] | undefined; let routes: GtfsRoute[] | undefined; + let reliability: ReliabilityReport | undefined; if (feedDataType === 'gtfs') { - const [datasetsResult, routesResult] = await Promise.all([ - fetchDatasets(feedId, accessToken, userContextJwt), - fetchRoutesData( - feedId, - (feed as GTFSFeedType)?.visualization_dataset_id ?? '', - ), - ]); + const [datasetsResult, routesResult, reliabilityResult] = await Promise.all( + [ + fetchDatasets(feedId, accessToken, userContextJwt), + fetchRoutesData( + feedId, + (feed as GTFSFeedType)?.visualization_dataset_id ?? '', + ), + fetchReliabilityData(feedId, accessToken, userContextJwt), + ], + ); initialDatasets = datasetsResult; totalRoutes = routesResult.totalRoutes; routeTypes = routesResult.routeTypes; routes = routesResult.routes; + reliability = reliabilityResult; } // Fetch related feeds for GTFS-RT @@ -250,5 +296,6 @@ export async function fetchCompleteFeedDataImpl( totalRoutes, routeTypes, routes, + reliability, }; } diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/page.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/page.tsx index b962f42f..7142ea5e 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/page.tsx +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/page.tsx @@ -51,6 +51,7 @@ export default async function StaticFeedPage({ relatedGtfsRtFeeds, totalRoutes, routeTypes, + reliability, } = feedData; return ( @@ -67,6 +68,7 @@ export default async function StaticFeedPage({ relatedGtfsRtFeeds={relatedGtfsRtFeeds} totalRoutes={totalRoutes} routeTypes={routeTypes} + reliability={reliability} /> ); diff --git a/src/app/services/feeds/index.ts b/src/app/services/feeds/index.ts index acfb115c..2659774e 100644 --- a/src/app/services/feeds/index.ts +++ b/src/app/services/feeds/index.ts @@ -188,6 +188,26 @@ export const getGtfsFeedAssociatedGtfsRtFeeds = async ( }); }; +export const getGtfsFeedReliability = async ( + id: string, + accessToken: string, + userContextJwt?: string, +): Promise< + | paths['/v1/gtfs_feeds/{id}/reliability']['get']['responses'][200]['content']['application/json'] + | undefined +> => { + const authMiddleware = generateAuthMiddlewareWithToken( + accessToken, + userContextJwt, + ); + return await withAuthMiddleware(authMiddleware, async () => { + const response = await client.GET('/v1/gtfs_feeds/{id}/reliability', { + params: { path: { id } }, + }); + return response.data; + }); +}; + export const getGtfsFeedDatasets = async ( id: string, accessToken: string, From 22d0aaf076a981de2a5a44f6d1a80e8317edceaa Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 2 Sep 2026 13:26:18 -0400 Subject: [PATCH 06/21] language --- messages/en.json | 27 ++++++++++++++++++++++++++- messages/fr.json | 27 ++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/messages/en.json b/messages/en.json index ce8da6f0..9d0fa530 100644 --- a/messages/en.json +++ b/messages/en.json @@ -256,8 +256,29 @@ "sealOfReliabilityAlt": "Seal of Reliability", "sealOfReliabilityTooltipShort": "Seal of Reliability: This feed meets MobilityData's baseline quality standard for being official, stable, available, compliant, and fresh.", "sealOfReliabilityLearnMore": "What is the Seal of Reliability?", + "sealOfReliabilityQualityTooltip": "Seal of Reliability is a feed quality measure determined by MobilityData. View details.", + "noSealLabel": "No seal yet", + "noSealTooltip": "This feed does not currently hold the Seal of Reliability.", + "sealEarnedLabel": "Earned", + "sealNotYetEarnedLabel": "Not Earned", + "sealInGracePeriodLabel": "In Grace Period", + "sealEarnedCaption": "All criteria met", + "sealGracePeriodCaption": "Seal retained while one or more criteria are in a grace period", + "sealCriteriaMetCaption": "{passed} out of {total} criteria met", + "seeFullAnalysis": "See full analysis", + "howTheseAreCalculated": "How are these calculated?", + "sealCriterionPass": "Pass", + "sealCriterionFail": "Fail", + "sealCriterionInGracePeriod": "At Risk", + "sealCriterionGracePeriodNote": "In grace period until {date}.", + "sealCriterionNotApplicable": "Not Applicable", + "sealCriterionNotEvaluated": "Not Evaluated", + "reliabilityAnalysisTitle": "Seal of Reliability Analysis", + "reliabilityAnalysisIntro": "A breakdown of the six criteria used to determine whether this feed holds the Seal of Reliability.", + "reliabilityDataPlaceholderWarning": "Per-criterion results aren't available from the API yet. The breakdown below is illustrative placeholder data.", "seeDetailPageProviders": "See detail page to view {providersCount} others", - "openFullQualityReport": "Open Full Quality Report", + "openFullValidationReport": "Open Full Validation Report", + "viewFeedQualityAnalysis": "View Feed Quality Analysis", "subscribe": "Subscribe to get feed update notifications", "unsubscribe": "Unsubscribe to stop receiving feed update notifications", "subscribedToFeed": "You'll receive updates for this feed", @@ -275,6 +296,10 @@ "apiAnnouncementsTooltip": "Notifies you about API-wide announcements from MobilityData.", "qualityReportUpdated": "Quality report updated", "officialFeedUpdated": "Official verification updated", + "sealEarnedAt": "Seal of Reliability earned", + "sealLostAt": "Seal of Reliability lost", + "sealEvaluatedAt": "Seal of Reliability last evaluated", + "pageGeneratedAt": "Page generated at", "serviceDateRange": "Service Date Range", "serviceDateRangeTooltip": "Dates are relative to the specified timezone. If no timezone is specified, the dates are in UTC.", "heatmapIntensity": "Stop Density", diff --git a/messages/fr.json b/messages/fr.json index 6feb147a..2ce37ec5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -256,8 +256,29 @@ "sealOfReliabilityAlt": "Sceau de fiabilité", "sealOfReliabilityTooltipShort": "Sceau de fiabilité : ce flux répond à la norme de qualité de base de MobilityData en matière de fiabilité officielle, de stabilité, de disponibilité, de conformité et de fraîcheur des données.", "sealOfReliabilityLearnMore": "Qu'est-ce que le Sceau de fiabilité ?", + "sealOfReliabilityQualityTooltip": "Le Sceau de fiabilité est une mesure de la qualité des flux déterminée par MobilityData. Voir les détails.", + "noSealLabel": "Pas encore de sceau", + "noSealTooltip": "Ce flux ne détient pas actuellement le Sceau de fiabilité.", + "sealEarnedLabel": "Obtenu", + "sealNotYetEarnedLabel": "Pas encore obtenu", + "sealInGracePeriodLabel": "Délai de grâce", + "sealEarnedCaption": "Les 6 critères sont respectés", + "sealGracePeriodCaption": "Le sceau est conservé pendant qu'un ou plusieurs critères sont en délai de grâce", + "sealCriteriaMetCaption": "{passed} critères sur {total} respectés", + "seeFullAnalysis": "Voir l'analyse complète", + "howTheseAreCalculated": "Comment sont-ils calculés ?", + "sealCriterionPass": "Réussi", + "sealCriterionFail": "Échoué", + "sealCriterionInGracePeriod": "À risque", + "sealCriterionGracePeriodNote": "En délai de grâce jusqu'au {date}.", + "sealCriterionNotApplicable": "Non applicable", + "sealCriterionNotEvaluated": "Non évalué", + "reliabilityAnalysisTitle": "Analyse du Sceau de fiabilité", + "reliabilityAnalysisIntro": "Un aperçu des six critères utilisés pour déterminer si ce flux détient le Sceau de fiabilité.", + "reliabilityDataPlaceholderWarning": "Les résultats par critère ne sont pas encore disponibles depuis l'API. Le détail ci-dessous est une donnée d'exemple à titre indicatif.", "seeDetailPageProviders": "See detail page to view {providersCount} others", - "openFullQualityReport": "Open Full Quality Report", + "openFullValidationReport": "Ouvrir le rapport de validation complet", + "viewFeedQualityAnalysis": "Voir l'analyse de la qualité du flux", "subscribe": "S'abonner", "unsubscribe": "Se désabonner", "subscribedToFeed": "Vous êtes abonné à ce flux", @@ -275,6 +296,10 @@ "apiAnnouncementsTooltip": "Vous avertit des annonces générales de l'API par MobilityData.", "qualityReportUpdated": "Quality report updated", "officialFeedUpdated": "Official verification updated", + "sealEarnedAt": "Seal of Reliability earned", + "sealLostAt": "Seal of Reliability lost", + "sealEvaluatedAt": "Seal of Reliability last evaluated", + "pageGeneratedAt": "Page generated at", "serviceDateRange": "Service Date Range", "serviceDateRangeTooltip": "Dates are relative to the specified timezone. If no timezone is specified, the dates are in UTC.", "heatmapIntensity": "Stop Density", From 0deceb8554a3d6774137b39cd4b7b4fa05d2ab85 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 2 Sep 2026 13:41:04 -0400 Subject: [PATCH 07/21] seal quality summary tests --- .../components/SealQualitySummary.spec.tsx | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 src/app/screens/Feed/components/SealQualitySummary.spec.tsx diff --git a/src/app/screens/Feed/components/SealQualitySummary.spec.tsx b/src/app/screens/Feed/components/SealQualitySummary.spec.tsx new file mode 100644 index 00000000..94d44fb8 --- /dev/null +++ b/src/app/screens/Feed/components/SealQualitySummary.spec.tsx @@ -0,0 +1,195 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '@mui/material/styles'; +import { theme } from '../../../Theme'; +import SealQualitySummary from './SealQualitySummary'; +import { type ApiSealCriterionKey } from '../../../constants/sealCriteria'; +import { type components } from '../../../services/feeds/types'; + +type ReliabilityCriterion = components['schemas']['ReliabilityCriterion']; +type FeedReliabilityReport = components['schemas']['FeedReliabilityReport']; + +jest.mock('../../../../i18n/navigation', () => ({ + Link: ({ + children, + href, + }: React.PropsWithChildren<{ href: string }>): React.ReactElement => ( + {children} + ), +})); + +function buildCriterion( + criterion: ApiSealCriterionKey, + overrides: Partial = {}, +): ReliabilityCriterion { + return { + criterion, + status: 'pass', + in_grace_period: false, + on_probation: false, + ...overrides, + }; +} + +function buildReliability( + overrides: Partial = {}, +): FeedReliabilityReport { + return { + feed_id: 'mdb-1', + has_seal: false, + on_probation: false, + criteria: [], + ...overrides, + }; +} + +function renderSummary( + reliability: FeedReliabilityReport | undefined, +): ReturnType { + return render( + + + , + ); +} + +describe('SealQualitySummary', () => { + it('renders the not-yet-earned status with no caption when there is no reliability data', () => { + renderSummary(undefined); + + expect(screen.getByTestId('BlockIcon')).toBeInTheDocument(); + expect(screen.getByText('sealNotYetEarnedLabel')).toBeInTheDocument(); + expect( + screen.queryByText('sealCriteriaMetCaption'), + ).not.toBeInTheDocument(); + expect(screen.queryAllByTestId(/^criterion-/)).toHaveLength(0); + }); + + it('renders the not-yet-earned status with a criteria-met caption when criteria are considered', () => { + renderSummary( + buildReliability({ + has_seal: false, + criteria: [ + buildCriterion('official', { status: 'pass' }), + buildCriterion('stable', { status: 'fail', in_grace_period: false }), + ], + }), + ); + + expect(screen.getByTestId('BlockIcon')).toBeInTheDocument(); + expect(screen.getByText('sealNotYetEarnedLabel')).toBeInTheDocument(); + expect(screen.getByText('sealCriteriaMetCaption')).toBeInTheDocument(); + expect(screen.getByTestId('criterion-official-pass')).toBeInTheDocument(); + expect(screen.getByTestId('criterion-stable-fail')).toBeInTheDocument(); + }); + + it('does not count not_applicable criteria towards the criteria-met caption', () => { + renderSummary( + buildReliability({ + has_seal: false, + criteria: [ + buildCriterion('official', { status: 'not_applicable' }), + buildCriterion('stable', { status: 'not_applicable' }), + ], + }), + ); + + expect( + screen.queryByText('sealCriteriaMetCaption'), + ).not.toBeInTheDocument(); + expect( + screen.getByTestId('criterion-official-notApplicable'), + ).toBeInTheDocument(); + expect( + screen.getByTestId('criterion-stable-notApplicable'), + ).toBeInTheDocument(); + }); + + it('renders the earned status when the seal is held and no criterion is in its grace period', () => { + renderSummary( + buildReliability({ + has_seal: true, + criteria: [ + buildCriterion('official', { status: 'pass' }), + buildCriterion('stable', { status: 'pass' }), + ], + }), + ); + + expect(screen.getByTestId('CheckCircleIcon')).toBeInTheDocument(); + expect(screen.getByText('sealEarnedLabel')).toBeInTheDocument(); + expect(screen.getByText('sealEarnedCaption')).toBeInTheDocument(); + expect( + screen.queryByText('sealCriteriaMetCaption'), + ).not.toBeInTheDocument(); + }); + + it('renders the grace-period status when the seal is held but a criterion is at risk', () => { + renderSummary( + buildReliability({ + has_seal: true, + criteria: [ + buildCriterion('official', { + status: 'fail', + in_grace_period: true, + grace_period_ends_at: '2026-08-24T04:00:00Z', + }), + buildCriterion('stable', { status: 'pass' }), + ], + }), + ); + + expect(screen.getByTestId('WarningAmberIcon')).toBeInTheDocument(); + expect(screen.getByText('sealInGracePeriodLabel')).toBeInTheDocument(); + expect(screen.getByText('sealGracePeriodCaption')).toBeInTheDocument(); + expect(screen.getByTestId('criterion-official-atRisk')).toBeInTheDocument(); + expect(screen.getByTestId('criterion-stable-pass')).toBeInTheDocument(); + }); + + it('treats a failure outside its grace period as a real failure, not at-risk', () => { + renderSummary( + buildReliability({ + has_seal: true, + criteria: [ + buildCriterion('official', { + status: 'fail', + in_grace_period: false, + }), + ], + }), + ); + + expect(screen.getByTestId('criterion-official-fail')).toBeInTheDocument(); + }); + + it.each(['unknown', 'never_evaluated'] as const)( + 'renders a %s criterion as notEvaluated', + (status) => { + renderSummary( + buildReliability({ + criteria: [buildCriterion('available', { status })], + }), + ); + + expect( + screen.getByTestId('criterion-available-notEvaluated'), + ).toBeInTheDocument(); + }, + ); + + it('links to the full seal-of-reliability analysis page', () => { + renderSummary(buildReliability()); + + const link = screen.getByRole('link', { + name: 'See Full Seal of Reliability Analysis', + }); + expect(link).toHaveAttribute( + 'href', + '/feeds/gtfs/mdb-1/seal-of-reliability', + ); + }); +}); From 559966eff1a4e1e9706a8b98246ad2b15257a134 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 2 Sep 2026 13:55:38 -0400 Subject: [PATCH 08/21] added seasonal feed chip --- messages/en.json | 2 ++ messages/fr.json | 2 ++ src/app/screens/Feed/FeedView.tsx | 1 + .../Feed/components/DataQualitySummary.tsx | 17 +++++++++++++++-- 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/messages/en.json b/messages/en.json index 9d0fa530..07c76935 100644 --- a/messages/en.json +++ b/messages/en.json @@ -259,6 +259,8 @@ "sealOfReliabilityQualityTooltip": "Seal of Reliability is a feed quality measure determined by MobilityData. View details.", "noSealLabel": "No seal yet", "noSealTooltip": "This feed does not currently hold the Seal of Reliability.", + "seasonalFeed": "Seasonal Feed", + "seasonalFeedTooltip": "This feed only provides service during recurring periods of the year, such as a summer-only or winter-only schedule.", "sealEarnedLabel": "Earned", "sealNotYetEarnedLabel": "Not Earned", "sealInGracePeriodLabel": "In Grace Period", diff --git a/messages/fr.json b/messages/fr.json index 2ce37ec5..392c8cbc 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -259,6 +259,8 @@ "sealOfReliabilityQualityTooltip": "Le Sceau de fiabilité est une mesure de la qualité des flux déterminée par MobilityData. Voir les détails.", "noSealLabel": "Pas encore de sceau", "noSealTooltip": "Ce flux ne détient pas actuellement le Sceau de fiabilité.", + "seasonalFeed": "Flux saisonnier", + "seasonalFeedTooltip": "Ce flux ne fournit un service que pendant des périodes récurrentes de l'année, comme un horaire d'été ou d'hiver uniquement.", "sealEarnedLabel": "Obtenu", "sealNotYetEarnedLabel": "Pas encore obtenu", "sealInGracePeriodLabel": "Délai de grâce", diff --git a/src/app/screens/Feed/FeedView.tsx b/src/app/screens/Feed/FeedView.tsx index e406283e..3249a925 100644 --- a/src/app/screens/Feed/FeedView.tsx +++ b/src/app/screens/Feed/FeedView.tsx @@ -209,6 +209,7 @@ export default async function FeedView({ feedId={feed.id ?? ''} feedDataType={feed.data_type ?? 'gtfs'} hasSeal={feed.reliability_seal?.has_seal} + isSeasonal={feed.seasonal} /> )} diff --git a/src/app/screens/Feed/components/DataQualitySummary.tsx b/src/app/screens/Feed/components/DataQualitySummary.tsx index 563a21e6..c39a226f 100644 --- a/src/app/screens/Feed/components/DataQualitySummary.tsx +++ b/src/app/screens/Feed/components/DataQualitySummary.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { Box, Chip } from '@mui/material'; -import { CheckCircle, ReportOutlined } from '@mui/icons-material'; +import { Box, Chip, Tooltip } from '@mui/material'; +import { CheckCircle, EventRepeat, ReportOutlined } from '@mui/icons-material'; import { type components } from '../../../services/feeds/types'; import { WarningContentBox } from '../../../components/WarningContentBox'; import { FeedStatusChip } from '../../../components/FeedStatus'; @@ -16,6 +16,7 @@ export interface DataQualitySummaryProps { feedId: string; feedDataType: string; hasSeal: boolean | undefined; + isSeasonal: boolean | undefined; } // Because this is a server component, the page will not render until the data is ready, hence the async @@ -26,6 +27,7 @@ export default async function DataQualitySummary({ feedId, feedDataType, hasSeal, + isSeasonal, }: DataQualitySummaryProps): Promise { const [t, tCommon, config] = await Promise.all([ getTranslations('feeds'), @@ -50,6 +52,17 @@ export default async function DataQualitySummary({ /> )} + {isSeasonal === true && ( + + } + label={t('seasonalFeed')} + variant='outlined' + color='info' + /> + + )} {latestDataset?.validation_report !== undefined && latestDataset.validation_report !== null && ( <> From 2e042732029c1089b8bc024a1f9166562bd74a77 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 2 Sep 2026 14:10:41 -0400 Subject: [PATCH 09/21] lint fix --- .../[feedId]/authed/seal-of-reliability/page.tsx | 6 +----- .../[feedId]/static/seal-of-reliability/page.tsx | 8 +------- src/app/components/SealOfReliabilityChip.tsx | 11 +++-------- .../Feed/components/FeedReliabilityView.tsx | 16 ++-------------- src/app/screens/Feed/components/FeedSummary.tsx | 6 ++---- .../Feed/components/SealQualitySummary.tsx | 2 +- 6 files changed, 10 insertions(+), 39 deletions(-) diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/seal-of-reliability/page.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/seal-of-reliability/page.tsx index 49d2a82f..a0e9b1c0 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/seal-of-reliability/page.tsx +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/seal-of-reliability/page.tsx @@ -1,16 +1,12 @@ import FeedReliabilityView from '../../../../../../screens/Feed/components/FeedReliabilityView'; import { type ReactElement } from 'react'; -interface Props { - params: Promise<{ locale: string; feedDataType: string; feedId: string }>; -} - /** * Force dynamic rendering for authenticated route. * This allows cookie() and headers() access. */ export const dynamic = 'force-dynamic'; -export default async function AuthedFeedReliabilityPage({}: Props): Promise { +export default async function AuthedFeedReliabilityPage(): Promise { return ; } diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx index 3cad01be..40db283a 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx @@ -1,18 +1,12 @@ import FeedReliabilityView from '../../../../../../screens/Feed/components/FeedReliabilityView'; import { type ReactElement } from 'react'; -interface Props { - params: Promise<{ locale: string; feedDataType: string; feedId: string }>; -} - /** * Seal of Reliability breakdown page (GUEST/ISR-cacheable version). * * IMPORTANT: This page does NOT call cookies() or headers() to remain * ISR-compatible. User session is not available in guest route. */ -export default async function StaticFeedReliabilityPage({ -}: Props): Promise { - +export default async function StaticFeedReliabilityPage(): Promise { return ; } diff --git a/src/app/components/SealOfReliabilityChip.tsx b/src/app/components/SealOfReliabilityChip.tsx index 69bb9d26..787e8c11 100644 --- a/src/app/components/SealOfReliabilityChip.tsx +++ b/src/app/components/SealOfReliabilityChip.tsx @@ -3,8 +3,7 @@ import { Chip, Tooltip } from '@mui/material'; import { useTranslations } from 'next-intl'; import WorkspacePremiumIcon from '@mui/icons-material/WorkspacePremium'; import { Link } from '../../i18n/navigation'; -import SealOfReliability, { -} from './SealOfReliability'; +import SealOfReliability from './SealOfReliability'; export interface SealOfReliabilityChipProps { hasSeal: boolean | undefined; @@ -34,11 +33,7 @@ export default function SealOfReliabilityChip({ component={Link} href={href} clickable - icon={ - - } + icon={} label={t('sealOfReliabilityAlt')} sx={{ background: 'white', @@ -47,7 +42,7 @@ export default function SealOfReliabilityChip({ borderRadius: '50%', boxShadow: '0 0 0 1.5px rgba(255,255,255,0.7)', marginLeft: '5px', - marginRight: '-6px' + marginRight: '-6px', }, }} /> diff --git a/src/app/screens/Feed/components/FeedReliabilityView.tsx b/src/app/screens/Feed/components/FeedReliabilityView.tsx index 91d694c7..71e4b965 100644 --- a/src/app/screens/Feed/components/FeedReliabilityView.tsx +++ b/src/app/screens/Feed/components/FeedReliabilityView.tsx @@ -1,19 +1,7 @@ import { type ReactElement } from 'react'; -import {Container, Typography } from '@mui/material'; -import { notFound } from 'next/navigation'; -import { - type AllFeedType, - isGtfsFeedType, -} from '../../../services/feeds/utils'; - - -interface Props { - -} - -export default async function FeedReliabilityView({ -}: Props): Promise { +import { Container, Typography } from '@mui/material'; +export default async function FeedReliabilityView(): Promise { return ( Reliability Page diff --git a/src/app/screens/Feed/components/FeedSummary.tsx b/src/app/screens/Feed/components/FeedSummary.tsx index 15dc81bf..4ab538e8 100644 --- a/src/app/screens/Feed/components/FeedSummary.tsx +++ b/src/app/screens/Feed/components/FeedSummary.tsx @@ -626,10 +626,8 @@ export default function FeedSummary({ {isGtfsFeedType(feed) && enableSealOfReliability && ( {feed.reliability_seal?.has_seal === true && ( - - + + )} diff --git a/src/app/screens/Feed/components/SealQualitySummary.tsx b/src/app/screens/Feed/components/SealQualitySummary.tsx index 543946e2..610badd2 100644 --- a/src/app/screens/Feed/components/SealQualitySummary.tsx +++ b/src/app/screens/Feed/components/SealQualitySummary.tsx @@ -120,7 +120,7 @@ export default function SealQualitySummary({ {sealStatus === 'earned' && t('sealEarnedCaption')} {sealStatus === 'gracePeriod' && t('sealGracePeriodCaption')} From 9162ece17d59152c5abcbf9a97609d2fc5d12baa Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 2 Sep 2026 14:57:53 -0400 Subject: [PATCH 10/21] on probation status --- messages/en.json | 1 + messages/fr.json | 1 + src/app/components/SealOfReliabilityChip.tsx | 16 +--------------- .../Feed/components/SealQualitySummary.spec.tsx | 17 +++++++++++++++++ .../Feed/components/SealQualitySummary.tsx | 16 +++++++++++----- 5 files changed, 31 insertions(+), 20 deletions(-) diff --git a/messages/en.json b/messages/en.json index 07c76935..7f48e38c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -275,6 +275,7 @@ "sealCriterionGracePeriodNote": "In grace period until {date}.", "sealCriterionNotApplicable": "Not Applicable", "sealCriterionNotEvaluated": "Not Evaluated", + "sealCriterionOnProbation": "On Probation", "reliabilityAnalysisTitle": "Seal of Reliability Analysis", "reliabilityAnalysisIntro": "A breakdown of the six criteria used to determine whether this feed holds the Seal of Reliability.", "reliabilityDataPlaceholderWarning": "Per-criterion results aren't available from the API yet. The breakdown below is illustrative placeholder data.", diff --git a/messages/fr.json b/messages/fr.json index 392c8cbc..3b8171ca 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -275,6 +275,7 @@ "sealCriterionGracePeriodNote": "En délai de grâce jusqu'au {date}.", "sealCriterionNotApplicable": "Non applicable", "sealCriterionNotEvaluated": "Non évalué", + "sealCriterionOnProbation": "En probation", "reliabilityAnalysisTitle": "Analyse du Sceau de fiabilité", "reliabilityAnalysisIntro": "Un aperçu des six critères utilisés pour déterminer si ce flux détient le Sceau de fiabilité.", "reliabilityDataPlaceholderWarning": "Les résultats par critère ne sont pas encore disponibles depuis l'API. Le détail ci-dessous est une donnée d'exemple à titre indicatif.", diff --git a/src/app/components/SealOfReliabilityChip.tsx b/src/app/components/SealOfReliabilityChip.tsx index 787e8c11..bc8063e6 100644 --- a/src/app/components/SealOfReliabilityChip.tsx +++ b/src/app/components/SealOfReliabilityChip.tsx @@ -1,7 +1,6 @@ 'use client'; import { Chip, Tooltip } from '@mui/material'; import { useTranslations } from 'next-intl'; -import WorkspacePremiumIcon from '@mui/icons-material/WorkspacePremium'; import { Link } from '../../i18n/navigation'; import SealOfReliability from './SealOfReliability'; @@ -50,18 +49,5 @@ export default function SealOfReliabilityChip({ ); } - return ( - - } - label={t('noSealLabel')} - variant='outlined' - sx={{ opacity: 0.7 }} - /> - - ); + return null; } diff --git a/src/app/screens/Feed/components/SealQualitySummary.spec.tsx b/src/app/screens/Feed/components/SealQualitySummary.spec.tsx index 94d44fb8..8bd0489e 100644 --- a/src/app/screens/Feed/components/SealQualitySummary.spec.tsx +++ b/src/app/screens/Feed/components/SealQualitySummary.spec.tsx @@ -181,6 +181,23 @@ describe('SealQualitySummary', () => { }, ); + it('renders a passing criterion on probation as its own state, not as a pass', () => { + renderSummary( + buildReliability({ + has_seal: false, + criteria: [ + buildCriterion('official', { status: 'pass', on_probation: true }), + buildCriterion('stable', { status: 'pass' }), + ], + }), + ); + + expect( + screen.getByTestId('criterion-official-probation'), + ).toBeInTheDocument(); + expect(screen.getByTestId('criterion-stable-pass')).toBeInTheDocument(); + }); + it('links to the full seal-of-reliability analysis page', () => { renderSummary(buildReliability()); diff --git a/src/app/screens/Feed/components/SealQualitySummary.tsx b/src/app/screens/Feed/components/SealQualitySummary.tsx index 610badd2..065ae0d8 100644 --- a/src/app/screens/Feed/components/SealQualitySummary.tsx +++ b/src/app/screens/Feed/components/SealQualitySummary.tsx @@ -20,14 +20,18 @@ type CriterionDisplayStatus = | 'atRisk' | 'fail' | 'notApplicable' - | 'notEvaluated'; + | 'notEvaluated' + | 'probation'; -// Covers all five verdicts the API can return for a criterion (pass, fail, -// unknown, not_applicable, never_evaluated) - only `fail` outside its grace -// period is a real failure; the rest are neutral, non-failing states. +// `on_probation` is independent of `status` - a criterion can read `pass` +// while on probation and still not count towards the seal, so it takes +// priority over the status-derived states below. function getCriterionDisplayStatus( criterion: ReliabilityCriterion, ): CriterionDisplayStatus { + if (criterion.on_probation) { + return 'probation'; + } switch (criterion.status) { case 'pass': return 'pass'; @@ -65,7 +69,7 @@ export default function SealQualitySummary({ (c) => c.status !== 'not_applicable', ); const passedCriteriaCount = consideredCriteria.filter( - (c) => c.status === 'pass', + (c) => c.status === 'pass' && !c.on_probation, ).length; const sealStatus: 'earned' | 'gracePeriod' | 'notEarned' = !hasSeal @@ -146,6 +150,7 @@ export default function SealQualitySummary({ fail: theme.palette.error.light, notApplicable: theme.palette.grey[500], notEvaluated: theme.palette.grey[500], + probation: theme.palette.info.light, }[displayStatus]; const statusLabel = { @@ -154,6 +159,7 @@ export default function SealQualitySummary({ fail: t('sealCriterionFail'), notApplicable: t('sealCriterionNotApplicable'), notEvaluated: t('sealCriterionNotEvaluated'), + probation: t('sealCriterionOnProbation'), }[displayStatus]; const graceNote = From 9d7beb83ace9da602e1e1237063b3a590f555c4b Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 2 Sep 2026 14:59:19 -0400 Subject: [PATCH 11/21] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- messages/fr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/fr.json b/messages/fr.json index 3b8171ca..273cb620 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -264,7 +264,7 @@ "sealEarnedLabel": "Obtenu", "sealNotYetEarnedLabel": "Pas encore obtenu", "sealInGracePeriodLabel": "Délai de grâce", - "sealEarnedCaption": "Les 6 critères sont respectés", + "sealEarnedCaption": "Tous les critères applicables sont respectés", "sealGracePeriodCaption": "Le sceau est conservé pendant qu'un ou plusieurs critères sont en délai de grâce", "sealCriteriaMetCaption": "{passed} critères sur {total} respectés", "seeFullAnalysis": "Voir l'analyse complète", From 6061640578376bf0b3e23fc7485580458c3806d9 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 2 Sep 2026 15:19:34 -0400 Subject: [PATCH 12/21] correct user featrue flags --- src/app/screens/Feed/FeedView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/screens/Feed/FeedView.tsx b/src/app/screens/Feed/FeedView.tsx index 3249a925..e1524894 100644 --- a/src/app/screens/Feed/FeedView.tsx +++ b/src/app/screens/Feed/FeedView.tsx @@ -35,7 +35,7 @@ import ClientSubscribeControls from './components/ClientSubscribeControls'; import { getBoundingBox } from './Feed.functions'; import dynamic from 'next/dynamic'; import { ContentBox } from '../../components/ContentBox'; -import { getUserRemoteConfigValues } from '../../../lib/remote-config.server'; +import { getRemoteConfigValues } from '../../../lib/remote-config.server'; const CoveredAreaMap = dynamic( async () => @@ -98,7 +98,7 @@ export default async function FeedView({ getTranslations('feeds'), getTranslations('gbfs'), getTranslations('common'), - getUserRemoteConfigValues(), + getRemoteConfigValues(), ]); // Basic derived data From 504a1862ca717d2f2e18684b461a039a375916b0 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 2 Sep 2026 15:22:03 -0400 Subject: [PATCH 13/21] correct configs and implementation --- .../[feedId]/lib/feed-data-shared.spec.ts | 91 +++++++++++++++++++ .../[feedId]/lib/feed-data-shared.ts | 29 +++--- .../[feedDataType]/[feedId]/lib/feed-data.ts | 15 ++- .../[feedId]/lib/guest-feed-data.ts | 7 +- .../Feed/components/DataQualitySummary.tsx | 4 +- 5 files changed, 125 insertions(+), 21 deletions(-) create mode 100644 src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data-shared.spec.ts diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data-shared.spec.ts b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data-shared.spec.ts new file mode 100644 index 00000000..844797c9 --- /dev/null +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data-shared.spec.ts @@ -0,0 +1,91 @@ +/** + * @jest-environment node + */ + +import { + fetchReliabilityData, + fetchCompleteFeedDataImpl, +} from './feed-data-shared'; + +jest.mock('server-only', () => ({})); +jest.mock('next/cache', () => ({ + unstable_cache: (fn: unknown) => fn, +})); + +const mockGetGtfsFeedReliability = jest.fn(); +const mockGetGtfsFeed = jest.fn(); +const mockGetGtfsFeedDatasets = jest.fn(); +const mockGetGtfsFeedRoutes = jest.fn(); + +jest.mock('../../../../../services/feeds', () => ({ + getGtfsFeedReliability: (...args: unknown[]) => + mockGetGtfsFeedReliability(...args), + getGtfsFeed: (...args: unknown[]) => mockGetGtfsFeed(...args), + getGtfsFeedDatasets: (...args: unknown[]) => mockGetGtfsFeedDatasets(...args), + getGtfsFeedRoutes: (...args: unknown[]) => mockGetGtfsFeedRoutes(...args), +})); + +describe('fetchReliabilityData', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns the reliability report on success', async () => { + const report = { feed_id: 'mdb-1', has_seal: true, criteria: [] }; + mockGetGtfsFeedReliability.mockResolvedValue(report); + + const result = await fetchReliabilityData('mdb-1', 'token', undefined); + + expect(result).toEqual(report); + }); + + // unstable_cache is mocked as a pass-through here, so this exercises the + // real cached-fetcher function: it must reject (not resolve to `null`) + // on failure, or a real unstable_cache would persist the failure as a + // 14-day negative-cache entry. + it('returns undefined without throwing when the API call fails', async () => { + mockGetGtfsFeedReliability.mockRejectedValue(new Error('network error')); + + await expect( + fetchReliabilityData('mdb-1', 'token', undefined), + ).resolves.toBeUndefined(); + }); +}); + +describe('fetchCompleteFeedDataImpl', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetGtfsFeed.mockResolvedValue({ id: 'mdb-1', data_type: 'gtfs' }); + mockGetGtfsFeedDatasets.mockResolvedValue([]); + mockGetGtfsFeedRoutes.mockResolvedValue(null); + }); + + it('does not call the reliability API when enableSealOfReliability is false', async () => { + const result = await fetchCompleteFeedDataImpl( + 'gtfs', + 'mdb-1', + 'token', + undefined, + false, + ); + + expect(mockGetGtfsFeedReliability).not.toHaveBeenCalled(); + expect(result.reliability).toBeUndefined(); + }); + + it('calls the reliability API when enableSealOfReliability is true', async () => { + const report = { feed_id: 'mdb-1', has_seal: true, criteria: [] }; + mockGetGtfsFeedReliability.mockResolvedValue(report); + + const result = await fetchCompleteFeedDataImpl( + 'gtfs', + 'mdb-1', + 'token', + undefined, + true, + ); + + expect(mockGetGtfsFeedReliability).toHaveBeenCalledTimes(1); + expect(result.reliability).toEqual(report); + }); +}); diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data-shared.ts b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data-shared.ts index 87d6ef7a..a5eb1339 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data-shared.ts +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data-shared.ts @@ -96,16 +96,12 @@ export async function fetchReliabilityData( ): Promise { const cachedFetch = unstable_cache( async (): Promise => { - try { - const reliability = await getGtfsFeedReliability( - feedId, - accessToken, - userContextJwt, - ); - return reliability ?? null; - } catch (e) { - return null; - } + const reliability = await getGtfsFeedReliability( + feedId, + accessToken, + userContextJwt, + ); + return reliability ?? null; }, [`feed-reliability-${feedId}`], { @@ -114,8 +110,12 @@ export async function fetchReliabilityData( }, ); - const reliability = await cachedFetch(); - return reliability ?? undefined; + try { + const reliability = await cachedFetch(); + return reliability ?? undefined; + } catch (e) { + return undefined; + } } /** @@ -211,6 +211,7 @@ export async function fetchCompleteFeedDataImpl( feedId: string, accessToken: string, userContextJwt: string | undefined, + enableSealOfReliability: boolean, ): Promise { // Fetch core feed data const feed = await fetchFeedByType( @@ -238,7 +239,9 @@ export async function fetchCompleteFeedDataImpl( feedId, (feed as GTFSFeedType)?.visualization_dataset_id ?? '', ), - fetchReliabilityData(feedId, accessToken, userContextJwt), + enableSealOfReliability + ? fetchReliabilityData(feedId, accessToken, userContextJwt) + : Promise.resolve(undefined), ], ); initialDatasets = datasetsResult; diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data.ts b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data.ts index 214f40bc..3344213c 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data.ts +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data.ts @@ -11,6 +11,7 @@ import { getUserContextJwtFromCookie, getCurrentUserFromCookie, } from '../../../../../utils/auth-server'; +import { getRemoteConfigValues } from '../../../../../../lib/remote-config.server'; import { fetchCompleteFeedDataImpl, type FeedDataResult, @@ -35,11 +36,14 @@ export const fetchCompleteFeedData = cache( feedDataType: string, feedId: string, ): Promise => { - const [accessToken, userContextJwt, user] = await Promise.all([ - getSSRAccessToken(), - getUserContextJwtFromCookie(), - getCurrentUserFromCookie(), - ]); + const [accessToken, userContextJwt, user, remoteConfig] = await Promise.all( + [ + getSSRAccessToken(), + getUserContextJwtFromCookie(), + getCurrentUserFromCookie(), + getRemoteConfigValues(), + ], + ); const userId = user?.uid ?? 'anonymous'; const cachedFetch = unstable_cache( @@ -49,6 +53,7 @@ export const fetchCompleteFeedData = cache( feedId, accessToken, userContextJwt, + remoteConfig.enableSealOfReliability, ); }, [`feed-complete-${feedDataType}-${feedId}-${userId}`], // unique cache key per user diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/guest-feed-data.ts b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/guest-feed-data.ts index 2bba8282..5f0c6c8a 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/guest-feed-data.ts +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/guest-feed-data.ts @@ -10,6 +10,7 @@ import 'server-only'; import { cache } from 'react'; import { unstable_cache } from 'next/cache'; import { getGuestGcipIdToken } from '../../../../../utils/auth-server'; +import { getRemoteConfigValues } from '../../../../../../lib/remote-config.server'; import { fetchCompleteFeedDataImpl, type FeedDataResult, @@ -32,12 +33,16 @@ export const fetchGuestFeedData = cache( async (feedDataType: string, feedId: string): Promise => { const cachedFetch = unstable_cache( async () => { - const accessToken = await getGuestGcipIdToken(); + const [accessToken, remoteConfig] = await Promise.all([ + getGuestGcipIdToken(), + getRemoteConfigValues(), + ]); return await fetchCompleteFeedDataImpl( feedDataType, feedId, accessToken, undefined, // no user context for guest + remoteConfig.enableSealOfReliability, ); }, [`feed-guest-${feedDataType}-${feedId}`], // unique cache key diff --git a/src/app/screens/Feed/components/DataQualitySummary.tsx b/src/app/screens/Feed/components/DataQualitySummary.tsx index c39a226f..8860f04e 100644 --- a/src/app/screens/Feed/components/DataQualitySummary.tsx +++ b/src/app/screens/Feed/components/DataQualitySummary.tsx @@ -7,7 +7,7 @@ import { FeedStatusChip } from '../../../components/FeedStatus'; import FeedVerificationChip from '../../../components/FeedVerificationChip'; import SealOfReliabilityChip from '../../../components/SealOfReliabilityChip'; import { getTranslations } from 'next-intl/server'; -import { getUserRemoteConfigValues } from '../../../../lib/remote-config.server'; +import { getRemoteConfigValues } from '../../../../lib/remote-config.server'; export interface DataQualitySummaryProps { feedStatus: components['schemas']['Feed']['status']; @@ -32,7 +32,7 @@ export default async function DataQualitySummary({ const [t, tCommon, config] = await Promise.all([ getTranslations('feeds'), getTranslations('common'), - getUserRemoteConfigValues(), + getRemoteConfigValues(), ]); return ( From 7c7f9213cc90ec0cfaa308985f1c020b3b85b55e Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 2 Sep 2026 15:26:43 -0400 Subject: [PATCH 14/21] accessibility + text --- .../Feed/components/SealQualitySummary.tsx | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/src/app/screens/Feed/components/SealQualitySummary.tsx b/src/app/screens/Feed/components/SealQualitySummary.tsx index 065ae0d8..9e494aa1 100644 --- a/src/app/screens/Feed/components/SealQualitySummary.tsx +++ b/src/app/screens/Feed/components/SealQualitySummary.tsx @@ -172,26 +172,43 @@ export default function SealQualitySummary({ })}` : ''; + const criterionDescription = `${tSeal(`criteria.${key}.title`)} — ${statusLabel}: ${tSeal( + `criteria.${key}.description`, + )}${graceNote}`; + return ( - + > + + ); })} @@ -209,7 +226,7 @@ export default function SealQualitySummary({ component={Link} href={`/feeds/${feedDataType}/${feedId}/seal-of-reliability`} > - See Full Seal of Reliability Analysis + {t('seeFullAnalysis')} From d55a7152dd511d8b4a439fac07e7dfe8c0acede5 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 2 Sep 2026 15:35:01 -0400 Subject: [PATCH 15/21] updated critera --- src/app/constants/sealCriteria.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/app/constants/sealCriteria.ts b/src/app/constants/sealCriteria.ts index d115b66d..bdc939ea 100644 --- a/src/app/constants/sealCriteria.ts +++ b/src/app/constants/sealCriteria.ts @@ -15,24 +15,6 @@ export type SealCriterionKey = | 'freshRolling' | 'freshContinuous'; -export interface SealCriterionResult { - key: SealCriterionKey; - icon: SvgIconComponent; - passed: boolean; -} - -// Per-criterion verdicts aren't exposed by the API yet (only the overall -// has_seal flag is) - hardcoded until GET /v1/gtfs_feeds/{id}/reliability -// is wired up on the frontend. -export const HARDCODED_SEAL_CRITERIA: SealCriterionResult[] = [ - { key: 'official', icon: VerifiedIcon, passed: true }, - { key: 'stable', icon: CodeIcon, passed: true }, - { key: 'available', icon: DownloadIcon, passed: true }, - { key: 'compliant', icon: RuleIcon, passed: true }, - { key: 'freshRolling', icon: EventAvailableIcon, passed: true }, - { key: 'freshContinuous', icon: SyncAltIcon, passed: false }, -]; - export type ApiSealCriterionKey = components['schemas']['ReliabilityCriterion']['criterion']; From 027003171d2bc3889e39787701b7073c96b56071 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 2 Sep 2026 15:35:09 -0400 Subject: [PATCH 16/21] fixed e2e tests --- cypress/e2e/feeds.cy.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/cypress/e2e/feeds.cy.ts b/cypress/e2e/feeds.cy.ts index dc50668b..34901337 100644 --- a/cypress/e2e/feeds.cy.ts +++ b/cypress/e2e/feeds.cy.ts @@ -44,8 +44,6 @@ describe('Feed page', () => { cy.get('[data-testid="data-quality-summary"]', { timeout: 10000 }).within( () => { cy.get('[data-testid="error-count"]').should('exist'); - cy.get('[data-testid="warning-count"]').should('exist'); - cy.get('[data-testid="info-count"]').should('exist'); }, ); }); From c2c995c79ef184c3212c6f065226abfb1f0bdab6 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 2 Sep 2026 15:44:57 -0400 Subject: [PATCH 17/21] unit test fix --- src/app/screens/Feed/components/SealQualitySummary.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/screens/Feed/components/SealQualitySummary.spec.tsx b/src/app/screens/Feed/components/SealQualitySummary.spec.tsx index 8bd0489e..309f4f05 100644 --- a/src/app/screens/Feed/components/SealQualitySummary.spec.tsx +++ b/src/app/screens/Feed/components/SealQualitySummary.spec.tsx @@ -202,7 +202,7 @@ describe('SealQualitySummary', () => { renderSummary(buildReliability()); const link = screen.getByRole('link', { - name: 'See Full Seal of Reliability Analysis', + name: 'seeFullAnalysis', }); expect(link).toHaveAttribute( 'href', From c0ca9e61055f841b338c9d903b67afe804973084 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 3 Sep 2026 09:20:57 -0400 Subject: [PATCH 18/21] seasonal feed ui placement --- src/app/screens/Feed/FeedView.tsx | 1 - .../Feed/components/DataQualitySummary.tsx | 17 ++--------------- src/app/screens/Feed/components/FeedSummary.tsx | 17 ++++++++++++++++- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/app/screens/Feed/FeedView.tsx b/src/app/screens/Feed/FeedView.tsx index e1524894..66cf22a4 100644 --- a/src/app/screens/Feed/FeedView.tsx +++ b/src/app/screens/Feed/FeedView.tsx @@ -209,7 +209,6 @@ export default async function FeedView({ feedId={feed.id ?? ''} feedDataType={feed.data_type ?? 'gtfs'} hasSeal={feed.reliability_seal?.has_seal} - isSeasonal={feed.seasonal} /> )} diff --git a/src/app/screens/Feed/components/DataQualitySummary.tsx b/src/app/screens/Feed/components/DataQualitySummary.tsx index 8860f04e..c12b2f20 100644 --- a/src/app/screens/Feed/components/DataQualitySummary.tsx +++ b/src/app/screens/Feed/components/DataQualitySummary.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { Box, Chip, Tooltip } from '@mui/material'; -import { CheckCircle, EventRepeat, ReportOutlined } from '@mui/icons-material'; +import { Box, Chip } from '@mui/material'; +import { CheckCircle, ReportOutlined } from '@mui/icons-material'; import { type components } from '../../../services/feeds/types'; import { WarningContentBox } from '../../../components/WarningContentBox'; import { FeedStatusChip } from '../../../components/FeedStatus'; @@ -16,7 +16,6 @@ export interface DataQualitySummaryProps { feedId: string; feedDataType: string; hasSeal: boolean | undefined; - isSeasonal: boolean | undefined; } // Because this is a server component, the page will not render until the data is ready, hence the async @@ -27,7 +26,6 @@ export default async function DataQualitySummary({ feedId, feedDataType, hasSeal, - isSeasonal, }: DataQualitySummaryProps): Promise { const [t, tCommon, config] = await Promise.all([ getTranslations('feeds'), @@ -52,17 +50,6 @@ export default async function DataQualitySummary({ /> )} - {isSeasonal === true && ( - - } - label={t('seasonalFeed')} - variant='outlined' - color='info' - /> - - )} {latestDataset?.validation_report !== undefined && latestDataset.validation_report !== null && ( <> diff --git a/src/app/screens/Feed/components/FeedSummary.tsx b/src/app/screens/Feed/components/FeedSummary.tsx index 4ab538e8..4e736739 100644 --- a/src/app/screens/Feed/components/FeedSummary.tsx +++ b/src/app/screens/Feed/components/FeedSummary.tsx @@ -29,6 +29,7 @@ import { import { useTranslations } from 'next-intl'; import CardSectionTitle from '../../../components/CardSectionTitle'; import CalendarTodayIcon from '@mui/icons-material/CalendarToday'; +import EventRepeatIcon from '@mui/icons-material/EventRepeat'; import LinkIcon from '@mui/icons-material/Link'; import DatasetIcon from '@mui/icons-material/Dataset'; import LayersIcon from '@mui/icons-material/Layers'; @@ -514,7 +515,21 @@ export default function FeedSummary({ {latestDataset?.service_date_range_start != undefined && latestDataset.service_date_range_end != undefined && ( - + + {isGtfsFeedType(feed) && feed.seasonal === true && ( + + + } + label={t('seasonalFeed')} + variant='outlined' + color='info' + size='small' + /> + + + )} {t('serviceDateRange')} From 6c12cd0056afc9db93616a0c6a3b43deffb80186 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 3 Sep 2026 09:53:46 -0400 Subject: [PATCH 19/21] extra seasonal text --- messages/en.json | 1 + messages/fr.json | 1 + src/app/screens/Feed/components/SealQualitySummary.tsx | 10 +++++++++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/messages/en.json b/messages/en.json index 7f48e38c..90e9da39 100644 --- a/messages/en.json +++ b/messages/en.json @@ -274,6 +274,7 @@ "sealCriterionInGracePeriod": "At Risk", "sealCriterionGracePeriodNote": "In grace period until {date}.", "sealCriterionNotApplicable": "Not Applicable", + "sealCriterionSeasonalNote": "This feed is seasonal, so rolling 7-day coverage doesn't apply.", "sealCriterionNotEvaluated": "Not Evaluated", "sealCriterionOnProbation": "On Probation", "reliabilityAnalysisTitle": "Seal of Reliability Analysis", diff --git a/messages/fr.json b/messages/fr.json index 273cb620..dc4b23f7 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -274,6 +274,7 @@ "sealCriterionInGracePeriod": "À risque", "sealCriterionGracePeriodNote": "En délai de grâce jusqu'au {date}.", "sealCriterionNotApplicable": "Non applicable", + "sealCriterionSeasonalNote": "Ce flux est saisonnier, la couverture glissante de 7 jours ne s'applique donc pas.", "sealCriterionNotEvaluated": "Non évalué", "sealCriterionOnProbation": "En probation", "reliabilityAnalysisTitle": "Analyse du Sceau de fiabilité", diff --git a/src/app/screens/Feed/components/SealQualitySummary.tsx b/src/app/screens/Feed/components/SealQualitySummary.tsx index 9e494aa1..f29b58b6 100644 --- a/src/app/screens/Feed/components/SealQualitySummary.tsx +++ b/src/app/screens/Feed/components/SealQualitySummary.tsx @@ -172,9 +172,17 @@ export default function SealQualitySummary({ })}` : ''; + // not_applicable on this criterion only ever means the feed + // is seasonal - seasonal feeds are excluded from the rolling + // 7-day coverage check entirely. + const seasonalNote = + key === 'freshRolling' && displayStatus === 'notApplicable' + ? ` ${t('sealCriterionSeasonalNote')}` + : ''; + const criterionDescription = `${tSeal(`criteria.${key}.title`)} — ${statusLabel}: ${tSeal( `criteria.${key}.description`, - )}${graceNote}`; + )}${graceNote}${seasonalNote}`; return ( Date: Thu, 3 Sep 2026 10:37:51 -0400 Subject: [PATCH 20/21] text change --- messages/en.json | 2 +- messages/fr.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/messages/en.json b/messages/en.json index 90e9da39..f5b37fd7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -263,7 +263,7 @@ "seasonalFeedTooltip": "This feed only provides service during recurring periods of the year, such as a summer-only or winter-only schedule.", "sealEarnedLabel": "Earned", "sealNotYetEarnedLabel": "Not Earned", - "sealInGracePeriodLabel": "In Grace Period", + "sealInGracePeriodLabel": "At Risk", "sealEarnedCaption": "All criteria met", "sealGracePeriodCaption": "Seal retained while one or more criteria are in a grace period", "sealCriteriaMetCaption": "{passed} out of {total} criteria met", diff --git a/messages/fr.json b/messages/fr.json index dc4b23f7..9d75bfa4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -263,7 +263,7 @@ "seasonalFeedTooltip": "Ce flux ne fournit un service que pendant des périodes récurrentes de l'année, comme un horaire d'été ou d'hiver uniquement.", "sealEarnedLabel": "Obtenu", "sealNotYetEarnedLabel": "Pas encore obtenu", - "sealInGracePeriodLabel": "Délai de grâce", + "sealInGracePeriodLabel": "À risque", "sealEarnedCaption": "Tous les critères applicables sont respectés", "sealGracePeriodCaption": "Le sceau est conservé pendant qu'un ou plusieurs critères sont en délai de grâce", "sealCriteriaMetCaption": "{passed} critères sur {total} respectés", From 504d19745792c27646598b114165e709eef5bfdd Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 3 Sep 2026 11:01:01 -0400 Subject: [PATCH 21/21] grace period icon color --- .../screens/Feed/components/SealQualitySummary.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/app/screens/Feed/components/SealQualitySummary.tsx b/src/app/screens/Feed/components/SealQualitySummary.tsx index f29b58b6..f19e558c 100644 --- a/src/app/screens/Feed/components/SealQualitySummary.tsx +++ b/src/app/screens/Feed/components/SealQualitySummary.tsx @@ -23,20 +23,24 @@ type CriterionDisplayStatus = | 'notEvaluated' | 'probation'; -// `on_probation` is independent of `status` - a criterion can read `pass` -// while on probation and still not count towards the seal, so it takes -// priority over the status-derived states below. +// `on_probation` and `in_grace_period` are both independent of `status` - a +// criterion can read `pass` while on probation, or while still inside a +// grace period from a recent failure, and in either case it takes priority +// over the plain status-derived states below. function getCriterionDisplayStatus( criterion: ReliabilityCriterion, ): CriterionDisplayStatus { if (criterion.on_probation) { return 'probation'; } + if (criterion.in_grace_period) { + return 'atRisk'; + } switch (criterion.status) { case 'pass': return 'pass'; case 'fail': - return criterion.in_grace_period ? 'atRisk' : 'fail'; + return 'fail'; case 'not_applicable': return 'notApplicable'; case 'unknown':