diff --git a/messages/en.json b/messages/en.json index d31b5eff..ee952c78 100644 --- a/messages/en.json +++ b/messages/en.json @@ -363,6 +363,18 @@ "sealCompliantNoReport": "No validation report is available for this feed's latest dataset.", "sealCompliantNoData": "This feed's compliance has not been evaluated yet.", "sealCompliantViewReport": "View latest validation report", + "sealComplianceErrorsAsOf": "Errors as of {date}", + "sealComplianceErrorsCurrent": "Current errors", + "sealComplianceValidatedOn": "Validated {date}", + "sealComplianceValidated": "Latest validation", + "sealComplianceDownloadDataset": "Download this dataset", + "sealComplianceNewBadge": "New", + "sealComplianceCarriedSummary": "{count, plural, one {# error is} other {# errors are}} carried from earlier datasets.", + "sealComplianceNewSummary": "{count, plural, one {# error is} other {# errors are}} new in this dataset.", + "sealComplianceTruncated": "Showing the {shown} most frequent of {total} errors. See the full validation report.", + "sealComplianceViewReportLink": "Validation report", + "sealComplianceOccurrences": "{count, plural, one {# time} other {# times}}", + "sealComplianceHistoryError": "Earlier datasets could not be loaded, so new errors are not marked.", "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.", diff --git a/messages/fr.json b/messages/fr.json index 424a37a6..32cd33b3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -363,6 +363,18 @@ "sealCompliantNoReport": "Aucun rapport de validation n'est disponible pour le dernier jeu de données de ce flux.", "sealCompliantNoData": "La conformité de ce flux n'a pas encore été évaluée.", "sealCompliantViewReport": "Voir le dernier rapport de validation", + "sealComplianceErrorsAsOf": "Erreurs au {date}", + "sealComplianceErrorsCurrent": "Erreurs actuelles", + "sealComplianceValidatedOn": "Validé le {date}", + "sealComplianceValidated": "Dernière validation", + "sealComplianceDownloadDataset": "Télécharger ce jeu de données", + "sealComplianceNewBadge": "Nouveau", + "sealComplianceCarriedSummary": "{count, plural, one {# erreur est reprise} other {# erreurs sont reprises}} de jeux de données antérieurs.", + "sealComplianceNewSummary": "{count, plural, one {# erreur est nouvelle} other {# erreurs sont nouvelles}} dans ce jeu de données.", + "sealComplianceTruncated": "Affichage des {shown} erreurs les plus fréquentes sur {total}. Voir le rapport de validation complet.", + "sealComplianceViewReportLink": "Rapport de validation", + "sealComplianceOccurrences": "{count, plural, one {# fois} other {# fois}}", + "sealComplianceHistoryError": "Les jeux de données antérieurs n'ont pas pu être chargés; les nouvelles erreurs ne sont donc pas signalées.", "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.", diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/seal-analysis-data.spec.ts b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/seal-analysis-data.spec.ts index 38e52284..5998d334 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/seal-analysis-data.spec.ts +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/seal-analysis-data.spec.ts @@ -6,11 +6,17 @@ import { AVAILABILITY_LIMIT, AVAILABILITY_MAX_EXTRA_PAGES, SEAL_ANALYSIS_REVALIDATE, + VALIDATION_REPORTS_LIMIT, fetchGuestSealAnalysisData, } from './seal-analysis-data'; jest.mock('server-only', () => ({})); +const mockGetValidatorRules = jest.fn(); +jest.mock('../../../../../screens/Feed/lib/validator-rules', () => ({ + getValidatorRules: async () => await mockGetValidatorRules(), +})); + // Pass-throughs so the real fetcher body runs. `cache` is stubbed because // React's request-scoped memoization has no scope in a bare node test. jest.mock('react', () => ({ @@ -29,6 +35,7 @@ jest.mock('next/cache', () => ({ const mockGetGtfsFeedReliability = jest.fn(); const mockGetGtfsFeedAvailability = jest.fn(); const mockGetGtfsFeedContinuousCoverage = jest.fn(); +const mockGetGtfsFeedValidationReports = jest.fn(); jest.mock('../../../../../services/feeds', () => ({ getGtfsFeedReliability: (...args: unknown[]) => @@ -37,6 +44,8 @@ jest.mock('../../../../../services/feeds', () => ({ mockGetGtfsFeedAvailability(...args), getGtfsFeedContinuousCoverage: (...args: unknown[]) => mockGetGtfsFeedContinuousCoverage(...args), + getGtfsFeedValidationReports: (...args: unknown[]) => + mockGetGtfsFeedValidationReports(...args), })); jest.mock('../../../../../utils/auth-server', () => ({ @@ -58,6 +67,16 @@ const availability = { // came back rather than the page size it asked for. const flattenedAvailability = { ...availability, offset: 0, limit: 1 }; const coverage = { feed_id: 'mdb-1', latest_files: [] }; +const validatorRules = { + invalid_color: { summary: 'A color is invalid.', files: ['routes.txt'] }, +}; +const validationReports = { + feed_id: 'mdb-1', + total: 0, + offset: 0, + limit: VALIDATION_REPORTS_LIMIT, + items: [], +}; describe('fetchGuestSealAnalysisData', () => { beforeEach(() => { @@ -65,17 +84,37 @@ describe('fetchGuestSealAnalysisData', () => { mockGetGtfsFeedReliability.mockResolvedValue(report); mockGetGtfsFeedAvailability.mockResolvedValue(availability); mockGetGtfsFeedContinuousCoverage.mockResolvedValue(coverage); + mockGetGtfsFeedValidationReports.mockResolvedValue(validationReports); + mockGetValidatorRules.mockResolvedValue(validatorRules); + }); + + it('fetches the validator rules alongside the endpoints, not after them', async () => { + await fetchGuestSealAnalysisData('gtfs', 'mdb-1'); + + expect(mockGetValidatorRules).toHaveBeenCalledTimes(1); + }); + + it('degrades to an empty rule index when that fetch fails', async () => { + mockGetValidatorRules.mockRejectedValue(new Error('boom')); + + const result = await fetchGuestSealAnalysisData('gtfs', 'mdb-1'); + + expect(result?.validatorRules).toEqual({}); + expect(result?.reliability).toEqual(report); }); - it('returns all three payloads on success', async () => { + it('returns all four payloads on success', async () => { const result = await fetchGuestSealAnalysisData('gtfs', 'mdb-1'); expect(result).toEqual({ reliability: report, availability: flattenedAvailability, continuousCoverage: coverage, + validationReports, + validatorRules, reliabilityError: false, availabilityError: false, + validationReportsError: false, }); }); @@ -84,11 +123,12 @@ describe('fetchGuestSealAnalysisData', () => { expect(SEAL_ANALYSIS_REVALIDATE).toBe(21600); // Keys exclude the caller so guest and authed share the same entries. - expect(mockUnstableCache).toHaveBeenCalledTimes(3); + expect(mockUnstableCache).toHaveBeenCalledTimes(4); for (const key of [ 'seal-analysis-reliability-mdb-1', 'seal-analysis-availability-mdb-1', 'seal-analysis-coverage-mdb-1', + 'seal-analysis-validation-reports-mdb-1', ]) { expect(mockUnstableCache).toHaveBeenCalledWith( [key], diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/seal-analysis-data.ts b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/seal-analysis-data.ts index 23a6aa81..b474d02a 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/seal-analysis-data.ts +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/seal-analysis-data.ts @@ -13,6 +13,7 @@ import { getGtfsFeedAvailability, getGtfsFeedContinuousCoverage, getGtfsFeedReliability, + getGtfsFeedValidationReports, } from '../../../../../services/feeds'; import type { components } from '../../../../../services/feeds/types'; import { @@ -21,12 +22,16 @@ import { getUserContextJwtFromCookie, } from '../../../../../utils/auth-server'; import { subMonthsUtc } from '../../../../../utils/date'; +import { getValidatorRules } from '../../../../../screens/Feed/lib/validator-rules'; +import { type ValidatorRuleInfo } from '../../../../../screens/Feed/lib/validation-notices'; type ReliabilityReport = components['schemas']['FeedReliabilityReport']; type AvailabilityResponse = components['schemas']['GtfsFeedAvailabilityResponse']; type ContinuousCoverageResponse = components['schemas']['GtfsFeedContinuousCoverageResponse']; +type ValidationReportsResponse = + components['schemas']['GtfsFeedValidationReportsResponse']; /** * 6 hours @@ -34,6 +39,12 @@ type ContinuousCoverageResponse = export const SEAL_ANALYSIS_REVALIDATE = 21600; const COVERAGE_LIMIT = 100; +/** + * How many past datasets the Compliant criterion shows alongside the latest + * one. The endpoint caps `limit` at 100; 20 is a couple of months of daily + * datasets, which is as far back as the history panel reads. + */ +export const VALIDATION_REPORTS_LIMIT = 20; /** Exported so the specs follow it rather than restating the page size. */ export const AVAILABILITY_LIMIT = 200; @@ -119,12 +130,16 @@ export interface SealAnalysisData { reliability?: ReliabilityReport; availability?: AvailabilityResponse; continuousCoverage?: ContinuousCoverageResponse; + validationReports?: ValidationReportsResponse; + /** Validator rule index, joined onto the notice codes at render time. */ + validatorRules: Record; /** * True when the reliability call failed outright - distinct from "this feed * has no verdict yet", which comes back as a successful response. */ reliabilityError: boolean; availabilityError: boolean; + validationReportsError: boolean; } /** @@ -198,7 +213,35 @@ function cachedContinuousCoverage( } /** - * Fetch the three seal endpoints together. + * The validation history, unfiltered. + * + * No `severity` is sent: the panel filters the notices it already holds, so + * one cached response serves every filter the reader picks rather than a + * round trip per toggle. + */ +function cachedValidationReports( + feedId: string, + accessToken: string, + userContextJwt: string | undefined, +): () => Promise { + return unstable_cache( + async () => + await getGtfsFeedValidationReports( + feedId, + accessToken, + { limit: VALIDATION_REPORTS_LIMIT }, + userContextJwt, + ), + [`seal-analysis-validation-reports-${feedId}`], + { + tags: [`feed-${feedId}`, 'seal-analysis'], + revalidate: SEAL_ANALYSIS_REVALIDATE, + }, + ); +} + +/** + * Fetch the four seal endpoints and the validator rule index together. * * `allSettled`, not `all`: the availability and continuous-coverage history * are supporting detail, so one of them failing degrades to `undefined` (with @@ -213,12 +256,21 @@ async function fetchSealAnalysisImpl( accessToken: string, userContextJwt: string | undefined, ): Promise { - const [reliabilityResult, availabilityResult, coverageResult] = - await Promise.allSettled([ - cachedReliability(feedId, accessToken, userContextJwt)(), - cachedAvailability(feedId, accessToken, userContextJwt)(), - cachedContinuousCoverage(feedId, accessToken, userContextJwt)(), - ]); + const [ + reliabilityResult, + availabilityResult, + coverageResult, + validationReportsResult, + validatorRulesResult, + ] = await Promise.allSettled([ + cachedReliability(feedId, accessToken, userContextJwt)(), + cachedAvailability(feedId, accessToken, userContextJwt)(), + cachedContinuousCoverage(feedId, accessToken, userContextJwt)(), + cachedValidationReports(feedId, accessToken, userContextJwt)(), + // Fetched here rather than at render time so it runs alongside the + // endpoints above instead of after them. + getValidatorRules(), + ]); return { reliability: @@ -233,6 +285,15 @@ async function fetchSealAnalysisImpl( availabilityError: availabilityResult.status === 'rejected', continuousCoverage: coverageResult.status === 'fulfilled' ? coverageResult.value : undefined, + validationReports: + validationReportsResult.status === 'fulfilled' + ? validationReportsResult.value + : undefined, + validationReportsError: validationReportsResult.status === 'rejected', + validatorRules: + validatorRulesResult.status === 'fulfilled' + ? validatorRulesResult.value + : {}, }; } diff --git a/src/app/screens/Feed/components/ComplianceCriterionBody.tsx b/src/app/screens/Feed/components/ComplianceCriterionBody.tsx index 936e6058..360ea8a7 100644 --- a/src/app/screens/Feed/components/ComplianceCriterionBody.tsx +++ b/src/app/screens/Feed/components/ComplianceCriterionBody.tsx @@ -1,18 +1,32 @@ import * as React from 'react'; -import { Box, Button, Typography } from '@mui/material'; -import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import { Alert, Box, Typography } from '@mui/material'; import { getTranslations } from 'next-intl/server'; import CriterionGraceCountdown from './CriterionGraceCountdown'; +import ValidationErrorsPanel from './ValidationErrorsPanel'; import { getComplianceSummary } from '../lib/compliance-report'; +import { + type ValidatorRuleInfo, + buildValidationErrorsModel, +} from '../lib/validation-notices'; +import { buildDatasetDownloadUrl } from '../../../services/feeds'; import { type components } from '../../../services/feeds/types'; type ReliabilityCriterion = components['schemas']['ReliabilityCriterion']; type ValidationReport = components['schemas']['ValidationReport']; +type ValidationReportsResponse = + components['schemas']['GtfsFeedValidationReportsResponse']; export interface ComplianceCriterionBodyProps { criterion: ReliabilityCriterion; /** Validation report of the feed's latest dataset, when it has one. */ report?: ValidationReport; + /** Feed id, used to build the dataset download URL. */ + feedId?: string; + /** Validation history of the feed, one entry per dataset. */ + validationReports?: ValidationReportsResponse; + validationReportsError?: boolean; + /** Validator rule index, fetched with the seal endpoints. */ + validatorRules?: Record; /** Pinned by the page so every date-derived branch agrees. */ now: Date; } @@ -20,21 +34,34 @@ export interface ComplianceCriterionBodyProps { /** * Body of the Compliant criterion: what the latest dataset's validation * report says, the 30-day countdown while an error is still inside its grace - * period, and a way through to the report itself. + * period, and the errors behind the verdict. */ export default async function ComplianceCriterionBody({ criterion, report, + feedId, + validationReports, + validationReportsError = false, + validatorRules, now, }: ComplianceCriterionBodyProps): Promise { const t = await getTranslations('feeds'); - const summary = getComplianceSummary(criterion, report, now); - const reportUrl = report?.url_html; + + const model = buildValidationErrorsModel(validationReports, validatorRules); + + // The criterion counts distinct codes, not occurrences, so the sentence + // and the list below it agree. + const summary = getComplianceSummary(criterion, report, now, { + fallbackErrorCount: model.totalCount, + }); + + const downloadUrl = + feedId != undefined && feedId.length > 0 && model.datasetId != undefined + ? buildDatasetDownloadUrl(feedId, model.datasetId) + : undefined; return ( - {/* Bold headline then detail, matching the shape Official and Stable - get from the shared criterion copy. */} {t(summary.subtitleKey)} @@ -44,27 +71,22 @@ export default async function ComplianceCriterionBody({ {summary.graceDaysLeft != undefined && ( )} - {reportUrl != undefined && reportUrl.length > 0 && ( - - - + {validationReportsError && ( + + {t('sealComplianceHistoryError')} + )} + + ); } diff --git a/src/app/screens/Feed/components/FeedReliabilityView.tsx b/src/app/screens/Feed/components/FeedReliabilityView.tsx index 47f4c523..07bc15db 100644 --- a/src/app/screens/Feed/components/FeedReliabilityView.tsx +++ b/src/app/screens/Feed/components/FeedReliabilityView.tsx @@ -227,6 +227,12 @@ export default async function FeedReliabilityView({ diff --git a/src/app/screens/Feed/components/ValidationErrorsPanel.tsx b/src/app/screens/Feed/components/ValidationErrorsPanel.tsx new file mode 100644 index 00000000..90096d48 --- /dev/null +++ b/src/app/screens/Feed/components/ValidationErrorsPanel.tsx @@ -0,0 +1,286 @@ +import * as React from 'react'; +import { + Box, + Button, + Divider, + Link as MuiLink, + Typography, +} from '@mui/material'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import DownloadIcon from '@mui/icons-material/Download'; +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import { getTranslations } from 'next-intl/server'; +import { + type ErrorRow, + type ValidationErrorsModel, + humanizeNoticeCode, + parseInlineCode, +} from '../lib/validation-notices'; +import { getValidatorRuleUrl } from '../lib/validator-rules'; +import { formatDateShort } from '../../../utils/date'; + +type Translate = Awaited>>; + +export interface ValidationErrorsPanelProps { + model: ValidationErrorsModel; + /** Built on the server, which has the environment's files host. */ + downloadUrl?: string; +} + +/** + * The validation errors of the dataset on show, each marked when it is new + * rather than carried from an earlier dataset. + */ +export default async function ValidationErrorsPanel({ + model, + downloadUrl, +}: ValidationErrorsPanelProps): Promise { + const t = await getTranslations('feeds'); + // Kept for a passing criterion too: the dataset and its report are still + // worth reaching, and the section would otherwise be empty. + if (model.datasetId == undefined) return null; + + const hasErrors = model.totalCount > 0; + const headingKey = + model.validatedAt == undefined + ? hasErrors + ? 'sealComplianceErrorsCurrent' + : 'sealComplianceValidated' + : hasErrors + ? 'sealComplianceErrorsAsOf' + : 'sealComplianceValidatedOn'; + + return ( + + + + {model.validatedAt != undefined + ? t(headingKey, { date: formatDateShort(model.validatedAt) }) + : t(headingKey)} + + + {downloadUrl != undefined && ( + + )} + {model.reportUrl != undefined && ( + + )} + + + + {hasErrors && ( + <> + + + {model.rows.map((row, index) => ( + + {index > 0 && } + + + ))} + + {model.totalCount > model.rows.length && ( + + {t.rich('sealComplianceTruncated', { + shown: model.rows.length, + total: model.totalCount, + // Inline so the report is one click from the sentence that + // sends you there, rather than back up at the header. + link: (chunks) => + model.reportUrl != undefined ? ( + + {chunks} + + ) : ( + <>{chunks} + ), + })} + + )} + + )} + + ); +} + +/** Says once what would otherwise repeat on every row. */ +function ProvenanceLine({ + model, + t, +}: { + model: ValidationErrorsModel; + t: Translate; +}): React.ReactElement | null { + const parts: string[] = []; + if (model.carriedCount > 0) { + parts.push( + t('sealComplianceCarriedSummary', { count: model.carriedCount }), + ); + } + if (model.newCount > 0) { + parts.push(t('sealComplianceNewSummary', { count: model.newCount })); + } + if (parts.length === 0) return null; + + return ( + + {parts.join(' ')} + + ); +} + +function ErrorListRow({ + row, + t, +}: { + row: ErrorRow; + t: Translate; +}): React.ReactElement { + return ( + + + + + + {row.code} + + {row.files.length > 0 && ( + + {row.files.join(', ')} + + )} + {row.isNew && ( + + {t('sealComplianceNewBadge')} + + )} + + + {parseInlineCode(row.summary ?? humanizeNoticeCode(row.code)).map( + (segment, index) => + segment.isCode ? ( + + {segment.text} + + ) : ( + {segment.text} + ), + )} + + + + {t('sealComplianceOccurrences', { count: row.total })} + + + ); +} diff --git a/src/app/screens/Feed/lib/compliance-report.spec.ts b/src/app/screens/Feed/lib/compliance-report.spec.ts index 8d15b47a..8daeda37 100644 --- a/src/app/screens/Feed/lib/compliance-report.spec.ts +++ b/src/app/screens/Feed/lib/compliance-report.spec.ts @@ -35,12 +35,12 @@ const failingReport: ValidationReport = { }; describe('getComplianceErrorCount', () => { - it('reports every occurrence, not just the distinct notice codes', () => { - expect(getComplianceErrorCount(failingReport)).toBe(7); + it('reports the distinct notice codes, not every occurrence', () => { + expect(getComplianceErrorCount(failingReport)).toBe(2); }); - it('falls back to the distinct count when the total is absent', () => { - expect(getComplianceErrorCount({ unique_error_count: 2 })).toBe(2); + it('falls back to the total when the distinct count is absent', () => { + expect(getComplianceErrorCount({ total_error: 7 })).toBe(7); }); it('is undefined without a report at all', () => { @@ -77,8 +77,8 @@ describe('getComplianceSummary', () => { ).toEqual({ subtitleKey: 'sealCompliantHasErrorsSubtitle', key: 'sealCompliantAtRisk', - values: { count: 7, graceDays: COMPLIANCE_GRACE_DAYS }, - errorCount: 7, + values: { count: 2, graceDays: COMPLIANCE_GRACE_DAYS }, + errorCount: 2, graceDaysLeft: 24, }); }); diff --git a/src/app/screens/Feed/lib/compliance-report.ts b/src/app/screens/Feed/lib/compliance-report.ts index c4079776..f5e101ad 100644 --- a/src/app/screens/Feed/lib/compliance-report.ts +++ b/src/app/screens/Feed/lib/compliance-report.ts @@ -43,19 +43,30 @@ export interface ComplianceSummary { errorCount?: number; } +/** Distinct codes, matching the list the criterion body renders. */ export function getComplianceErrorCount( report: ValidationReport | undefined, ): number | undefined { - return report?.total_error ?? report?.unique_error_count; + return report?.unique_error_count ?? report?.total_error; +} + +export interface ComplianceSummaryOptions { + /** + * Used when the dataset's own validation report carries no count, which + * happens for feeds whose `latest_dataset.validation_report` is empty. + */ + fallbackErrorCount?: number; } export function getComplianceSummary( criterion: ReliabilityCriterion, report: ValidationReport | undefined, now: Date = new Date(), + options: ComplianceSummaryOptions = {}, ): ComplianceSummary { const displayStatus = getCriterionDisplayStatus(criterion); - const errorCount = getComplianceErrorCount(report) ?? 0; + const errorCount = + getComplianceErrorCount(report) ?? options.fallbackErrorCount ?? 0; const graceDays = COMPLIANCE_GRACE_DAYS; if (displayStatus === 'notApplicable') { diff --git a/src/app/screens/Feed/lib/validation-notices.spec.ts b/src/app/screens/Feed/lib/validation-notices.spec.ts new file mode 100644 index 00000000..9086b53d --- /dev/null +++ b/src/app/screens/Feed/lib/validation-notices.spec.ts @@ -0,0 +1,351 @@ +import { + MAX_ERROR_ROWS, + buildValidationErrorsModel, + humanizeNoticeCode, + parseInlineCode, +} from './validation-notices'; +import { type components } from '../../../services/feeds/types'; + +type FeedValidationReport = components['schemas']['GtfsFeedValidationReport']; +type Notice = components['schemas']['GtfsFeedValidationNotice']; + +function notice( + code: string, + severity: Notice['severity'], + total: number, +): Notice { + return { code, severity, total }; +} + +function reportOf( + overrides: Partial & { dataset_id: string }, +): FeedValidationReport { + return { is_latest: false, notices: [], ...overrides }; +} + +const latest = reportOf({ + dataset_id: 'mdb-1-003', + is_latest: true, + validated_at: '2026-06-03T00:00:00Z', + url_html: 'https://reports.example.com/003.html', + notices: [ + notice('invalid_color', 'ERROR', 3), + notice('missing_required_field', 'ERROR', 47), + notice('unused_shape', 'WARNING', 900), + ], +}); + +const older = reportOf({ + dataset_id: 'mdb-1-002', + validated_at: '2026-06-01T00:00:00Z', + notices: [notice('missing_required_field', 'ERROR', 47)], +}); + +const response = { + feed_id: 'mdb-1', + latest, + total: 2, + offset: 0, + limit: 20, + items: [latest, older], +}; + +const rules = { + missing_required_field: { + summary: 'A required field is missing.', + files: ['trips.txt'], + }, +}; + +describe('buildValidationErrorsModel', () => { + it('returns an empty model when there is no response', () => { + const model = buildValidationErrorsModel(undefined); + + expect(model.rows).toEqual([]); + expect(model.datasetId).toBeUndefined(); + expect(model.newCount).toBe(0); + expect(model.carriedCount).toBe(0); + }); + + it('returns an empty model when the history carries no datasets', () => { + expect( + buildValidationErrorsModel({ ...response, latest: undefined, items: [] }) + .rows, + ).toEqual([]); + }); + + it('lists only errors, most raised first', () => { + const model = buildValidationErrorsModel(response, rules); + + expect(model.rows.map((row) => row.code)).toEqual([ + 'missing_required_field', + 'invalid_color', + ]); + }); + + it('drops warnings and info even when they are raised far more often', () => { + const model = buildValidationErrorsModel(response, rules); + + expect(model.rows.some((row) => row.code === 'unused_shape')).toBe(false); + }); + + it('lists nothing when the dataset has no errors, but keeps the dataset', () => { + // The panel still shows the download and report links for a passing + // criterion, so it needs the dataset even with an empty list. + const clean = reportOf({ + dataset_id: 'mdb-1-004', + is_latest: true, + validated_at: '2026-06-04T00:00:00Z', + url_html: 'https://reports.example.com/004.html', + notices: [notice('unused_shape', 'WARNING', 2)], + }); + const model = buildValidationErrorsModel({ + ...response, + latest: clean, + items: [clean], + }); + + expect(model.rows).toEqual([]); + expect(model.datasetId).toBe('mdb-1-004'); + expect(model.validatedAt).toBe('2026-06-04T00:00:00Z'); + expect(model.reportUrl).toBe('https://reports.example.com/004.html'); + }); + + it('joins the validator wording and file onto a code', () => { + const model = buildValidationErrorsModel(response, rules); + + expect(model.rows[0].summary).toBe('A required field is missing.'); + expect(model.rows[0].files).toEqual(['trips.txt']); + }); + + it('leaves a code with no rule entry without wording, rather than guessing', () => { + const model = buildValidationErrorsModel(response, rules); + const row = model.rows.find((r) => r.code === 'invalid_color'); + + expect(row?.summary).toBeUndefined(); + expect(row?.files).toEqual([]); + }); + + it('marks a code absent from every earlier dataset as new', () => { + const model = buildValidationErrorsModel(response, rules); + + expect(model.rows.find((r) => r.code === 'invalid_color')?.isNew).toBe( + true, + ); + expect( + model.rows.find((r) => r.code === 'missing_required_field')?.isNew, + ).toBe(false); + expect(model.newCount).toBe(1); + expect(model.carriedCount).toBe(1); + }); + + it('counts a code carried at any severity as carried, not new', () => { + // The code was a warning before and an error now; it is still not new. + const wasWarning = reportOf({ + dataset_id: 'mdb-1-002', + validated_at: '2026-06-01T00:00:00Z', + notices: [notice('invalid_color', 'WARNING', 1)], + }); + const model = buildValidationErrorsModel( + { ...response, items: [latest, wasWarning] }, + rules, + ); + + expect(model.rows.find((r) => r.code === 'invalid_color')?.isNew).toBe( + false, + ); + }); + + it('treats every code as new when the history holds only this dataset', () => { + const model = buildValidationErrorsModel( + { ...response, items: [latest] }, + rules, + ); + + expect(model.newCount).toBe(2); + expect(model.carriedCount).toBe(0); + }); + + it('carries the dataset id, date and report URL through', () => { + const model = buildValidationErrorsModel(response, rules); + + expect(model.datasetId).toBe('mdb-1-003'); + expect(model.validatedAt).toBe('2026-06-03T00:00:00Z'); + expect(model.reportUrl).toBe('https://reports.example.com/003.html'); + }); + + it('falls back to the newest dataset when the API flags none as latest', () => { + // Real feeds come back with `latest: null` and `is_latest` false on every + // entry. + const model = buildValidationErrorsModel( + { + ...response, + latest: undefined, + items: [ + reportOf({ ...older, is_latest: false }), + reportOf({ ...latest, is_latest: false }), + ], + }, + rules, + ); + + expect(model.datasetId).toBe('mdb-1-003'); + }); + + it('prefers the flagged item over the newest when the API sets one', () => { + const model = buildValidationErrorsModel( + { + ...response, + latest: undefined, + items: [ + reportOf({ ...latest, is_latest: false }), + reportOf({ ...older, is_latest: true }), + ], + }, + rules, + ); + + expect(model.datasetId).toBe('mdb-1-002'); + }); + + it('does not count the shown dataset as one of its own earlier datasets', () => { + const model = buildValidationErrorsModel( + { + ...response, + latest: undefined, + items: [reportOf({ ...latest, is_latest: false })], + }, + rules, + ); + + expect(model.rows.every((row) => row.isNew)).toBe(true); + }); +}); + +describe('humanizeNoticeCode', () => { + it('reads a snake_case code as a sentence', () => { + expect(humanizeNoticeCode('missing_required_field')).toBe( + 'Missing required field', + ); + }); + + it('leaves a code with no separators alone beyond its first letter', () => { + expect(humanizeNoticeCode('duplicate')).toBe('Duplicate'); + }); + + it('returns an empty code untouched', () => { + expect(humanizeNoticeCode('')).toBe(''); + }); +}); + +describe('the top-errors cap', () => { + const many = (count: number) => + reportOf({ + dataset_id: 'mdb-1-010', + is_latest: true, + notices: Array.from({ length: count }, (_, index) => + notice(`code_${index}`, 'ERROR', count - index), + ), + }); + + it('lists at most the five most raised errors', () => { + const report = many(12); + const model = buildValidationErrorsModel({ + ...response, + latest: report, + items: [report], + }); + + expect(model.rows).toHaveLength(MAX_ERROR_ROWS); + expect(model.rows.map((row) => row.code)).toEqual([ + 'code_0', + 'code_1', + 'code_2', + 'code_3', + 'code_4', + ]); + }); + + it('reports the full count so the sentence above the list stays right', () => { + const report = many(12); + const model = buildValidationErrorsModel({ + ...response, + latest: report, + items: [report], + }); + + expect(model.totalCount).toBe(12); + }); + + it('counts new and carried over every error, not just the listed ones', () => { + const report = many(12); + const earlier = reportOf({ + dataset_id: 'mdb-1-009', + notices: [notice('code_11', 'ERROR', 1)], + }); + const model = buildValidationErrorsModel({ + ...response, + latest: report, + items: [report, earlier], + }); + + // code_11 is the least raised, so it falls outside the five shown, but + // it is still the one carried error. + expect(model.carriedCount).toBe(1); + expect(model.newCount).toBe(11); + }); + + it('leaves a short list untouched', () => { + const report = many(3); + const model = buildValidationErrorsModel({ + ...response, + latest: report, + items: [report], + }); + + expect(model.rows).toHaveLength(3); + expect(model.totalCount).toBe(3); + }); +}); + +describe('parseInlineCode', () => { + it('splits a backtick span out of the surrounding text', () => { + expect( + parseInlineCode('Decreasing `shape_dist_traveled` in `shapes.txt`.'), + ).toEqual([ + { text: 'Decreasing ', isCode: false }, + { text: 'shape_dist_traveled', isCode: true }, + { text: ' in ', isCode: false }, + { text: 'shapes.txt', isCode: true }, + { text: '.', isCode: false }, + ]); + }); + + it('leaves text with no backticks in one plain segment', () => { + expect(parseInlineCode('A required field is missing.')).toEqual([ + { text: 'A required field is missing.', isCode: false }, + ]); + }); + + it('does not read underscores in an identifier as emphasis', () => { + const [segment] = parseInlineCode('`shape_dist_traveled`'); + + expect(segment).toEqual({ text: 'shape_dist_traveled', isCode: true }); + }); + + it('keeps an unmatched backtick literal rather than swallowing the rest', () => { + expect(parseInlineCode('Missing `stops.txt and more')).toEqual([ + { text: 'Missing `stops.txt and more', isCode: false }, + ]); + }); + + it('drops the empty segments a leading span leaves behind', () => { + expect(parseInlineCode('`stops.txt`')).toEqual([ + { text: 'stops.txt', isCode: true }, + ]); + }); + + it('returns nothing for an empty string', () => { + expect(parseInlineCode('')).toEqual([]); + }); +}); diff --git a/src/app/screens/Feed/lib/validation-notices.ts b/src/app/screens/Feed/lib/validation-notices.ts new file mode 100644 index 00000000..c2a16a31 --- /dev/null +++ b/src/app/screens/Feed/lib/validation-notices.ts @@ -0,0 +1,156 @@ +/** + * Reads the feed's validation history into the error codes the Compliant + * criterion lists, and marks which of them are new. + * + * The API gives a code, a severity and a count, so readable wording comes + * from the validator's own rule index (see lib/validator-rules.ts). + */ + +import { type components } from '../../../services/feeds/types'; + +type ValidationReportsResponse = + components['schemas']['GtfsFeedValidationReportsResponse']; +type FeedValidationReport = components['schemas']['GtfsFeedValidationReport']; + +/** + * Error codes listed, most raised first. The rest are left to the full + * validation report. + */ +export const MAX_ERROR_ROWS = 5; + +/** What the validator's rule index says about one notice code. */ +export interface ValidatorRuleInfo { + summary?: string; + /** GTFS files the rule reads, e.g. `trips.txt`. */ + files: string[]; +} + +export interface ErrorRow { + code: string; + /** Times the code was raised in this dataset. */ + total: number; + summary?: string; + files: string[]; + /** Absent from every earlier dataset in the fetched history. */ + isNew: boolean; +} + +export interface ValidationErrorsModel { + datasetId?: string; + validatedAt?: string; + /** HTML validation report of that dataset. */ + reportUrl?: string; + /** Error codes, most raised first, capped at `MAX_ERROR_ROWS`. */ + rows: ErrorRow[]; + /** Error codes in the report, including those `rows` leaves out. */ + totalCount: number; + /** Counted over every error code, not just the listed ones. */ + newCount: number; + carriedCount: number; +} + +const EMPTY: ValidationErrorsModel = { + rows: [], + totalCount: 0, + newCount: 0, + carriedCount: 0, +}; + +/** Newest first, undated last. */ +function byValidatedAtDesc( + a: FeedValidationReport, + b: FeedValidationReport, +): number { + if (a.validated_at == null && b.validated_at == null) return 0; + if (a.validated_at == null) return 1; + if (b.validated_at == null) return -1; + return b.validated_at.localeCompare(a.validated_at); +} + +/** + * `latest` first, then the flagged item. Both are absent for some feeds, so + * the newest validated report is the last resort. + */ +function selectReport( + response: ValidationReportsResponse, +): FeedValidationReport | undefined { + return ( + response.latest ?? + response.items.find((item) => item.is_latest) ?? + [...response.items].sort(byValidatedAtDesc)[0] + ); +} + +export function buildValidationErrorsModel( + response: ValidationReportsResponse | undefined, + rules: Record = {}, +): ValidationErrorsModel { + if (response == undefined) return EMPTY; + + const report = selectReport(response); + if (report == undefined) return EMPTY; + + // Matched on id rather than `is_latest`, which the API leaves false on + // every entry for some feeds. + const earlierCodes = new Set(); + for (const item of response.items) { + if (item.dataset_id === report.dataset_id) continue; + for (const notice of item.notices) { + earlierCodes.add(notice.code); + } + } + + const errors: ErrorRow[] = report.notices + .filter((notice) => notice.severity === 'ERROR') + .map((notice) => ({ + code: notice.code, + total: notice.total, + summary: rules[notice.code]?.summary, + files: rules[notice.code]?.files ?? [], + isNew: !earlierCodes.has(notice.code), + })) + .sort((a, b) => b.total - a.total || a.code.localeCompare(b.code)); + + const newCount = errors.filter((row) => row.isNew).length; + + return { + datasetId: report.dataset_id, + validatedAt: report.validated_at ?? undefined, + reportUrl: report.url_html ?? undefined, + rows: errors.slice(0, MAX_ERROR_ROWS), + totalCount: errors.length, + newCount, + carriedCount: errors.length - newCount, + }; +} + +/** `missing_required_field` reads as "Missing required field". */ +export function humanizeNoticeCode(code: string): string { + const words = code.split('_').filter((word) => word.length > 0); + if (words.length === 0) return code; + return words + .map((word, index) => + index === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word, + ) + .join(' '); +} + +export interface TextSegment { + text: string; + /** Rendered in the monospace face, as `stops.txt` is in the source. */ + isCode: boolean; +} + +/** + * Splits the validator's wording on backtick spans. + * + * Backticks are the only markup these summaries use, and a general markdown + * renderer would read the underscores in identifiers like + * `shape_dist_traveled` as emphasis. An unmatched backtick stays literal. + */ +export function parseInlineCode(text: string): TextSegment[] { + return text + .split(/`([^`]+)`/) + .map((part, index) => ({ text: part, isCode: index % 2 === 1 })) + .filter((segment) => segment.text.length > 0); +} diff --git a/src/app/screens/Feed/lib/validator-rules.spec.ts b/src/app/screens/Feed/lib/validator-rules.spec.ts new file mode 100644 index 00000000..30aceca9 --- /dev/null +++ b/src/app/screens/Feed/lib/validator-rules.spec.ts @@ -0,0 +1,65 @@ +/** + * @jest-environment node + */ + +jest.mock('server-only', () => ({})); + +import { getValidatorRuleUrl, parseValidatorRules } from './validator-rules'; + +describe('parseValidatorRules', () => { + it('reads the summary and file references of a rule', () => { + const rules = parseValidatorRules({ + invalid_color: { + shortSummary: 'A color is invalid.', + references: { fileReferences: ['routes.txt'] }, + }, + }); + + expect(rules.invalid_color).toEqual({ + summary: 'A color is invalid.', + files: ['routes.txt'], + }); + }); + + it('leaves the summary absent when the rule carries an empty one', () => { + const rules = parseValidatorRules({ + some_rule: { shortSummary: '', references: { fileReferences: [] } }, + }); + + expect(rules.some_rule).toEqual({ summary: undefined, files: [] }); + }); + + it('drops non-string file references rather than rendering them', () => { + const rules = parseValidatorRules({ + some_rule: { references: { fileReferences: ['trips.txt', 42, null] } }, + }); + + expect(rules.some_rule.files).toEqual(['trips.txt']); + }); + + it('tolerates a rule with no references block', () => { + const rules = parseValidatorRules({ some_rule: { shortSummary: 'Hi.' } }); + + expect(rules.some_rule).toEqual({ summary: 'Hi.', files: [] }); + }); + + it('skips entries that are not objects', () => { + const rules = parseValidatorRules({ some_rule: 'nope', other: 5 }); + + expect(rules).toEqual({}); + }); + + it('returns an empty index for a payload that is not an object', () => { + expect(parseValidatorRules(null)).toEqual({}); + expect(parseValidatorRules('rules')).toEqual({}); + expect(parseValidatorRules(undefined)).toEqual({}); + }); +}); + +describe('getValidatorRuleUrl', () => { + it('points at the rule anchor on the validator rules page', () => { + expect(getValidatorRuleUrl('missing_required_field')).toBe( + 'https://gtfs-validator.mobilitydata.org/rules.html#missing_required_field-rule', + ); + }); +}); diff --git a/src/app/screens/Feed/lib/validator-rules.ts b/src/app/screens/Feed/lib/validator-rules.ts new file mode 100644 index 00000000..4a93f1c0 --- /dev/null +++ b/src/app/screens/Feed/lib/validator-rules.ts @@ -0,0 +1,81 @@ +/** + * The GTFS validator's own rule index, used to put readable wording and a + * file name against the bare notice codes the feed API returns. + * + * The index is the validator's, not this app's, so it is read from the + * validator site rather than copied here: a new release adds rules without + * needing a change on this side. It is supporting detail only, so a failed + * fetch degrades to an empty index and the notice list falls back to the + * humanized code. + */ + +import 'server-only'; +import { type ValidatorRuleInfo } from './validation-notices'; + +/** The published rule index behind gtfs-validator.mobilitydata.org/rules.html */ +const VALIDATOR_RULES_URL = + 'https://gtfs-validator.mobilitydata.org/rules.json'; + +/** Rules change only when the validator is released, so this is cached a day. */ +const VALIDATOR_RULES_REVALIDATE = 86400; + +/** Anchor for one rule on the validator's rules page. */ +export function getValidatorRuleUrl(code: string): string { + return `https://gtfs-validator.mobilitydata.org/rules.html#${code}-rule`; +} + +/** Only the fields read here; the published entries carry a good deal more. */ +interface RawRule { + shortSummary?: unknown; + references?: { fileReferences?: unknown }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** + * Narrowed by hand rather than trusted: this is a third-party document, and + * a shape change there should empty a field, not throw on the seal page. + */ +export function parseValidatorRules( + payload: unknown, +): Record { + if (!isRecord(payload)) return {}; + const rules: Record = {}; + for (const [code, value] of Object.entries(payload)) { + if (!isRecord(value)) continue; + const raw = value as RawRule; + const summary = + typeof raw.shortSummary === 'string' && raw.shortSummary.length > 0 + ? raw.shortSummary + : undefined; + const fileReferences = isRecord(raw.references) + ? raw.references.fileReferences + : undefined; + const files = Array.isArray(fileReferences) + ? fileReferences.filter( + (file): file is string => typeof file === 'string', + ) + : []; + rules[code] = { summary, files }; + } + return rules; +} + +export async function getValidatorRules(): Promise< + Record +> { + try { + const response = await fetch(VALIDATOR_RULES_URL, { + next: { + revalidate: VALIDATOR_RULES_REVALIDATE, + tags: ['validator-rules'], + }, + }); + if (!response.ok) return {}; + return parseValidatorRules(await response.json()); + } catch { + return {}; + } +} diff --git a/src/app/services/feeds/index.ts b/src/app/services/feeds/index.ts index d863035a..50b726c2 100644 --- a/src/app/services/feeds/index.ts +++ b/src/app/services/feeds/index.ts @@ -253,6 +253,30 @@ export const getGtfsFeedContinuousCoverage = async ( }); }; +export const getGtfsFeedValidationReports = async ( + id: string, + accessToken: string, + queryParams?: paths['/v1/gtfs_feeds/{id}/validation_reports']['get']['parameters']['query'], + userContextJwt?: string, +): Promise< + | paths['/v1/gtfs_feeds/{id}/validation_reports']['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}/validation_reports', + { + params: { query: queryParams, path: { id } }, + }, + ); + return response.data; + }); +}; + export const getGtfsFeedDatasets = async ( id: string, accessToken: string, @@ -353,6 +377,23 @@ export const getLicense = async ( * @param datasetId - The dataset ID (visualization_dataset_id ) * @returns The URL for the routes.json file */ +/** + * Builds the download URL for a dataset's GTFS zip. + * + * The history endpoints return a `dataset_id` but no `hosted_url`, so the + * canonical hosted path is rebuilt here, the same way `buildRoutesUrl` does + * for the map tiles of a dataset. + * @param feedId - The feed ID + * @param datasetId - The stable dataset ID, e.g. mdb-123-202604290029 + * @returns The URL of that dataset's zip + */ +export function buildDatasetDownloadUrl( + feedId: string, + datasetId: string, +): string { + return `${getFeedFilesBaseUrl()}/${feedId}/${datasetId}/${datasetId}.zip`; +} + export function buildRoutesUrl(feedId: string, datasetId: string): string { return `${getFeedFilesBaseUrl()}/${feedId}/${datasetId}/pmtiles/routes.json`; } diff --git a/src/app/services/feeds/types.ts b/src/app/services/feeds/types.ts index 97e26541..c3be5015 100644 --- a/src/app/services/feeds/types.ts +++ b/src/app/services/feeds/types.ts @@ -242,7 +242,7 @@ export interface paths { }; cookie?: never; }; - /** @description Returns the continuous coverage of a GTFS feed: `latest_state` and `latest_failure`, plus the history, one entry per dataset ordered by `downloaded_at` from newest to oldest. Each entry carries the service window the dataset covers, the window declared in its `feed_info.txt`, whether the two agree, and how much that dataset overlaps the previous (older) one. */ + /** @description Returns the continuous coverage of a GTFS feed: `latest_state` and `latest_failure`, each carrying the two datasets it compares, plus the history, one entry per dataset ordered by `downloaded_at` from newest to oldest. Each entry carries the service window the dataset covers, the window declared in its `feed_info.txt`, whether the two agree, and how much that dataset overlaps the previous (older) one. */ get: operations['getGtfsFeedContinuousCoverage']; put?: never; post?: never; @@ -252,6 +252,26 @@ export interface paths { patch?: never; trace?: never; }; + '/v1/gtfs_feeds/{id}/validation_reports': { + parameters: { + query?: never; + header?: never; + path: { + /** @description The feed ID of the requested feed. */ + id: components['parameters']['feed_id_path_param']; + }; + cookie?: never; + }; + /** @description Returns the validation history of a GTFS feed: one entry per dataset, carrying the counts of its most recent validation report and the notice codes behind them, ordered by `validated_at` from newest to oldest. `latest` is the entry for the feed's current dataset. */ + get: operations['getGtfsFeedValidationReports']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/v1/datasets/gtfs/{id}': { parameters: { query?: never; @@ -964,15 +984,15 @@ export interface components { */ error_type?: string | null; }; - /** @description `latest_state` is the feed's latest dataset measured against the one before it; `latest_failure` is the same measurement at the criterion's last observed failure. Both have the structure of an `items[]` entry, and either can be null. Together they name at most four datasets, shared when the latest state is itself the failure. */ + /** @description `latest_state` is the feed's latest dataset measured against the one before it; `latest_failure` is the same at the criterion's last observed failure. Each carries both datasets of the comparison, and either can be null. */ GtfsFeedContinuousCoverageResponse: { /** * @description Unique identifier of the GTFS feed. * @example mdb-123 */ feed_id: string; - latest_state?: components['schemas']['GtfsFeedContinuousCoverage']; - latest_failure?: components['schemas']['GtfsFeedContinuousCoverage']; + latest_state?: components['schemas']['GtfsFeedContinuousCoverageBoundary']; + latest_failure?: components['schemas']['GtfsFeedContinuousCoverageBoundary']; /** * @description Total number of matching datasets regardless of limit and offset. * @example 42 @@ -991,10 +1011,15 @@ export interface components { /** @description One entry per dataset, ordered by downloaded_at from newest to oldest. The first entry of the unpaged list is the feed's current coverage; it is marked with `is_latest`. */ items: components['schemas']['GtfsFeedContinuousCoverage'][]; }; + /** @description Two successive datasets: `newer` and the one downloaded immediately before it. `older` is null when `newer` is the feed's first dataset. */ + GtfsFeedContinuousCoverageBoundary: { + newer: components['schemas']['GtfsFeedContinuousCoverage']; + older?: components['schemas']['GtfsFeedContinuousCoverage']; + }; /** * @description The coverage one dataset contributes, and how it lines up with the dataset downloaded just before it. * - * Three windows are reported. `service_window` is the service dates the validator derived from `calendar.txt` and `calendar_dates.txt`; `feed_info_window` is what the dataset's `feed_info.txt` declares; `coverage_window` is the one the calculation actually used, with `coverage_window_source` naming which of the two it came from. Any of them may be absent when the dataset did not supply the underlying files. + * Three windows are reported. `service_window` is the service dates the validator derived from `calendar.txt` and `calendar_dates.txt`; `feed_info_window` is what the dataset's `feed_info.txt` declares; `coverage_window` is the one the criterion measures by - the declared window, falling back to the validated one - with `coverage_window_source` naming which of the two it came from. Any of them may be absent when the dataset did not supply the underlying files. */ GtfsFeedContinuousCoverage: { /** @@ -1016,11 +1041,9 @@ export interface components { coverage_window?: components['schemas']['ServiceDateWindow']; /** * @description Which input `coverage_window` was taken from. - * * `service_dates` - the service dates derived by the validator from `calendar.txt` and - * `calendar_dates.txt`. - * * `feed_info` - the dates declared in `feed_info.txt`, used only when the service dates - * are missing. - * @example service_dates + * * `feed_info` - the dates declared in `feed_info.txt`. * `service_dates` - the service dates derived by the validator from `calendar.txt` and + * `calendar_dates.txt`, used when the dataset declares no range. + * @example feed_info * @enum {string|null} */ coverage_window_source?: 'service_dates' | 'feed_info' | null; @@ -1450,6 +1473,87 @@ export interface components { /** @example 8635fdac4fbff025b4eaca6972fcc9504bc1552d */ commit_hash?: string; }; + /** @description The feed's validation history, one entry per dataset. `latest` is the entry for the feed's current dataset, whatever page or filter was requested, and is null when the feed has no validated dataset. */ + GtfsFeedValidationReportsResponse: { + /** + * @description Unique identifier of the GTFS feed. + * @example mdb-123 + */ + feed_id: string; + latest?: components['schemas']['GtfsFeedValidationReport']; + /** + * @description Total number of matching datasets regardless of limit and offset. + * @example 42 + */ + total: number; + /** + * @description Offset of the first returned item. + * @example 0 + */ + offset: number; + /** + * @description Maximum number of items returned. + * @example 20 + */ + limit: number; + /** @description One entry per dataset, ordered by validated_at from newest to oldest. */ + items: components['schemas']['GtfsFeedValidationReport'][]; + }; + /** @description The most recent validation report of one dataset. `total_*` counts every notice raised; `unique_*` counts the distinct codes behind them. */ + GtfsFeedValidationReport: { + /** + * @description Stable identifier of the validated dataset. + * @example mdb-123-202604290029 + */ + dataset_id: string; + /** + * @description Whether this is the feed's latest dataset. + * @example true + */ + is_latest: boolean; + /** + * Format: date-time + * @example 2026-06-28T00:29:00Z + */ + validated_at?: string | null; + /** @example 4.2.0 */ + validator_version?: string | null; + /** @example 10 */ + total_error?: number | null; + /** @example 20 */ + total_warning?: number | null; + /** @example 30 */ + total_info?: number | null; + /** @example 1 */ + unique_error_count?: number | null; + /** @example 2 */ + unique_warning_count?: number | null; + /** @example 3 */ + unique_info_count?: number | null; + /** @description JSON validation report URL. */ + url_json?: string | null; + /** @description HTML validation report URL. */ + url_html?: string | null; + /** @description The notice codes raised, newest report only, ordered by severity then by count. Filtered by the `severity` query parameter when one is given. */ + notices: components['schemas']['GtfsFeedValidationNotice'][]; + }; + GtfsFeedValidationNotice: { + /** + * @description Validator notice code. + * @example invalid_phone_number + */ + code: string; + /** + * @example ERROR + * @enum {string} + */ + severity: 'ERROR' | 'WARNING' | 'INFO'; + /** + * @description How many times this code was raised. + * @example 10 + */ + total: number; + }; /** @description Validation report */ ValidationReport: { /** @@ -1768,6 +1872,23 @@ export interface components { continuous_coverage_downloaded_after: string; /** @description Only include datasets downloaded at or before this timestamp. Date should be in ISO 8601 date-time format. */ continuous_coverage_downloaded_before: string; + /** @description Only include reports validated at or after this timestamp. Date should be in ISO 8601 date-time format. */ + validation_validated_after: string; + /** @description Only include reports validated at or before this timestamp. Date should be in ISO 8601 date-time format. */ + validation_validated_before: string; + /** @description Only include reports with at least this many errors. Use 1 to list only datasets that failed validation. */ + validation_min_errors: number; + /** @description Only include reports with at least this many warnings. */ + validation_min_warnings: number; + /** + * @description Limit the `notices` of each entry to these severities. Repeat the parameter for more than one. The counts are unaffected. + * @example [ + * "ERROR" + * ] + */ + validation_severity: ('ERROR' | 'WARNING' | 'INFO')[]; + /** @description The number of items to be returned. Maximum is 100. */ + limit_query_param_validation_reports_endpoint: number; /** @description Sort order of results by checked_at. Use `desc` for newest first (default) or `asc` for oldest first. */ availability_sort: 'asc' | 'desc'; }; @@ -2235,6 +2356,70 @@ export interface operations { }; }; }; + getGtfsFeedValidationReports: { + parameters: { + query?: { + /** @description Only include reports validated at or after this timestamp. Date should be in ISO 8601 date-time format. */ + validated_after?: components['parameters']['validation_validated_after']; + /** @description Only include reports validated at or before this timestamp. Date should be in ISO 8601 date-time format. */ + validated_before?: components['parameters']['validation_validated_before']; + /** @description Only include reports with at least this many errors. Use 1 to list only datasets that failed validation. */ + min_errors?: components['parameters']['validation_min_errors']; + /** @description Only include reports with at least this many warnings. */ + min_warnings?: components['parameters']['validation_min_warnings']; + /** + * @description Limit the `notices` of each entry to these severities. Repeat the parameter for more than one. The counts are unaffected. + * @example [ + * "ERROR" + * ] + */ + severity?: components['parameters']['validation_severity']; + /** @description The number of items to be returned. Maximum is 100. */ + limit?: components['parameters']['limit_query_param_validation_reports_endpoint']; + /** @description Offset of the first item to return. */ + offset?: components['parameters']['offset']; + }; + header?: never; + path: { + /** @description The feed ID of the requested feed. */ + id: components['parameters']['feed_id_path_param']; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Validation history for the GTFS feed, ordered by validated_at (newest first). */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['GtfsFeedValidationReportsResponse']; + }; + }; + /** @description GTFS feed not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid request parameters. */ + 422: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Internal server error. */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; getDatasetGtfs: { parameters: { query?: never;