diff --git a/external_types/DatabaseCatalogAPI.yaml b/external_types/DatabaseCatalogAPI.yaml index 552f5afa..ebc32611 100644 --- a/external_types/DatabaseCatalogAPI.yaml +++ b/external_types/DatabaseCatalogAPI.yaml @@ -332,10 +332,11 @@ paths: - $ref: "#/components/parameters/feed_id_path_param" get: description: > - Returns the continuous coverage history for a GTFS feed: one entry per dataset, ordered by - `downloaded_at` from newest to oldest. Each entry carries the service window the dataset - covers, the window declared in its `feed_info.txt`, whether the two agree, and how much that - dataset overlaps the previous (older) one. + Returns the continuous coverage of a GTFS feed: `latest_state` and `latest_failure`, + plus the history, one entry per dataset ordered by `downloaded_at` from newest to oldest. + Each entry carries the service window the dataset covers, the window declared in its + `feed_info.txt`, whether the two agree, and how much that dataset overlaps the previous + (older) one. tags: - "feeds" operationId: getGtfsFeedContinuousCoverage @@ -1203,13 +1204,14 @@ components: description: > One criterion's contribution to the Seal of Reliability. - `status` is the criterion's own check at the last evaluation, undebounced, so a criterion - can read `fail` while the feed still holds the seal - that is the at-risk state, and - `in_grace_period` distinguishes it from a confirmed failure. Conversely a criterion can - read `pass` while `on_probation` is true, in which case it still does not count towards - the seal. The three states a client renders are therefore: healthy (`pass`), at risk - (`fail` with `in_grace_period`), and failing (`fail` without it) - with `on_probation` - as an independent flag on top. + `status` is the criterion's debounced verdict - the one the seal is decided on, so a + client can always explain the `has_seal` beside it. A criterion failing its daily check + but still inside its grace period reads `pass` with `in_grace_period` true: grace is not + a failing state, it is the warning before one. Conversely a criterion can read `pass` + while `on_probation` is true, in which case it still does not count towards the seal. + The three states a client renders are therefore: healthy (`pass`), at risk (`pass` with + `in_grace_period`), and failing (`fail`) - with `on_probation` as an independent flag + on top. type: object required: - criterion @@ -1237,14 +1239,15 @@ components: example: compliant status: description: > - The criterion's verdict at the last evaluation, with no grace period applied. - * `pass` - the check passed. - * `fail` - the check failed. The seal is only withdrawn once the failure outlasts - the criterion's grace period, so check `in_grace_period` before presenting this - as a loss. - * `unknown` - the criterion was evaluated but its inputs were missing, so no verdict - could be reached this time. It is skipped when deciding the seal rather than counted - as a failure. + The criterion's debounced verdict: what it contributes to the seal, grace period + already applied. + * `pass` - the criterion is not counting against the seal. Either its check passed, + or the check failed and the failure is still inside the criterion's grace period, + which `in_grace_period` tells apart. + * `fail` - the failure is confirmed and the criterion is withholding the seal. + * `unknown` - not produced. A run whose inputs were missing reaches no verdict and + leaves this value untouched, so the last verdict stands. Listed only because the + underlying column can hold it. * `not_applicable` - the criterion does not apply to this feed (for example a coverage criterion on a seasonal feed) and is withdrawn from the seal entirely. * `never_evaluated` - the criterion has produced no verdict for this feed yet. It is @@ -1259,10 +1262,12 @@ components: example: fail in_grace_period: description: > - Whether a failing check is still inside the criterion's grace period, and so is not - yet counting against the seal. Can only be true while `status` is `fail`, and is - always false while `on_probation` is true, since a failure during probation restarts - probation outright rather than being absorbed. + Whether the criterion's daily check is currently failing but the failure is still + inside its grace period, and so is not yet counting against the seal. This is the + at-risk state, and the only thing in the response that reports the raw daily check. + Can only be true while `status` is `pass`, and is always false while `on_probation` + is true, since a failure during probation restarts probation outright rather than + being absorbed. type: boolean example: true grace_period_ends_at: @@ -1387,10 +1392,14 @@ components: GtfsFeedContinuousCoverageResponse: type: object + description: > + `latest_state` is the feed's latest dataset measured against the one before it; + `latest_failure` is the same measurement at the criterion's last observed failure. Both + have the structure of an `items[]` entry, and either can be null. Together they name at + most four datasets, shared when the latest state is itself the failure. required: - feed_id - items - - latest_files - total - offset - limit @@ -1399,67 +1408,10 @@ components: type: string description: Unique identifier of the GTFS feed. example: mdb-123 - latest_files: - type: array - description: > - The files the calculation reads for the feed's latest dataset (the `items[]` entry - with `is_latest: true`), and whether each was present. Always returned in the same - order with one entry per file, so a client can render a fixed row. - items: - $ref: "#/components/schemas/GtfsFeedContinuousCoverageFile" - latest_coverage_window: - $ref: "#/components/schemas/ServiceDateWindow" - latest_coverage_window_source: - type: string - nullable: true - description: > - Which input the latest dataset's `latest_coverage_window` was taken from. - - * `service_dates` - the service dates derived by the validator from `calendar.txt` and - `calendar_dates.txt`. - * `feed_info` - the dates declared in `feed_info.txt`, used only when the service dates - are missing. - enum: - - service_dates - - feed_info - example: service_dates - latest_within_max_coverage_window: - type: boolean - nullable: true - description: > - Whether the latest dataset's `latest_coverage_window` stays inside the maximum - coverage window the seal allows (two years). Null when there is no coverage window to - measure. - example: true - latest_service_window: - $ref: "#/components/schemas/ServiceDateWindow" - latest_feed_info_window: - $ref: "#/components/schemas/ServiceDateWindow" - latest_feed_info_matches: - type: boolean - nullable: true - description: > - Whether the latest dataset's `latest_feed_info_window` agrees with - `latest_service_window` on both bounds. Null when either window is missing, which is - not the same as a mismatch. - example: true - latest_overlap_days: - type: integer - nullable: true - description: > - Days of overlap between the latest dataset's coverage window and that of the dataset - immediately older than it. Zero means the windows meet exactly; a gap is reported as - `latest_gap_days` instead. Null when either window is missing or there is no older - dataset. - example: 15 - latest_gap_days: - type: integer - nullable: true - description: > - Days of uncovered service between the end of the older dataset's window and the start - of the latest dataset's window. Null when the windows overlap or meet, which is the - passing case. - example: 3 + latest_state: + $ref: "#/components/schemas/GtfsFeedContinuousCoverage" + latest_failure: + $ref: "#/components/schemas/GtfsFeedContinuousCoverage" total: type: integer description: Total number of matching datasets regardless of limit and offset. @@ -2677,12 +2629,12 @@ components: limit_query_param_availability_endpoint: name: limit in: query - description: The number of items to be returned. Maximum is 100. + description: The number of items to be returned. Maximum is 200. required: False schema: type: integer minimum: 0 - maximum: 100 + maximum: 200 default: 100 example: 10 @@ -2761,4 +2713,4 @@ components: $ref: "./BearerTokenSchema.yaml#/components/securitySchemes/Authentication" security: - - Authentication: [] + - Authentication: [] \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index f40abd53..d16e5d64 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,46 @@ "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 day ended with a successful fetch.", + "sealAvailabilityRecovered": "{count, plural, one {# day ended with a failed fetch} other {# days ended with a failed fetch}} (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}. Ensure this feed is available consistently for {months, plural, one {# month} other {# months}} to pass this criterion.", + "sealAvailabilityFailingUndated": "The feed has been unreachable. Ensure 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.", + "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}}", + "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 +720,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..dfce7ff8 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,46 @@ "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": "Chaque journée s'est terminée par une récupération réussie.", + "sealAvailabilityRecovered": "{count, plural, one {# journée s'est terminée} other {# journées se sont terminées}} par une récupération en échec (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.", + "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}}", + "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.", 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]/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) => ( + + ))} + + + ); +} 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 8df0dbb7..38e52284 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 @@ -3,6 +3,8 @@ */ import { + AVAILABILITY_LIMIT, + AVAILABILITY_MAX_EXTRA_PAGES, SEAL_ANALYSIS_REVALIDATE, fetchGuestSealAnalysisData, } from './seal-analysis-data'; @@ -43,21 +45,23 @@ 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 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: AVAILABILITY_LIMIT, + 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', () => { beforeEach(() => { jest.clearAllMocks(); - mockGetRemoteConfigValues.mockResolvedValue({ - enableSealOfReliability: true, - }); mockGetGtfsFeedReliability.mockResolvedValue(report); mockGetGtfsFeedAvailability.mockResolvedValue(availability); mockGetGtfsFeedContinuousCoverage.mockResolvedValue(coverage); @@ -68,35 +72,50 @@ describe('fetchGuestSealAnalysisData', () => { expect(result).toEqual({ reliability: report, - availability, + 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 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: AVAILABILITY_LIMIT, + offset: 0, + sort: 'desc', + }, undefined, ); + // One page covers the window, so `total` never asks for a second call. + expect(mockGetGtfsFeedAvailability).toHaveBeenCalledTimes(1); expect(mockGetGtfsFeedContinuousCoverage).toHaveBeenCalledWith( 'mdb-1', 'guest-token', @@ -105,43 +124,116 @@ describe('fetchGuestSealAnalysisData', () => { ); }); - it('discards the whole entry when the reliability call fails', async () => { + it('clamps the window start to a real date at month end', async () => { + // Six months before Aug 31 is Feb 31, which rolls forward to Mar 3 unless + // the subtraction clamps - losing days the heatmap draws. + jest.useFakeTimers().setSystemTime(new Date('2026-08-31T09:15:00Z')); + try { + await fetchGuestSealAnalysisData('gtfs', 'mdb-1'); + } finally { + jest.useRealTimers(); + } + + expect(mockGetGtfsFeedAvailability).toHaveBeenCalledWith( + 'mdb-1', + 'guest-token', + expect.objectContaining({ from: '2026-02-28T00:00:00.000Z' }), + undefined, + ); + }); + + it('fetches the follow-up pages `total` reports beyond the first', async () => { + const page = (offset: number, total: number, count: number): unknown => ({ + feed_id: 'mdb-1', + total, + offset, + limit: AVAILABILITY_LIMIT, + checks: Array.from({ length: count }, (_, index) => ({ + checked_at: `2026-09-08T04:00:0${index % 10}Z`, + success: true, + })), + }); + // Two full pages and a partial third. + const total = AVAILABILITY_LIMIT * 2 + 50; + mockGetGtfsFeedAvailability + .mockResolvedValueOnce(page(0, total, AVAILABILITY_LIMIT)) + .mockResolvedValueOnce( + page(AVAILABILITY_LIMIT, total, AVAILABILITY_LIMIT), + ) + .mockResolvedValueOnce(page(AVAILABILITY_LIMIT * 2, total, 50)); + + const result = await fetchGuestSealAnalysisData('gtfs', 'mdb-1'); + + expect(mockGetGtfsFeedAvailability).toHaveBeenCalledTimes(3); + for (const offset of [AVAILABILITY_LIMIT, AVAILABILITY_LIMIT * 2]) { + expect(mockGetGtfsFeedAvailability).toHaveBeenCalledWith( + 'mdb-1', + 'guest-token', + expect.objectContaining({ offset, limit: AVAILABILITY_LIMIT }), + undefined, + ); + } + expect(result?.availability?.checks).toHaveLength(total); + }); + + it('stops at the page cap rather than walking the whole history', async () => { + mockGetGtfsFeedAvailability.mockResolvedValue({ + feed_id: 'mdb-1', + total: AVAILABILITY_LIMIT * 500, + offset: 0, + limit: AVAILABILITY_LIMIT, + checks: Array.from({ length: AVAILABILITY_LIMIT }, () => check), + }); + + const result = await fetchGuestSealAnalysisData('gtfs', 'mdb-1'); + + // The first page plus the follow-up pages the cap allows. + expect(mockGetGtfsFeedAvailability).toHaveBeenCalledTimes( + AVAILABILITY_MAX_EXTRA_PAGES + 1, + ); + expect(result?.availability?.checks).toHaveLength( + AVAILABILITY_LIMIT * (AVAILABILITY_MAX_EXTRA_PAGES + 1), + ); + }); + + 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?.continuousCoverage).toBeUndefined(); + expect(result?.availabilityError).toBe(true); + expect(result?.continuousCoverage).toEqual(coverage); expect(result?.reliabilityError).toBe(false); expect(result?.reliability).toEqual(report); }); - it('fetches nothing when the seal feature flag is off', async () => { - mockGetRemoteConfigValues.mockResolvedValue({ - enableSealOfReliability: false, - }); + 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).toBeUndefined(); - expect(mockGetGtfsFeedReliability).not.toHaveBeenCalled(); + 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.each(['gtfs_rt', 'gbfs'])( 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..23a6aa81 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'; @@ -19,7 +20,7 @@ import { getSSRAccessToken, getUserContextJwtFromCookie, } from '../../../../../utils/auth-server'; -import { getRemoteConfigValues } from '../../../../../../lib/remote-config.server'; +import { subMonthsUtc } from '../../../../../utils/date'; type ReliabilityReport = components['schemas']['FeedReliabilityReport']; type AvailabilityResponse = @@ -32,13 +33,87 @@ type ContinuousCoverageResponse = */ export const SEAL_ANALYSIS_REVALIDATE = 21600; +const COVERAGE_LIMIT = 100; +/** Exported so the specs follow it rather than restating the page size. */ +export const AVAILABILITY_LIMIT = 200; + +/** + * 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; +export const AVAILABILITY_MAX_EXTRA_PAGES = 5; + /** - * 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". + * The newest checks going back `AVAILABILITY_HISTORY_MONTHS`, flattened into + * one response. + * + * One call covers the window in the ordinary case. The response reports the + * `total` matching the window, so anything beyond the first page is fetched + * as a fixed set of follow-up requests in parallel rather than a serial walk. + * Sorted newest-first so that a feed checked often enough to overflow the cap + * keeps the days the heatmap actually draws. */ -const HISTORY_LIMIT = 100; +async function fetchAvailabilityHistory( + feedId: string, + accessToken: string, + userContextJwt: string | undefined, + now: Date, +): Promise { + // `subMonthsUtc` clamps the day to the target month's length: subtracting + // six months from Aug 31 by hand lands on Feb 31, which rolls forward to + // Mar 3 and cuts days the heatmap draws out of the requested window. + const from = subMonthsUtc( + new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), + ), + AVAILABILITY_HISTORY_MONTHS, + ).toISOString(); + + const fetchPage = async ( + offset: number, + ): Promise => + await getGtfsFeedAvailability( + feedId, + accessToken, + { + from, + limit: AVAILABILITY_LIMIT, + offset, + // Passed explicitly because the OpenAPI spec contradicts itself on the + // default ordering of `checks`. + sort: 'desc', + }, + userContextJwt, + ); + + const firstPage = await fetchPage(0); + if (firstPage == undefined) { + return undefined; + } + + const checks = [...firstPage.checks]; + + // An empty first page means there is nothing to walk, whatever `total` says. + if (checks.length > 0 && firstPage.total > AVAILABILITY_LIMIT) { + const pageCount = Math.min( + Math.ceil(firstPage.total / AVAILABILITY_LIMIT), + AVAILABILITY_MAX_EXTRA_PAGES + 1, + ); + const rest = await Promise.all( + Array.from( + { length: pageCount - 1 }, + async (_, index) => await fetchPage((index + 1) * AVAILABILITY_LIMIT), + ), + ); + for (const page of rest) { + checks.push(...(page?.checks ?? [])); + } + } + + return { ...firstPage, offset: 0, limit: checks.length, checks }; +} export interface SealAnalysisData { reliability?: ReliabilityReport; @@ -49,39 +124,100 @@ export interface SealAnalysisData { * has no verdict yet", which comes back as a successful response. */ reliabilityError: boolean; + availabilityError: boolean; } /** - * Fetch the three seal endpoints together. + * 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. * - * `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. + * 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. */ -async function fetchSealAnalysisImpl( +function cachedReliability( feedId: string, accessToken: string, userContextJwt: string | undefined, -): Promise { - const [reliabilityResult, availabilityResult, coverageResult] = - await Promise.allSettled([ - getGtfsFeedReliability(feedId, accessToken, userContextJwt), - getGtfsFeedAvailability( +): () => 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, - // Passed explicitly because the OpenAPI spec contradicts itself on the - // default ordering of `checks`. - { limit: HISTORY_LIMIT, sort: 'desc' }, userContextJwt, + new Date(), ), - getGtfsFeedContinuousCoverage( + [`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 }, + { limit: COVERAGE_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` (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, + accessToken: string, + userContextJwt: string | undefined, +): Promise { + const [reliabilityResult, availabilityResult, coverageResult] = + await Promise.allSettled([ + cachedReliability(feedId, accessToken, userContextJwt)(), + cachedAvailability(feedId, accessToken, userContextJwt)(), + cachedContinuousCoverage(feedId, accessToken, userContextJwt)(), ]); return { @@ -94,57 +230,16 @@ 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, - enableSealOfReliability: boolean, -): boolean { +function isSealAnalysisApplicable(feedDataType: string): boolean { // The three endpoints exist only under /v1/gtfs_feeds. - return feedDataType === 'gtfs' && enableSealOfReliability; + return feedDataType === 'gtfs'; } /** @@ -155,29 +250,21 @@ 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 ( feedDataType: string, feedId: string, ): Promise => { - const [accessToken, remoteConfig] = await Promise.all([ - getGuestGcipIdToken(), - getRemoteConfigValues(), - ]); - - if ( - !isSealAnalysisApplicable( - feedDataType, - remoteConfig.enableSealOfReliability, - ) - ) { + if (!isSealAnalysisApplicable(feedDataType)) { return undefined; } - return await cachedSealAnalysis(feedId, accessToken, undefined)(); + const accessToken = await getGuestGcipIdToken(); + + return await fetchSealAnalysisImpl(feedId, accessToken, undefined); }, ); @@ -190,21 +277,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 cachedSealAnalysis(feedId, accessToken, userContextJwt)(); + 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 efa0e920..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 @@ -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 }>; @@ -43,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), @@ -73,9 +74,12 @@ export default async function StaticFeedReliabilityPage({ ); } + const { feed, initialDatasets } = feedResult.value; + return ( ); diff --git a/src/app/components/AuthSessionProvider.spec.tsx b/src/app/components/AuthSessionProvider.spec.tsx index 4d68b0b6..ddb8cb0a 100644 --- a/src/app/components/AuthSessionProvider.spec.tsx +++ b/src/app/components/AuthSessionProvider.spec.tsx @@ -33,7 +33,14 @@ jest.mock('../../firebase', () => ({ // ---------- Mock: session-service ---------- jest.mock('../services/session-service', () => ({ - setUserCookieSession: jest.fn().mockResolvedValue(undefined), + setUserCookieSession: jest.fn().mockResolvedValue('fresh'), +})); + +// ---------- Mock: i18n navigation ---------- + +const mockRefresh = jest.fn(); +jest.mock('../../i18n/navigation', () => ({ + useRouter: () => ({ refresh: mockRefresh }), })); // ---------- Mock: user-feature-flag-service ---------- @@ -90,7 +97,12 @@ function renderProvider(): RenderResult { ); } -const mockUser = { uid: 'user-1' }; +const mockUser = { uid: 'user-1', isAnonymous: false }; +const mockGuest = { uid: 'guest-1', isAnonymous: true }; + +function mockSessionStatus(status: string): void { + (setUserCookieSession as jest.Mock).mockResolvedValue(status); +} // ---------- Tests ---------- @@ -212,6 +224,172 @@ describe('AuthSessionProvider', () => { }); }); + // The proxy routes a request with no valid `md_session` to the guest + // `static/` tree, so a document rendered before the cookie was established is + // an anonymous view. Refreshing re-runs the proxy with the cookie in place. + describe('refreshing after the session cookie is established', () => { + it('refreshes when an expired cookie is renewed', async () => { + mockSessionStatus('renewal'); + renderProvider(); + + await act(async () => { + capturedAuthCallback(mockUser); + }); + + expect(mockRefresh).toHaveBeenCalledTimes(1); + }); + + it('refreshes when a session is established for a new identity', async () => { + mockSessionStatus('new'); + renderProvider(); + + await act(async () => { + capturedAuthCallback(mockUser); + }); + + expect(mockRefresh).toHaveBeenCalledTimes(1); + }); + + it('does not refresh when the cookie was already fresh', async () => { + mockSessionStatus('fresh'); + renderProvider(); + + await act(async () => { + capturedAuthCallback(mockUser); + }); + + expect(mockRefresh).not.toHaveBeenCalled(); + }); + + it('does not refresh when the POST failed', async () => { + mockSessionStatus('failed'); + renderProvider(); + + await act(async () => { + capturedAuthCallback(mockUser); + }); + + expect(mockRefresh).not.toHaveBeenCalled(); + }); + + // Guests are routed to `static/` with or without a cookie, so a refresh + // would land on the very same tree. + it('does not refresh for an anonymous user', async () => { + mockSessionStatus('new'); + renderProvider(); + + await act(async () => { + capturedAuthCallback(mockGuest); + }); + + expect(mockRefresh).not.toHaveBeenCalled(); + }); + + // The hourly renewal on a long-open tab is a page that already rendered + // under the right tree. + it('does not refresh again on a later renewal for the same user', async () => { + mockSessionStatus('fresh'); + renderProvider(); + + await act(async () => { + capturedAuthCallback(mockUser); + }); + + mockSessionStatus('renewal'); + await act(async () => { + jest.advanceTimersByTime(RENEWAL_INTERVAL_MS); + }); + + expect(setUserCookieSession).toHaveBeenCalledTimes(2); + expect(mockRefresh).not.toHaveBeenCalled(); + }); + + // A failed POST leaves no cookie, so the route on screen is still the + // guest one - the retry that finally establishes the session has to be the + // one that refreshes it. + it('refreshes on the retry after the first POST failed', async () => { + mockSessionStatus('failed'); + renderProvider(); + + await act(async () => { + capturedAuthCallback(mockUser); + }); + expect(mockRefresh).not.toHaveBeenCalled(); + + mockSessionStatus('new'); + await act(async () => { + jest.advanceTimersByTime(RENEWAL_INTERVAL_MS); + }); + + expect(mockRefresh).toHaveBeenCalledTimes(1); + }); + + it('refreshes on the retry after the first POST rejected', async () => { + (setUserCookieSession as jest.Mock).mockRejectedValueOnce( + new Error('network'), + ); + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + renderProvider(); + + await act(async () => { + capturedAuthCallback(mockUser); + }); + expect(mockRefresh).not.toHaveBeenCalled(); + + mockSessionStatus('new'); + await act(async () => { + jest.advanceTimersByTime(RENEWAL_INTERVAL_MS); + }); + + expect(mockRefresh).toHaveBeenCalledTimes(1); + consoleError.mockRestore(); + }); + + // Releasing the uid must not resurrect a refresh for a sync that already + // succeeded - only the failed one is rolled back. + it('does not refresh again when a later renewal fails', async () => { + mockSessionStatus('new'); + renderProvider(); + + await act(async () => { + capturedAuthCallback(mockUser); + }); + expect(mockRefresh).toHaveBeenCalledTimes(1); + + mockSessionStatus('failed'); + await act(async () => { + jest.advanceTimersByTime(RENEWAL_INTERVAL_MS); + }); + + mockSessionStatus('renewal'); + await act(async () => { + jest.advanceTimersByTime(RENEWAL_INTERVAL_MS); + }); + + expect(mockRefresh).toHaveBeenCalledTimes(1); + }); + + // Signing in on a page that was rendered for a guest is a wrong-tree + // render too, even though it is not the first sync of this page's life. + it('refreshes when the identity changes from guest to signed in', async () => { + mockSessionStatus('new'); + renderProvider(); + + await act(async () => { + capturedAuthCallback(mockGuest); + }); + expect(mockRefresh).not.toHaveBeenCalled(); + + await act(async () => { + capturedAuthCallback(mockUser); + }); + + expect(mockRefresh).toHaveBeenCalledTimes(1); + }); + }); + describe('when no user is present', () => { it('dispatches anonymousLogin', async () => { renderProvider(); diff --git a/src/app/components/AuthSessionProvider.tsx b/src/app/components/AuthSessionProvider.tsx index ccc887d6..50e05c53 100644 --- a/src/app/components/AuthSessionProvider.tsx +++ b/src/app/components/AuthSessionProvider.tsx @@ -13,6 +13,7 @@ import { useDispatch } from 'react-redux'; import { app } from '../../firebase'; import { anonymousLogin } from '../store/profile-reducer'; import { setUserCookieSession } from '../services/session-service'; +import { useRouter } from '../../i18n/navigation'; import { revalidateUserFeatureFlags } from '../services/user-feature-flag-service'; interface AuthSession { @@ -62,7 +63,9 @@ export function useAuthSession(): AuthSession { * * 1. Triggers anonymous sign-in when no user exists. * 2. Re-establishes the `md_session` cookie on return visits (Firebase - * restores auth from IndexedDB but the 1-hour cookie has expired). + * restores auth from IndexedDB but the 1-hour cookie has expired), then + * refreshes the route so the proxy can re-run with the restored cookie — + * the document was served from the guest `static/` tree without it. * 3. Schedules the next renewal at exactly `expiresAt - 5 min` using * a setTimeout derived from the value stored in localStorage. * 4. Deduplicates POSTs across tabs — localStorage is shared across all @@ -87,6 +90,16 @@ export function AuthSessionProvider({ displayName: null, }); const intervalRef = useRef | null>(null); + /** + * The last identity whose session this page has already resolved. Only the + * first resolution for a given uid can have followed a wrong-tree render. + */ + const settledUidRef = useRef(null); + const router = useRouter(); + const routerRef = useRef(router); + useEffect(() => { + routerRef.current = router; + }, [router]); useEffect(() => { /** @@ -95,13 +108,54 @@ export function AuthSessionProvider({ * the session without a poller of their own. */ const syncSession = (uid: string, isAnonymous: boolean): void => { + /** + * Claimed synchronously so two overlapping syncs for the same uid - an + * onIdTokenChanged landing on top of an in-flight POST - don't both count + * as the first and both refresh. + */ + const previousSettledUid = settledUidRef.current; + const isFirstForUid = previousSettledUid !== uid; + settledUidRef.current = uid; + + /** + * Nothing was established, so this uid is not settled after all. Released + * again - unless a newer identity has since claimed the ref - so the + * five-minute retry still counts as the first sync for this user and can + * refresh the guest-rendered route it inherited. + */ + const releaseUid = (): void => { + if (settledUidRef.current === uid) { + settledUidRef.current = previousSettledUid; + } + }; + setUserCookieSession() - .then((wasRenewed) => { - if (wasRenewed && !isAnonymous) { + .then((status) => { + if (status === 'failed') { + releaseUid(); + return; + } + if (status === 'renewal' && !isAnonymous) { void revalidateUserFeatureFlags(uid); } + /** + * Addresses the issue where a cookie is expired and the user + * goes directly to a page that requires authentication (ex: feed detail) + * If the user goes on the feed detail page directly after the + * cookie expires (ex: coming back the next day) it will call the + * server component with an expired cookie resulting in wrong path + * Solution is to recognize this from the client and refresh the page + */ + if ( + isFirstForUid && + !isAnonymous && + (status === 'new' || status === 'renewal') + ) { + routerRef.current.refresh(); + } }) .catch(() => { + releaseUid(); console.error('Failed to establish session cookie'); }); }; @@ -148,6 +202,7 @@ export function AuthSessionProvider({ return () => { unsubscribe(); if (intervalRef.current != null) clearInterval(intervalRef.current); + settledUidRef.current = null; }; }, [dispatch]); 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/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..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)); } /** @@ -218,6 +222,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: subMonthsUtc(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 +258,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()))) + : subMonthsUtc(end, PROBATION_MONTHS); + + return { start, end }; } /** How far through the probation window `now` sits, as 0-100. */ @@ -299,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/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/AvailabilityCriterionBody.tsx b/src/app/screens/Feed/components/AvailabilityCriterionBody.tsx new file mode 100644 index 00000000..6033620d --- /dev/null +++ b/src/app/screens/Feed/components/AvailabilityCriterionBody.tsx @@ -0,0 +1,150 @@ +import * as React from 'react'; +import { Alert, 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; + availabilityError?: boolean; +} + +/** + * 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, + availabilityError = false, +}: AvailabilityCriterionBodyProps): Promise { + const t = await getTranslations('feeds'); + 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 - + // 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 && ( + + )} + + {availabilityError && ( + + {t('sealAvailabilityErrorDescription')} + + )} + + {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 }, + }} + /> + ); +} 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 ( + + )} + + ); +} 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} + + + ); +} diff --git a/src/app/screens/Feed/components/CriterionSection.spec.tsx b/src/app/screens/Feed/components/CriterionSection.spec.tsx index 1ef70a47..c6b47d3f 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,81 @@ 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(); + }); +}); diff --git a/src/app/screens/Feed/components/CriterionSection.tsx b/src/app/screens/Feed/components/CriterionSection.tsx index b7dba0f6..e906fda2 100644 --- a/src/app/screens/Feed/components/CriterionSection.tsx +++ b/src/app/screens/Feed/components/CriterionSection.tsx @@ -12,6 +12,7 @@ import { import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { useTranslations } from 'next-intl'; import { Link } from '../../../../i18n/navigation'; +import CriterionProbationProgress from './CriterionProbationProgress'; import { API_CRITERION_TO_KEY, SEAL_CRITERION_ICONS, @@ -19,6 +20,7 @@ import { getCriterionCopy, getCriterionDisplayStatus, getCriterionStatusColor, + getProbationWindowFromEnd, } from '../../../constants/sealCriteria'; import { type components } from '../../../services/feeds/types'; import { formatDateShort } from '../../../utils/date'; @@ -29,6 +31,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 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 +59,9 @@ export default function CriterionSection({ context, producerUrl, statusChip, + metaChips, + children, + hideProbationProgress, }: CriterionSectionProps): React.ReactElement { const t = useTranslations('feeds'); const tSeal = useTranslations('sealOfReliability'); @@ -58,6 +79,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 +101,14 @@ export default function CriterionSection({ sx={{ mb: 0, height: '100%' }} data-testid={`criterion-section-${key}`} > + {/* Wraps rather than clips: with a metric chip alongside the status + chip, the row outgrows a narrow card. */} @@ -97,7 +130,15 @@ export default function CriterionSection({ /> {tSeal(copy.titleKey)} - + + {metaChips} {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/DataQualitySummary.tsx b/src/app/screens/Feed/components/DataQualitySummary.tsx index eee8188e..d7aa5ede 100644 --- a/src/app/screens/Feed/components/DataQualitySummary.tsx +++ b/src/app/screens/Feed/components/DataQualitySummary.tsx @@ -45,14 +45,12 @@ export default async function DataQualitySummary({ {config.enableFeedStatusBadge && ( )} - {config.enableSealOfReliability && ( - - )} + {latestDataset?.validation_report !== undefined && latestDataset.validation_report !== null && ( diff --git a/src/app/screens/Feed/components/FeedReliabilityView.tsx b/src/app/screens/Feed/components/FeedReliabilityView.tsx index 14b7d429..47f4c523 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 && ( + + + } + > + + + + )} diff --git a/src/app/screens/Feed/components/FeedSummary.tsx b/src/app/screens/Feed/components/FeedSummary.tsx index 31c9dc7a..408e2bb1 100644 --- a/src/app/screens/Feed/components/FeedSummary.tsx +++ b/src/app/screens/Feed/components/FeedSummary.tsx @@ -57,6 +57,7 @@ import { formatDateShort } from '../../../utils/date'; import ExternalIds from './ExternalIds'; import SealQualitySummary from './SealQualitySummary'; import SealOfReliability from '../../../components/SealOfReliability'; +import { useRemoteConfig } from '../../../context/RemoteConfigProvider'; const Locations = dynamic( async () => await import('../../../components/Locations'), @@ -70,7 +71,6 @@ export interface FeedSummaryProps { autoDiscoveryUrl?: string; totalRoutes?: number; routeTypes?: string[]; - enableSealOfReliability?: boolean; reliability?: components['schemas']['FeedReliabilityReport']; /** Server-pinned "now" for date-derived criterion copy. */ now: Date; @@ -83,12 +83,12 @@ export default function FeedSummary({ autoDiscoveryUrl, routeTypes, totalRoutes, - enableSealOfReliability = false, reliability, now, }: FeedSummaryProps): React.ReactElement { const t = useTranslations('feeds'); const tCommon = useTranslations('common'); + const { config } = useRemoteConfig(); const theme = useTheme(); const [openLocationDetails, setOpenLocationDetails] = useState< 'summary' | 'fullList' | undefined @@ -641,44 +641,47 @@ export default function FeedSummary({ )} - {isGtfsFeedType(feed) && enableSealOfReliability && ( - - {feed.reliability_seal?.has_seal === true && ( - - - - )} - - - {t('sealOfReliabilityAlt')} - - + {feed.reliability_seal?.has_seal === true && ( + + + + )} + + + {t('sealOfReliabilityAlt')} + - - - - - - - )} + + + + + + + + )} {latestDataset?.validation_report?.features != undefined && latestDataset?.validation_report?.features.length > 0 && ( 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..e63e838e --- /dev/null +++ b/src/app/screens/Feed/lib/availability-history.spec.ts @@ -0,0 +1,360 @@ +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("takes the day's last check as its status, whatever the input order", () => { + for (const checks of [ + [ + check('2026-09-08', true, '04:00:00'), + check('2026-09-08', false, '16:00:00'), + ], + [ + check('2026-09-08', false, '16:00:00'), + check('2026-09-08', true, '04:00:00'), + ], + ]) { + const calendar = buildAvailabilityCalendar(checks, { + 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('recovers a day whose earlier checks failed but whose last one passed', () => { + const calendar = buildAvailabilityCalendar( + [ + check('2026-09-08', false, '04:00:00'), + check('2026-09-08', false, '10:00:00'), + check('2026-09-08', true, '16:00:00'), + ], + { now: NOW, months: 1 }, + ); + + const day = calendar.days.find((d) => d.date === '2026-09-08'); + expect(day?.status).toBe('success'); + expect(day?.checkCount).toBe(3); + expect(calendar.failureCount).toBe(0); + expect(calendar.successCount).toBe(1); + }); + + it('lets a failure stand when two checks share the same instant', () => { + const calendar = buildAvailabilityCalendar( + [ + check('2026-09-08', true, '16:00:00'), + check('2026-09-08', false, '16:00:00'), + ], + { now: NOW, months: 1 }, + ); + + expect(calendar.days.find((d) => d.date === '2026-09-08')?.status).toBe( + 'failure', + ); + }); + + 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('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( + criterion({ status: 'fail', first_failure_at: '2026-08-01T04:00:00Z' }), + calendar, + NOW, + ).key, + ).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( + 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('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( + 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..f749bdfa --- /dev/null +++ b/src/app/screens/Feed/lib/availability-history.ts @@ -0,0 +1,327 @@ +/** + * 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. Where a + * day holds several checks, the last one decides its status - a feed that + * failed in the morning and was back up by the evening ended the day + * available. + */ +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< + string, + { latestAt: number; success: boolean; checkCount: number } + >(); + for (const check of checks) { + const checkedAt = new Date(check.checked_at); + if (isNaN(checkedAt.getTime()) || checkedAt < start) { + continue; + } + const key = toUtcDayKey(checkedAt); + const at = checkedAt.getTime(); + const entry = byDay.get(key); + if (entry == undefined) { + byDay.set(key, { latestAt: at, success: check.success, checkCount: 1 }); + continue; + } + entry.checkCount += 1; + if (at > entry.latestAt) { + entry.latestAt = at; + entry.success = check.success; + } else if (at === entry.latestAt) { + // Two checks on the same instant have no "last" between them, and the + // input order is not meaningful - the loader flattens pages fetched in + // parallel. Let the failure stand so the result doesn't depend on it. + entry.success = entry.success && check.success; + } + } + + 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.success + ? 'success' + : 'failure', + }); + } + + 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(), + availabilityError = false, +): 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. `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: + 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) + : 0, + }; + } + if (displayStatus === 'fail') { + 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' || + calendar.successCount + calendar.failureCount === 0 + ) { + // 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: {} }; + } + return { + key: 'sealAvailabilityRecovered', + values: { + count: calendar.failureCount, + graceDays, + date: + calendar.lastFailureDate != undefined + ? formatDateShort(calendar.lastFailureDate) + : '', + }, + }; +} 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) }, + }; +} diff --git a/src/app/services/feeds/types.ts b/src/app/services/feeds/types.ts index 42824bba..97e26541 100644 --- a/src/app/services/feeds/types.ts +++ b/src/app/services/feeds/types.ts @@ -242,7 +242,7 @@ export interface paths { }; cookie?: never; }; - /** @description Returns the continuous coverage history for a GTFS feed: one entry per dataset, ordered by `downloaded_at` from newest to oldest. Each entry carries the service window the dataset covers, the window declared in its `feed_info.txt`, whether the two agree, and how much that dataset overlaps the previous (older) one. */ + /** @description Returns the continuous coverage of a GTFS feed: `latest_state` and `latest_failure`, plus the history, one entry per dataset ordered by `downloaded_at` from newest to oldest. Each entry carries the service window the dataset covers, the window declared in its `feed_info.txt`, whether the two agree, and how much that dataset overlaps the previous (older) one. */ get: operations['getGtfsFeedContinuousCoverage']; put?: never; post?: never; @@ -821,7 +821,7 @@ export interface components { }; /** * @description One criterion's contribution to the Seal of Reliability. - * `status` is the criterion's own check at the last evaluation, undebounced, so a criterion can read `fail` while the feed still holds the seal - that is the at-risk state, and `in_grace_period` distinguishes it from a confirmed failure. Conversely a criterion can read `pass` while `on_probation` is true, in which case it still does not count towards the seal. The three states a client renders are therefore: healthy (`pass`), at risk (`fail` with `in_grace_period`), and failing (`fail` without it) - with `on_probation` as an independent flag on top. + * `status` is the criterion's debounced verdict - the one the seal is decided on, so a client can always explain the `has_seal` beside it. A criterion failing its daily check but still inside its grace period reads `pass` with `in_grace_period` true: grace is not a failing state, it is the warning before one. Conversely a criterion can read `pass` while `on_probation` is true, in which case it still does not count towards the seal. The three states a client renders are therefore: healthy (`pass`), at risk (`pass` with `in_grace_period`), and failing (`fail`) - with `on_probation` as an independent flag on top. */ ReliabilityCriterion: { /** @@ -843,14 +843,14 @@ export interface components { | 'fresh_coverage' | 'fresh_continuous'; /** - * @description The criterion's verdict at the last evaluation, with no grace period applied. - * * `pass` - the check passed. - * * `fail` - the check failed. The seal is only withdrawn once the failure outlasts - * the criterion's grace period, so check `in_grace_period` before presenting this - * as a loss. - * * `unknown` - the criterion was evaluated but its inputs were missing, so no verdict - * could be reached this time. It is skipped when deciding the seal rather than counted - * as a failure. + * @description The criterion's debounced verdict: what it contributes to the seal, grace period already applied. + * * `pass` - the criterion is not counting against the seal. Either its check passed, + * or the check failed and the failure is still inside the criterion's grace period, + * which `in_grace_period` tells apart. + * * `fail` - the failure is confirmed and the criterion is withholding the seal. + * * `unknown` - not produced. A run whose inputs were missing reaches no verdict and + * leaves this value untouched, so the last verdict stands. Listed only because the + * underlying column can hold it. * * `not_applicable` - the criterion does not apply to this feed (for example a * coverage criterion on a seasonal feed) and is withdrawn from the seal entirely. * * `never_evaluated` - the criterion has produced no verdict for this feed yet. It is @@ -865,7 +865,7 @@ export interface components { | 'not_applicable' | 'never_evaluated'; /** - * @description Whether a failing check is still inside the criterion's grace period, and so is not yet counting against the seal. Can only be true while `status` is `fail`, and is always false while `on_probation` is true, since a failure during probation restarts probation outright rather than being absorbed. + * @description Whether the criterion's daily check is currently failing but the failure is still inside its grace period, and so is not yet counting against the seal. This is the at-risk state, and the only thing in the response that reports the raw daily check. Can only be true while `status` is `pass`, and is always false while `on_probation` is true, since a failure during probation restarts probation outright rather than being absorbed. * @example true */ in_grace_period: boolean; @@ -964,47 +964,15 @@ export interface components { */ error_type?: string | null; }; + /** @description `latest_state` is the feed's latest dataset measured against the one before it; `latest_failure` is the same measurement at the criterion's last observed failure. Both have the structure of an `items[]` entry, and either can be null. Together they name at most four datasets, shared when the latest state is itself the failure. */ GtfsFeedContinuousCoverageResponse: { /** * @description Unique identifier of the GTFS feed. * @example mdb-123 */ feed_id: string; - /** @description The files the calculation reads for the feed's latest dataset (the `items[]` entry with `is_latest: true`), and whether each was present. Always returned in the same order with one entry per file, so a client can render a fixed row. */ - latest_files: components['schemas']['GtfsFeedContinuousCoverageFile'][]; - latest_coverage_window?: components['schemas']['ServiceDateWindow']; - /** - * @description Which input the latest dataset's `latest_coverage_window` was taken from. - * * `service_dates` - the service dates derived by the validator from `calendar.txt` and - * `calendar_dates.txt`. - * * `feed_info` - the dates declared in `feed_info.txt`, used only when the service dates - * are missing. - * @example service_dates - * @enum {string|null} - */ - latest_coverage_window_source?: 'service_dates' | 'feed_info' | null; - /** - * @description Whether the latest dataset's `latest_coverage_window` stays inside the maximum coverage window the seal allows (two years). Null when there is no coverage window to measure. - * @example true - */ - latest_within_max_coverage_window?: boolean | null; - latest_service_window?: components['schemas']['ServiceDateWindow']; - latest_feed_info_window?: components['schemas']['ServiceDateWindow']; - /** - * @description Whether the latest dataset's `latest_feed_info_window` agrees with `latest_service_window` on both bounds. Null when either window is missing, which is not the same as a mismatch. - * @example true - */ - latest_feed_info_matches?: boolean | null; - /** - * @description Days of overlap between the latest dataset's coverage window and that of the dataset immediately older than it. Zero means the windows meet exactly; a gap is reported as `latest_gap_days` instead. Null when either window is missing or there is no older dataset. - * @example 15 - */ - latest_overlap_days?: number | null; - /** - * @description Days of uncovered service between the end of the older dataset's window and the start of the latest dataset's window. Null when the windows overlap or meet, which is the passing case. - * @example 3 - */ - latest_gap_days?: number | null; + latest_state?: components['schemas']['GtfsFeedContinuousCoverage']; + latest_failure?: components['schemas']['GtfsFeedContinuousCoverage']; /** * @description Total number of matching datasets regardless of limit and offset. * @example 42 @@ -1788,7 +1756,7 @@ export interface components { system_id_param: string; /** @description Filter feeds by their supported GBFS version. This is a string that follows the semantic versioning format. */ version_param: string; - /** @description The number of items to be returned. Maximum is 100. */ + /** @description The number of items to be returned. Maximum is 200. */ limit_query_param_availability_endpoint: number; /** @description Return availability checks performed at or after this timestamp. Date should be in ISO 8601 date-time format. */ availability_from: string; @@ -2129,7 +2097,7 @@ export interface operations { from?: components['parameters']['availability_from']; /** @description Return availability checks performed at or before this timestamp. Date should be in ISO 8601 date-time format. */ to?: components['parameters']['availability_to']; - /** @description The number of items to be returned. Maximum is 100. */ + /** @description The number of items to be returned. Maximum is 200. */ limit?: components['parameters']['limit_query_param_availability_endpoint']; /** @description Offset of the first item to return. */ offset?: components['parameters']['offset']; diff --git a/src/app/services/session-service.ts b/src/app/services/session-service.ts index 7d13c839..baeda92c 100644 --- a/src/app/services/session-service.ts +++ b/src/app/services/session-service.ts @@ -10,15 +10,24 @@ interface SessionMeta { expiresAt: number; } -type SessionStatus = +/** + * Outcome of {@link setUserCookieSession}. + * + * Callers use this to tell "the cookie was already good" from "we had to + * establish one". The latter means the current document was rendered without + * a valid `md_session`, and the proxy therefore routed it as a guest. + */ +export type SessionStatus = /** Session is valid — no POST needed. */ | 'fresh' /** Prior session for this user existed but expired — a renewal. */ | 'renewal' /** No prior session for this user — first login or identity change. */ - | 'new'; + | 'new' + /** The POST failed — no session was established. */ + | 'failed'; -function getSessionStatus(uid: string): SessionStatus { +function getSessionStatus(uid: string): Exclude { try { const raw = localStorage.getItem(STORED_SESSION_KEY); const meta = raw != null ? (JSON.parse(raw) as SessionMeta) : null; @@ -40,18 +49,19 @@ function getSessionStatus(uid: string): SessionStatus { * Identity changes (e.g. anonymous → authenticated) are handled * automatically: a different uid always triggers a fresh POST. * - * Returns true when an existing session was renewed (same uid, cookie was - * stale). Returns false when the session was freshly established (first login) - * or was still fresh (no-op). + * Returns the status that was acted on: `'fresh'` when no POST was needed, + * `'renewal'` or `'new'` when one succeeded, `'failed'` when it did not. + * Anything other than `'fresh'` means the document currently on screen was + * rendered without this user's session — see AuthSessionProvider. */ -export const setUserCookieSession = async (): Promise => { - if (typeof window === 'undefined') return false; +export const setUserCookieSession = async (): Promise => { + if (typeof window === 'undefined') return 'fresh'; const user = app.auth().currentUser; - if (user == null) return false; + if (user == null) return 'fresh'; const sessionStatus = getSessionStatus(user.uid); - if (sessionStatus === 'fresh') return false; + if (sessionStatus === 'fresh') return 'fresh'; const idToken = await user.getIdToken(); const resp = await fetch('/api/session', { @@ -72,10 +82,10 @@ export const setUserCookieSession = async (): Promise => { } catch { // Private browsing or storage quota exceeded — best-effort. } - return sessionStatus === 'renewal'; + return sessionStatus; } - return false; + return 'failed'; }; /** diff --git a/src/app/utils/date.spec.ts b/src/app/utils/date.spec.ts index baeb8422..9009f568 100644 --- a/src/app/utils/date.spec.ts +++ b/src/app/utils/date.spec.ts @@ -1,4 +1,8 @@ -import { getTimeLeftForTokenExpiration, displayFormattedDate } from './date'; +import { + getTimeLeftForTokenExpiration, + displayFormattedDate, + subMonthsUtc, +} from './date'; describe('displayFormattedDate', () => { test('returns empty string for null', () => { @@ -110,3 +114,31 @@ describe('getTimeLeftForTokenExpiration', () => { expect(timeLeft.future).toBe(false); }); }); + +describe('subMonthsUtc', () => { + it('subtracts whole months in UTC, preserving the time of day', () => { + expect( + subMonthsUtc(new Date('2026-09-11T13:45:30.250Z'), 6).toISOString(), + ).toBe('2026-03-11T13:45:30.250Z'); + }); + + it('clamps to the last day of the target month instead of rolling forward', () => { + // Feb 31 does not exist: plain Date.UTC arithmetic would land on Mar 3. + expect( + subMonthsUtc(new Date('2026-08-31T00:00:00Z'), 6).toISOString(), + ).toBe('2026-02-28T00:00:00.000Z'); + // Leap year, so the same subtraction stops a day later. + expect( + subMonthsUtc(new Date('2024-08-31T00:00:00Z'), 6).toISOString(), + ).toBe('2024-02-29T00:00:00.000Z'); + expect( + subMonthsUtc(new Date('2026-05-31T00:00:00Z'), 1).toISOString(), + ).toBe('2026-04-30T00:00:00.000Z'); + }); + + it('crosses the year boundary', () => { + expect( + subMonthsUtc(new Date('2026-01-31T00:00:00Z'), 2).toISOString(), + ).toBe('2025-11-30T00:00:00.000Z'); + }); +}); diff --git a/src/app/utils/date.ts b/src/app/utils/date.ts index 25ae4148..4fd45b51 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 @@ -63,3 +75,60 @@ 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. + * + * The day of the month is clamped to the target month's length, the way + * date-fns's `subMonths` does it. Handing `Date.UTC` an out-of-range day + * instead rolls it *forward* into the next month - six months before Aug 31 + * would be Feb 31, i.e. Mar 3 - which silently shortens any window built + * from it. + */ +export function subMonthsUtc(date: Date, months: number): Date { + const targetYear = date.getUTCFullYear(); + const targetMonth = date.getUTCMonth() - months; + // Day 0 of the following month is the last day of the target month, and + // `Date.UTC` normalises a month outside 0-11 into the right year for us. + const daysInTargetMonth = new Date( + Date.UTC(targetYear, targetMonth + 1, 0), + ).getUTCDate(); + + return new Date( + Date.UTC( + targetYear, + targetMonth, + Math.min(date.getUTCDate(), daysInTargetMonth), + date.getUTCHours(), + date.getUTCMinutes(), + date.getUTCSeconds(), + date.getUTCMilliseconds(), + ), + ); +} 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 => {