From be235c5f6d2f6f0d8cda481b408f46a2bd205701 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 10 Sep 2026 12:15:23 -0400 Subject: [PATCH 01/28] updated skeleton loading --- .../components/SealReliabilitySkeleton.tsx | 118 ++++++++++++++---- 1 file changed, 94 insertions(+), 24 deletions(-) diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/components/SealReliabilitySkeleton.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/components/SealReliabilitySkeleton.tsx index 21231804..f468a9e4 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/components/SealReliabilitySkeleton.tsx +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/components/SealReliabilitySkeleton.tsx @@ -2,10 +2,18 @@ import { Box, Container, Skeleton } from '@mui/material'; const CRITERION_CHIP_COUNT = 6; +/** + * Width-to-height of the availability heatmap: ~27 week columns of square + * cells over 7 day rows. Held as a ratio rather than a height because the + * real grid's cells scale with the width it is given. + */ +const HEATMAP_ASPECT_RATIO = '27 / 7'; + /** * Loading skeleton for the Seal of Reliability analysis page, mirroring * `FeedReliabilityView`'s layout: header, page title row, seal banner with - * its criteria chips, then the two detailed criterion cards. + * its criteria chips, the two-up Official / Stable cards, then the full-width + * Available and Compliant cards. * ref: https://nextjs.org/docs/app/api-reference/file-conventions/loading */ export default function SealReliabilitySkeleton(): React.ReactElement { @@ -105,7 +113,7 @@ export default function SealReliabilitySkeleton(): React.ReactElement { - {/* Criterion cards skeleton */} + {/* Official / Stable card skeletons */} {Array.from({ length: 2 }).map((_, i) => ( - - - - + ))} + + {/* Available card skeleton: summary line, heatmap, legend */} + + {/* Two chips: the uptime figure sits beside the status chip. */} + + + + + + + {Array.from({ length: 3 }).map((_, i) => ( + + ))} + + + + {/* Compliant card skeleton: summary line and the report link */} + + + + + + + ); } + +/** Criterion card header: the title on the left, its chips on the right. */ +function CriterionHeaderSkeleton({ + chipCount = 1, +}: { + chipCount?: number; +}): React.ReactElement { + return ( + + + + {Array.from({ length: chipCount }).map((_, i) => ( + + ))} + + + ); +} From 774bed3f4dfc344278f33cc9c426d2b9080a9def Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 10 Sep 2026 12:18:45 -0400 Subject: [PATCH 02/28] include latest dataset for reliability view --- .../[feedId]/authed/seal-of-reliability/page.tsx | 7 ++++++- .../[feedId]/static/seal-of-reliability/page.tsx | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/seal-of-reliability/page.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/seal-of-reliability/page.tsx index a3f5f485..b439df82 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/seal-of-reliability/page.tsx +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/authed/seal-of-reliability/page.tsx @@ -2,6 +2,7 @@ import FeedReliabilityView from '../../../../../../screens/Feed/components/FeedR import { type ReactElement } from 'react'; import { fetchCompleteFeedData } from '../../lib/feed-data'; import { fetchAuthedSealAnalysisData } from '../../lib/seal-analysis-data'; +import { getLatestDataset } from '../../../../../../screens/Feed/Feed.functions'; import { notFound } from 'next/navigation'; interface Props { @@ -41,6 +42,10 @@ export default async function AuthedFeedReliabilityPage({ } return ( - + ); } diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx index efa0e920..ad8fce51 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx @@ -3,6 +3,7 @@ import { type ReactElement } from 'react'; import { notFound } from 'next/navigation'; import { fetchGuestFeedData } from '../../lib/guest-feed-data'; import { fetchGuestSealAnalysisData } from '../../lib/seal-analysis-data'; +import { getLatestDataset } from '../../../../../../screens/Feed/Feed.functions'; interface Props { params: Promise<{ feedDataType: string; feedId: string }>; @@ -73,9 +74,12 @@ export default async function StaticFeedReliabilityPage({ ); } + const { feed, initialDatasets } = feedResult.value; + return ( ); From 10ecc544fbc7eccf12e8c136c66616d6393f3440 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 10 Sep 2026 12:20:31 -0400 Subject: [PATCH 03/28] probation window helper functions --- src/app/constants/sealCriteria.spec.ts | 29 ++++++++++++++++++++ src/app/constants/sealCriteria.ts | 37 +++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/app/constants/sealCriteria.spec.ts b/src/app/constants/sealCriteria.spec.ts index 291d5370..8dd89494 100644 --- a/src/app/constants/sealCriteria.spec.ts +++ b/src/app/constants/sealCriteria.spec.ts @@ -274,6 +274,35 @@ describe('getProbationWindow', () => { }), ).toBeUndefined(); }); + + it('takes the earliest start across every criterion on probation, not the one derived from the latest end', () => { + const probationWindow = getProbationWindow({ + feed_id: 'mdb-1', + has_seal: false, + on_probation: true, + // The feed-level end is the latest of the two - it belongs to + // `compliant`, whose own window starts later than `available`'s. + probation_ends_at: '2027-01-16T00:00:00Z', + criteria: [ + buildCriterion('available', { + on_probation: true, + probation_ends_at: '2026-10-16T00:00:00Z', + }), + buildCriterion('compliant', { + on_probation: true, + probation_ends_at: '2027-01-16T00:00:00Z', + }), + ], + }); + + expect(probationWindow?.end.toISOString()).toBe('2027-01-16T00:00:00.000Z'); + // `available`'s own start (2026-10-16 minus 6 months), not + // `compliant`'s (2027-01-16 minus 6 months, which the old + // end-minus-PROBATION_MONTHS shortcut would have produced instead). + expect(probationWindow?.start.toISOString()).toBe( + '2026-04-16T00:00:00.000Z', + ); + }); }); describe('getProbationProgressPercent', () => { diff --git a/src/app/constants/sealCriteria.ts b/src/app/constants/sealCriteria.ts index e1f1ee6b..0bc5bb80 100644 --- a/src/app/constants/sealCriteria.ts +++ b/src/app/constants/sealCriteria.ts @@ -218,6 +218,30 @@ export interface ProbationWindow { /** * The API reports only when probation ends, and probation is defined as * PROBATION_MONTHS clean months, so the start is derived from the end. + * + * `undefined` when there is no end date - the feed or criterion is not on + * probation, or the window elapsed without the nightly job clearing it. + */ +export function getProbationWindowFromEnd( + endsAt: string | null | undefined, +): ProbationWindow | undefined { + if (endsAt == null) { + return undefined; + } + const end = new Date(endsAt); + if (isNaN(end.getTime())) { + return undefined; + } + return { start: subMonths(end, PROBATION_MONTHS), end }; +} + +/** + * The feed-level probation window, for the seal banner. + * + * `probation_ends_at` is already the latest end across every criterion on + * probation, but each criterion serves its own fixed-length window, so the + * one ending last isn't necessarily the one that started first. The start is + * the earliest start among them instead of being derived from that end. */ export function getProbationWindow( reliability: FeedReliabilityReport | undefined, @@ -230,7 +254,18 @@ export function getProbationWindow( if (isNaN(end.getTime())) { return undefined; } - return { start: subMonths(end, PROBATION_MONTHS), end }; + + const starts = (reliability?.criteria ?? []) + .filter((c) => c.on_probation) + .map((c) => getProbationWindowFromEnd(c.probation_ends_at)?.start) + .filter((d): d is Date => d != null); + + const start = + starts.length > 0 + ? new Date(Math.min(...starts.map((d) => d.getTime()))) + : subMonths(end, PROBATION_MONTHS); + + return { start, end }; } /** How far through the probation window `now` sits, as 0-100. */ From ab1d029562f58dfcaa8b54aa868634287092f9c2 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 10 Sep 2026 12:23:44 -0400 Subject: [PATCH 04/28] availability ui elements --- .../components/AvailabilityCriterionBody.tsx | 137 ++++++++++++++++++ .../Feed/components/AvailabilityHeatmap.tsx | 134 +++++++++++++++++ .../components/AvailabilityUptimeChip.tsx | 46 ++++++ 3 files changed, 317 insertions(+) create mode 100644 src/app/screens/Feed/components/AvailabilityCriterionBody.tsx create mode 100644 src/app/screens/Feed/components/AvailabilityHeatmap.tsx create mode 100644 src/app/screens/Feed/components/AvailabilityUptimeChip.tsx diff --git a/src/app/screens/Feed/components/AvailabilityCriterionBody.tsx b/src/app/screens/Feed/components/AvailabilityCriterionBody.tsx new file mode 100644 index 00000000..58b5bb18 --- /dev/null +++ b/src/app/screens/Feed/components/AvailabilityCriterionBody.tsx @@ -0,0 +1,137 @@ +import * as React from 'react'; +import { Box, Typography } from '@mui/material'; +import CircleIcon from '@mui/icons-material/Circle'; +import { getTranslations } from 'next-intl/server'; +import AvailabilityHeatmap from './AvailabilityHeatmap'; +import CriterionGraceCountdown from './CriterionGraceCountdown'; +import CriterionProbationProgress from './CriterionProbationProgress'; +import { + AVAILABILITY_HISTORY_MONTHS, + type AvailabilityCalendar, + getAvailabilitySummary, +} from '../lib/availability-history'; +import { + getCriterionDisplayStatus, + getProbationWindowFromEnd, +} from '../../../constants/sealCriteria'; +import { type components } from '../../../services/feeds/types'; +import { theme } from '../../../Theme'; + +type ReliabilityCriterion = components['schemas']['ReliabilityCriterion']; + +export interface AvailabilityCriterionBodyProps { + criterion: ReliabilityCriterion; + /** + * Built by the page, which also needs it for the header's uptime chip. + * Empty of checks when the history call failed - the criterion still + * renders, it just has no record to show. + */ + calendar: AvailabilityCalendar; + /** Pinned by the page so every date-derived branch agrees. */ + now: Date; +} + +/** + * Body of the Available criterion: what the daily fetch record says, the + * record itself as a heatmap, and - while the feed is inside its 14-day + * window - how long is left to restore access. + */ +export default async function AvailabilityCriterionBody({ + criterion, + calendar, + now, +}: AvailabilityCriterionBodyProps): Promise { + const t = await getTranslations('feeds'); + const summary = getAvailabilitySummary(criterion, calendar, now); + const hasHistory = calendar.successCount + calendar.failureCount > 0; + + // Probation excludes a grace period, so only one of these ever renders - + // both occupy the same slot, right under the summary sentence. + const probationWindow = + getCriterionDisplayStatus(criterion) === 'probation' + ? getProbationWindowFromEnd(criterion.probation_ends_at) + : undefined; + + return ( + + + {t('sealAvailabilityIntro', { months: AVAILABILITY_HISTORY_MONTHS })}{' '} + {t(summary.key, summary.values)} + + + {summary.graceDaysLeft != undefined && ( + + )} + + {probationWindow != undefined && ( + + )} + + {hasHistory && ( + <> + + + + + {calendar.uncheckedCount > 0 && ( + + )} + + + )} + + ); +} + +function LegendItem({ + color, + label, +}: { + color: string; + label: string; +}): React.ReactElement { + return ( + + + {label} + + ); +} diff --git a/src/app/screens/Feed/components/AvailabilityHeatmap.tsx b/src/app/screens/Feed/components/AvailabilityHeatmap.tsx new file mode 100644 index 00000000..bcb38049 --- /dev/null +++ b/src/app/screens/Feed/components/AvailabilityHeatmap.tsx @@ -0,0 +1,134 @@ +import * as React from 'react'; +import { Box, Tooltip, Typography } from '@mui/material'; +import { getTranslations } from 'next-intl/server'; +import { + type AvailabilityCalendar, + type AvailabilityDayStatus, +} from '../lib/availability-history'; +import { formatDateShort, formatMonthShort } from '../../../utils/date'; +import { theme } from '../../../Theme'; + +/** + * Columns stretch to fill the card, so a cell's size follows the width it is + * given. This is the floor: below it the grid scrolls sideways rather than + * shrinking the days into invisibility. + */ +const MIN_CELL_SIZE = 10; +const CELL_GAP = 4; + +const STATUS_COLORS: Record = { + success: theme.vars.palette.success.light, + failure: theme.vars.palette.error.main, + unchecked: theme.vars.palette.action.disabledBackground, +}; + +const STATUS_TOOLTIP_KEYS: Record = { + success: 'sealAvailabilityDaySuccess', + failure: 'sealAvailabilityDayFailure', + unchecked: 'sealAvailabilityDayUnchecked', +}; + +/** A percentage radius so the corners stay proportional as cells scale up. */ +const CELL_SX = { aspectRatio: '1 / 1', borderRadius: '18%' } as const; + +export interface AvailabilityHeatmapProps { + calendar: AvailabilityCalendar; +} + +/** + * The daily fetch record as a contribution-style grid: one column per week, + * one cell per day, Sunday at the top. Rendered on the server - the only + * interactive leaves are the per-day tooltips, which are Client Components in + * their own right, so colors come from the theme module rather than useTheme. + */ +export default async function AvailabilityHeatmap({ + calendar, +}: AvailabilityHeatmapProps): Promise { + const t = await getTranslations('feeds'); + + if (calendar.weeks.length === 0) { + return null; + } + + const columns = `repeat(${calendar.weeks.length}, minmax(${MIN_CELL_SIZE}px, 1fr))`; + const minWidth = + calendar.weeks.length * MIN_CELL_SIZE + + (calendar.weeks.length - 1) * CELL_GAP; + + return ( + + + + {calendar.monthLabels.map((label) => ( + + {formatMonthShort(label.date)} + + ))} + + + + {calendar.weeks.flatMap((week, weekIndex) => + week.map((day, dayIndex) => + day == undefined ? ( + // Keeps the row height when a padded week starts or ends the + // window, so the grid stays square. + + ) : ( + + + + ), + ), + )} + + + + ); +} diff --git a/src/app/screens/Feed/components/AvailabilityUptimeChip.tsx b/src/app/screens/Feed/components/AvailabilityUptimeChip.tsx new file mode 100644 index 00000000..180fb899 --- /dev/null +++ b/src/app/screens/Feed/components/AvailabilityUptimeChip.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { Chip } from '@mui/material'; +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; +import { getTranslations } from 'next-intl/server'; +import { + type CriterionDisplayStatus, + getCriterionStatusColor, +} from '../../../constants/sealCriteria'; + +export interface AvailabilityUptimeChipProps { + /** Share of checked days that succeeded, 0-100. */ + uptimePercent: number; + /** Colors the chip the same as the criterion it summarises. */ + displayStatus: CriterionDisplayStatus; +} + +/** + * Headline number for the Available criterion: the share of days in the + * window whose fetch succeeded. Days the job never checked are excluded, so + * a gap in the record doesn't read as downtime. + */ +export default async function AvailabilityUptimeChip({ + uptimePercent, + displayStatus, +}: AvailabilityUptimeChipProps): Promise { + const t = await getTranslations('feeds'); + const color = getCriterionStatusColor(displayStatus); + + return ( + } + label={t('sealAvailabilityUptime', { + percent: uptimePercent.toFixed(1), + })} + sx={{ + color, + borderColor: color, + flexShrink: 0, + '& .MuiChip-icon': { color }, + }} + /> + ); +} From 8672e9cd78fa5139002e65536b36b93d9076502f Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 10 Sep 2026 12:25:23 -0400 Subject: [PATCH 05/28] criterion reusable elements: probation and grace period --- .../components/CriterionGraceCountdown.tsx | 33 ++++++ .../components/CriterionProbationProgress.tsx | 105 ++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 src/app/screens/Feed/components/CriterionGraceCountdown.tsx create mode 100644 src/app/screens/Feed/components/CriterionProbationProgress.tsx diff --git a/src/app/screens/Feed/components/CriterionGraceCountdown.tsx b/src/app/screens/Feed/components/CriterionGraceCountdown.tsx new file mode 100644 index 00000000..6c49be9f --- /dev/null +++ b/src/app/screens/Feed/components/CriterionGraceCountdown.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { Alert, AlertTitle } from '@mui/material'; + +export interface CriterionGraceCountdownProps { + /** What is left to do and by when, e.g. "20 days left to resolve 3 errors". */ + title: string; + /** What happens if it isn't done in time. */ + description: string; +} + +/** + * The deadline notice a criterion shows while it is inside a grace period. + * + * A warning Alert, the same shape the grace periods are documented with on + * the "how it is calculated" page, so the same rule reads the same way + * wherever a producer meets it. Warning rather than error because the seal is + * still held - the failure only counts once the window elapses. + */ +export default function CriterionGraceCountdown({ + title, + description, +}: CriterionGraceCountdownProps): React.ReactElement { + return ( + + {title} + {description} + + ); +} diff --git a/src/app/screens/Feed/components/CriterionProbationProgress.tsx b/src/app/screens/Feed/components/CriterionProbationProgress.tsx new file mode 100644 index 00000000..55ffbc82 --- /dev/null +++ b/src/app/screens/Feed/components/CriterionProbationProgress.tsx @@ -0,0 +1,105 @@ +'use client'; + +import * as React from 'react'; +import { + Alert, + AlertTitle, + Box, + LinearProgress, + Typography, +} from '@mui/material'; +import { useTranslations } from 'next-intl'; +import { + type ProbationWindow, + getProbationProgressPercent, +} from '../../../constants/sealCriteria'; +import { formatDateShort } from '../../../utils/date'; + +export interface CriterionProbationProgressProps { + probationWindow: ProbationWindow; + /** Pinned by the page so the bar renders the same either side of hydration. */ + now?: Date; +} + +/** + * How far a criterion has served of the six clean months it owes after a + * confirmed failure. + * + * An info Alert rather than a warning: nothing is currently wrong, the + * criterion is passing and rebuilding its record. The bar mirrors the + * feed-level one in the seal banner, so the two read as the same clock. + */ +export default function CriterionProbationProgress({ + probationWindow, + now, +}: CriterionProbationProgressProps): React.ReactElement { + const t = useTranslations('feeds'); + const endsOn = formatDateShort(probationWindow.end.toISOString()); + + return ( + + + {t('sealCriterionProbationNote', { date: endsOn })} + + + + + + + + ); +} + +function ProbationBound({ + label, + date, + align, +}: { + label: string; + date: string; + align?: 'right'; +}): React.ReactElement { + return ( + + {/* Inherits the Alert's color rather than taking text.secondary, which + would clash with the info palette. */} + + {label} + + + {date} + + + ); +} From a5f596c650444ad053407d3fc5e1df1bf7614caf Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 10 Sep 2026 12:26:52 -0400 Subject: [PATCH 06/28] compliance ui element --- .../components/ComplianceCriterionBody.tsx | 70 ++++++ .../Feed/lib/compliance-report.spec.ts | 203 ++++++++++++++++++ src/app/screens/Feed/lib/compliance-report.ts | 129 +++++++++++ 3 files changed, 402 insertions(+) create mode 100644 src/app/screens/Feed/components/ComplianceCriterionBody.tsx create mode 100644 src/app/screens/Feed/lib/compliance-report.spec.ts create mode 100644 src/app/screens/Feed/lib/compliance-report.ts diff --git a/src/app/screens/Feed/components/ComplianceCriterionBody.tsx b/src/app/screens/Feed/components/ComplianceCriterionBody.tsx new file mode 100644 index 00000000..936e6058 --- /dev/null +++ b/src/app/screens/Feed/components/ComplianceCriterionBody.tsx @@ -0,0 +1,70 @@ +import * as React from 'react'; +import { Box, Button, Typography } from '@mui/material'; +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import { getTranslations } from 'next-intl/server'; +import CriterionGraceCountdown from './CriterionGraceCountdown'; +import { getComplianceSummary } from '../lib/compliance-report'; +import { type components } from '../../../services/feeds/types'; + +type ReliabilityCriterion = components['schemas']['ReliabilityCriterion']; +type ValidationReport = components['schemas']['ValidationReport']; + +export interface ComplianceCriterionBodyProps { + criterion: ReliabilityCriterion; + /** Validation report of the feed's latest dataset, when it has one. */ + report?: ValidationReport; + /** Pinned by the page so every date-derived branch agrees. */ + now: Date; +} + +/** + * 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. + */ +export default async function ComplianceCriterionBody({ + criterion, + report, + now, +}: ComplianceCriterionBodyProps): Promise { + const t = await getTranslations('feeds'); + const summary = getComplianceSummary(criterion, report, now); + const reportUrl = report?.url_html; + + return ( + + {/* Bold headline then detail, matching the shape Official and Stable + get from the shared criterion copy. */} + + {t(summary.subtitleKey)} + + + {t(summary.key, summary.values)} + + + {summary.graceDaysLeft != undefined && ( + + )} + + {reportUrl != undefined && reportUrl.length > 0 && ( + + + + )} + + ); +} diff --git a/src/app/screens/Feed/lib/compliance-report.spec.ts b/src/app/screens/Feed/lib/compliance-report.spec.ts new file mode 100644 index 00000000..8d15b47a --- /dev/null +++ b/src/app/screens/Feed/lib/compliance-report.spec.ts @@ -0,0 +1,203 @@ +import { + COMPLIANCE_GRACE_DAYS, + getComplianceErrorCount, + getComplianceSummary, +} from './compliance-report'; +import { type components } from '../../../services/feeds/types'; + +type ReliabilityCriterion = components['schemas']['ReliabilityCriterion']; +type ValidationReport = components['schemas']['ValidationReport']; + +const NOW = new Date('2026-09-09T12:00:00Z'); + +function criterion( + overrides: Partial = {}, +): ReliabilityCriterion { + return { + criterion: 'compliant', + status: 'pass', + in_grace_period: false, + on_probation: false, + ...overrides, + }; +} + +const cleanReport: ValidationReport = { + validated_at: '2026-09-08T04:00:00Z', + total_error: 0, + unique_error_count: 0, +}; + +const failingReport: ValidationReport = { + validated_at: '2026-09-08T04:00:00Z', + total_error: 7, + unique_error_count: 2, +}; + +describe('getComplianceErrorCount', () => { + it('reports every occurrence, not just the distinct notice codes', () => { + expect(getComplianceErrorCount(failingReport)).toBe(7); + }); + + it('falls back to the distinct count when the total is absent', () => { + expect(getComplianceErrorCount({ unique_error_count: 2 })).toBe(2); + }); + + it('is undefined without a report at all', () => { + expect(getComplianceErrorCount(undefined)).toBeUndefined(); + }); +}); + +describe('getComplianceSummary', () => { + it('reports the validation date while the criterion passes', () => { + expect(getComplianceSummary(criterion(), cleanReport, NOW)).toEqual({ + subtitleKey: 'sealCompliantNoErrorsSubtitle', + key: 'sealCompliantPassing', + values: { date: 'Sep 8, 2026' }, + }); + }); + + it('drops the date when the report does not carry one', () => { + expect(getComplianceSummary(criterion(), { total_error: 0 }, NOW).key).toBe( + 'sealCompliantPassingUndated', + ); + }); + + it('counts down the 30-day window while an error is inside its grace period', () => { + expect( + getComplianceSummary( + criterion({ + status: 'fail', + in_grace_period: true, + grace_period_ends_at: '2026-10-03T00:00:00Z', + }), + failingReport, + NOW, + ), + ).toEqual({ + subtitleKey: 'sealCompliantHasErrorsSubtitle', + key: 'sealCompliantAtRisk', + values: { count: 7, graceDays: COMPLIANCE_GRACE_DAYS }, + errorCount: 7, + graceDaysLeft: 24, + }); + }); + + it('floors an elapsed grace deadline at zero rather than counting backwards', () => { + expect( + getComplianceSummary( + criterion({ + status: 'fail', + in_grace_period: true, + grace_period_ends_at: '2026-08-01T00:00:00Z', + }), + failingReport, + NOW, + ).graceDaysLeft, + ).toBe(0); + }); + + it('reads as a spent grace window once the failure is confirmed', () => { + const summary = getComplianceSummary( + criterion({ status: 'fail' }), + failingReport, + NOW, + ); + + expect(summary.key).toBe('sealCompliantFailing'); + expect(summary.graceDaysLeft).toBeUndefined(); + }); + + it('names the error probation is being served for', () => { + expect( + getComplianceSummary( + criterion({ + on_probation: true, + last_failure_at: '2026-07-02T04:00:00Z', + }), + cleanReport, + NOW, + ), + ).toEqual({ + subtitleKey: 'sealCompliantNoErrorsSubtitle', + key: 'sealCompliantProbation', + values: { date: 'Jul 2, 2026' }, + }); + }); + + it('drops the date when no last failure was kept', () => { + expect( + getComplianceSummary(criterion({ on_probation: true }), cleanReport, NOW) + .key, + ).toBe('sealCompliantProbationUndated'); + }); + + it('says the report is missing rather than claiming a clean validation', () => { + expect(getComplianceSummary(criterion(), undefined, NOW).key).toBe( + 'sealCompliantNoReport', + ); + }); + + it('separates never-evaluated from a clean pass', () => { + expect( + getComplianceSummary( + criterion({ status: 'never_evaluated' }), + cleanReport, + NOW, + ).key, + ).toBe('sealCompliantNoData'); + }); + + it('withdraws the criterion when it does not apply', () => { + expect( + getComplianceSummary( + criterion({ status: 'not_applicable' }), + cleanReport, + NOW, + ).key, + ).toBe('sealCompliantNotApplicable'); + }); + + it.each([ + [ + 'a clean report', + criterion(), + cleanReport, + 'sealCompliantNoErrorsSubtitle', + ], + [ + 'probation, which is served while passing', + criterion({ on_probation: true }), + cleanReport, + 'sealCompliantNoErrorsSubtitle', + ], + [ + 'a failing report', + criterion({ status: 'fail' }), + failingReport, + 'sealCompliantHasErrorsSubtitle', + ], + [ + 'a missing report', + criterion(), + undefined, + 'sealCompliantNoReportSubtitle', + ], + [ + 'a never-evaluated criterion', + criterion({ status: 'never_evaluated' }), + cleanReport, + 'sealCompliantNotEvaluatedSubtitle', + ], + [ + 'a withdrawn criterion', + criterion({ status: 'not_applicable' }), + cleanReport, + 'sealCompliantNotApplicableSubtitle', + ], + ])('heads %s with its own subtitle', (_label, given, report, subtitleKey) => { + expect(getComplianceSummary(given, report, NOW).subtitleKey).toBe( + subtitleKey, + ); + }); +}); diff --git a/src/app/screens/Feed/lib/compliance-report.ts b/src/app/screens/Feed/lib/compliance-report.ts new file mode 100644 index 00000000..c4079776 --- /dev/null +++ b/src/app/screens/Feed/lib/compliance-report.ts @@ -0,0 +1,129 @@ +/** + * Reads the latest dataset's validation report into the wording and numbers + * the Compliant criterion renders. + */ + +import { type components } from '../../../services/feeds/types'; +import { + getCriterionDisplayStatus, + getDaysUntil, +} from '../../../constants/sealCriteria'; +import { formatDateShort } from '../../../utils/date'; + +type ReliabilityCriterion = components['schemas']['ReliabilityCriterion']; +type ValidationReport = components['schemas']['ValidationReport']; + +/** Days producers get to fix a validation error before the seal is revoked. */ +export const COMPLIANCE_GRACE_DAYS = 30; + +/** + * Headline for the state, mirroring the bold subtitle Official and Stable + * get from the shared criterion copy. Keys in the `feeds` namespace. + */ +const SUBTITLE_KEYS = { + noErrors: 'sealCompliantNoErrorsSubtitle', + hasErrors: 'sealCompliantHasErrorsSubtitle', + noReport: 'sealCompliantNoReportSubtitle', + notEvaluated: 'sealCompliantNotEvaluatedSubtitle', + notApplicable: 'sealCompliantNotApplicableSubtitle', +} as const; + +export interface ComplianceSummary { + /** Bold headline for the state. Key in the `feeds` namespace. */ + subtitleKey: string; + /** The sentence below the subtitle. Key in the `feeds` namespace. */ + key: string; + values: Record; + /** + * Days left in the grace period, when one is running. Drives the deadline + * notice; absent for every other state. + */ + graceDaysLeft?: number; + /** Errors in the latest report, when there are any to report. */ + errorCount?: number; +} + +export function getComplianceErrorCount( + report: ValidationReport | undefined, +): number | undefined { + return report?.total_error ?? report?.unique_error_count; +} + +export function getComplianceSummary( + criterion: ReliabilityCriterion, + report: ValidationReport | undefined, + now: Date = new Date(), +): ComplianceSummary { + const displayStatus = getCriterionDisplayStatus(criterion); + const errorCount = getComplianceErrorCount(report) ?? 0; + const graceDays = COMPLIANCE_GRACE_DAYS; + + if (displayStatus === 'notApplicable') { + return { + subtitleKey: SUBTITLE_KEYS.notApplicable, + key: 'sealCompliantNotApplicable', + values: {}, + }; + } + // At risk and failing both mean the report has errors; what separates them + // is how much of the grace period is left, which the sentence carries. + if (displayStatus === 'atRisk') { + return { + subtitleKey: SUBTITLE_KEYS.hasErrors, + key: 'sealCompliantAtRisk', + values: { count: errorCount, graceDays }, + errorCount, + graceDaysLeft: + criterion.grace_period_ends_at != null + ? getDaysUntil(criterion.grace_period_ends_at, now) + : 0, + }; + } + if (displayStatus === 'fail') { + return { + subtitleKey: SUBTITLE_KEYS.hasErrors, + key: 'sealCompliantFailing', + values: { count: errorCount, graceDays }, + }; + } + + if (displayStatus === 'probation') { + return { + subtitleKey: SUBTITLE_KEYS.noErrors, + ...(criterion.last_failure_at != null + ? { + key: 'sealCompliantProbation', + values: { date: formatDateShort(criterion.last_failure_at) }, + } + : { key: 'sealCompliantProbationUndated', values: {} }), + }; + } + if (report == undefined) { + return { + subtitleKey: SUBTITLE_KEYS.noReport, + key: 'sealCompliantNoReport', + values: {}, + }; + } + if (displayStatus === 'notEvaluated') { + return { + subtitleKey: SUBTITLE_KEYS.notEvaluated, + key: 'sealCompliantNoData', + values: {}, + }; + } + // The validated-on date is worth stating, but the report doesn't always + // carry one, so the wording drops it rather than showing an empty date. + if (report.validated_at == null) { + return { + subtitleKey: SUBTITLE_KEYS.noErrors, + key: 'sealCompliantPassingUndated', + values: {}, + }; + } + return { + subtitleKey: SUBTITLE_KEYS.noErrors, + key: 'sealCompliantPassing', + values: { date: formatDateShort(report.validated_at) }, + }; +} From d0dd75113686a18cc11d4c854f5991db88df9b82 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 10 Sep 2026 12:50:57 -0400 Subject: [PATCH 07/28] date function --- src/app/utils/date.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/app/utils/date.ts b/src/app/utils/date.ts index 25ae4148..ecd78e04 100644 --- a/src/app/utils/date.ts +++ b/src/app/utils/date.ts @@ -31,6 +31,18 @@ export const formatDateShort = ( }).format(date); }; +/** Short month name for a date, in the same UTC-by-default frame as + * formatDateShort - used for the column headers of date grids. */ +export const formatMonthShort = ( + dateString: string, + timeZone?: string, +): string => { + return new Intl.DateTimeFormat('en-US', { + timeZone: timeZone ?? 'UTC', + month: 'short', + }).format(new Date(dateString)); +}; + /** * * @param dateString date in ISO format From 7f546c4f6b83f7e3e25ae4142188846858d93a50 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 10 Sep 2026 13:03:51 -0400 Subject: [PATCH 08/28] available and compliant ui elements --- .../Feed/components/CriterionSection.spec.tsx | 171 +++++++++++++++++- .../Feed/components/CriterionSection.tsx | 134 +++++++++++--- .../Feed/components/FeedReliabilityView.tsx | 75 +++++++- 3 files changed, 352 insertions(+), 28 deletions(-) diff --git a/src/app/screens/Feed/components/CriterionSection.spec.tsx b/src/app/screens/Feed/components/CriterionSection.spec.tsx index 1ef70a47..d08a5ace 100644 --- a/src/app/screens/Feed/components/CriterionSection.spec.tsx +++ b/src/app/screens/Feed/components/CriterionSection.spec.tsx @@ -41,6 +41,8 @@ function renderSection( criterion: ReliabilityCriterion, context?: SealCriterionContext, producerUrl?: string, + children?: React.ReactNode, + metaChips?: React.ReactNode, ): ReturnType { return render( @@ -48,8 +50,11 @@ function renderSection( criterion={criterion} context={context} producerUrl={producerUrl} - statusChip={null} - /> + statusChip={} + metaChips={metaChips} + > + {children} + , ); } @@ -160,3 +165,165 @@ describe('CriterionSection stable', () => { ).toBeInTheDocument(); }); }); + +describe('CriterionSection body slot', () => { + it('keeps the header but drops the generic copy when a body is supplied', () => { + renderSection( + buildCriterion('available', { status: 'pass' }), + undefined, + undefined, +

