diff --git a/.nuxtrc b/.nuxtrc
index 640f280..0e6b68e 100644
--- a/.nuxtrc
+++ b/.nuxtrc
@@ -1 +1 @@
-setups.@nuxt/test-utils="4.0.3"
\ No newline at end of file
+setups.@nuxt/test-utils="4.1.0"
\ No newline at end of file
diff --git a/README.md b/README.md
index 85ffdd4..ff4d3f9 100644
--- a/README.md
+++ b/README.md
@@ -100,9 +100,13 @@ condition de publier vos modifications.
Un abonnement SNCF pour les 16-27 ans qui donne accès à un nombre illimité de trajets sur les
trains éligibles, dans la limite des places réservées à l'abonnement. Ces places sont
-contingentées : un train peut circuler sans être ouvert à TGVmax. C'est ce contingent que
+contingentées : un train peut circuler sans être ouvert à l'abonnement. C'est ce contingent que
Trainquillou rend visible.
+La SNCF a renommé l'offre **MAX JEUNE** en 2023. « TGVmax » reste le nom sous lequel la plupart
+des abonnés la connaissent, et celui du dataset open data : les deux termes cohabitent donc dans
+l'interface et dans les métadonnées, sans en privilégier un.
+
### Trainquillou réserve-t-il mes billets ?
Non. Il montre où il reste des places et renvoie vers SNCF Connect ou Trainline pour la
@@ -128,9 +132,12 @@ service gratuit, sans but lucratif.
### Mes visites sont-elles suivies ?
-L'instance officielle mesure son audience avec [Rybbit](https://rybbit.io) : sans cookie, sans
-identifiant persistant, sans profil publicitaire, données hébergées dans l'UE. Aucun bandeau de
-consentement n'est nécessaire, faute de donnée personnelle collectée.
+L'instance officielle mesure son audience avec [Rybbit](https://rybbit.com) : sans cookie, sans
+identifiant persistant, sans profil publicitaire, et sans stockage des adresses IP d'après sa
+[politique de confidentialité](https://rybbit.com/privacy). Aucun bandeau de consentement n'est
+nécessaire, faute de donnée personnelle collectée.
+
+Le site lui-même tourne sur un VPS OVH à Gravelines (Nord, France), sans CDN intermédiaire.
Rien de tout cela n'est actif dans le code que vous clonez : la mesure ne s'active que si vous
fournissez votre propre identifiant au build (voir ci-dessous). Une instance auto-hébergée
@@ -196,6 +203,26 @@ uv run scripts/build-booking.py # slugs de ville pour les liens de réserva
Ils nécessitent [uv](https://docs.astral.sh/uv/) ; les dépendances sont déclarées dans l'en-tête
de chaque script.
+### Images du hero
+
+L'image d'accueil est l'élément LCP de la page : ses variantes sont générées à la main et
+commitées, plutôt que produites par un module Nuxt. L'image ne change jamais, et `.output/`
+doit rester sans binaire natif pour qu'un build lancé depuis un Mac arm64 tourne dans le
+conteneur amd64 (voir `infra/deploy.sh`). Deux variantes AVIF (69 % de moins que le JPEG à
+qualité indiscernable), `hero.jpg` en repli pour les navigateurs sans AVIF.
+
+Pour les régénérer, avec [vips](https://www.libvips.org/) (`brew install vips`) :
+
+```bash
+vips thumbnail public/hero.jpg 'public/hero-800.avif[Q=58]' 800
+vips thumbnail public/hero.jpg 'public/hero-1672.avif[Q=58]' 1672
+vips thumbnail public/hero.jpg public/hero-800.jpg 800
+vips thumbnail public/hero.jpg public/hero-1200.jpg 1200
+```
+
+**Ne pas utiliser `sips` pour l'AVIF** : il produit un fichier dont Chromium lit les dimensions
+mais pas les pixels, et le hero s'affiche vide.
+
## Contribuer
Les contributions sont bienvenues. Quelques conventions :
diff --git a/app/components/DestinationCard.vue b/app/components/DestinationCard.vue
index 7324e5f..77eaf14 100644
--- a/app/components/DestinationCard.vue
+++ b/app/components/DestinationCard.vue
@@ -2,14 +2,8 @@
import type { Destination, SearchMode } from '~~/shared/types'
import { prettyLabel } from '~~/shared/stations'
-/**
- * Une ligne de résultat. Elle sert à *choisir* une ville, pas à l'étudier : nom, notoriété,
- * durée du trajet le plus court, amplitude des départs. Le détail (horaires, réservation,
- * dates de retour) vit dans la fiche de la carte, ouverte au clic.
- *
- * Une recherche à quatre semaines renvoie couramment 70 destinations, une exploration sur
- * une semaine en renvoie 130 : tout déplier ferait plusieurs mètres de défilement.
- */
+/** One result row: enough to *pick* a city, not to study it. A search commonly returns
+ * 70 to 130 of them, so the detail lives in the map popover. */
const props = defineProps<{
destination: Destination
mode: SearchMode
@@ -49,8 +43,8 @@ function formatDate(iso: string): string {
@focus="emit('hover', destination.label)"
@blur="emit('hover', null)"
>
-
+
@@ -58,9 +52,7 @@ function formatDate(iso: string): string {
{{ pop.stars }}
-
+
popularityTier(props.destination.popularity))
const name = computed(() => prettyLabel(props.destination.label))
const originName = computed(() => prettyLabel(props.originLabel))
-/** En recherche inverse, le trajet part de la ville affichée et rejoint la gare cherchée. */
+/** In reverse search the trip starts from the displayed city and reaches the searched one. */
const isInbound = computed(() => props.mode === 'to')
const fromName = computed(() => (isInbound.value ? name.value : originName.value))
const toName = computed(() => (isInbound.value ? originName.value : name.value))
@@ -57,7 +52,6 @@ function formatDate(iso: string): string {
role="dialog"
:aria-label="`Détails pour ${name}`"
>
-
{{ name }}
@@ -79,12 +73,10 @@ function formatDate(iso: string): string {
+ Trainquillou est un projet indépendant, né d'un besoin très simple : savoir
+ où l'abonnement TGVmax (aujourd'hui MAX JEUNE) permet d'aller un jour donné,
+ plutôt que de tester les villes une par une dans un moteur de réservation. La réponse
+ existe dans les données ouvertes de la SNCF ; il manquait juste une carte pour la lire.
+
+
+
+ Le site est développé et maintenu par Clément, sur son
+ temps libre, et son code est public sous licence AGPL-3.0 : chacun peut le lire, le
+ vérifier, le corriger ou en faire tourner sa propre instance. Il est gratuit et le
+ restera — pas d'offre payante en préparation, pas de fonctionnalité gardée derrière un
+ compte.
+
+ La liste des trajets origine-destination sur une fenêtre glissante de 30 jours, avec
+ pour chacun un indicateur : des places d'abonnement sont ouvertes à la réservation,
+ ou non. Trainquillou ne garde que les premiers. C'est la SNCF qui publie cet
+ indicateur, nous ne le calculons pas.
+
+
+
+
Fenêtre de 30 jours
+
+ Ce n'est pas une limite que le site s'impose : les places d'abonnement n'ouvrent que
+ 30 jours avant le départ, et le jeu de données ne contient rien au-delà. Le
+ sélecteur de date s'arrête donc là.
+
+
+
+
Fraîcheur
+
+ La SNCF rafraîchit le jeu de données chaque jour en début de matinée. Pour ne pas
+ marteler son API, Trainquillou garde chaque recherche 10 minutes en cache côté
+ serveur, et la liste des gares 6 heures. Une place peut donc partir entre le moment
+ où elle s'affiche ici et celui où vous la réservez.
+
+
+
+
Ce que le site ne sait pas faire
+
+ Il n'accède pas à votre abonnement, ne réserve rien, ne connaît pas le nombre de
+ places restantes sur un train, et ne distingue pas MAX JEUNE de MAX SENIOR : le jeu
+ de données publie un seul indicateur pour les deux. Il vous montre où chercher, la
+ réservation se fait sur SNCF Connect.
+
+
+
+
+
+
+
Où le site est hébergé
+
+ Sur un serveur privé virtuel loué chez OVH, dans son
+ centre de données de Gravelines (Nord, France). Pas de
+ CDN intermédiaire, pas de réplication hors de France : les requêtes vont du navigateur à
+ cette machine, et nulle part ailleurs. Seul le fond de carte est servi par un tiers,
+ OpenFreeMap, à partir des données d'OpenStreetMap.
+
+
+ Hébergeur : OVH SAS, 2 rue Kellermann, 59100 Roubaix, France — RCS Lille Métropole
+ 424 761 419.
+
+
+
+
+
Ce que le site mesure
+
+ Le strict nécessaire pour savoir si quelqu'un s'en sert. L'instance officielle utilise
+ Rybbit, une mesure d'audience open source qui fonctionne
+ sans cookie, sans identifiant persistant et sans
+ stocker les adresses IP. Il n'y a ni régie publicitaire, ni traceur commercial, ni
+ revente, ni profilage — et donc rien à accepter dans une bannière.
+
+
+ Le site lui-même ne demande aucun compte et n'enregistre aucune donnée personnelle : vos
+ recherches vivent dans l'URL de votre navigateur, pas dans une base. Une instance
+ auto-hébergée n'a par défaut aucune mesure d'audience du tout — elle s'active par
+ variable d'environnement au moment de la construction.
+
+
+
+
+
Nous écrire, contribuer
+
+ Un libellé de gare mal placé sur la carte, une destination manquante, une idée, un
+ désaccord : tout passe par le dépôt GitHub, qui sert à la fois de boîte aux lettres et
+ d'historique public des corrections. Les signalements y sont visibles de tous, ce qui
+ vaut mieux qu'un e-mail privé pour un projet ouvert.
+
+ Trainquillou n'est pas affilié à la SNCF, ne vend pas de billets et ne touche aucune
+ commission sur les liens vers SNCF Connect ou Trainline.
+
+
+
+
+
Voir où vous pouvez partir
+
+ Ouvrir la carte →
+
+
+
+
+
+
+
diff --git a/app/pages/app.vue b/app/pages/app.vue
index 57bdec2..9d65844 100644
--- a/app/pages/app.vue
+++ b/app/pages/app.vue
@@ -7,16 +7,16 @@ const itinerary = useItinerary()
const { cache: returnsCache, loading: returnsLoading, load: loadReturns } = useReturns()
const hovered = ref(null)
const selectedRoute = ref(0)
-/** Destination dont la fiche est ouverte sur la carte. */
+/** Destination whose popover is open on the map. */
const selectedDestination = ref(null)
-/** Labels retenus par les filtres du rail ; `null` quand aucun filtre n'est actif. */
+/** Labels kept by the rail filters; `null` when no filter is active. */
const visibleLabels = ref(null)
-// Une nouvelle recherche invalide la sélection : la gare peut ne plus être dans les résultats.
+// A new search invalidates the selection: the station may be gone from the results.
watch(result, () => { selectedDestination.value = null })
-// Filtrer jusqu'à masquer la destination ouverte laisserait sa fiche ancrée sur un marqueur
-// qui n'existe plus.
+// Filtering out the open destination would leave its popover anchored to a marker
+// that no longer exists.
watch(visibleLabels, (labels) => {
if (labels && selectedDestination.value && !labels.includes(selectedDestination.value)) {
selectedDestination.value = null
@@ -25,13 +25,11 @@ watch(visibleLabels, (labels) => {
const isRoute = computed(() => mode.value === 'route')
-// L'état de chargement de l'itinéraire est client-only (fetch côté client). On ne
-// l'expose qu'après le montage pour éviter un mismatch d'hydratation sur le bouton.
+// Itinerary loading is client-only: expose it after mount to avoid a hydration mismatch.
const isMounted = ref(false)
onMounted(() => (isMounted.value = true))
const searchLoading = computed(() => isMounted.value && (isRoute.value ? itinerary.pending.value : pending.value))
-// Réinitialise la sélection quand un nouvel itinéraire arrive.
watch(() => itinerary.route.value, () => { selectedRoute.value = 0 })
const returnsByDest = computed(() => {
@@ -40,20 +38,14 @@ const returnsByDest = computed(() => {
return map
})
-/**
- * Sur un écran étroit, le formulaire et la carte se partagent déjà toute la hauteur : laissé
- * déplié, il ne reste plus un pixel pour les résultats. Il se replie donc en un résumé dès
- * qu'une recherche a abouti. Sur desktop la colonne est assez haute, il reste ouvert.
- */
+// On a narrow screen the form and the map already take the full height: the form collapses
+// to a summary once a search succeeds, or the results get no room at all.
const isNarrow = ref(false)
const formOpen = ref(true)
-/**
- * Sur un écran étroit, carte et liste ne tiennent pas ensemble : la carte réduite au
- * tiers de la hauteur ne séparait pas les marqueurs franciliens, et les trois
- * destinations qui restaient visibles ne faisaient pas une liste. On en montre donc
- * une seule à la fois, au choix.
- */
+// On a narrow screen map and list do not fit together: a map cut to a third of the height did
+// not separate the Paris-area markers, and the three destinations left visible did not make a
+// list. So only one shows at a time.
const mobileView = ref<'map' | 'list'>('list')
const MOBILE_VIEWS = [
{ key: 'map', label: 'Carte', icon: 'M12 21c4-4.6 6-7.8 6-10.5a6 6 0 1 0-12 0C6 13.2 8 16.4 12 21Zm0-9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z' },
@@ -64,14 +56,13 @@ onMounted(() => {
const mq = window.matchMedia('(max-width: 767px)')
isNarrow.value = mq.matches
mq.addEventListener('change', (e) => (isNarrow.value = e.matches))
- // Sans recherche en cours, la liste n'a qu'une phrase à afficher : la carte fait un
- // meilleur écran d'accueil. Avec une recherche — lien partagé, retour navigateur —
- // les résultats arrivent, autant ouvrir là où ils vont s'afficher.
+ // With no search running the list has one sentence to show, so the map makes the better
+ // landing screen. With one — shared link, browser back — results are coming, so open where
+ // they will appear.
mobileView.value = hasQuery.value ? 'list' : 'map'
})
-// Ne replier que sur une recherche qui a effectivement abouti : `useItinerary` étant
-// `server: false`, son résultat passe de `undefined` à `null` au montage, et ce seul
-// changement suffisait à replier un formulaire qui n'avait encore rien à résumer.
+// Only collapse on a search that actually succeeded: `useItinerary` is `server: false`, so
+// its result goes undefined → null at mount, which was enough to collapse an empty form.
watch([result, () => itinerary.route.value], ([found, foundRoute]) => {
if (isNarrow.value && (found || foundRoute)) formOpen.value = false
})
@@ -79,26 +70,24 @@ function onSearch(params: Parameters[0]) {
search(params)
if (!isNarrow.value) return
formOpen.value = false
- // Vers la liste : c'est elle qui porte le squelette de chargement, le message d'erreur
- // et son bouton de reprise, le décompte, le tri et les filtres. Rester sur la carte
- // laisserait la recherche sans retour visible pendant qu'elle tourne.
+ // To the list: it carries the loading skeleton, the error and its retry button, the count,
+ // the sort and the filters. Staying on the map would leave a running search with no
+ // visible feedback.
mobileView.value = 'list'
}
-/** Sur desktop les deux affichages cohabitent ; sur écran étroit la bascule tranche. */
+/** On desktop both views coexist; on a narrow screen the toggle decides. */
const showList = computed(() => !isNarrow.value || mobileView.value === 'list')
/**
- * Carte recouverte par la liste : elle reste dimensionnée mais sort du parcours clavier
- * et de l'arbre d'accessibilité, sinon ses marqueurs — qui sont des boutons — restent
- * atteignables derrière la liste qui les cache.
+ * Map covered by the list: it stays sized but leaves the tab order and the accessibility tree,
+ * otherwise its markers — which are buttons — stay reachable behind the list hiding them.
*/
const mapCovered = computed(() => isNarrow.value && showList.value)
/**
- * Ouvrir une fiche depuis la liste, sur écran étroit, suppose de passer sur la carte :
- * c'est là qu'elle s'ancre, et un appui sans effet visible se lit comme une panne.
- * Et toujours sélectionner, jamais désélectionner — le second appui d'une bascule n'a
- * pas de sens quand on n'a pas vu le résultat du premier.
+ * Opening a popover from the list, on a narrow screen, means switching to the map: that is
+ * where it anchors, and a tap with no visible effect reads as a breakage. And always select,
+ * never deselect — the second tap of a toggle makes no sense when the first was never seen.
*/
function onSelectDestination(label: string) {
if (isNarrow.value && mobileView.value === 'list') {
@@ -109,7 +98,7 @@ function onSelectDestination(label: string) {
selectedDestination.value = selectedDestination.value === label ? null : label
}
-/** Résumé de la recherche en cours, affiché à la place du formulaire replié. */
+/** Summary of the current search, shown in place of the collapsed form. */
const summary = computed(() => {
const station = isRoute.value ? itinerary.from.value : origin.value
if (!station) return null
@@ -124,10 +113,7 @@ const summary = computed(() => {
return `${where} · ${when}`
})
-/**
- * État replié, dérivé plutôt que déclaré : il exige un résumé à afficher, ce qui garantit
- * qu'un des deux affichages est toujours visible et jamais une colonne de recherche vide.
- */
+/** Derived, not declared: it requires a summary, which guarantees the column is never empty. */
const collapsed = computed(() => isNarrow.value && !formOpen.value && Boolean(summary.value))
async function onShowReturns(destLabel: string) {
@@ -135,7 +121,6 @@ async function onShowReturns(destLabel: string) {
await loadReturns(destLabel, result.value.origin.label, result.value.date)
}
-// Relance la recherche d'itinéraire sur une date suggérée.
function onPickRouteDate(d: string) {
search({
mode: 'route',
@@ -146,17 +131,8 @@ function onPickRouteDate(d: string) {
})
}
-/**
- * L'application vit dans son URL (`?origin=&date=&mode=`), ce qui en fait une
- * infinité d'adresses distinctes servant la même coquille : le maillage en génère
- * déjà six cents depuis les pages gare et la page d'accueil. Sans canonique elles
- * s'indexent séparément, toutes avec le même titre et aucun contenu rendu côté
- * serveur, et diluent le budget de crawl sur des variantes vides.
- *
- * `noindex, follow` plutôt qu'une simple canonique : il n'y a rien à indexer ici
- * (les résultats sont chargés côté client), mais les liens sortants doivent
- * continuer à transmettre leur poids.
- */
+// `noindex, follow`: the app is one client-rendered shell behind hundreds of URL variants
+// (`?origin=&date=&mode=`) — nothing to index, but outgoing links still pass their weight.
const { public: { siteUrl } } = useRuntimeConfig()
useHead({
@@ -168,42 +144,44 @@ useHead({