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 (