custom body

, + ); + + expect( + screen.getByTestId('criterion-section-available'), + ).toBeInTheDocument(); + expect(screen.getByText('criteria.available.title')).toBeInTheDocument(); + expect(screen.getByText('custom body')).toBeInTheDocument(); + expect( + screen.queryByText('criteria.available.description'), + ).not.toBeInTheDocument(); + }); +}); + +describe('CriterionSection probation', () => { + it('says when probation ends, and how far through it is', () => { + renderSection( + buildCriterion('available', { + status: 'pass', + on_probation: true, + probation_ends_at: '2027-01-16T00:00:00Z', + }), + { now: NOW }, + ); + + const note = screen.getByTestId('criterion-probation-note'); + expect(note).toHaveTextContent('sealCriterionProbationNote'); + // Window runs Jul 16 2026 - Jan 16 2027; NOW is Sep 8, roughly a third in. + const bar = note.querySelector('[role="progressbar"]'); + expect(bar).toHaveAttribute('aria-valuenow', '29'); + }); + + it('says when probation ends alongside a custom body', () => { + renderSection( + buildCriterion('compliant', { + status: 'pass', + on_probation: true, + probation_ends_at: '2027-01-16T00:00:00Z', + }), + { now: NOW }, + undefined, +

custom body

, + ); + + expect(screen.getByText('custom body')).toBeInTheDocument(); + expect(screen.getByTestId('criterion-probation-note')).toBeInTheDocument(); + }); + + it('omits the note when the probation window has already elapsed', () => { + renderSection( + buildCriterion('available', { status: 'pass', on_probation: true }), + ); + + expect( + screen.queryByTestId('criterion-probation-note'), + ).not.toBeInTheDocument(); + }); + + it('omits the note when the criterion is not on probation', () => { + renderSection( + buildCriterion('available', { + status: 'pass', + probation_ends_at: '2027-01-16T00:00:00Z', + }), + ); + + expect( + screen.queryByTestId('criterion-probation-note'), + ).not.toBeInTheDocument(); + }); +}); + +describe('CriterionSection days-left chip', () => { + const atRisk = (): ReliabilityCriterion => + buildCriterion('compliant', { + status: 'fail', + in_grace_period: true, + grace_period_ends_at: '2026-09-28T00:00:00Z', + }); + + it('counts the days left against the date the page pinned', () => { + renderSection(atRisk(), { now: NOW }); + + expect(screen.getByTestId('criterion-days-left-chip')).toHaveTextContent( + 'sealCriterionDaysLeftChip', + ); + }); + + it('sits between the meta chips and the status chip', () => { + renderSection( + atRisk(), + { now: NOW }, + undefined, + undefined, + , + ); + + const order = [ + screen.getByTestId('meta-chip'), + screen.getByTestId('criterion-days-left-chip'), + screen.getByTestId('status-chip'), + ]; + order.slice(1).forEach((node, i) => { + // Node.DOCUMENT_POSITION_FOLLOWING - each chip comes after the last. + expect(order[i].compareDocumentPosition(node) & 4).toBeTruthy(); + }); + }); + + it('is absent when the criterion is not in a grace period', () => { + renderSection(buildCriterion('compliant', { status: 'pass' }), { + now: NOW, + }); + + expect( + screen.queryByTestId('criterion-days-left-chip'), + ).not.toBeInTheDocument(); + }); + + it('is absent when the grace period reports no deadline', () => { + renderSection( + buildCriterion('compliant', { status: 'fail', in_grace_period: true }), + { now: NOW }, + ); + + expect( + screen.queryByTestId('criterion-days-left-chip'), + ).not.toBeInTheDocument(); + }); + + it('counts down probation too, which runs to its own deadline', () => { + renderSection( + buildCriterion('available', { + status: 'pass', + on_probation: true, + probation_ends_at: '2027-01-16T00:00:00Z', + }), + { now: NOW }, + ); + + expect(screen.getByTestId('criterion-days-left-chip')).toHaveTextContent( + 'sealCriterionDaysLeftChip', + ); + }); + + it('is absent when probation reports no deadline', () => { + renderSection( + buildCriterion('available', { status: 'pass', on_probation: true }), + { now: NOW }, + ); + + expect( + screen.queryByTestId('criterion-days-left-chip'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/app/screens/Feed/components/CriterionSection.tsx b/src/app/screens/Feed/components/CriterionSection.tsx index b7dba0f6..3bed5b2d 100644 --- a/src/app/screens/Feed/components/CriterionSection.tsx +++ b/src/app/screens/Feed/components/CriterionSection.tsx @@ -5,13 +5,16 @@ import { Box, Card, CardContent, + Chip, IconButton, Tooltip, Typography, } from '@mui/material'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import AccessTimeIcon from '@mui/icons-material/AccessTime'; import { useTranslations } from 'next-intl'; import { Link } from '../../../../i18n/navigation'; +import CriterionProbationProgress from './CriterionProbationProgress'; import { API_CRITERION_TO_KEY, SEAL_CRITERION_ICONS, @@ -19,6 +22,8 @@ import { getCriterionCopy, getCriterionDisplayStatus, getCriterionStatusColor, + getDaysUntil, + getProbationWindowFromEnd, } from '../../../constants/sealCriteria'; import { type components } from '../../../services/feeds/types'; import { formatDateShort } from '../../../utils/date'; @@ -29,6 +34,22 @@ export interface CriterionSectionProps { /** The feed's producer URL, shown when its shape is what fails Stable. */ producerUrl?: string; statusChip: React.ReactNode; + /** + * Extra chips for the header, placed left of the grace-period countdown and + * the status chip - a criterion's own headline figure, such as uptime. + */ + metaChips?: React.ReactNode; + /** + * Replaces the generic subtitle / description body. Criteria backed by + * their own history endpoint - Available, Compliant - say more with that + * data than the shared copy can, but keep this header and container. + */ + children?: React.ReactNode; + /** + * Suppresses the section's own probation note. Available renders it itself, + * inline with its grace-period countdown, instead of at the card's end. + */ + hideProbationProgress?: boolean; } /** @@ -41,6 +62,9 @@ export default function CriterionSection({ context, producerUrl, statusChip, + metaChips, + children, + hideProbationProgress, }: CriterionSectionProps): React.ReactElement { const t = useTranslations('feeds'); const tSeal = useTranslations('sealOfReliability'); @@ -51,6 +75,20 @@ export default function CriterionSection({ const color = getCriterionStatusColor(displayStatus); const copy = getCriterionCopy(criterion, context); + // Both states run to a deadline, and they are mutually exclusive - a + // failure during probation restarts it rather than opening a grace period - + // so one chip counts down whichever is running. `now` is pinned by the page + // and threaded through the context so it renders identically on the server + // and after hydration. + const deadline = + displayStatus === 'atRisk' + ? criterion.grace_period_ends_at + : displayStatus === 'probation' + ? criterion.probation_ends_at + : undefined; + const daysLeft = + deadline != null ? getDaysUntil(deadline, context?.now) : undefined; + const graceNote = displayStatus === 'atRisk' && criterion.grace_period_ends_at != null ? t('sealCriterionGracePeriodNote', { @@ -58,6 +96,15 @@ export default function CriterionSection({ }) : undefined; + // Probation excludes a grace period - a failure during probation restarts + // it outright - so these two never appear together. `probation_ends_at` + // is null once the window has elapsed without the nightly job clearing it, + // which leaves the chip to carry the state on its own. + const probationWindow = + displayStatus === 'probation' + ? getProbationWindowFromEnd(criterion.probation_ends_at) + : undefined; + // Only the flagged-URL case is about the URL's shape, so only it earns the // side-by-side comparison. const showUrlComparison = @@ -71,11 +118,14 @@ export default function CriterionSection({ sx={{ mb: 0, height: '100%' }} data-testid={`criterion-section-${key}`} > + {/* Wraps rather than clips: with a metric chip and a grace countdown + alongside the status chip, the row outgrows a narrow card. */} @@ -97,7 +147,15 @@ export default function CriterionSection({ /> {tSeal(copy.titleKey)} - + + {metaChips} + {daysLeft != undefined && ( + } + label={t('sealCriterionDaysLeftChip', { days: daysLeft })} + sx={{ + color, + borderColor: color, + flexShrink: 0, + '& .MuiChip-icon': { color }, + }} + /> + )} {statusChip} - - {tSeal(copy.subtitleKey)} - - - {tSeal(copy.descriptionKey)} - - {graceNote != undefined && ( - - {graceNote} - + {children ?? ( + <> + + {tSeal(copy.subtitleKey)} + + + {tSeal(copy.descriptionKey)} + + {graceNote != undefined && ( + + {graceNote} + + )} + {showUrlComparison && ( + + + + + )} + )} - {showUrlComparison && ( - - - - + {!(hideProbationProgress ?? false) && probationWindow != undefined && ( + )} diff --git a/src/app/screens/Feed/components/FeedReliabilityView.tsx b/src/app/screens/Feed/components/FeedReliabilityView.tsx index 14b7d429..fc57d14e 100644 --- a/src/app/screens/Feed/components/FeedReliabilityView.tsx +++ b/src/app/screens/Feed/components/FeedReliabilityView.tsx @@ -13,6 +13,9 @@ import ScrollToTop from './ScrollToTop'; import SealSection from './SealSection'; import CriterionSection from './CriterionSection'; import CriterionStatusChip from './CriterionStatusChip'; +import AvailabilityCriterionBody from './AvailabilityCriterionBody'; +import AvailabilityUptimeChip from './AvailabilityUptimeChip'; +import ComplianceCriterionBody from './ComplianceCriterionBody'; // Utils import { type AllFeedType } from '../../../services/feeds/utils'; @@ -23,16 +26,19 @@ import { type SealCriterionContext, } from '../../../constants/sealCriteria'; import { formatProvidersSorted } from '../Feed.functions'; +import { buildAvailabilityCalendar } from '../lib/availability-history'; import { displayFormattedDate } from '../../../utils/date'; import SectionContainer from '../../../components/SectionContainer'; interface Props { feed: AllFeedType; + latestDataset?: components['schemas']['GtfsDataset']; sealAnalysis?: SealAnalysisData; } export default async function FeedReliabilityView({ feed, + latestDataset, sealAnalysis, }: Props): Promise { if (feed == undefined) notFound(); @@ -43,10 +49,11 @@ export default async function FeedReliabilityView({ // Pinned once here so every date-derived branch below resolves to the same // instant during SSR and hydration. This route is force-dynamic, so it is // request time. + const now = new Date(); const criterionContext: SealCriterionContext = { isProducerUrlUnstable: feed.source_info?.is_producer_url_unstable, feedCreatedAt: feed.created_at, - now: new Date(), + now, }; const producerUrl = feed.source_info?.producer_url; @@ -61,6 +68,15 @@ export default async function FeedReliabilityView({ const officialCriterion = findCriterion('official'); const stableCriterion = findCriterion('stable'); + const availableCriterion = findCriterion('available'); + const compliantCriterion = findCriterion('compliant'); + + // Built once and handed down, so the header's uptime chip and the grid in + // the body are reading the same window. + const availabilityCalendar = buildAvailabilityCalendar( + sealAnalysis?.availability?.checks, + { now }, + ); return ( )} + + {/* Available and Compliant each carry a full history, so they get + a row of their own rather than sharing the two-up grid. */} + {availableCriterion != undefined && ( + + + ) + } + statusChip={ + + } + > + + + + )} + + {compliantCriterion != undefined && ( + + + } + > + + + + )}
From a6b93b24be575bcc4b3abcc9e09dc8f929368d06 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 10 Sep 2026 13:07:27 -0400 Subject: [PATCH 09/28] availability data handle --- .../[feedId]/lib/seal-analysis-data.spec.ts | 65 +++- .../[feedId]/lib/seal-analysis-data.ts | 84 ++++- .../Feed/lib/availability-history.spec.ts | 289 +++++++++++++++++ .../screens/Feed/lib/availability-history.ts | 293 ++++++++++++++++++ 4 files changed, 715 insertions(+), 16 deletions(-) create mode 100644 src/app/screens/Feed/lib/availability-history.spec.ts create mode 100644 src/app/screens/Feed/lib/availability-history.ts 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 8df0dbb7..0d7e96cc 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 @@ -49,7 +49,17 @@ jest.mock('../../../../../../lib/remote-config.server', () => ({ })); const report = { feed_id: 'mdb-1', has_seal: true, criteria: [] }; -const availability = { feed_id: 'mdb-1', total: 1, offset: 0, limit: 100 }; +const check = { checked_at: '2026-09-08T04:00:00Z', success: true }; +const availability = { + feed_id: 'mdb-1', + total: 1, + offset: 0, + limit: 100, + checks: [check], +}; +// The loader flattens the pages it walked, so `limit` reports how many checks +// 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: [] }; describe('fetchGuestSealAnalysisData', () => { @@ -68,7 +78,7 @@ describe('fetchGuestSealAnalysisData', () => { expect(result).toEqual({ reliability: report, - availability, + availability: flattenedAvailability, continuousCoverage: coverage, reliabilityError: false, }); @@ -88,13 +98,18 @@ describe('fetchGuestSealAnalysisData', () => { ); }); - it('requests the newest page of each history endpoint', async () => { + it('requests six months of availability and the newest coverage page', async () => { await fetchGuestSealAnalysisData('gtfs', 'mdb-1'); expect(mockGetGtfsFeedAvailability).toHaveBeenCalledWith( 'mdb-1', 'guest-token', - { limit: 100, sort: 'desc' }, + { + from: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T00:00:00\.000Z$/), + limit: 100, + offset: 0, + sort: 'desc', + }, undefined, ); expect(mockGetGtfsFeedContinuousCoverage).toHaveBeenCalledWith( @@ -105,6 +120,48 @@ describe('fetchGuestSealAnalysisData', () => { ); }); + it('walks a second availability page when the first does not cover the window', async () => { + const page = (offset: number, total: number): unknown => ({ + feed_id: 'mdb-1', + total, + offset, + limit: 100, + checks: Array.from({ length: 100 }, (_, index) => ({ + checked_at: `2026-09-08T04:00:0${index % 10}Z`, + success: true, + })), + }); + mockGetGtfsFeedAvailability + .mockResolvedValueOnce(page(0, 150)) + .mockResolvedValueOnce({ ...(page(100, 150) as object), checks: [] }); + + const result = await fetchGuestSealAnalysisData('gtfs', 'mdb-1'); + + expect(mockGetGtfsFeedAvailability).toHaveBeenCalledTimes(2); + expect(mockGetGtfsFeedAvailability).toHaveBeenLastCalledWith( + 'mdb-1', + 'guest-token', + expect.objectContaining({ offset: 100 }), + undefined, + ); + expect(result?.availability?.checks).toHaveLength(100); + }); + + it('stops at the page cap rather than walking the whole history', async () => { + mockGetGtfsFeedAvailability.mockResolvedValue({ + feed_id: 'mdb-1', + total: 5000, + offset: 0, + limit: 100, + checks: Array.from({ length: 100 }, () => check), + }); + + const result = await fetchGuestSealAnalysisData('gtfs', 'mdb-1'); + + expect(mockGetGtfsFeedAvailability).toHaveBeenCalledTimes(2); + expect(result?.availability?.checks).toHaveLength(200); + }); + it('discards the whole entry when the reliability call fails', async () => { mockGetGtfsFeedReliability.mockRejectedValue(new Error('network error')); 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 3c8ccd3a..150d606e 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 @@ -24,6 +24,7 @@ import { getRemoteConfigValues } from '../../../../../../lib/remote-config.serve type ReliabilityReport = components['schemas']['FeedReliabilityReport']; type AvailabilityResponse = components['schemas']['GtfsFeedAvailabilityResponse']; +type AvailabilityCheck = components['schemas']['GtfsFeedAvailabilityCheck']; type ContinuousCoverageResponse = components['schemas']['GtfsFeedContinuousCoverageResponse']; @@ -33,13 +34,79 @@ type ContinuousCoverageResponse = export const SEAL_ANALYSIS_REVALIDATE = 21600; /** - * Both history endpoints are paginated with a maximum of 100 items. We take - * the newest page, which is what a breakdown UI needs; revisit this if the - * design calls for a specific time window (both endpoints also accept - * date-range filters) rather than "the most recent N". + * Both history endpoints are paginated with a maximum of 100 items. + * + * Continuous coverage takes the newest page, which is what a breakdown UI + * needs. Availability instead asks for a fixed window - the heatmap draws six + * months of daily checks, which is more than one page holds - so it pages. */ const HISTORY_LIMIT = 100; +/** + * How far back the availability heatmap looks. Kept in step with + * AVAILABILITY_HISTORY_MONTHS in screens/Feed/lib/availability-history.ts, + * which decides how much of it is drawn. + */ +const AVAILABILITY_HISTORY_MONTHS = 6; + +/** + * Daily checks over six months are ~183 items, so two pages cover the window + * with room to spare. The cap keeps a feed checked more than once a day from + * turning one render into an unbounded page walk. + */ +const AVAILABILITY_MAX_PAGES = 2; + +/** + * The newest checks going back `AVAILABILITY_HISTORY_MONTHS`, flattened into + * one response. Sorted newest-first so that a feed checked often enough to + * overflow the page cap keeps the days the heatmap actually draws. + */ +async function fetchAvailabilityHistory( + feedId: string, + accessToken: string, + userContextJwt: string | undefined, + now: Date, +): Promise { + const from = new Date( + Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth() - AVAILABILITY_HISTORY_MONTHS, + now.getUTCDate(), + ), + ).toISOString(); + + let firstPage: AvailabilityResponse | undefined; + const checks: AvailabilityCheck[] = []; + + for (let page = 0; page < AVAILABILITY_MAX_PAGES; page++) { + const response = await getGtfsFeedAvailability( + feedId, + accessToken, + { + from, + limit: HISTORY_LIMIT, + offset: page * HISTORY_LIMIT, + // Passed explicitly because the OpenAPI spec contradicts itself on the + // default ordering of `checks`. + sort: 'desc', + }, + userContextJwt, + ); + if (response == undefined) { + break; + } + firstPage ??= response; + checks.push(...response.checks); + if (checks.length >= response.total || response.checks.length === 0) { + break; + } + } + + return firstPage == undefined + ? undefined + : { ...firstPage, offset: 0, limit: checks.length, checks }; +} + export interface SealAnalysisData { reliability?: ReliabilityReport; availability?: AvailabilityResponse; @@ -68,14 +135,7 @@ async function fetchSealAnalysisImpl( const [reliabilityResult, availabilityResult, coverageResult] = await Promise.allSettled([ getGtfsFeedReliability(feedId, accessToken, userContextJwt), - getGtfsFeedAvailability( - feedId, - accessToken, - // Passed explicitly because the OpenAPI spec contradicts itself on the - // default ordering of `checks`. - { limit: HISTORY_LIMIT, sort: 'desc' }, - userContextJwt, - ), + fetchAvailabilityHistory(feedId, accessToken, userContextJwt, new Date()), getGtfsFeedContinuousCoverage( feedId, accessToken, diff --git a/src/app/screens/Feed/lib/availability-history.spec.ts b/src/app/screens/Feed/lib/availability-history.spec.ts new file mode 100644 index 00000000..3bb245dc --- /dev/null +++ b/src/app/screens/Feed/lib/availability-history.spec.ts @@ -0,0 +1,289 @@ +import { + AVAILABILITY_GRACE_DAYS, + buildAvailabilityCalendar, + getAvailabilitySummary, +} from './availability-history'; +import { type components } from '../../../services/feeds/types'; + +type AvailabilityCheck = components['schemas']['GtfsFeedAvailabilityCheck']; +type ReliabilityCriterion = components['schemas']['ReliabilityCriterion']; + +// A Wednesday, so the leading/trailing padding of the week columns is +// non-trivial in both directions. +const NOW = new Date('2026-09-09T12:00:00Z'); + +function check( + date: string, + success = true, + time = '04:00:00', +): AvailabilityCheck { + return { + checked_at: `${date}T${time}Z`, + success, + request_method: 'HEAD', + }; +} + +function criterion( + overrides: Partial = {}, +): ReliabilityCriterion { + return { + criterion: 'available', + status: 'pass', + in_grace_period: false, + on_probation: false, + ...overrides, + }; +} + +describe('buildAvailabilityCalendar', () => { + it('covers the whole window, one entry per day, oldest first', () => { + const calendar = buildAvailabilityCalendar([], { now: NOW, months: 1 }); + + expect(calendar.days[0].date).toBe('2026-08-10'); + expect(calendar.days[calendar.days.length - 1].date).toBe('2026-09-09'); + expect(calendar.days).toHaveLength(31); + }); + + it('marks checked days by outcome and leaves the rest unchecked', () => { + const calendar = buildAvailabilityCalendar( + [check('2026-09-07'), check('2026-09-08', false)], + { now: NOW, months: 1 }, + ); + + expect(calendar.successCount).toBe(1); + expect(calendar.failureCount).toBe(1); + expect(calendar.uncheckedCount).toBe(29); + expect(calendar.days.find((d) => d.date === '2026-09-07')?.status).toBe( + 'success', + ); + expect(calendar.days.find((d) => d.date === '2026-09-08')?.status).toBe( + 'failure', + ); + expect(calendar.days.find((d) => d.date === '2026-09-09')?.status).toBe( + 'unchecked', + ); + }); + + it('counts a day as failed when any of its checks failed', () => { + const calendar = buildAvailabilityCalendar( + [ + check('2026-09-08', true, '04:00:00'), + check('2026-09-08', false, '16:00:00'), + ], + { now: NOW, months: 1 }, + ); + + const day = calendar.days.find((d) => d.date === '2026-09-08'); + expect(day?.status).toBe('failure'); + expect(day?.checkCount).toBe(2); + expect(calendar.failureCount).toBe(1); + }); + + it('ignores checks from before the window and unparseable timestamps', () => { + const calendar = buildAvailabilityCalendar( + [ + check('2026-01-01', false), + { checked_at: 'nonsense', success: false, request_method: 'HEAD' }, + ], + { now: NOW, months: 1 }, + ); + + expect(calendar.failureCount).toBe(0); + expect(calendar.uncheckedCount).toBe(31); + }); + + it('reports uptime over checked days only, so gaps are not downtime', () => { + const calendar = buildAvailabilityCalendar( + [check('2026-09-06'), check('2026-09-07'), check('2026-09-08', false)], + { now: NOW, months: 1 }, + ); + + expect(calendar.uptimePercent).toBeCloseTo((2 / 3) * 100); + }); + + it('has no uptime figure when nothing was ever checked', () => { + expect( + buildAvailabilityCalendar([], { now: NOW, months: 1 }).uptimePercent, + ).toBeUndefined(); + }); + + it('reports the most recent failure in the window', () => { + const calendar = buildAvailabilityCalendar( + [check('2026-08-20', false), check('2026-09-02', false)], + { now: NOW, months: 1 }, + ); + + expect(calendar.lastFailureDate).toBe('2026-09-02'); + }); + + it('lays days out as Sunday-first week columns, padded at both ends', () => { + const calendar = buildAvailabilityCalendar([], { now: NOW, months: 1 }); + + // Aug 10 2026 is a Monday, so Sunday of that column is blank. + expect(calendar.weeks[0][0]).toBeNull(); + expect(calendar.weeks[0][1]?.date).toBe('2026-08-10'); + expect(calendar.weeks.every((week) => week.length === 7)).toBe(true); + // Sep 9 is a Wednesday, so the final column is blank from Thursday on. + const lastWeek = calendar.weeks[calendar.weeks.length - 1]; + expect(lastWeek[3]?.date).toBe('2026-09-09'); + expect(lastWeek[4]).toBeNull(); + }); + + it('labels each month over the column its first day starts', () => { + const calendar = buildAvailabilityCalendar([], { now: NOW, months: 1 }); + + expect(calendar.monthLabels).toEqual([ + { columnIndex: 0, date: '2026-08-10' }, + { columnIndex: 3, date: '2026-09-01' }, + ]); + }); + + it('drops a sliver of a month rather than crowding the next label', () => { + // A 6-month window ending Jun 30 opens on Wednesday Dec 31, so December + // holds three cells of the first column and January takes it over. + const calendar = buildAvailabilityCalendar([], { + now: new Date('2026-06-30T12:00:00Z'), + }); + + expect(calendar.days[0].date).toBe('2025-12-31'); + expect(calendar.monthLabels[0]).toEqual({ + columnIndex: 0, + date: '2026-01-01', + }); + }); +}); + +describe('getAvailabilitySummary', () => { + const calendar = buildAvailabilityCalendar( + [check('2026-09-07'), check('2026-09-08', false)], + { now: NOW, months: 1 }, + ); + + it('counts the recovered failures while the criterion passes', () => { + expect(getAvailabilitySummary(criterion(), calendar, NOW)).toEqual({ + key: 'sealAvailabilityRecovered', + values: { + count: 1, + graceDays: AVAILABILITY_GRACE_DAYS, + date: 'Sep 8, 2026', + }, + }); + }); + + it('says so plainly when nothing failed', () => { + const clean = buildAvailabilityCalendar([check('2026-09-07')], { + now: NOW, + months: 1, + }); + + expect(getAvailabilitySummary(criterion(), clean, NOW).key).toBe( + 'sealAvailabilityNoFailures', + ); + }); + + it('counts down the grace period while the feed is at risk', () => { + const summary = getAvailabilitySummary( + criterion({ + status: 'fail', + in_grace_period: true, + grace_period_ends_at: '2026-09-20T00:00:00Z', + first_failure_at: '2026-09-06T04:00:00Z', + }), + calendar, + NOW, + ); + + // The day count drives the deadline notice, not the sentence. + expect(summary).toEqual({ + key: 'sealAvailabilityAtRisk', + values: { date: 'Sep 6, 2026' }, + graceDaysLeft: 11, + }); + }); + + it('reads as a spent grace window once the failure is confirmed', () => { + expect( + getAvailabilitySummary( + criterion({ status: 'fail', first_failure_at: '2026-08-01T04:00:00Z' }), + calendar, + NOW, + ).key, + ).toBe('sealAvailabilityFailing'); + }); + + it('names the error probation is being served for', () => { + expect( + getAvailabilitySummary( + criterion({ + on_probation: true, + last_failure_at: '2026-07-02T04:00:00Z', + }), + calendar, + NOW, + ), + ).toEqual({ + key: 'sealAvailabilityProbation', + values: { date: 'Jul 2, 2026' }, + }); + }); + + it('drops the date when no last failure was kept', () => { + expect( + getAvailabilitySummary(criterion({ on_probation: true }), calendar, NOW) + .key, + ).toBe('sealAvailabilityProbationUndated'); + }); + + it('reads as no data when the criterion passes but nothing was checked', () => { + const empty = buildAvailabilityCalendar([], { now: NOW, months: 1 }); + + expect(getAvailabilitySummary(criterion(), empty, NOW).key).toBe( + 'sealAvailabilityNoData', + ); + }); + + it('reads as no data when the criterion was never evaluated', () => { + expect( + getAvailabilitySummary( + criterion({ status: 'never_evaluated' }), + calendar, + NOW, + ).key, + ).toBe('sealAvailabilityNoData'); + }); + + it('withdraws the criterion when it does not apply', () => { + expect( + getAvailabilitySummary( + criterion({ status: 'not_applicable' }), + calendar, + NOW, + ).key, + ).toBe('sealAvailabilityNotApplicable'); + }); + + it('floors an elapsed grace deadline at zero rather than counting backwards', () => { + expect( + getAvailabilitySummary( + criterion({ + status: 'fail', + in_grace_period: true, + grace_period_ends_at: '2026-08-01T00:00:00Z', + }), + calendar, + NOW, + ).graceDaysLeft, + ).toBe(0); + }); + + it('leaves the deadline out of every state but the grace period', () => { + expect( + getAvailabilitySummary(criterion(), calendar, NOW).graceDaysLeft, + ).toBeUndefined(); + expect( + getAvailabilitySummary(criterion({ status: 'fail' }), calendar, NOW) + .graceDaysLeft, + ).toBeUndefined(); + }); +}); diff --git a/src/app/screens/Feed/lib/availability-history.ts b/src/app/screens/Feed/lib/availability-history.ts new file mode 100644 index 00000000..df1756c9 --- /dev/null +++ b/src/app/screens/Feed/lib/availability-history.ts @@ -0,0 +1,293 @@ +/** + * Turns the raw availability history into the day grid, counts and summary + * wording the Available criterion renders. + * + * Everything here is pure and UTC-based: the API timestamps are UTC, and + * bucketing by the UTC calendar day keeps the grid identical whatever the + * server's zone is. + */ + +import { type components } from '../../../services/feeds/types'; +import { + getCriterionDisplayStatus, + getDaysUntil, +} from '../../../constants/sealCriteria'; +import { formatDateShort } from '../../../utils/date'; + +type AvailabilityCheck = components['schemas']['GtfsFeedAvailabilityCheck']; +type ReliabilityCriterion = components['schemas']['ReliabilityCriterion']; + +/** Days producers get to restore access before Available costs the seal. */ +export const AVAILABILITY_GRACE_DAYS = 14; + +/** How far back the heatmap looks. Mirrors the fetch window. */ +export const AVAILABILITY_HISTORY_MONTHS = 6; + +/** + * A day never checked is neither a success nor a failure - the nightly job + * simply produced nothing for it - so it gets its own state rather than being + * folded into either count. + */ +export type AvailabilityDayStatus = 'success' | 'failure' | 'unchecked'; + +export interface AvailabilityDay { + /** yyyy-MM-dd, UTC. */ + date: string; + status: AvailabilityDayStatus; + /** How many checks ran that day. */ + checkCount: number; +} + +export interface AvailabilityMonthLabel { + /** Index into `weeks` of the column the label sits above. */ + columnIndex: number; + /** First day of the month inside the window, as yyyy-MM-dd. */ + date: string; +} + +export interface AvailabilityCalendar { + /** Oldest day first. */ + days: AvailabilityDay[]; + /** + * Columns oldest first, each 7 cells Sunday..Saturday. `null` pads the + * partial first and last weeks so every column is the same height. + */ + weeks: Array>; + monthLabels: AvailabilityMonthLabel[]; + successCount: number; + failureCount: number; + uncheckedCount: number; + /** Share of *checked* days that succeeded, 0-100. Undefined when none were. */ + uptimePercent?: number; + /** Most recent failed day in the window, as yyyy-MM-dd. */ + lastFailureDate?: string; +} + +function toUtcDayKey(date: Date): string { + return date.toISOString().slice(0, 10); +} + +/** + * One entry per calendar day in the window, ending on `now`'s UTC day. A day + * counts as a failure as soon as one of its checks failed - a feed that came + * back up later the same day still had an outage. + */ +export function buildAvailabilityCalendar( + checks: AvailabilityCheck[] = [], + { + now = new Date(), + months = AVAILABILITY_HISTORY_MONTHS, + }: { now?: Date; months?: number } = {}, +): AvailabilityCalendar { + const end = new Date(`${toUtcDayKey(now)}T00:00:00Z`); + // All-UTC arithmetic - date-fns works in local time, so a DST boundary + // inside the window could shift the first column by a day. + // The +1 makes the window exactly `months` long, inclusive of both ends. + const start = new Date( + Date.UTC( + end.getUTCFullYear(), + end.getUTCMonth() - months, + end.getUTCDate() + 1, + ), + ); + + const byDay = new Map(); + for (const check of checks) { + const checkedAt = new Date(check.checked_at); + if (isNaN(checkedAt.getTime()) || checkedAt < start) { + continue; + } + const key = toUtcDayKey(checkedAt); + const entry = byDay.get(key) ?? { failed: false, checkCount: 0 }; + entry.checkCount += 1; + entry.failed = entry.failed || !check.success; + byDay.set(key, entry); + } + + const days: AvailabilityDay[] = []; + for ( + let cursor = new Date(start); + cursor <= end; + cursor = new Date(cursor.getTime() + 86400000) + ) { + const date = toUtcDayKey(cursor); + const entry = byDay.get(date); + days.push({ + date, + checkCount: entry?.checkCount ?? 0, + status: + entry == undefined ? 'unchecked' : entry.failed ? 'failure' : 'success', + }); + } + + const successCount = days.filter((d) => d.status === 'success').length; + const failureCount = days.filter((d) => d.status === 'failure').length; + const uncheckedCount = days.length - successCount - failureCount; + const checkedCount = successCount + failureCount; + + return { + days, + weeks: buildWeeks(days), + monthLabels: buildMonthLabels(days), + successCount, + failureCount, + uncheckedCount, + uptimePercent: + checkedCount === 0 ? undefined : (successCount / checkedCount) * 100, + lastFailureDate: days.findLast((d) => d.status === 'failure')?.date, + }; +} + +/** GitHub-contribution layout: one column per week, Sunday at the top. */ +function buildWeeks( + days: AvailabilityDay[], +): Array> { + if (days.length === 0) { + return []; + } + const weeks: Array> = []; + const leadingBlanks = new Date(`${days[0].date}T00:00:00Z`).getUTCDay(); + let week: Array = Array(leadingBlanks).fill(null); + + for (const day of days) { + week.push(day); + if (week.length === 7) { + weeks.push(week); + week = []; + } + } + if (week.length > 0) { + weeks.push([...week, ...Array(7 - week.length).fill(null)]); + } + return weeks; +} + +/** + * A label needs roughly this many columns to itself before the next one, or + * the two month names run together. + */ +const MIN_LABEL_COLUMN_GAP = 3; + +/** One label per month, over the column holding that month's first day. */ +function buildMonthLabels(days: AvailabilityDay[]): AvailabilityMonthLabel[] { + const labels: AvailabilityMonthLabel[] = []; + let seenMonth: string | undefined; + let columnIndex = 0; + let slot = + days.length > 0 ? new Date(`${days[0].date}T00:00:00Z`).getUTCDay() : 0; + + for (const day of days) { + const month = day.date.slice(0, 7); + if (month !== seenMonth) { + seenMonth = month; + labels.push({ columnIndex, date: day.date }); + } + slot += 1; + if (slot === 7) { + slot = 0; + columnIndex += 1; + } + } + // The window rarely starts on the 1st, so its first month is usually a + // sliver whose label would collide with the next one. Where two labels are + // too close, the later - fuller - month is the one worth naming. + const spaced: AvailabilityMonthLabel[] = []; + for (const label of labels) { + const previous = spaced[spaced.length - 1]; + if ( + previous != undefined && + label.columnIndex - previous.columnIndex < MIN_LABEL_COLUMN_GAP + ) { + spaced[spaced.length - 1] = label; + continue; + } + spaced.push(label); + } + return spaced; +} + +/** + * Which sentence the Available body leads with, as a key in the `feeds` + * namespace plus its interpolation values. Mirrors the criterion's display + * status so grace period and probation read as themselves rather than as a + * bare failure. + */ +export interface AvailabilitySummary { + key: string; + values: Record; + /** + * Days left in the grace period, when one is running. Drives the deadline + * notice; absent for every other state. + */ + graceDaysLeft?: number; +} + +export function getAvailabilitySummary( + criterion: ReliabilityCriterion, + calendar: AvailabilityCalendar, + now: Date = new Date(), +): AvailabilitySummary { + const displayStatus = getCriterionDisplayStatus(criterion); + const graceDays = AVAILABILITY_GRACE_DAYS; + + if (displayStatus === 'notApplicable') { + return { key: 'sealAvailabilityNotApplicable', values: {} }; + } + // `last_failure_at` is what probation is being served for, and the API + // keeps it even once the criterion passes again - but not always, so the + // wording falls back to one that names no date. + if (displayStatus === 'probation') { + return criterion.last_failure_at != null + ? { + key: 'sealAvailabilityProbation', + values: { date: formatDateShort(criterion.last_failure_at) }, + } + : { key: 'sealAvailabilityProbationUndated', values: {} }; + } + // The countdown itself lives in the deadline notice, so the sentence only + // has to say since when. + if (displayStatus === 'atRisk') { + return { + key: 'sealAvailabilityAtRisk', + values: { date: formatFailureDate(criterion.first_failure_at) }, + graceDaysLeft: + criterion.grace_period_ends_at != null + ? getDaysUntil(criterion.grace_period_ends_at, now) + : 0, + }; + } + if (displayStatus === 'fail') { + return { + key: 'sealAvailabilityFailing', + values: { + months: AVAILABILITY_HISTORY_MONTHS, + date: formatFailureDate(criterion.first_failure_at), + }, + }; + } + if ( + displayStatus === 'notEvaluated' || + calendar.successCount + calendar.failureCount === 0 + ) { + return { key: 'sealAvailabilityNoData', values: {} }; + } + if (calendar.failureCount === 0) { + return { key: 'sealAvailabilityNoFailures', values: {} }; + } + return { + key: 'sealAvailabilityRecovered', + values: { + count: calendar.failureCount, + graceDays, + date: + calendar.lastFailureDate != undefined + ? formatDateShort(calendar.lastFailureDate) + : '', + }, + }; +} + +/** `first_failure_at` is cleared once a feed recovers, so it can be absent. */ +function formatFailureDate(date?: string | null): string { + return date == null ? '' : formatDateShort(date); +} From 106f4a8626d3296816520e18255d3dc21d83f48b Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 10 Sep 2026 13:07:41 -0400 Subject: [PATCH 10/28] language keys --- messages/en.json | 40 +++++++++++++++++++++++++++++++++++++++- messages/fr.json | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/messages/en.json b/messages/en.json index f40abd53..07232444 100644 --- a/messages/en.json +++ b/messages/en.json @@ -272,7 +272,9 @@ "sealCriterionPass": "Passing", "sealCriterionFail": "Failing", "sealCriterionInGracePeriod": "At Risk", + "sealCriterionDaysLeftChip": "{days, plural, one {# day} other {# days}} left", "sealCriterionGracePeriodNote": "In grace period until {date}.", + "sealCriterionProbationNote": "On probation until {date}.", "sealCriterionNotApplicable": "Not Applicable", "sealCriterionNotEvaluated": "Not Evaluated", "sealCriterionOnProbation": "On Probation", @@ -321,6 +323,42 @@ "sealEarnedAt": "Seal of Reliability earned", "sealLostAt": "Seal of Reliability lost", "sealEvaluatedAt": "Seal of Reliability last evaluated", + "sealAvailabilityIntro": "Daily fetch results over the last {months, plural, one {# month} other {# months}}.", + "sealAvailabilityNoFailures": "Every fetch succeeded.", + "sealAvailabilityRecovered": "{count, plural, one {# failed fetch} other {# failed fetches}} (last on {date}) recovered within the {graceDays}-day grace window.", + "sealAvailabilityAtRisk": "The feed has been unreachable since {date}.", + "sealAvailabilityGraceTitle": "{days, plural, one {# day} other {# days}} left to restore access", + "sealAvailabilityGraceDescription": "Restore access to the feed before the grace period ends to keep the Seal.", + "sealAvailabilityFailing": "The feed has been unreachable since {date}. Assure this feed is available consistently for {months, plural, one {# month} other {# months}} to pass this criterion.", + "sealAvailabilityProbation": "Fetches are succeeding again. This criterion is rebuilding its six-month record after its last recorded error on {date}.", + "sealAvailabilityProbationUndated": "Fetches are succeeding again. This criterion is rebuilding its six-month record after a confirmed failure.", + "sealAvailabilityNotApplicable": "Daily availability checks don't apply to this feed.", + "sealAvailabilityNoData": "No availability checks have been recorded for this feed yet.", + "sealAvailabilitySuccessfulDays": "{count, plural, one {# successful day} other {# successful days}}", + "sealAvailabilityFailedDays": "{count, plural, one {# failed day} other {# failed days}}", + "sealAvailabilityUncheckedDays": "{count, plural, one {# day not checked} other {# days not checked}}", + "sealAvailabilityUptime": "{percent}% uptime", + "sealAvailabilityHeatmapLabel": "Daily fetch results: {success} successful days and {failed} failed days.", + "sealAvailabilityDaySuccess": "{date}: fetch succeeded", + "sealAvailabilityDayFailure": "{date}: fetch failed", + "sealAvailabilityDayUnchecked": "{date}: not checked", + "sealCompliantNoErrorsSubtitle": "No validation errors", + "sealCompliantHasErrorsSubtitle": "Validation errors found", + "sealCompliantNoReportSubtitle": "No validation report available", + "sealCompliantNotEvaluatedSubtitle": "Not evaluated yet", + "sealCompliantNotApplicableSubtitle": "Not applicable to this feed", + "sealCompliantPassing": "The latest dataset was validated on {date} with no errors.", + "sealCompliantPassingUndated": "The latest dataset validates with no errors.", + "sealCompliantAtRisk": "The latest validation report has {count, plural, one {# error} other {# errors}}.", + "sealCompliantGraceTitle": "{days, plural, one {# day} other {# days}} left to resolve errors", + "sealCompliantGraceDescription": "Resolve these errors before the grace period ends to keep the Seal.", + "sealCompliantFailing": "The latest validation report has {count, plural, one {# error} other {# errors}}, resolve these validation errors to pass this criterion.", + "sealCompliantProbation": "The latest dataset validates with no errors. This criterion is rebuilding its six-month record after its last recorded error on {date}.", + "sealCompliantProbationUndated": "The latest dataset validates with no errors. This criterion is rebuilding its six-month record after a confirmed failure.", + "sealCompliantNotApplicable": "Validation doesn't apply to this feed.", + "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", "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.", @@ -678,7 +716,7 @@ "shortTitle": "Official", "notAuthorizedSubtitle": "Not authorized by the transit agency", "notAuthorizedDescription": "This feed is created by an unaffiliated community member rather than the transit agency, making it unofficial.", - "subtitle": "Authorized by the transit agency.", + "subtitle": "Authorized by the transit agency", "description": "The feed has been confirmed as an official source, published by or on behalf of the transit agency." }, "stable": { diff --git a/messages/fr.json b/messages/fr.json index fd15f3fd..3e61ecce 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -272,7 +272,9 @@ "sealCriterionPass": "Réussi", "sealCriterionFail": "Échoué", "sealCriterionInGracePeriod": "À risque", + "sealCriterionDaysLeftChip": "{days, plural, one {# jour restant} other {# jours restants}}", "sealCriterionGracePeriodNote": "En délai de grâce jusqu'au {date}.", + "sealCriterionProbationNote": "En probation jusqu'au {date}.", "sealCriterionNotApplicable": "Non applicable", "sealCriterionNotEvaluated": "Non évalué", "sealCriterionOnProbation": "En probation", @@ -321,6 +323,42 @@ "sealEarnedAt": "Seal of Reliability earned", "sealLostAt": "Seal of Reliability lost", "sealEvaluatedAt": "Seal of Reliability last evaluated", + "sealAvailabilityIntro": "Résultats des récupérations quotidiennes des {months, plural, one {# dernier mois} other {# derniers mois}}.", + "sealAvailabilityNoFailures": "Toutes les récupérations ont réussi.", + "sealAvailabilityRecovered": "{count, plural, one {# récupération a échoué} other {# récupérations ont échoué}} (la dernière le {date}) et le problème a été corrigé dans le délai de grâce de {graceDays} jours.", + "sealAvailabilityAtRisk": "Le flux est inaccessible depuis le {date}.", + "sealAvailabilityGraceTitle": "Il reste {days, plural, one {# jour} other {# jours}} pour rétablir l'accès", + "sealAvailabilityGraceDescription": "Rétablissez l'accès au flux avant la fin du délai de grâce pour conserver le Sceau.", + "sealAvailabilityFailing": "Le flux est inaccessible depuis le {date}. Assurez la disponibilité de ce flux de manière constante pendant {months, plural, one {# mois} other {# mois}} pour valider ce critère.", + "sealAvailabilityProbation": "Les récupérations réussissent de nouveau. Ce critère reconstruit son historique de six mois après sa dernière erreur enregistrée le {date}.", + "sealAvailabilityProbationUndated": "Les récupérations réussissent de nouveau. Ce critère reconstruit son historique de six mois après un échec confirmé.", + "sealAvailabilityNotApplicable": "Les vérifications quotidiennes de disponibilité ne s'appliquent pas à ce flux.", + "sealAvailabilityNoData": "Aucune vérification de disponibilité n'a encore été enregistrée pour ce flux.", + "sealAvailabilitySuccessfulDays": "{count, plural, one {# jour réussi} other {# jours réussis}}", + "sealAvailabilityFailedDays": "{count, plural, one {# jour en échec} other {# jours en échec}}", + "sealAvailabilityUncheckedDays": "{count, plural, one {# jour non vérifié} other {# jours non vérifiés}}", + "sealAvailabilityUptime": "{percent} % de disponibilité", + "sealAvailabilityHeatmapLabel": "Résultats des récupérations quotidiennes : {success} jours réussis et {failed} jours en échec.", + "sealAvailabilityDaySuccess": "{date} : récupération réussie", + "sealAvailabilityDayFailure": "{date} : récupération en échec", + "sealAvailabilityDayUnchecked": "{date} : non vérifié", + "sealCompliantNoErrorsSubtitle": "Aucune erreur de validation", + "sealCompliantHasErrorsSubtitle": "Erreurs de validation détectées", + "sealCompliantNoReportSubtitle": "Aucun rapport de validation disponible", + "sealCompliantNotEvaluatedSubtitle": "Pas encore évalué", + "sealCompliantNotApplicableSubtitle": "Non applicable à ce flux", + "sealCompliantPassing": "Le dernier jeu de données a été validé le {date} sans erreur.", + "sealCompliantPassingUndated": "Le dernier jeu de données est validé sans erreur.", + "sealCompliantAtRisk": "Le dernier rapport de validation comporte {count, plural, one {# erreur} other {# erreurs}}.", + "sealCompliantGraceTitle": "Il reste {days, plural, one {# jour} other {# jours}} pour corriger les erreurs", + "sealCompliantGraceDescription": "Corrigez ces erreurs avant la fin du délai de grâce pour conserver le Sceau.", + "sealCompliantFailing": "Le dernier rapport de validation comporte {count, plural, one {# erreur} other {# erreurs}}, corrigez ces erreurs de validation pour valider ce critère.", + "sealCompliantProbation": "Le dernier jeu de données est validé sans erreur. Ce critère reconstruit son historique de six mois après sa dernière erreur enregistrée le {date}.", + "sealCompliantProbationUndated": "Le dernier jeu de données est validé sans erreur. Ce critère reconstruit son historique de six mois après un échec confirmé.", + "sealCompliantNotApplicable": "La validation ne s'applique pas à ce flux.", + "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", "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.", From fe9ae7cdbfc691b39d64b4f2a2290f2a62b1874c Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 10 Sep 2026 14:06:46 -0400 Subject: [PATCH 11/28] stronger error handling from availability --- messages/en.json | 2 + messages/fr.json | 2 + .../[feedId]/lib/seal-analysis-data.spec.ts | 58 ++++--- .../[feedId]/lib/seal-analysis-data.ts | 145 +++++++++++------- .../components/AvailabilityCriterionBody.tsx | 17 +- .../Feed/components/FeedReliabilityView.tsx | 1 + .../Feed/lib/availability-history.spec.ts | 8 + .../screens/Feed/lib/availability-history.ts | 8 +- 8 files changed, 162 insertions(+), 79 deletions(-) diff --git a/messages/en.json b/messages/en.json index 07232444..8bc79e8a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -334,6 +334,8 @@ "sealAvailabilityProbationUndated": "Fetches are succeeding again. This criterion is rebuilding its six-month record after a confirmed failure.", "sealAvailabilityNotApplicable": "Daily availability checks don't apply to this feed.", "sealAvailabilityNoData": "No availability checks have been recorded for this feed yet.", + "sealAvailabilityError": "Availability history could not be loaded right now.", + "sealAvailabilityErrorDescription": "We couldn't load this feed's daily fetch history. Please try again later.", "sealAvailabilitySuccessfulDays": "{count, plural, one {# successful day} other {# successful days}}", "sealAvailabilityFailedDays": "{count, plural, one {# failed day} other {# failed days}}", "sealAvailabilityUncheckedDays": "{count, plural, one {# day not checked} other {# days not checked}}", diff --git a/messages/fr.json b/messages/fr.json index 3e61ecce..798b580a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -334,6 +334,8 @@ "sealAvailabilityProbationUndated": "Les récupérations réussissent de nouveau. Ce critère reconstruit son historique de six mois après un échec confirmé.", "sealAvailabilityNotApplicable": "Les vérifications quotidiennes de disponibilité ne s'appliquent pas à ce flux.", "sealAvailabilityNoData": "Aucune vérification de disponibilité n'a encore été enregistrée pour ce flux.", + "sealAvailabilityError": "L'historique de disponibilité n'a pas pu être chargé pour le moment.", + "sealAvailabilityErrorDescription": "Nous n'avons pas pu charger l'historique des récupérations quotidiennes de ce flux. Veuillez réessayer plus tard.", "sealAvailabilitySuccessfulDays": "{count, plural, one {# jour réussi} other {# jours réussis}}", "sealAvailabilityFailedDays": "{count, plural, one {# jour en échec} other {# jours en échec}}", "sealAvailabilityUncheckedDays": "{count, plural, one {# jour non vérifié} other {# jours non vérifiés}}", 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 0d7e96cc..2c314c09 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 @@ -81,21 +81,29 @@ describe('fetchGuestSealAnalysisData', () => { availability: flattenedAvailability, continuousCoverage: coverage, reliabilityError: false, + availabilityError: false, }); }); - it('caches on the feed id alone, with a 6 hour TTL', async () => { + it('caches each endpoint separately on the feed id alone, with a 6 hour TTL', async () => { await fetchGuestSealAnalysisData('gtfs', 'mdb-1'); expect(SEAL_ANALYSIS_REVALIDATE).toBe(21600); - // Key excludes the caller so guest and authed share one entry. - expect(mockUnstableCache).toHaveBeenCalledWith( - ['seal-analysis-mdb-1'], - expect.objectContaining({ - revalidate: 21600, - tags: ['feed-mdb-1', 'seal-analysis'], - }), - ); + // Keys exclude the caller so guest and authed share the same entries. + expect(mockUnstableCache).toHaveBeenCalledTimes(3); + for (const key of [ + 'seal-analysis-reliability-mdb-1', + 'seal-analysis-availability-mdb-1', + 'seal-analysis-coverage-mdb-1', + ]) { + expect(mockUnstableCache).toHaveBeenCalledWith( + [key], + expect.objectContaining({ + revalidate: 21600, + tags: ['feed-mdb-1', 'seal-analysis'], + }), + ); + } }); it('requests six months of availability and the newest coverage page', async () => { @@ -162,32 +170,44 @@ describe('fetchGuestSealAnalysisData', () => { expect(result?.availability?.checks).toHaveLength(200); }); - it('discards the whole entry when the reliability call fails', async () => { + it('flags reliabilityError without discarding the other endpoints', async () => { mockGetGtfsFeedReliability.mockRejectedValue(new Error('network error')); const result = await fetchGuestSealAnalysisData('gtfs', 'mdb-1'); expect(result?.reliabilityError).toBe(true); expect(result?.reliability).toBeUndefined(); - // The loader throws inside unstable_cache so a transient failure isn't - // held for the 6 hour TTL, and the rescue rebuilds the result from - // nothing - so the history that did come back goes with it. Both seal - // pages throw to their error boundary on reliabilityError, so none of it - // would have rendered anyway. - expect(result?.availability).toBeUndefined(); - expect(result?.continuousCoverage).toBeUndefined(); + // Each endpoint has its own cache entry, so a failed reliability call + // isn't held for the 6 hour TTL, and it doesn't take the sibling + // endpoints' successful, independently-cached results down with it. Both + // seal pages still throw to their error boundary on reliabilityError + // regardless, so none of this would render anyway. + expect(result?.availability).toEqual(flattenedAvailability); + expect(result?.continuousCoverage).toEqual(coverage); }); - it('degrades a failed history call without flagging reliabilityError', async () => { + it('flags availabilityError without discarding reliability or coverage', async () => { mockGetGtfsFeedAvailability.mockRejectedValue(new Error('boom')); - mockGetGtfsFeedContinuousCoverage.mockRejectedValue(new Error('boom')); const result = await fetchGuestSealAnalysisData('gtfs', 'mdb-1'); expect(result?.availability).toBeUndefined(); + expect(result?.availabilityError).toBe(true); + expect(result?.continuousCoverage).toEqual(coverage); + expect(result?.reliabilityError).toBe(false); + expect(result?.reliability).toEqual(report); + }); + + it('degrades a failed continuous-coverage call without flagging any error', async () => { + mockGetGtfsFeedContinuousCoverage.mockRejectedValue(new Error('boom')); + + const result = await fetchGuestSealAnalysisData('gtfs', 'mdb-1'); + expect(result?.continuousCoverage).toBeUndefined(); expect(result?.reliabilityError).toBe(false); + expect(result?.availabilityError).toBe(false); expect(result?.reliability).toEqual(report); + expect(result?.availability).toEqual(flattenedAvailability); }); it('fetches nothing when the seal feature flag is off', async () => { 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 150d606e..a5ed0e78 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 @@ -1,8 +1,9 @@ /** * Seal of Reliability data fetching for the dedicated seal-of-reliability * - * The cache key is the feed id alone, so a single entry is shared across - * requests and users, guest and authenticated alike. + * Each of the three endpoints below has its own cache entry, keyed by feed id + * alone, so each is shared across requests and users, guest and authenticated + * alike. */ import 'server-only'; @@ -116,16 +117,89 @@ export interface SealAnalysisData { * has no verdict yet", which comes back as a successful response. */ reliabilityError: boolean; + availabilityError: boolean; +} + +/** + * Cache entries below are keyed by feed id only - the analysis describes the + * feed, not the caller - so guests and authenticated users read the same + * entries. The credentials are closed over purely to authenticate the calls + * and are intentionally excluded from the key. + * + * Each endpoint gets its own `unstable_cache` entry rather than one entry for + * all three. `unstable_cache` never persists a rejected call, so keeping them + * separate means a failing endpoint simply isn't cached - and retries on the + * next request - without discarding a sibling endpoint's successful, and + * cacheable, result. + */ +function cachedReliability( + feedId: string, + accessToken: string, + userContextJwt: string | undefined, +): () => Promise { + return unstable_cache( + async () => + await getGtfsFeedReliability(feedId, accessToken, userContextJwt), + [`seal-analysis-reliability-${feedId}`], + { + tags: [`feed-${feedId}`, 'seal-analysis'], + revalidate: SEAL_ANALYSIS_REVALIDATE, + }, + ); +} + +function cachedAvailability( + feedId: string, + accessToken: string, + userContextJwt: string | undefined, +): () => Promise { + return unstable_cache( + async () => + await fetchAvailabilityHistory( + feedId, + accessToken, + userContextJwt, + new Date(), + ), + [`seal-analysis-availability-${feedId}`], + { + tags: [`feed-${feedId}`, 'seal-analysis'], + revalidate: SEAL_ANALYSIS_REVALIDATE, + }, + ); +} + +function cachedContinuousCoverage( + feedId: string, + accessToken: string, + userContextJwt: string | undefined, +): () => Promise { + return unstable_cache( + async () => + await getGtfsFeedContinuousCoverage( + feedId, + accessToken, + { limit: HISTORY_LIMIT }, + userContextJwt, + ), + [`seal-analysis-coverage-${feedId}`], + { + tags: [`feed-${feedId}`, 'seal-analysis'], + revalidate: SEAL_ANALYSIS_REVALIDATE, + }, + ); } /** * Fetch the three seal endpoints together. * * `allSettled`, not `all`: the availability and continuous-coverage history - * are supporting detail, so one of them failing degrades to `undefined` - * rather than taking down a page that can still show the criteria. Only the - * reliability breakdown reports failure, via `reliabilityError`, because the - * seal page has nothing to render without it. + * are supporting detail, so one of them failing degrades to `undefined` (with + * its own `*Error` flag, for availability) rather than taking down a page + * that can still show the criteria. Only the reliability breakdown ever + * bubbles up as a thrown error past the exported loaders, because the seal + * page has nothing to render without it - see `fetchGuestSealAnalysisData` + * and `fetchAuthedSealAnalysisData`. */ async function fetchSealAnalysisImpl( feedId: string, @@ -134,14 +208,9 @@ async function fetchSealAnalysisImpl( ): Promise { const [reliabilityResult, availabilityResult, coverageResult] = await Promise.allSettled([ - getGtfsFeedReliability(feedId, accessToken, userContextJwt), - fetchAvailabilityHistory(feedId, accessToken, userContextJwt, new Date()), - getGtfsFeedContinuousCoverage( - feedId, - accessToken, - { limit: HISTORY_LIMIT }, - userContextJwt, - ), + cachedReliability(feedId, accessToken, userContextJwt)(), + cachedAvailability(feedId, accessToken, userContextJwt)(), + cachedContinuousCoverage(feedId, accessToken, userContextJwt)(), ]); return { @@ -154,50 +223,12 @@ async function fetchSealAnalysisImpl( availabilityResult.status === 'fulfilled' ? availabilityResult.value : undefined, + availabilityError: availabilityResult.status === 'rejected', continuousCoverage: coverageResult.status === 'fulfilled' ? coverageResult.value : undefined, }; } -/** - * The shared cache entry. Keyed by feed id only - the analysis describes the - * feed, not the caller - so guests and authenticated users read the same - * entry. The credentials are closed over purely to authenticate the calls and - * are intentionally excluded from the key. - */ -function cachedSealAnalysis( - feedId: string, - accessToken: string, - userContextJwt: string | undefined, -): () => Promise { - const cachedFetch = unstable_cache( - async () => { - const result = await fetchSealAnalysisImpl( - feedId, - accessToken, - userContextJwt, - ); - if (result.reliabilityError) { - throw new Error(`Failed to load reliability data for feed ${feedId}`); - } - return result; - }, - [`seal-analysis-${feedId}`], - { - tags: [`feed-${feedId}`, 'seal-analysis'], - revalidate: SEAL_ANALYSIS_REVALIDATE, - }, - ); - - return async () => { - try { - return await cachedFetch(); - } catch { - return { reliabilityError: true }; - } - }; -} - /** `undefined` whenever there is no analysis to fetch, rather than an error. */ function isSealAnalysisApplicable( feedDataType: string, @@ -215,8 +246,8 @@ function isSealAnalysisApplicable( * from a statically rendered page would still drag that page's ISR TTL down * to 6 hours, so its only caller is the force-dynamic seal page. * - * `cache()` dedupes within a single request; the `unstable_cache` entry it - * wraps dedupes across requests and users. + * `cache()` dedupes within a single request; the per-endpoint `unstable_cache` + * entries it reads from dedupe across requests and users. */ export const fetchGuestSealAnalysisData = cache( async ( @@ -237,7 +268,7 @@ export const fetchGuestSealAnalysisData = cache( return undefined; } - return await cachedSealAnalysis(feedId, accessToken, undefined)(); + return await fetchSealAnalysisImpl(feedId, accessToken, undefined); }, ); @@ -265,6 +296,6 @@ export const fetchAuthedSealAnalysisData = cache( return undefined; } - return await cachedSealAnalysis(feedId, accessToken, userContextJwt)(); + return await fetchSealAnalysisImpl(feedId, accessToken, userContextJwt); }, ); diff --git a/src/app/screens/Feed/components/AvailabilityCriterionBody.tsx b/src/app/screens/Feed/components/AvailabilityCriterionBody.tsx index 58b5bb18..6033620d 100644 --- a/src/app/screens/Feed/components/AvailabilityCriterionBody.tsx +++ b/src/app/screens/Feed/components/AvailabilityCriterionBody.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Box, Typography } from '@mui/material'; +import { Alert, Box, Typography } from '@mui/material'; import CircleIcon from '@mui/icons-material/Circle'; import { getTranslations } from 'next-intl/server'; import AvailabilityHeatmap from './AvailabilityHeatmap'; @@ -29,6 +29,7 @@ export interface AvailabilityCriterionBodyProps { calendar: AvailabilityCalendar; /** Pinned by the page so every date-derived branch agrees. */ now: Date; + availabilityError?: boolean; } /** @@ -40,9 +41,15 @@ export default async function AvailabilityCriterionBody({ criterion, calendar, now, + availabilityError = false, }: AvailabilityCriterionBodyProps): Promise { const t = await getTranslations('feeds'); - const summary = getAvailabilitySummary(criterion, calendar, now); + const summary = getAvailabilitySummary( + criterion, + calendar, + now, + availabilityError, + ); const hasHistory = calendar.successCount + calendar.failureCount > 0; // Probation excludes a grace period, so only one of these ever renders - @@ -75,6 +82,12 @@ export default async function AvailabilityCriterionBody({ /> )} + {availabilityError && ( + + {t('sealAvailabilityErrorDescription')} + + )} + {hasHistory && ( <> diff --git a/src/app/screens/Feed/components/FeedReliabilityView.tsx b/src/app/screens/Feed/components/FeedReliabilityView.tsx index fc57d14e..47f4c523 100644 --- a/src/app/screens/Feed/components/FeedReliabilityView.tsx +++ b/src/app/screens/Feed/components/FeedReliabilityView.tsx @@ -205,6 +205,7 @@ export default async function FeedReliabilityView({ criterion={availableCriterion} calendar={availabilityCalendar} now={now} + availabilityError={sealAnalysis?.availabilityError} /> diff --git a/src/app/screens/Feed/lib/availability-history.spec.ts b/src/app/screens/Feed/lib/availability-history.spec.ts index 3bb245dc..2f56a5a5 100644 --- a/src/app/screens/Feed/lib/availability-history.spec.ts +++ b/src/app/screens/Feed/lib/availability-history.spec.ts @@ -253,6 +253,14 @@ describe('getAvailabilitySummary', () => { ).toBe('sealAvailabilityNoData'); }); + it('reads as an error, not as no data, when the history fetch failed', () => { + const empty = buildAvailabilityCalendar([], { now: NOW, months: 1 }); + + expect(getAvailabilitySummary(criterion(), empty, NOW, true).key).toBe( + 'sealAvailabilityError', + ); + }); + it('withdraws the criterion when it does not apply', () => { expect( getAvailabilitySummary( diff --git a/src/app/screens/Feed/lib/availability-history.ts b/src/app/screens/Feed/lib/availability-history.ts index df1756c9..da829457 100644 --- a/src/app/screens/Feed/lib/availability-history.ts +++ b/src/app/screens/Feed/lib/availability-history.ts @@ -226,6 +226,7 @@ export function getAvailabilitySummary( criterion: ReliabilityCriterion, calendar: AvailabilityCalendar, now: Date = new Date(), + availabilityError = false, ): AvailabilitySummary { const displayStatus = getCriterionDisplayStatus(criterion); const graceDays = AVAILABILITY_GRACE_DAYS; @@ -269,7 +270,12 @@ export function getAvailabilitySummary( displayStatus === 'notEvaluated' || calendar.successCount + calendar.failureCount === 0 ) { - return { key: 'sealAvailabilityNoData', values: {} }; + // An empty calendar means either "nothing recorded yet" or "the history + // fetch just failed" - `availabilityError` tells them apart so the copy + // doesn't claim a feed has no record when it simply couldn't be loaded. + return availabilityError + ? { key: 'sealAvailabilityError', values: {} } + : { key: 'sealAvailabilityNoData', values: {} }; } if (calendar.failureCount === 0) { return { key: 'sealAvailabilityNoFailures', values: {} }; From b06916ede95bc7020ec75afc6e073d86059ed524 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 10 Sep 2026 14:16:19 -0400 Subject: [PATCH 12/28] date utc adjustment --- src/app/constants/sealCriteria.ts | 16 +++++++----- src/app/utils/date.ts | 43 +++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/app/constants/sealCriteria.ts b/src/app/constants/sealCriteria.ts index 0bc5bb80..529600c6 100644 --- a/src/app/constants/sealCriteria.ts +++ b/src/app/constants/sealCriteria.ts @@ -1,7 +1,11 @@ import { type SvgIconComponent } from '@mui/icons-material'; -import { differenceInCalendarDays, isAfter, subMonths } from 'date-fns'; +import { isAfter } from 'date-fns'; import { theme as appTheme } from '../Theme'; -import { formatDateShort } from '../utils/date'; +import { + formatDateShort, + subMonthsUtc, + utcCalendarDayDiff, +} from '../utils/date'; import VerifiedIcon from '@mui/icons-material/Verified'; import CodeIcon from '@mui/icons-material/Code'; import DownloadIcon from '@mui/icons-material/Download'; @@ -192,7 +196,7 @@ export function getGracePeriodCriteria( * already be in the past when the nightly job hasn't acted on it yet. */ export function getDaysUntil(date: string, now = new Date()): number { - return Math.max(0, differenceInCalendarDays(new Date(date), now)); + return Math.max(0, utcCalendarDayDiff(new Date(date), now)); } /** @@ -232,7 +236,7 @@ export function getProbationWindowFromEnd( if (isNaN(end.getTime())) { return undefined; } - return { start: subMonths(end, PROBATION_MONTHS), end }; + return { start: subMonthsUtc(end, PROBATION_MONTHS), end }; } /** @@ -263,7 +267,7 @@ export function getProbationWindow( const start = starts.length > 0 ? new Date(Math.min(...starts.map((d) => d.getTime()))) - : subMonths(end, PROBATION_MONTHS); + : subMonthsUtc(end, PROBATION_MONTHS); return { start, end }; } @@ -334,7 +338,7 @@ export function isFeedWithinProbationWindow( if (isNaN(createdAt.getTime())) { return false; } - return isAfter(createdAt, subMonths(now, PROBATION_MONTHS)); + return isAfter(createdAt, subMonthsUtc(now, PROBATION_MONTHS)); } /** diff --git a/src/app/utils/date.ts b/src/app/utils/date.ts index ecd78e04..a85a2a44 100644 --- a/src/app/utils/date.ts +++ b/src/app/utils/date.ts @@ -75,3 +75,46 @@ export const formatTokenExpiration = (duration: Duration): string => { return `${hours}:${minutes}:${seconds}`; }; + +/** + * Calendar-day difference between `date` and `now`, in UTC. Useful whenever + * `now` is a single Date instance shared by a server render and the client + * that hydrates it: date-fns's `differenceInCalendarDays` buckets by the + * *local* calendar day of whichever machine runs it, so a server in UTC and a + * browser in another zone can round the same instant to different days, most + * visibly near midnight. Rebuilding both sides at UTC midnight keeps the diff + * identical wherever it runs. + */ +export function utcCalendarDayDiff(date: Date, now: Date): number { + const dateUtcDay = Date.UTC( + date.getUTCFullYear(), + date.getUTCMonth(), + date.getUTCDate(), + ); + const nowUtcDay = Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate(), + ); + return Math.round((dateUtcDay - nowUtcDay) / 86400000); +} + +/** + * `date` minus `months`, in UTC. Same rationale as `utcCalendarDayDiff`: + * date-fns's `subMonths` rolls the calendar back in local time, so it can + * return a different instant - and therefore a different displayed date - + * depending on the timezone of the machine that evaluates it. + */ +export function subMonthsUtc(date: Date, months: number): Date { + return new Date( + Date.UTC( + date.getUTCFullYear(), + date.getUTCMonth() - months, + date.getUTCDate(), + date.getUTCHours(), + date.getUTCMinutes(), + date.getUTCSeconds(), + date.getUTCMilliseconds(), + ), + ); +} From 1a1ea59b8e282bf39c8b7ec69760c2e06498fd5a Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 10 Sep 2026 14:25:13 -0400 Subject: [PATCH 13/28] stronger error message for compliant --- messages/en.json | 2 + messages/fr.json | 2 + .../Feed/lib/availability-history.spec.ts | 24 ++++++++++++ .../screens/Feed/lib/availability-history.ts | 38 +++++++++++-------- 4 files changed, 51 insertions(+), 15 deletions(-) diff --git a/messages/en.json b/messages/en.json index 8bc79e8a..97d048a8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -327,9 +327,11 @@ "sealAvailabilityNoFailures": "Every fetch succeeded.", "sealAvailabilityRecovered": "{count, plural, one {# failed fetch} other {# failed fetches}} (last on {date}) recovered within the {graceDays}-day grace window.", "sealAvailabilityAtRisk": "The feed has been unreachable since {date}.", + "sealAvailabilityAtRiskUndated": "The feed has been unreachable.", "sealAvailabilityGraceTitle": "{days, plural, one {# day} other {# days}} left to restore access", "sealAvailabilityGraceDescription": "Restore access to the feed before the grace period ends to keep the Seal.", "sealAvailabilityFailing": "The feed has been unreachable since {date}. Assure this feed is available consistently for {months, plural, one {# month} other {# months}} to pass this criterion.", + "sealAvailabilityFailingUndated": "The feed has been unreachable. Assure this feed is available consistently for {months, plural, one {# month} other {# months}} to pass this criterion.", "sealAvailabilityProbation": "Fetches are succeeding again. This criterion is rebuilding its six-month record after its last recorded error on {date}.", "sealAvailabilityProbationUndated": "Fetches are succeeding again. This criterion is rebuilding its six-month record after a confirmed failure.", "sealAvailabilityNotApplicable": "Daily availability checks don't apply to this feed.", diff --git a/messages/fr.json b/messages/fr.json index 798b580a..0b6a889a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -327,9 +327,11 @@ "sealAvailabilityNoFailures": "Toutes les récupérations ont réussi.", "sealAvailabilityRecovered": "{count, plural, one {# récupération a échoué} other {# récupérations ont échoué}} (la dernière le {date}) et le problème a été corrigé dans le délai de grâce de {graceDays} jours.", "sealAvailabilityAtRisk": "Le flux est inaccessible depuis le {date}.", + "sealAvailabilityAtRiskUndated": "Le flux est inaccessible.", "sealAvailabilityGraceTitle": "Il reste {days, plural, one {# jour} other {# jours}} pour rétablir l'accès", "sealAvailabilityGraceDescription": "Rétablissez l'accès au flux avant la fin du délai de grâce pour conserver le Sceau.", "sealAvailabilityFailing": "Le flux est inaccessible depuis le {date}. Assurez la disponibilité de ce flux de manière constante pendant {months, plural, one {# mois} other {# mois}} pour valider ce critère.", + "sealAvailabilityFailingUndated": "Le flux est inaccessible. Assurez la disponibilité de ce flux de manière constante pendant {months, plural, one {# mois} other {# mois}} pour valider ce critère.", "sealAvailabilityProbation": "Les récupérations réussissent de nouveau. Ce critère reconstruit son historique de six mois après sa dernière erreur enregistrée le {date}.", "sealAvailabilityProbationUndated": "Les récupérations réussissent de nouveau. Ce critère reconstruit son historique de six mois après un échec confirmé.", "sealAvailabilityNotApplicable": "Les vérifications quotidiennes de disponibilité ne s'appliquent pas à ce flux.", diff --git a/src/app/screens/Feed/lib/availability-history.spec.ts b/src/app/screens/Feed/lib/availability-history.spec.ts index 2f56a5a5..eaf005fc 100644 --- a/src/app/screens/Feed/lib/availability-history.spec.ts +++ b/src/app/screens/Feed/lib/availability-history.spec.ts @@ -202,6 +202,24 @@ describe('getAvailabilitySummary', () => { }); }); + it('drops the date when at risk with no first failure kept', () => { + expect( + getAvailabilitySummary( + criterion({ + status: 'fail', + in_grace_period: true, + grace_period_ends_at: '2026-09-20T00:00:00Z', + }), + calendar, + NOW, + ), + ).toEqual({ + key: 'sealAvailabilityAtRiskUndated', + values: {}, + graceDaysLeft: 11, + }); + }); + it('reads as a spent grace window once the failure is confirmed', () => { expect( getAvailabilitySummary( @@ -212,6 +230,12 @@ describe('getAvailabilitySummary', () => { ).toBe('sealAvailabilityFailing'); }); + it('drops the date when failing with no first failure kept', () => { + expect( + getAvailabilitySummary(criterion({ status: 'fail' }), calendar, NOW).key, + ).toBe('sealAvailabilityFailingUndated'); + }); + it('names the error probation is being served for', () => { expect( getAvailabilitySummary( diff --git a/src/app/screens/Feed/lib/availability-history.ts b/src/app/screens/Feed/lib/availability-history.ts index da829457..0c23c96d 100644 --- a/src/app/screens/Feed/lib/availability-history.ts +++ b/src/app/screens/Feed/lib/availability-history.ts @@ -246,11 +246,19 @@ export function getAvailabilitySummary( : { key: 'sealAvailabilityProbationUndated', values: {} }; } // The countdown itself lives in the deadline notice, so the sentence only - // has to say since when. + // has to say since when. `first_failure_at` is cleared once a feed + // recovers, so - as with `last_failure_at` above - the wording falls back + // to one that names no date rather than interpolating a blank. if (displayStatus === 'atRisk') { return { - key: 'sealAvailabilityAtRisk', - values: { date: formatFailureDate(criterion.first_failure_at) }, + key: + criterion.first_failure_at != null + ? 'sealAvailabilityAtRisk' + : 'sealAvailabilityAtRiskUndated', + values: + criterion.first_failure_at != null + ? { date: formatDateShort(criterion.first_failure_at) } + : {}, graceDaysLeft: criterion.grace_period_ends_at != null ? getDaysUntil(criterion.grace_period_ends_at, now) @@ -258,13 +266,18 @@ export function getAvailabilitySummary( }; } if (displayStatus === 'fail') { - return { - key: 'sealAvailabilityFailing', - values: { - months: AVAILABILITY_HISTORY_MONTHS, - date: formatFailureDate(criterion.first_failure_at), - }, - }; + return criterion.first_failure_at != null + ? { + key: 'sealAvailabilityFailing', + values: { + months: AVAILABILITY_HISTORY_MONTHS, + date: formatDateShort(criterion.first_failure_at), + }, + } + : { + key: 'sealAvailabilityFailingUndated', + values: { months: AVAILABILITY_HISTORY_MONTHS }, + }; } if ( displayStatus === 'notEvaluated' || @@ -292,8 +305,3 @@ export function getAvailabilitySummary( }, }; } - -/** `first_failure_at` is cleared once a feed recovers, so it can be absent. */ -function formatFailureDate(date?: string | null): string { - return date == null ? '' : formatDateShort(date); -} From 2033ce6c9116c3ad75b3ff545796c9a84c08fdc6 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 10 Sep 2026 14:47:12 -0400 Subject: [PATCH 14/28] days chip removed and text adjustments --- messages/en.json | 2 +- messages/fr.json | 2 +- .../Feed/components/CriterionSection.spec.tsx | 84 ------------------- .../Feed/components/CriterionSection.tsx | 40 +-------- 4 files changed, 6 insertions(+), 122 deletions(-) diff --git a/messages/en.json b/messages/en.json index 97d048a8..64403014 100644 --- a/messages/en.json +++ b/messages/en.json @@ -356,7 +356,7 @@ "sealCompliantAtRisk": "The latest validation report has {count, plural, one {# error} other {# errors}}.", "sealCompliantGraceTitle": "{days, plural, one {# day} other {# days}} left to resolve errors", "sealCompliantGraceDescription": "Resolve these errors before the grace period ends to keep the Seal.", - "sealCompliantFailing": "The latest validation report has {count, plural, one {# error} other {# errors}}, resolve these validation errors to pass this criterion.", + "sealCompliantFailing": "The latest validation report has {count, plural, one {# error} other {# errors}}. Resolve these validation errors to pass this criterion.", "sealCompliantProbation": "The latest dataset validates with no errors. This criterion is rebuilding its six-month record after its last recorded error on {date}.", "sealCompliantProbationUndated": "The latest dataset validates with no errors. This criterion is rebuilding its six-month record after a confirmed failure.", "sealCompliantNotApplicable": "Validation doesn't apply to this feed.", diff --git a/messages/fr.json b/messages/fr.json index 0b6a889a..1dbdd367 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -356,7 +356,7 @@ "sealCompliantAtRisk": "Le dernier rapport de validation comporte {count, plural, one {# erreur} other {# erreurs}}.", "sealCompliantGraceTitle": "Il reste {days, plural, one {# jour} other {# jours}} pour corriger les erreurs", "sealCompliantGraceDescription": "Corrigez ces erreurs avant la fin du délai de grâce pour conserver le Sceau.", - "sealCompliantFailing": "Le dernier rapport de validation comporte {count, plural, one {# erreur} other {# erreurs}}, corrigez ces erreurs de validation pour valider ce critère.", + "sealCompliantFailing": "Le dernier rapport de validation comporte {count, plural, one {# erreur} other {# erreurs}}. Corrigez ces erreurs de validation pour valider ce critère.", "sealCompliantProbation": "Le dernier jeu de données est validé sans erreur. Ce critère reconstruit son historique de six mois après sa dernière erreur enregistrée le {date}.", "sealCompliantProbationUndated": "Le dernier jeu de données est validé sans erreur. Ce critère reconstruit son historique de six mois après un échec confirmé.", "sealCompliantNotApplicable": "La validation ne s'applique pas à ce flux.", diff --git a/src/app/screens/Feed/components/CriterionSection.spec.tsx b/src/app/screens/Feed/components/CriterionSection.spec.tsx index d08a5ace..c6b47d3f 100644 --- a/src/app/screens/Feed/components/CriterionSection.spec.tsx +++ b/src/app/screens/Feed/components/CriterionSection.spec.tsx @@ -243,87 +243,3 @@ describe('CriterionSection probation', () => { ).not.toBeInTheDocument(); }); }); - -describe('CriterionSection days-left chip', () => { - const atRisk = (): ReliabilityCriterion => - buildCriterion('compliant', { - status: 'fail', - in_grace_period: true, - grace_period_ends_at: '2026-09-28T00:00:00Z', - }); - - it('counts the days left against the date the page pinned', () => { - renderSection(atRisk(), { now: NOW }); - - expect(screen.getByTestId('criterion-days-left-chip')).toHaveTextContent( - 'sealCriterionDaysLeftChip', - ); - }); - - it('sits between the meta chips and the status chip', () => { - renderSection( - atRisk(), - { now: NOW }, - undefined, - undefined, - , - ); - - const order = [ - screen.getByTestId('meta-chip'), - screen.getByTestId('criterion-days-left-chip'), - screen.getByTestId('status-chip'), - ]; - order.slice(1).forEach((node, i) => { - // Node.DOCUMENT_POSITION_FOLLOWING - each chip comes after the last. - expect(order[i].compareDocumentPosition(node) & 4).toBeTruthy(); - }); - }); - - it('is absent when the criterion is not in a grace period', () => { - renderSection(buildCriterion('compliant', { status: 'pass' }), { - now: NOW, - }); - - expect( - screen.queryByTestId('criterion-days-left-chip'), - ).not.toBeInTheDocument(); - }); - - it('is absent when the grace period reports no deadline', () => { - renderSection( - buildCriterion('compliant', { status: 'fail', in_grace_period: true }), - { now: NOW }, - ); - - expect( - screen.queryByTestId('criterion-days-left-chip'), - ).not.toBeInTheDocument(); - }); - - it('counts down probation too, which runs to its own deadline', () => { - renderSection( - buildCriterion('available', { - status: 'pass', - on_probation: true, - probation_ends_at: '2027-01-16T00:00:00Z', - }), - { now: NOW }, - ); - - expect(screen.getByTestId('criterion-days-left-chip')).toHaveTextContent( - 'sealCriterionDaysLeftChip', - ); - }); - - it('is absent when probation reports no deadline', () => { - renderSection( - buildCriterion('available', { status: 'pass', on_probation: true }), - { now: NOW }, - ); - - expect( - screen.queryByTestId('criterion-days-left-chip'), - ).not.toBeInTheDocument(); - }); -}); diff --git a/src/app/screens/Feed/components/CriterionSection.tsx b/src/app/screens/Feed/components/CriterionSection.tsx index 3bed5b2d..e906fda2 100644 --- a/src/app/screens/Feed/components/CriterionSection.tsx +++ b/src/app/screens/Feed/components/CriterionSection.tsx @@ -5,13 +5,11 @@ import { Box, Card, CardContent, - Chip, IconButton, Tooltip, Typography, } from '@mui/material'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; -import AccessTimeIcon from '@mui/icons-material/AccessTime'; import { useTranslations } from 'next-intl'; import { Link } from '../../../../i18n/navigation'; import CriterionProbationProgress from './CriterionProbationProgress'; @@ -22,7 +20,6 @@ import { getCriterionCopy, getCriterionDisplayStatus, getCriterionStatusColor, - getDaysUntil, getProbationWindowFromEnd, } from '../../../constants/sealCriteria'; import { type components } from '../../../services/feeds/types'; @@ -35,8 +32,8 @@ export interface CriterionSectionProps { producerUrl?: string; statusChip: React.ReactNode; /** - * Extra chips for the header, placed left of the grace-period countdown and - * the status chip - a criterion's own headline figure, such as uptime. + * Extra chips for the header, placed left of the status chip - a + * criterion's own headline figure, such as uptime. */ metaChips?: React.ReactNode; /** @@ -75,20 +72,6 @@ export default function CriterionSection({ const color = getCriterionStatusColor(displayStatus); const copy = getCriterionCopy(criterion, context); - // Both states run to a deadline, and they are mutually exclusive - a - // failure during probation restarts it rather than opening a grace period - - // so one chip counts down whichever is running. `now` is pinned by the page - // and threaded through the context so it renders identically on the server - // and after hydration. - const deadline = - displayStatus === 'atRisk' - ? criterion.grace_period_ends_at - : displayStatus === 'probation' - ? criterion.probation_ends_at - : undefined; - const daysLeft = - deadline != null ? getDaysUntil(deadline, context?.now) : undefined; - const graceNote = displayStatus === 'atRisk' && criterion.grace_period_ends_at != null ? t('sealCriterionGracePeriodNote', { @@ -118,8 +101,8 @@ export default function CriterionSection({ sx={{ mb: 0, height: '100%' }} data-testid={`criterion-section-${key}`} > - {/* Wraps rather than clips: with a metric chip and a grace countdown - alongside the status chip, the row outgrows a narrow card. */} + {/* Wraps rather than clips: with a metric chip alongside the status + chip, the row outgrows a narrow card. */} {metaChips} - {daysLeft != undefined && ( - } - label={t('sealCriterionDaysLeftChip', { days: daysLeft })} - sx={{ - color, - borderColor: color, - flexShrink: 0, - '& .MuiChip-icon': { color }, - }} - /> - )} {statusChip} From c16f4f798b0b95516b5fdbcd3857a2cca8d9339b Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Fri, 11 Sep 2026 08:06:46 -0400 Subject: [PATCH 15/28] remote config do not cache errored fetch --- src/lib/remote-config.server.spec.ts | 50 ++++++++++++++++++++++++++++ src/lib/remote-config.server.ts | 47 ++++++++++++-------------- 2 files changed, 72 insertions(+), 25 deletions(-) diff --git a/src/lib/remote-config.server.spec.ts b/src/lib/remote-config.server.spec.ts index 3dbc8a4a..1b165b27 100644 --- a/src/lib/remote-config.server.spec.ts +++ b/src/lib/remote-config.server.spec.ts @@ -276,6 +276,56 @@ describe('remote-config.server', () => { }); }); + describe('fetch failures', () => { + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => { + // silence expected error logging + }); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('returns defaults when getTemplate rejects', async () => { + mockGetTemplate.mockRejectedValue(new Error('firebase unavailable')); + + const result = await getRemoteConfigValuesForUser('user@example.com'); + + expect(result).toEqual(defaultRemoteConfigValues); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + + it('returns defaults when the Admin app cannot be initialized', async () => { + const { getFirebaseAdminApp } = jest.requireMock('./firebase-admin'); + getFirebaseAdminApp.mockImplementationOnce(() => { + throw new Error('Missing server-side credentials.'); + }); + + const result = await getRemoteConfigValuesForUser('user@example.com'); + + expect(result).toEqual(defaultRemoteConfigValues); + }); + + it('does not persist the fallback: a later request refetches', async () => { + mockGetTemplate.mockRejectedValueOnce(new Error('transient')); + + const failed = await getRemoteConfigValuesForUser('user@example.com'); + expect(failed.enableMetrics).toBe(false); + + mockGetTemplate.mockResolvedValue({ + parameters: { + enableMetrics: { defaultValue: { value: 'true' } }, + }, + }); + + const recovered = await getRemoteConfigValuesForUser('user@example.com'); + expect(recovered.enableMetrics).toBe(true); + }); + }); + describe('refreshRemoteConfig', () => { it('calls revalidateTag with remote-config tag', async () => { const { revalidateTag } = jest.requireMock('next/cache'); diff --git a/src/lib/remote-config.server.ts b/src/lib/remote-config.server.ts index d1b6df08..759ad9af 100644 --- a/src/lib/remote-config.server.ts +++ b/src/lib/remote-config.server.ts @@ -46,6 +46,9 @@ function parseConfigValue( /** * Fetch Remote Config from Firebase Admin SDK. * Returns the template parameters merged with defaults. + * + * Deliberately does NOT swallow errors: this function runs inside + * unstable_cache, which caches resolved values but not rejections. */ async function fetchRemoteConfigFromFirebase(): Promise { // Dev/mock bypass: return defaults without touching Admin SDK @@ -58,33 +61,27 @@ async function fetchRemoteConfigFromFirebase(): Promise { const app = getFirebaseAdminApp(); const remoteConfigAdmin = getRemoteConfig(app); - try { - const template = await remoteConfigAdmin.getTemplate(); - const fetchedConfig = { ...defaultRemoteConfigValues }; - - // Process each parameter from the template - for (const [key, parameter] of Object.entries(template.parameters)) { - if ( - key in defaultRemoteConfigValues && - parameter.defaultValue != undefined - ) { - const defaultVal = parameter.defaultValue as { value?: string }; - if (defaultVal.value !== undefined) { - const parsedValue = parseConfigValue( - defaultVal.value, - defaultRemoteConfigValues[key as keyof RemoteConfigValues], - ); - (fetchedConfig as Record)[key] = parsedValue; - } + const template = await remoteConfigAdmin.getTemplate(); + const fetchedConfig = { ...defaultRemoteConfigValues }; + + // Process each parameter from the template + for (const [key, parameter] of Object.entries(template.parameters)) { + if ( + key in defaultRemoteConfigValues && + parameter.defaultValue != undefined + ) { + const defaultVal = parameter.defaultValue as { value?: string }; + if (defaultVal.value !== undefined) { + const parsedValue = parseConfigValue( + defaultVal.value, + defaultRemoteConfigValues[key as keyof RemoteConfigValues], + ); + (fetchedConfig as Record)[key] = parsedValue; } } - - return fetchedConfig; - } catch (error) { - console.error('Failed to fetch Remote Config from Firebase:', error); - // Return defaults on error - return defaultRemoteConfigValues; } + + return fetchedConfig; } /** @@ -107,7 +104,7 @@ const fetchRemoteConfigCached = unstable_cache( * - react cache() deduplicates calls within the same request (e.g., layout + page) * - unstable_cache persists across requests and Vercel function instances * - Cache revalidates after CACHE_DURATION_SECONDS - * - On error, returns defaults + * - On error, returns defaults without caching them, so the next request retries */ export const getRemoteConfigValues = cache( async (): Promise => { From 1f98692a3595b6541c7f23ec6c6c93db0913a481 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Fri, 11 Sep 2026 09:31:45 -0400 Subject: [PATCH 16/28] removed feature flag in server api call --- .../[feedId]/lib/feed-data-shared.spec.ts | 30 ++++++++------- .../[feedId]/lib/feed-data-shared.ts | 5 +-- .../[feedDataType]/[feedId]/lib/feed-data.ts | 15 +++----- .../[feedId]/lib/guest-feed-data.ts | 7 +--- .../[feedId]/lib/seal-analysis-data.spec.ts | 19 ---------- .../[feedId]/lib/seal-analysis-data.ts | 38 +++++-------------- .../static/seal-of-reliability/page.tsx | 4 +- 7 files changed, 36 insertions(+), 82 deletions(-) diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data-shared.spec.ts b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data-shared.spec.ts index e6051712..bf399f08 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data-shared.spec.ts +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data-shared.spec.ts @@ -11,6 +11,7 @@ jest.mock('server-only', () => ({})); const mockGetGtfsFeedReliability = jest.fn(); const mockGetGtfsFeed = jest.fn(); +const mockGetGtfsRtFeed = jest.fn(); const mockGetGtfsFeedDatasets = jest.fn(); const mockGetGtfsFeedRoutes = jest.fn(); const mockGetGtfsFeedAvailability = jest.fn(); @@ -24,6 +25,7 @@ jest.mock('../../../../../services/feeds', () => ({ getGtfsFeedContinuousCoverage: (...args: unknown[]) => mockGetGtfsFeedContinuousCoverage(...args), getGtfsFeed: (...args: unknown[]) => mockGetGtfsFeed(...args), + getGtfsRtFeed: (...args: unknown[]) => mockGetGtfsRtFeed(...args), getGtfsFeedDatasets: (...args: unknown[]) => mockGetGtfsFeedDatasets(...args), getGtfsFeedRoutes: (...args: unknown[]) => mockGetGtfsFeedRoutes(...args), })); @@ -60,33 +62,36 @@ describe('fetchCompleteFeedDataImpl', () => { mockGetGtfsFeedRoutes.mockResolvedValue(null); }); - it('does not call the reliability API when enableSealOfReliability is false', async () => { + // The seal is gated client-side by useRemoteConfig(), so the report is + // always fetched here - otherwise a Remote Config admin bypass would open + // the UI onto data the server never loaded. + it('always calls the reliability API for gtfs feeds', async () => { + mockGetGtfsFeedReliability.mockResolvedValue(report); + const result = await fetchCompleteFeedDataImpl( 'gtfs', 'mdb-1', 'token', undefined, - false, ); - expect(mockGetGtfsFeedReliability).not.toHaveBeenCalled(); - expect(result.reliability).toBeUndefined(); + expect(mockGetGtfsFeedReliability).toHaveBeenCalledTimes(1); + expect(result.reliability).toEqual(report); + expect(result.reliabilityError).toBe(false); }); - it('calls the reliability API when enableSealOfReliability is true', async () => { - mockGetGtfsFeedReliability.mockResolvedValue(report); + it('does not call the reliability API for non-gtfs feeds', async () => { + mockGetGtfsRtFeed.mockResolvedValue({ id: 'mdb-1', data_type: 'gtfs_rt' }); const result = await fetchCompleteFeedDataImpl( - 'gtfs', + 'gtfs_rt', 'mdb-1', 'token', undefined, - true, ); - expect(mockGetGtfsFeedReliability).toHaveBeenCalledTimes(1); - expect(result.reliability).toEqual(report); - expect(result.reliabilityError).toBe(false); + expect(mockGetGtfsFeedReliability).not.toHaveBeenCalled(); + expect(result.reliability).toBeUndefined(); }); it('flags reliabilityError when the reliability API fails', async () => { @@ -97,7 +102,6 @@ describe('fetchCompleteFeedDataImpl', () => { 'mdb-1', 'token', undefined, - true, ); expect(result.reliability).toBeUndefined(); @@ -112,7 +116,7 @@ describe('fetchCompleteFeedDataImpl', () => { it('does not fetch the availability or continuous-coverage history', async () => { mockGetGtfsFeedReliability.mockResolvedValue(report); - await fetchCompleteFeedDataImpl('gtfs', 'mdb-1', 'token', undefined, true); + await fetchCompleteFeedDataImpl('gtfs', 'mdb-1', 'token', undefined); expect(mockGetGtfsFeedAvailability).not.toHaveBeenCalled(); expect(mockGetGtfsFeedContinuousCoverage).not.toHaveBeenCalled(); diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data-shared.ts b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data-shared.ts index 8d6a9311..d1fc69e3 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data-shared.ts +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data-shared.ts @@ -209,7 +209,6 @@ export async function fetchCompleteFeedDataImpl( feedId: string, accessToken: string, userContextJwt: string | undefined, - enableSealOfReliability: boolean, ): Promise { // Fetch core feed data const feed = await fetchFeedByType( @@ -238,9 +237,7 @@ export async function fetchCompleteFeedDataImpl( feedId, (feed as GTFSFeedType)?.visualization_dataset_id ?? '', ), - enableSealOfReliability - ? fetchReliabilityData(feedId, accessToken, userContextJwt) - : Promise.resolve({ reliability: undefined, failed: false }), + fetchReliabilityData(feedId, accessToken, userContextJwt), ], ); initialDatasets = datasetsResult; diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data.ts b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data.ts index 3344213c..214f40bc 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data.ts +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data.ts @@ -11,7 +11,6 @@ import { getUserContextJwtFromCookie, getCurrentUserFromCookie, } from '../../../../../utils/auth-server'; -import { getRemoteConfigValues } from '../../../../../../lib/remote-config.server'; import { fetchCompleteFeedDataImpl, type FeedDataResult, @@ -36,14 +35,11 @@ export const fetchCompleteFeedData = cache( feedDataType: string, feedId: string, ): Promise => { - const [accessToken, userContextJwt, user, remoteConfig] = await Promise.all( - [ - getSSRAccessToken(), - getUserContextJwtFromCookie(), - getCurrentUserFromCookie(), - getRemoteConfigValues(), - ], - ); + const [accessToken, userContextJwt, user] = await Promise.all([ + getSSRAccessToken(), + getUserContextJwtFromCookie(), + getCurrentUserFromCookie(), + ]); const userId = user?.uid ?? 'anonymous'; const cachedFetch = unstable_cache( @@ -53,7 +49,6 @@ export const fetchCompleteFeedData = cache( feedId, accessToken, userContextJwt, - remoteConfig.enableSealOfReliability, ); }, [`feed-complete-${feedDataType}-${feedId}-${userId}`], // unique cache key per user diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/guest-feed-data.ts b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/guest-feed-data.ts index 5f0c6c8a..2bba8282 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/guest-feed-data.ts +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/guest-feed-data.ts @@ -10,7 +10,6 @@ import 'server-only'; import { cache } from 'react'; import { unstable_cache } from 'next/cache'; import { getGuestGcipIdToken } from '../../../../../utils/auth-server'; -import { getRemoteConfigValues } from '../../../../../../lib/remote-config.server'; import { fetchCompleteFeedDataImpl, type FeedDataResult, @@ -33,16 +32,12 @@ export const fetchGuestFeedData = cache( async (feedDataType: string, feedId: string): Promise => { const cachedFetch = unstable_cache( async () => { - const [accessToken, remoteConfig] = await Promise.all([ - getGuestGcipIdToken(), - getRemoteConfigValues(), - ]); + const accessToken = await getGuestGcipIdToken(); return await fetchCompleteFeedDataImpl( feedDataType, feedId, accessToken, undefined, // no user context for guest - remoteConfig.enableSealOfReliability, ); }, [`feed-guest-${feedDataType}-${feedId}`], // unique cache key diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/seal-analysis-data.spec.ts b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/seal-analysis-data.spec.ts index 2c314c09..ad595fce 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 @@ -43,11 +43,6 @@ jest.mock('../../../../../utils/auth-server', () => ({ getUserContextJwtFromCookie: async () => 'user-jwt', })); -const mockGetRemoteConfigValues = jest.fn(); -jest.mock('../../../../../../lib/remote-config.server', () => ({ - getRemoteConfigValues: async () => await mockGetRemoteConfigValues(), -})); - const report = { feed_id: 'mdb-1', has_seal: true, criteria: [] }; const check = { checked_at: '2026-09-08T04:00:00Z', success: true }; const availability = { @@ -65,9 +60,6 @@ const coverage = { feed_id: 'mdb-1', latest_files: [] }; describe('fetchGuestSealAnalysisData', () => { beforeEach(() => { jest.clearAllMocks(); - mockGetRemoteConfigValues.mockResolvedValue({ - enableSealOfReliability: true, - }); mockGetGtfsFeedReliability.mockResolvedValue(report); mockGetGtfsFeedAvailability.mockResolvedValue(availability); mockGetGtfsFeedContinuousCoverage.mockResolvedValue(coverage); @@ -210,17 +202,6 @@ describe('fetchGuestSealAnalysisData', () => { expect(result?.availability).toEqual(flattenedAvailability); }); - it('fetches nothing when the seal feature flag is off', async () => { - mockGetRemoteConfigValues.mockResolvedValue({ - enableSealOfReliability: false, - }); - - const result = await fetchGuestSealAnalysisData('gtfs', 'mdb-1'); - - expect(result).toBeUndefined(); - expect(mockGetGtfsFeedReliability).not.toHaveBeenCalled(); - }); - it.each(['gtfs_rt', 'gbfs'])( 'fetches nothing for %s feeds, which have no seal endpoints', async (feedDataType) => { 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 a5ed0e78..ec3f117d 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 @@ -20,7 +20,6 @@ import { getSSRAccessToken, getUserContextJwtFromCookie, } from '../../../../../utils/auth-server'; -import { getRemoteConfigValues } from '../../../../../../lib/remote-config.server'; type ReliabilityReport = components['schemas']['FeedReliabilityReport']; type AvailabilityResponse = @@ -230,12 +229,9 @@ async function fetchSealAnalysisImpl( } /** `undefined` whenever there is no analysis to fetch, rather than an error. */ -function isSealAnalysisApplicable( - feedDataType: string, - enableSealOfReliability: boolean, -): boolean { +function isSealAnalysisApplicable(feedDataType: string): boolean { // The three endpoints exist only under /v1/gtfs_feeds. - return feedDataType === 'gtfs' && enableSealOfReliability; + return feedDataType === 'gtfs'; } /** @@ -254,20 +250,12 @@ export const fetchGuestSealAnalysisData = cache( feedDataType: string, feedId: string, ): Promise => { - const [accessToken, remoteConfig] = await Promise.all([ - getGuestGcipIdToken(), - getRemoteConfigValues(), - ]); - - if ( - !isSealAnalysisApplicable( - feedDataType, - remoteConfig.enableSealOfReliability, - ) - ) { + if (!isSealAnalysisApplicable(feedDataType)) { return undefined; } + const accessToken = await getGuestGcipIdToken(); + return await fetchSealAnalysisImpl(feedId, accessToken, undefined); }, ); @@ -281,21 +269,15 @@ export const fetchAuthedSealAnalysisData = cache( feedDataType: string, feedId: string, ): Promise => { - const [accessToken, userContextJwt, remoteConfig] = await Promise.all([ + if (!isSealAnalysisApplicable(feedDataType)) { + return undefined; + } + + const [accessToken, userContextJwt] = await Promise.all([ getSSRAccessToken(), getUserContextJwtFromCookie(), - getRemoteConfigValues(), ]); - if ( - !isSealAnalysisApplicable( - feedDataType, - remoteConfig.enableSealOfReliability, - ) - ) { - return undefined; - } - return await fetchSealAnalysisImpl(feedId, accessToken, userContextJwt); }, ); diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx index ad8fce51..6f2ad2fd 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/static/seal-of-reliability/page.tsx @@ -44,8 +44,8 @@ export default async function StaticFeedReliabilityPage({ // Settled rather than all-or-nothing: the two requests fail for unrelated // reasons and need unrelated responses. A missing feed is a 404; a seal - // loader that can't mint a token, read Remote Config, or reach its cache is - // a reliability error on a page that does exist. + // loader that can't mint a token or reach its cache is a reliability error + // on a page that does exist. const [feedResult, sealResult] = await Promise.allSettled([ fetchGuestFeedData(feedDataType, feedId), fetchGuestSealAnalysisData(feedDataType, feedId), From 5c07a740fb130bf185273129e1ce82e70d6aa755 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Fri, 11 Sep 2026 09:33:02 -0400 Subject: [PATCH 17/28] moved the seal firebase feature flag client side --- src/app/components/SealOfReliabilityChip.tsx | 4 +- src/app/screens/Feed/FeedView.tsx | 7 +- .../ClientQualityAnalysisButton.tsx | 8 +- .../Feed/components/DataQualitySummary.tsx | 14 ++-- .../screens/Feed/components/FeedSummary.tsx | 81 ++++++++++--------- 5 files changed, 60 insertions(+), 54 deletions(-) diff --git a/src/app/components/SealOfReliabilityChip.tsx b/src/app/components/SealOfReliabilityChip.tsx index 0a8aa746..5812ae00 100644 --- a/src/app/components/SealOfReliabilityChip.tsx +++ b/src/app/components/SealOfReliabilityChip.tsx @@ -3,6 +3,7 @@ import { Chip, Tooltip } from '@mui/material'; import { useTranslations } from 'next-intl'; import { Link } from '../../i18n/navigation'; import SealOfReliability from './SealOfReliability'; +import { useRemoteConfig } from '../context/RemoteConfigProvider'; export interface SealOfReliabilityChipProps { hasSeal: boolean | undefined; @@ -20,8 +21,9 @@ export default function SealOfReliabilityChip({ disableLink = false, }: SealOfReliabilityChipProps): React.ReactElement | null { const t = useTranslations('feeds'); + const { config } = useRemoteConfig(); - if (hasSeal == undefined) { + if (!config.enableSealOfReliability || hasSeal == undefined) { return null; } diff --git a/src/app/screens/Feed/FeedView.tsx b/src/app/screens/Feed/FeedView.tsx index efb5b780..a261f602 100644 --- a/src/app/screens/Feed/FeedView.tsx +++ b/src/app/screens/Feed/FeedView.tsx @@ -36,7 +36,6 @@ import { } from './Feed.functions'; import dynamic from 'next/dynamic'; import { ContentBox } from '../../components/ContentBox'; -import { getRemoteConfigValues } from '../../../lib/remote-config.server'; import SectionContainer from '../../components/SectionContainer'; const CoveredAreaMap = dynamic( @@ -95,10 +94,9 @@ export default async function FeedView({ isMobilityDatabaseAdmin = false, }: Props): Promise { if (feed == undefined) notFound(); - const [t, tGbfs, config] = await Promise.all([ + const [t, tGbfs] = await Promise.all([ getTranslations('feeds'), getTranslations('gbfs'), - getRemoteConfigValues(), ]); // Pinned on the server so the six-month "building record" branch in the @@ -223,7 +221,7 @@ export default async function FeedView({ downloadLatestUrl.length > 0 && ( )} - {isGtfsFeedType(feed) && config.enableSealOfReliability && ( + {isGtfsFeedType(feed) && ( diff --git a/src/app/screens/Feed/components/ClientQualityAnalysisButton.tsx b/src/app/screens/Feed/components/ClientQualityAnalysisButton.tsx index ce9215ed..f4c6be09 100644 --- a/src/app/screens/Feed/components/ClientQualityAnalysisButton.tsx +++ b/src/app/screens/Feed/components/ClientQualityAnalysisButton.tsx @@ -4,6 +4,7 @@ import { Button } from '@mui/material'; import { sendGAEvent } from '@next/third-parties/google'; import { useTranslations } from 'next-intl'; import { Link } from '../../../../i18n/navigation'; +import { useRemoteConfig } from '../../../context/RemoteConfigProvider'; export default function ClientQualityAnalysisButton({ feedId, @@ -11,8 +12,9 @@ export default function ClientQualityAnalysisButton({ }: { feedId: string; feedDataType: string; -}): React.ReactElement { +}): React.ReactElement | null { const t = useTranslations('feeds'); + const { config } = useRemoteConfig(); const handleViewFeedQualityAnalysisClick = (): void => { sendGAEvent('event', 'view_feed_quality_analysis', { @@ -21,6 +23,10 @@ export default function ClientQualityAnalysisButton({ }); }; + if (!config.enableSealOfReliability) { + return null; + } + return (