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 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'); }, ); }); diff --git a/messages/en.json b/messages/en.json index ce8da6f0..f5b37fd7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -256,8 +256,33 @@ "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.", + "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": "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", + "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", + "sealCriterionSeasonalNote": "This feed is seasonal, so rolling 7-day coverage doesn't apply.", + "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.", "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 +300,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..9d75bfa4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -256,8 +256,33 @@ "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é.", + "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": "À 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", + "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", + "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é", + "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 +300,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", 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]/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..a0e9b1c0 --- /dev/null +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/seal-of-reliability/page.tsx @@ -0,0 +1,12 @@ +import FeedReliabilityView from '../../../../../../screens/Feed/components/FeedReliabilityView'; +import { type ReactElement } from 'react'; + +/** + * Force dynamic rendering for authenticated route. + * This allows cookie() and headers() access. + */ +export const dynamic = 'force-dynamic'; + +export default async function AuthedFeedReliabilityPage(): Promise { + return ; +} 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 d245616a..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 @@ -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 => { + const reliability = await getGtfsFeedReliability( + feedId, + accessToken, + userContextJwt, + ); + return reliability ?? null; + }, + [`feed-reliability-${feedId}`], + { + tags: [`feed-${feedId}`], + revalidate: 1209600, // 14 days - public reliability data, revalidated on demand via /api/revalidate when the feed updates + }, + ); + + try { + const reliability = await cachedFetch(); + return reliability ?? undefined; + } catch (e) { + return undefined; + } +} + /** * Fetch related feeds for GTFS-RT feeds. * Returns both GTFS and GTFS-RT related feeds. @@ -170,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( @@ -182,24 +224,31 @@ 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 ?? '', + ), + enableSealOfReliability + ? fetchReliabilityData(feedId, accessToken, userContextJwt) + : Promise.resolve(undefined), + ], + ); initialDatasets = datasetsResult; totalRoutes = routesResult.totalRoutes; routeTypes = routesResult.routeTypes; routes = routesResult.routes; + reliability = reliabilityResult; } // Fetch related feeds for GTFS-RT @@ -250,5 +299,6 @@ export async function fetchCompleteFeedDataImpl( totalRoutes, routeTypes, routes, + reliability, }; } 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/[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/[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..40db283a --- /dev/null +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx @@ -0,0 +1,12 @@ +import FeedReliabilityView from '../../../../../../screens/Feed/components/FeedReliabilityView'; +import { type ReactElement } from 'react'; + +/** + * 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(): Promise { + return ; +} 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/components/SealOfReliabilityChip.tsx b/src/app/components/SealOfReliabilityChip.tsx new file mode 100644 index 00000000..bc8063e6 --- /dev/null +++ b/src/app/components/SealOfReliabilityChip.tsx @@ -0,0 +1,53 @@ +'use client'; +import { Chip, Tooltip } from '@mui/material'; +import { useTranslations } from 'next-intl'; +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 null; +} diff --git a/src/app/constants/sealCriteria.ts b/src/app/constants/sealCriteria.ts new file mode 100644 index 00000000..bdc939ea --- /dev/null +++ b/src/app/constants/sealCriteria.ts @@ -0,0 +1,43 @@ +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 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, + }; diff --git a/src/app/screens/Feed/FeedView.tsx b/src/app/screens/Feed/FeedView.tsx index 1b93cf7c..66cf22a4 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 { getRemoteConfigValues } 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'), + getRemoteConfigValues(), ]); // 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..c12b2f20 100644 --- a/src/app/screens/Feed/components/DataQualitySummary.tsx +++ b/src/app/screens/Feed/components/DataQualitySummary.tsx @@ -2,17 +2,20 @@ 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'; +import { getRemoteConfigValues } from '../../../../lib/remote-config.server'; 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,11 +23,14 @@ export default async function DataQualitySummary({ feedStatus, isOfficialFeed, latestDataset, + feedId, + feedDataType, + hasSeal, }: DataQualitySummaryProps): Promise { const [t, tCommon, config] = await Promise.all([ getTranslations('feeds'), getTranslations('common'), - getUserRemoteConfigValues(), + getRemoteConfigValues(), ]); return ( @@ -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/FeedReliabilityView.tsx b/src/app/screens/Feed/components/FeedReliabilityView.tsx new file mode 100644 index 00000000..71e4b965 --- /dev/null +++ b/src/app/screens/Feed/components/FeedReliabilityView.tsx @@ -0,0 +1,10 @@ +import { type ReactElement } from 'react'; +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 f5deb513..4e736739 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, @@ -28,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'; @@ -38,6 +40,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 +49,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 +70,8 @@ export interface FeedSummaryProps { autoDiscoveryUrl?: string; totalRoutes?: number; routeTypes?: string[]; + enableSealOfReliability?: boolean; + reliability?: components['schemas']['FeedReliabilityReport']; } export default function FeedSummary({ @@ -78,6 +81,8 @@ export default function FeedSummary({ autoDiscoveryUrl, routeTypes, totalRoutes, + enableSealOfReliability = false, + reliability, }: FeedSummaryProps): React.ReactElement { const t = useTranslations('feeds'); const tCommon = useTranslations('common'); @@ -510,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')} @@ -619,6 +638,40 @@ 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 +714,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.spec.tsx b/src/app/screens/Feed/components/SealQualitySummary.spec.tsx new file mode 100644 index 00000000..309f4f05 --- /dev/null +++ b/src/app/screens/Feed/components/SealQualitySummary.spec.tsx @@ -0,0 +1,212 @@ +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('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()); + + const link = screen.getByRole('link', { + name: 'seeFullAnalysis', + }); + expect(link).toHaveAttribute( + 'href', + '/feeds/gtfs/mdb-1/seal-of-reliability', + ); + }); +}); diff --git a/src/app/screens/Feed/components/SealQualitySummary.tsx b/src/app/screens/Feed/components/SealQualitySummary.tsx new file mode 100644 index 00000000..f19e558c --- /dev/null +++ b/src/app/screens/Feed/components/SealQualitySummary.tsx @@ -0,0 +1,246 @@ +'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' + | 'probation'; + +// `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 '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' && !c.on_probation, + ).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], + probation: theme.palette.info.light, + }[displayStatus]; + + const statusLabel = { + pass: t('sealCriterionPass'), + atRisk: t('sealCriterionInGracePeriod'), + fail: t('sealCriterionFail'), + notApplicable: t('sealCriterionNotApplicable'), + notEvaluated: t('sealCriterionNotEvaluated'), + probation: t('sealCriterionOnProbation'), + }[displayStatus]; + + const graceNote = + displayStatus === 'atRisk' && + criterion.grace_period_ends_at != null + ? ` ${t('sealCriterionGracePeriodNote', { + date: new Date( + criterion.grace_period_ends_at, + ).toDateString(), + })}` + : ''; + + // 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}${seasonalNote}`; + + return ( + + + + + + ); + })} + + + + + + + + + + ); +} 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,