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 && (
+ }
+ data-testid='validation-errors-download'
+ >
+ {t('sealComplianceDownloadDataset')}
+
+ )}
+ {model.reportUrl != undefined && (
+ }
+ >
+ {t('sealComplianceViewReportLink')}
+
+ )}
+
+
+
+ {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;