Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"mcpServers": {
"chrome-devtools": {
"command": "npx",
"args": ["-y", "chrome-devtools-mcp@latest"]
Comment thread
Alessandro100 marked this conversation as resolved.
},
"next-devtools": {
"command": "npx",
"args": ["-y", "next-devtools-mcp@latest"]
}
}
}
2 changes: 0 additions & 2 deletions cypress/e2e/feeds.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
},
);
});
Expand Down
31 changes: 30 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
31 changes: 30 additions & 1 deletion messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export default async function AuthedFeedPage({
relatedGtfsRtFeeds,
totalRoutes,
routeTypes,
reliability,
} = feedData;

return (
Expand All @@ -77,6 +78,7 @@ export default async function AuthedFeedPage({
relatedGtfsRtFeeds={relatedGtfsRtFeeds}
totalRoutes={totalRoutes}
routeTypes={routeTypes}
reliability={reliability}
isMobilityDatabaseAdmin={isAdmin}
/>
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ReactElement> {
return <FeedReliabilityView />;
}
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import 'server-only';
import { unstable_cache } from 'next/cache';
import {
getFeed,
getGtfsFeed,
Expand All @@ -12,6 +13,7 @@ import {
getGtfsFeedDatasets,
getGtfsFeedRoutes,
getGtfsFeedAssociatedGtfsRtFeeds,
getGtfsFeedReliability,
} from '../../../../../services/feeds';
import {
type GTFSFeedType,
Expand All @@ -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;
Expand All @@ -32,6 +35,7 @@ export interface FeedDataResult {
totalRoutes?: number;
routeTypes?: string[];
routes?: GtfsRoute[];
reliability?: ReliabilityReport;
}

/**
Expand Down Expand Up @@ -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<ReliabilityReport | undefined> {
const cachedFetch = unstable_cache(
async (): Promise<ReliabilityReport | null> => {
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;
}
}

Comment thread
davidgamez marked this conversation as resolved.
/**
* Fetch related feeds for GTFS-RT feeds.
* Returns both GTFS and GTFS-RT related feeds.
Expand Down Expand Up @@ -170,6 +211,7 @@ export async function fetchCompleteFeedDataImpl(
feedId: string,
accessToken: string,
userContextJwt: string | undefined,
enableSealOfReliability: boolean,
): Promise<FeedDataResult> {
// Fetch core feed data
const feed = await fetchFeedByType(
Expand All @@ -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
Expand Down Expand Up @@ -250,5 +299,6 @@ export async function fetchCompleteFeedDataImpl(
totalRoutes,
routeTypes,
routes,
reliability,
};
}
Loading
Loading