diff --git a/messages/en.json b/messages/en.json
index 1386a2e8..b4560367 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -577,6 +577,7 @@
"That's where the MobilityData Seal of Reliability comes in. As a clear measure of baseline quality, the Seal of Reliability is applied to feeds that meet five key criteria: they're official, stable, available, compliant, and fresh."
],
"ctaButton": "See a feed measured against the standard (todo)",
+ "calculationButton": "See how it's calculated",
"imageAlt": "current placeholder",
"feedsMeetingStandard": "Feeds meeting the standard",
"meetsPercent": "{percent}% meets",
@@ -617,6 +618,7 @@
},
"criteria": {
"title": "The Criteria for the Seal of Reliability",
+ "calculationButton": "See how we calculate each criterion",
"official": {
"title": "Official",
"subtitle": "Authorized by the transit agency.",
@@ -695,6 +697,217 @@
"question": "As a data producer, how do I know if I'm at risk of losing the Seal?",
"answer": "The best way is to check your feed on the Mobility Database 1-2 times a month to see your Seal status. Our team is working on email notifications, so in the future you can be notified when you've entered a grace period."
}
+ },
+ "howItIsCalculated": {
+ "eyebrow": "Seal of Reliability",
+ "title": "How it is calculated",
+ "intro": "The Seal of Reliability is made up of six criteria. This page documents the process that informed this definition, how each feed is evaluated when it's added to the Mobility Database, and the exact rules behind each criterion. This includes what triggers a pass or a violation, and how grace periods work.",
+ "methodology": {
+ "title": "Methodology",
+ "description": "The six criteria were selected based on three research sources.",
+ "consultation": {
+ "title": "Consultation",
+ "description": "Interviews with consuming applications identified the most common reliability pitfalls they encounter with GTFS Schedule data."
+ },
+ "realWorldData": {
+ "title": "Real-world data",
+ "description": "Analysis of feeds across the Mobility Database revealed patterns in how feeds fail, how often, and when they get fixed."
+ },
+ "bestPractices": {
+ "title": "GTFS Best Practices",
+ "description": "The criteria reflect recommendations from the GTFS Best Practices, grounding the Seal in the community's own standards."
+ }
+ },
+ "official": {
+ "title": "Official",
+ "paragraphs": [
+ "A feed is Official when MobilityData has confirmed it is authorized by the transit agency. This means it is published by, or on behalf of, the agency itself. MobilityData assesses this based on the domain of the producer URL, the feed submission contributor's personal information, the agency's website, and the `feed_info.txt` file. This determination is made manually and does not change based on automated checks.",
+ "Unlike the other five criteria, Official status is not subject to the six-month probation."
+ ],
+ "notice": "There is no grace period for Official. The designation is either confirmed or it is not."
+ },
+ "stable": {
+ "title": "Stable",
+ "description": "The Mobility Database evaluates stability based on the producer URL. A feed is considered Stable when:",
+ "items": [
+ "The same URL has served the feed without redirects for at least six months",
+ "The URL appears to be permanent, meaning it does not look like a temporary file path with dates, times or seasons"
+ ],
+ "notice": "There is no grace period for Stable. If the URL changes, the criterion fails immediately and the six-month probation restarts from the date the new URL is added as a new feed."
+ },
+ "available": {
+ "title": "Available",
+ "description": "Once per day, the Mobility Database attempts to download the feed from its producer URL. A fetch is considered successful when the response returns an HTTP response in the 200 range.",
+ "noticeTitle": "14-day grace period.",
+ "notice": "If a fetch fails, producers have 14 days to restore access. If the feed is still unreachable after 14 days, the Seal is revoked."
+ },
+ "compliant": {
+ "title": "Compliant",
+ "description": "Each time the feed is downloaded, it is run through the Official GTFS Schedule Validator. A feed is Compliant when the validation report shows zero errors. Warnings and info notices do not affect compliance.",
+ "validatorScope": "The Validator checks conformance with the GTFS specification's “must” requirements. A single error will trigger a violation.",
+ "noticeTitle": "30-day grace period.",
+ "notice": "If a validation error appears, producers have 30 days to fix it. If the error persists after 30 days, the Seal is revoked."
+ },
+ "freshRolling": {
+ "title": "Fresh: Rolling 7 days of coverage",
+ "description": "Each day the feed is downloaded, the Mobility Database looks at the last date of service coverage in the feed's `calendar.txt` and `calendar_dates.txt`. That date must be at least 7 days after the download date. This ensures riders and downstream applications always have at least a full week of service data available.",
+ "examplesTitle": "Examples",
+ "minimumLabel": "Aug 13 (7d min)",
+ "examples": {
+ "pass": {
+ "title": "Fetched Aug 6, 2026",
+ "axisStart": "Fetched: Aug 6",
+ "lastService": "Last service: Aug 20 (14 days ahead)",
+ "caption": "Last service date is Aug 20: 14 days ahead. Above the 7-day minimum.",
+ "diagramAriaLabel": "Coverage from August 6 to August 20, 2026, extending past the 7 day minimum marked at August 13."
+ },
+ "fail": {
+ "title": "Fetched Aug 6, 2026",
+ "axisStart": "Fetched: Aug 6",
+ "lastService": "Last service: Aug 10 (4 days ahead)",
+ "caption": "Last service date is Aug 10: only 4 days ahead. Below the 7-day minimum. Violation triggered.",
+ "diagramAriaLabel": "Coverage from August 6 to August 10, 2026, stopping short of the 7 day minimum marked at August 13."
+ }
+ },
+ "noticeTitle": "14-day grace period.",
+ "notice": "If coverage drops below 7 days, producers have 14 days to publish an update. If coverage is still insufficient after 14 days, the Seal is revoked."
+ },
+ "freshContinuous": {
+ "title": "Fresh: Continuous coverage",
+ "description": "Feeds are expected to maintain a continuous, up-to-date service calendar with no gaps between versions, and a service window of two years or less.",
+ "rules": {
+ "noGaps": {
+ "title": "No gaps between datasets",
+ "description": "Service coverage must be continuous across consecutive feed versions. A gap occurs when a new version's first service date falls more than one day after the previous version's last service date. If v1 ends Aug 31, v2 must begin no later than Sept 1. A Sept 2 start is a violation."
+ },
+ "serviceWindow": {
+ "title": "Two-year service window",
+ "description": "The total span of service dates in a dataset must not exceed two years. Feeds covering more than two years ahead are considered to contain stale, unreliable future data."
+ }
+ },
+ "feedInfo": {
+ "title": "How `feed_info.txt` affects the calculation",
+ "description": "When a feed includes `feed_info.txt`, producers can declare an official start and end date for their feed. MobilityData treats this as a declaration of intent. The presence of this file changes what counts as a gap violation.",
+ "without": {
+ "title": "Without `feed_info.txt`",
+ "items": [
+ "`calendar.txt` and `calendar_dates.txt` are the only source of truth",
+ "Any gap in calendar data between consecutive datasets is a violation",
+ "A service window over two years is a violation"
+ ]
+ },
+ "with": {
+ "title": "With `feed_info.txt`",
+ "items": [
+ "A declared date range over two years is a violation",
+ "A gap in both the declared range and the calendar data is a violation",
+ "A calendar gap alone — when the declared range in `feed_info.txt` is continuous — is not a violation"
+ ]
+ }
+ },
+ "scenarios": {
+ "title": "Scenarios",
+ "calendarSourceLabel": "Calendar data",
+ "declaredSourceLabel": "Declared range from `feed_info.txt`",
+ "feedInfoPresent": "`feed_info.txt` present",
+ "gapLabel": "Gap",
+ "ticks": {
+ "jan01": "Jan 01",
+ "jan15": "Jan 15",
+ "apr14": "Apr 14",
+ "apr15": "Apr 15",
+ "may15": "May 15",
+ "jul20": "Jul 20",
+ "aug14": "Aug 14"
+ },
+ "versions": {
+ "v1": "v1",
+ "v2": "v2",
+ "v3": "v3"
+ },
+ "continuous": {
+ "title": "Continuous, no gaps",
+ "caption": "Three consecutive datasets with no gaps and a total service window under two years.",
+ "diagramAriaLabel": "Three consecutive datasets covering January 1 to August 14, 2026 with no gaps."
+ },
+ "gap": {
+ "title": "Gap between datasets",
+ "caption": "Service ends Apr 14 in v1, but v2 does not begin until May 15. One-month gap triggers a violation.",
+ "diagramAriaLabel": "Three datasets covering January 1 to August 14, 2026 with a one month gap between April 14 and May 15."
+ },
+ "declaredContinuous": {
+ "title": "Calendar gap, declared range continuous",
+ "caption": "Calendar data has a gap between Apr 15 and May 14. But `feed_info.txt` declares an unbroken range of Jan 15 to Aug 14. Calendar gap alone is not enough to trigger a violation.",
+ "diagramAriaLabel": "Calendar data with a gap between April 15 and May 14, 2026.",
+ "declaredDiagramAriaLabel": "A single declared range covering January 15 to August 14, 2026 with no gap."
+ },
+ "declaredGap": {
+ "title": "Gap in both calendar and declared range",
+ "caption": "Both calendar data and `feed_info.txt` show a gap between Apr 15 and May 14. Both conditions present — violation triggered.",
+ "diagramAriaLabel": "Calendar data with a gap between April 15 and May 14, 2026.",
+ "declaredDiagramAriaLabel": "Two declared ranges with a gap between April 15 and May 14, 2026."
+ }
+ },
+ "noticeTitle": "No grace period.",
+ "notice": "Gaps between datasets and service windows over two years violate the Seal immediately. This must be fixed consistently for six months to pass probation and earn back the Seal."
+ },
+ "earning": {
+ "title": "How the Seal is earned over time",
+ "phaseLabel": "Phase {number}",
+ "phases": {
+ "added": {
+ "title": "Feed added",
+ "description": "Evaluated immediately against all criteria"
+ },
+ "window": {
+ "title": "Probation",
+ "description": "6 months per criterion (except Official), from the first day each is satisfied"
+ },
+ "earned": {
+ "title": "Seal earned",
+ "description": "Grace periods now apply"
+ }
+ },
+ "paragraphs": [
+ "When a feed is added to the Mobility Database, it is evaluated against every criterion. For each criterion (except Official), a six-month probation starts from the first day it is satisfied, not the day the feed was added. All five criteria must each complete their six-month probation before the Seal is awarded.",
+ "Stable requires six months of uninterrupted URL history, so no newly added feed can satisfy Stable until it has been in the database for at least six months.",
+ "MobilityData evaluates feeds over time to help ensure consistent trustworthiness. Some feeds may have met the Seal's criteria before being added to the Mobility Database, but the evaluation focuses on what can be measured from the date of addition."
+ ],
+ "clock": {
+ "title": "When the clock starts",
+ "tableAriaLabel": "When probation starts for each criterion",
+ "columns": {
+ "criterion": "Criterion",
+ "clockStarts": "Probation starts from…"
+ },
+ "stable": {
+ "criterion": "Stable",
+ "description": "First day the URL is added to the Mobility Database, provided MobilityData has not flagged it with `is_producer_url_unstable=true`. A flagged URL remains in a failing state even after six months have elapsed, since the URL appears to be temporary."
+ },
+ "available": {
+ "criterion": "Available",
+ "description": "First successful daily download with an HTTP response in the 200 range"
+ },
+ "compliant": {
+ "criterion": "Compliant",
+ "description": "First validation report with 0 errors"
+ },
+ "freshRolling": {
+ "criterion": "Fresh: Rolling 7 days of coverage",
+ "description": "First daily fetch where the last service date from `calendar.txt` and `calendar_dates.txt` extends at least 7 days ahead"
+ },
+ "freshContinuous": {
+ "criterion": "Fresh: Continuous coverage",
+ "description": "First dataset version with no gap between consecutive datasets and a service window of two years or less, from a mix of `feed_info.txt` and calendar data"
+ }
+ },
+ "noticeTitle": "Grace periods only apply after the Seal is earned.",
+ "notice": "During the earning window, before the feed has the Seal, a lapse in any criterion resets that criterion's clock from the date it is next satisfied. There is no buffer. Grace periods only become available once the Seal has been awarded."
+ },
+ "result": {
+ "pass": "Pass",
+ "fail": "Fail"
+ }
}
},
"footer": {
diff --git a/messages/fr.json b/messages/fr.json
index 9a5096b5..1668062e 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -577,6 +577,7 @@
"C'est là qu'intervient le Sceau de fiabilité de MobilityData. En tant que mesure claire de la qualité de référence, le Sceau de fiabilité est attribué aux flux qui répondent à cinq critères clés : ils sont officiels, stables, disponibles, conformes et récents."
],
"ctaButton": "Voir un flux évalué selon la norme (à venir)",
+ "calculationButton": "Voir comment il est calculé",
"imageAlt": "espace réservé actuel",
"feedsMeetingStandard": "Flux respectant la norme",
"meetsPercent": "{percent} % conformes",
@@ -617,6 +618,7 @@
},
"criteria": {
"title": "Les critères du Sceau de fiabilité",
+ "calculationButton": "Voir comment chaque critère est calculé",
"official": {
"title": "Officiel",
"subtitle": "Autorisé par l'agence de transport.",
@@ -695,6 +697,217 @@
"question": "En tant que producteur de données, comment savoir si je risque de perdre le Sceau ?",
"answer": "La meilleure façon est de consulter le statut de votre flux sur la Mobility Database 1 à 2 fois par mois. Notre équipe travaille sur des notifications par courriel, afin qu'à l'avenir vous puissiez être averti dès l'entrée en période de grâce."
}
+ },
+ "howItIsCalculated": {
+ "eyebrow": "Sceau de fiabilité",
+ "title": "Comment il est calculé",
+ "intro": "Le Sceau de fiabilité repose sur six critères. Cette page documente la démarche qui a mené à cette définition, la façon dont chaque flux est évalué lors de son ajout à la Mobility Database, et les règles exactes derrière chaque critère. Cela inclut ce qui déclenche une réussite ou un manquement, ainsi que le fonctionnement des périodes de grâce.",
+ "methodology": {
+ "title": "Méthodologie",
+ "description": "Les six critères ont été retenus à partir de trois sources de recherche.",
+ "consultation": {
+ "title": "Consultation",
+ "description": "Des entretiens avec des applications consommatrices ont permis d'identifier les problèmes de fiabilité les plus courants rencontrés avec les données GTFS Schedule."
+ },
+ "realWorldData": {
+ "title": "Données réelles",
+ "description": "L'analyse des flux de la Mobility Database a révélé des tendances : comment les flux échouent, à quelle fréquence, et quand ils sont corrigés."
+ },
+ "bestPractices": {
+ "title": "Bonnes pratiques GTFS",
+ "description": "Les critères reprennent les recommandations des bonnes pratiques GTFS, ancrant le Sceau dans les normes établies par la communauté elle-même."
+ }
+ },
+ "official": {
+ "title": "Officiel",
+ "paragraphs": [
+ "Un flux est Officiel lorsque MobilityData a confirmé qu'il est autorisé par l'agence de transport. Cela signifie qu'il est publié par l'agence elle-même ou en son nom. MobilityData évalue ce point à partir du domaine de l'URL du producteur, des informations personnelles du contributeur ayant soumis le flux, du site web de l'agence et du fichier `feed_info.txt`. Cette détermination est faite manuellement et ne change pas en fonction de vérifications automatisées.",
+ "Contrairement aux cinq autres critères, le statut Officiel n'est pas soumis à la période probatoire de six mois."
+ ],
+ "notice": "Il n'y a pas de période de grâce pour le critère Officiel. La désignation est soit confirmée, soit elle ne l'est pas."
+ },
+ "stable": {
+ "title": "Stable",
+ "description": "La Mobility Database évalue la stabilité à partir de l'URL du producteur. Un flux est considéré comme Stable lorsque :",
+ "items": [
+ "La même URL sert le flux sans redirection depuis au moins six mois",
+ "L'URL semble permanente, c'est-à-dire qu'elle ne ressemble pas à un chemin de fichier temporaire comportant des dates, des heures ou des saisons"
+ ],
+ "notice": "Il n'y a pas de période de grâce pour le critère Stable. Si l'URL change, le critère échoue immédiatement et la période probatoire de six mois repart à la date à laquelle la nouvelle URL est ajoutée en tant que nouveau flux."
+ },
+ "available": {
+ "title": "Disponible",
+ "description": "Une fois par jour, la Mobility Database tente de télécharger le flux depuis l'URL de son producteur. Une récupération est considérée comme réussie lorsque la réponse HTTP se situe dans la plage 200.",
+ "noticeTitle": "Période de grâce de 14 jours.",
+ "notice": "En cas d'échec de récupération, les producteurs disposent de 14 jours pour rétablir l'accès. Si le flux reste inaccessible après 14 jours, le Sceau est retiré."
+ },
+ "compliant": {
+ "title": "Conforme",
+ "description": "À chaque téléchargement, le flux est analysé par le Validateur officiel GTFS Schedule. Un flux est Conforme lorsque le rapport de validation ne comporte aucune erreur. Les avertissements et les notices d'information n'affectent pas la conformité.",
+ "validatorScope": "Le Validateur vérifie la conformité aux exigences « must » de la spécification GTFS. Une seule erreur déclenche un manquement.",
+ "noticeTitle": "Période de grâce de 30 jours.",
+ "notice": "Si une erreur de validation apparaît, les producteurs disposent de 30 jours pour la corriger. Si l'erreur persiste après 30 jours, le Sceau est retiré."
+ },
+ "freshRolling": {
+ "title": "Récent : couverture glissante de 7 jours",
+ "description": "Chaque jour où le flux est téléchargé, la Mobility Database examine la dernière date de couverture de service dans les fichiers `calendar.txt` et `calendar_dates.txt` du flux. Cette date doit se situer au moins 7 jours après la date de téléchargement. Cela garantit que les usagers et les applications en aval disposent toujours d'au moins une semaine complète de données de service.",
+ "examplesTitle": "Exemples",
+ "minimumLabel": "13 août (min 7 j)",
+ "examples": {
+ "pass": {
+ "title": "Récupéré le 6 août 2026",
+ "axisStart": "Récupéré : 6 août",
+ "lastService": "Dernier service : 20 août (14 jours d'avance)",
+ "caption": "La dernière date de service est le 20 août : 14 jours d'avance. Au-dessus du minimum de 7 jours.",
+ "diagramAriaLabel": "Couverture du 6 au 20 août 2026, dépassant le minimum de 7 jours marqué au 13 août."
+ },
+ "fail": {
+ "title": "Récupéré le 6 août 2026",
+ "axisStart": "Récupéré : 6 août",
+ "lastService": "Dernier service : 10 août (4 jours d'avance)",
+ "caption": "La dernière date de service est le 10 août : seulement 4 jours d'avance. En dessous du minimum de 7 jours. Manquement déclenché.",
+ "diagramAriaLabel": "Couverture du 6 au 10 août 2026, s'arrêtant avant le minimum de 7 jours marqué au 13 août."
+ }
+ },
+ "noticeTitle": "Période de grâce de 14 jours.",
+ "notice": "Si la couverture descend sous 7 jours, les producteurs disposent de 14 jours pour publier une mise à jour. Si la couverture reste insuffisante après 14 jours, le Sceau est retiré."
+ },
+ "freshContinuous": {
+ "title": "Récent : couverture continue",
+ "description": "Les flux doivent maintenir un calendrier de service continu et à jour, sans interruption entre les versions, avec une fenêtre de service de deux ans ou moins.",
+ "rules": {
+ "noGaps": {
+ "title": "Aucune interruption entre les jeux de données",
+ "description": "La couverture de service doit être continue d'une version de flux à la suivante. Une interruption survient lorsque la première date de service d'une nouvelle version se situe plus d'un jour après la dernière date de service de la version précédente. Si v1 se termine le 31 août, v2 doit commencer au plus tard le 1er septembre. Un début au 2 septembre constitue un manquement."
+ },
+ "serviceWindow": {
+ "title": "Fenêtre de service de deux ans",
+ "description": "L'étendue totale des dates de service d'un jeu de données ne doit pas dépasser deux ans. Les flux couvrant plus de deux ans à l'avance sont considérés comme contenant des données futures obsolètes et peu fiables."
+ }
+ },
+ "feedInfo": {
+ "title": "Comment `feed_info.txt` influence le calcul",
+ "description": "Lorsqu'un flux inclut `feed_info.txt`, les producteurs peuvent déclarer une date de début et de fin officielles pour leur flux. MobilityData considère cela comme une déclaration d'intention. La présence de ce fichier modifie ce qui constitue un manquement lié à une interruption.",
+ "without": {
+ "title": "Sans `feed_info.txt`",
+ "items": [
+ "`calendar.txt` et `calendar_dates.txt` sont la seule source de vérité",
+ "Toute interruption dans les données du calendrier entre des jeux de données consécutifs constitue un manquement",
+ "Une fenêtre de service de plus de deux ans constitue un manquement"
+ ]
+ },
+ "with": {
+ "title": "Avec `feed_info.txt`",
+ "items": [
+ "Une plage de dates déclarée de plus de deux ans constitue un manquement",
+ "Une interruption présente à la fois dans la plage déclarée et dans les données du calendrier constitue un manquement",
+ "Une interruption du calendrier seule — lorsque la plage déclarée dans `feed_info.txt` est continue — ne constitue pas un manquement"
+ ]
+ }
+ },
+ "scenarios": {
+ "title": "Scénarios",
+ "calendarSourceLabel": "Données du calendrier",
+ "declaredSourceLabel": "Plage déclarée d'après `feed_info.txt`",
+ "feedInfoPresent": "`feed_info.txt` présent",
+ "gapLabel": "Écart",
+ "ticks": {
+ "jan01": "1 janv.",
+ "jan15": "15 janv.",
+ "apr14": "14 avr.",
+ "apr15": "15 avr.",
+ "may15": "15 mai",
+ "jul20": "20 juill.",
+ "aug14": "14 août"
+ },
+ "versions": {
+ "v1": "v1",
+ "v2": "v2",
+ "v3": "v3"
+ },
+ "continuous": {
+ "title": "Continu, sans interruption",
+ "caption": "Trois jeux de données consécutifs sans interruption et une fenêtre de service totale inférieure à deux ans.",
+ "diagramAriaLabel": "Trois jeux de données consécutifs couvrant du 1er janvier au 14 août 2026 sans interruption."
+ },
+ "gap": {
+ "title": "Interruption entre les jeux de données",
+ "caption": "Le service se termine le 14 avr. dans v1, mais v2 ne commence que le 15 mai. Une interruption d'un mois déclenche un manquement.",
+ "diagramAriaLabel": "Trois jeux de données couvrant du 1er janvier au 14 août 2026, avec une interruption d'un mois entre le 14 avril et le 15 mai."
+ },
+ "declaredContinuous": {
+ "title": "Interruption du calendrier, plage déclarée continue",
+ "caption": "Les données du calendrier présentent une interruption entre le 15 avr. et le 14 mai. Mais `feed_info.txt` déclare une plage ininterrompue du 15 janv. au 14 août. Une interruption du calendrier seule ne suffit pas à déclencher un manquement.",
+ "diagramAriaLabel": "Données de calendrier présentant une interruption entre le 15 avril et le 14 mai 2026.",
+ "declaredDiagramAriaLabel": "Une seule plage déclarée couvrant du 15 janvier au 14 août 2026, sans interruption."
+ },
+ "declaredGap": {
+ "title": "Interruption dans le calendrier et dans la plage déclarée",
+ "caption": "Les données du calendrier et `feed_info.txt` présentent tous deux une interruption entre le 15 avr. et le 14 mai. Les deux conditions sont réunies — manquement déclenché.",
+ "diagramAriaLabel": "Données de calendrier présentant une interruption entre le 15 avril et le 14 mai 2026.",
+ "declaredDiagramAriaLabel": "Deux plages déclarées séparées par une interruption entre le 15 avril et le 14 mai 2026."
+ }
+ },
+ "noticeTitle": "Aucune période de grâce.",
+ "notice": "Les interruptions entre jeux de données et les fenêtres de service de plus de deux ans font perdre le Sceau immédiatement. La situation doit être corrigée de façon constante pendant six mois pour réussir la période probatoire et regagner le Sceau."
+ },
+ "earning": {
+ "title": "Comment le Sceau s'obtient dans le temps",
+ "phaseLabel": "Phase {number}",
+ "phases": {
+ "added": {
+ "title": "Flux ajouté",
+ "description": "Évalué immédiatement selon tous les critères"
+ },
+ "window": {
+ "title": "Période probatoire",
+ "description": "6 mois par critère (sauf Officiel), à compter du premier jour où chacun est satisfait"
+ },
+ "earned": {
+ "title": "Sceau obtenu",
+ "description": "Les périodes de grâce s'appliquent désormais"
+ }
+ },
+ "paragraphs": [
+ "Lorsqu'un flux est ajouté à la Mobility Database, il est évalué selon chaque critère. Pour chaque critère (sauf Officiel), une période probatoire de six mois démarre le premier jour où il est satisfait, et non le jour où le flux a été ajouté. Les cinq critères doivent chacun compléter leur période probatoire de six mois avant que le Sceau ne soit attribué.",
+ "Le critère Stable exige six mois d'historique d'URL ininterrompu : aucun flux nouvellement ajouté ne peut donc satisfaire Stable avant d'être présent dans la base de données depuis au moins six mois.",
+ "MobilityData évalue les flux dans la durée afin de garantir une fiabilité constante. Certains flux respectaient peut-être déjà les critères du Sceau avant leur ajout à la Mobility Database, mais l'évaluation porte sur ce qui peut être mesuré à partir de la date d'ajout."
+ ],
+ "clock": {
+ "title": "Quand le compte à rebours démarre",
+ "tableAriaLabel": "Quand la période probatoire démarre pour chaque critère",
+ "columns": {
+ "criterion": "Critère",
+ "clockStarts": "La période probatoire démarre…"
+ },
+ "stable": {
+ "criterion": "Stable",
+ "description": "Le premier jour où l'URL est ajoutée à la Mobility Database, à condition que MobilityData ne l'ait pas signalée avec `is_producer_url_unstable=true`. Une URL signalée reste en échec même après six mois, puisqu'elle semble temporaire."
+ },
+ "available": {
+ "criterion": "Disponible",
+ "description": "Au premier téléchargement quotidien réussi avec une réponse HTTP dans la plage 200"
+ },
+ "compliant": {
+ "criterion": "Conforme",
+ "description": "Au premier rapport de validation comportant 0 erreur"
+ },
+ "freshRolling": {
+ "criterion": "Récent : couverture glissante de 7 jours",
+ "description": "À la première récupération quotidienne où la dernière date de service issue de `calendar.txt` et `calendar_dates.txt` s'étend au moins 7 jours à l'avance"
+ },
+ "freshContinuous": {
+ "criterion": "Récent : couverture continue",
+ "description": "À la première version de jeu de données sans interruption entre jeux consécutifs et avec une fenêtre de service de deux ans ou moins, à partir des données de `feed_info.txt` et du calendrier"
+ }
+ },
+ "noticeTitle": "Les périodes de grâce ne s'appliquent qu'une fois le Sceau obtenu.",
+ "notice": "Pendant la période d'acquisition, avant que le flux n'ait le Sceau, un manquement à un critère réinitialise le compte à rebours de ce critère à la date où il est de nouveau satisfait. Il n'y a aucune marge. Les périodes de grâce ne deviennent disponibles qu'une fois le Sceau attribué."
+ },
+ "result": {
+ "pass": "Réussite",
+ "fail": "Échec"
+ }
}
},
"footer": {
diff --git a/src/app/Theme.ts b/src/app/Theme.ts
index c54b8073..6dd615f2 100644
--- a/src/app/Theme.ts
+++ b/src/app/Theme.ts
@@ -1,5 +1,3 @@
-'use client';
-
import { type PaletteColor, createTheme } from '@mui/material/styles';
import type {} from '@mui/material/themeCssVarsAugmentation';
import { type Property } from 'csstype';
@@ -286,7 +284,7 @@ export const theme = createTheme({
fontWeight: 700,
fontSize: '1.25rem', // h6 size
lineHeight: 1.6,
- marginBottom: '0.5rem',
+ marginBottom: '8px',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
diff --git a/src/app/[locale]/seal-of-reliability/components/SealOfReliabilityDescriptionPage.tsx b/src/app/[locale]/seal-of-reliability/components/SealOfReliabilityDescriptionPage.tsx
index 6834b903..5ab3fa2e 100644
--- a/src/app/[locale]/seal-of-reliability/components/SealOfReliabilityDescriptionPage.tsx
+++ b/src/app/[locale]/seal-of-reliability/components/SealOfReliabilityDescriptionPage.tsx
@@ -17,6 +17,7 @@ import {
CardContent,
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import { type ReactElement } from 'react';
import Image from 'next/image';
import LockIcon from '@mui/icons-material/Lock';
@@ -24,6 +25,7 @@ import { getTranslations } from 'next-intl/server';
import CardSectionTitle from '../../../components/CardSectionTitle';
import SectionContainer from '../../../components/SectionContainer';
import { accordionStyle } from '../../../components/accordionStyle';
+import { Link as LocaleLink } from '../../../../i18n/navigation';
import {
benefitEntries,
criteriaEntries,
@@ -56,6 +58,7 @@ export default async function SealOfReliabilityDescriptionPage(): Promise
@@ -71,9 +74,22 @@ export default async function SealOfReliabilityDescriptionPage(): Promise
))}
-
+
+
+
+
+
+
))}
+
+
+ }
+ >
+ {t('criteria.calculationButton')}
+
+
+
diff --git a/src/app/[locale]/seal-of-reliability/how-it-is-calculated/components/AxisTicks.tsx b/src/app/[locale]/seal-of-reliability/how-it-is-calculated/components/AxisTicks.tsx
new file mode 100644
index 00000000..c46c3b5c
--- /dev/null
+++ b/src/app/[locale]/seal-of-reliability/how-it-is-calculated/components/AxisTicks.tsx
@@ -0,0 +1,47 @@
+import { Box, Typography } from '@mui/material';
+import { type ReactElement } from 'react';
+
+export interface AxisTick {
+ id: string;
+ leftPercent: number;
+ label: string;
+}
+
+/**
+ * Keeps the first and last labels inside the track instead of letting them
+ * hang off the edges.
+ */
+function tickTransform(leftPercent: number): string {
+ if (leftPercent <= 5) return 'translateX(0)';
+ // Right-align anything near the end so the final labels stack up against the
+ // edge instead of overlapping each other.
+ if (leftPercent >= 85) return 'translateX(-100%)';
+ return 'translateX(-50%)';
+}
+
+export default function AxisTicks({
+ ticks,
+}: {
+ ticks: AxisTick[];
+}): ReactElement {
+ return (
+
+ {ticks.map((tick) => (
+
+ {tick.label}
+
+ ))}
+
+ );
+}
diff --git a/src/app/[locale]/seal-of-reliability/how-it-is-calculated/components/HowItIsCalculatedPage.tsx b/src/app/[locale]/seal-of-reliability/how-it-is-calculated/components/HowItIsCalculatedPage.tsx
new file mode 100644
index 00000000..957944bb
--- /dev/null
+++ b/src/app/[locale]/seal-of-reliability/how-it-is-calculated/components/HowItIsCalculatedPage.tsx
@@ -0,0 +1,463 @@
+import {
+ Alert,
+ AlertTitle,
+ Box,
+ Card,
+ CardContent,
+ Container,
+ Link,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Typography,
+} from '@mui/material';
+import { type ReactElement } from 'react';
+import { getTranslations } from 'next-intl/server';
+import VerifiedIcon from '@mui/icons-material/Verified';
+import CodeIcon from '@mui/icons-material/Code';
+import DownloadIcon from '@mui/icons-material/Download';
+import RuleIcon from '@mui/icons-material/Rule';
+import EventAvailableIcon from '@mui/icons-material/EventAvailable';
+import SyncAltIcon from '@mui/icons-material/SyncAlt';
+import { fontFamily, theme } from '../../../../Theme';
+import SectionContainer from '../../../../components/SectionContainer';
+import CardSectionTitle from '../../../../components/CardSectionTitle';
+import {
+ GTFS_VALIDATOR_URL,
+ clockStartEntries,
+ continuousRuleEntries,
+ earningPhaseEntries,
+ feedInfoComparisonEntries,
+ methodologyEntries,
+ rollingCoverageExamples,
+ scenarioEntries,
+} from '../lib/content';
+import RichText from '../../../../components/RichText';
+import RollingCoverageCard from './RollingCoverageCard';
+import ScenarioCard from './ScenarioCard';
+
+const { primary, success, divider } = theme.vars.palette;
+
+const twoColumnGrid = {
+ display: 'grid',
+ gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' },
+ gap: 2,
+} as const;
+
+const threeColumnGrid = {
+ display: 'grid',
+ gridTemplateColumns: { xs: '1fr', md: '1fr 1fr 1fr' },
+ gap: 2,
+} as const;
+
+/**
+ * The four short criteria sit two-per-row, so each container stretches to its
+ * neighbour's height and the body fills the leftover space. That keeps the
+ * closing notice pinned to the bottom of both cards in a row.
+ */
+const criterionContainerStyle = {
+ display: 'flex',
+ flexDirection: 'column',
+} as const;
+
+/** Spacing between the blocks that make up a single criterion's explanation. */
+const criterionBodyStyle = {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 1.5,
+ flexGrow: 1,
+ // Pushes the trailing notice down when the neighbouring card is taller.
+ '& > :last-child': { mt: 'auto' },
+} as const;
+
+function BulletList({ items }: { items: string[] }): ReactElement {
+ return (
+
+ {items.map((item) => (
+