Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .nuxtrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
setups.@nuxt/test-utils="4.0.3"
setups.@nuxt/test-utils="4.1.0"
35 changes: 31 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 :
Expand Down
18 changes: 5 additions & 13 deletions app/components/DestinationCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -49,18 +43,16 @@ function formatDate(iso: string): string {
@focus="emit('hover', destination.label)"
@blur="emit('hover', null)"
>
<!-- Barre d'accent plutôt qu'un liseré tout autour : lisible du coin de l'œil en
faisant défiler, sans redessiner un cadre autour de chaque ligne. -->
<!-- An accent bar rather than an outline: readable out of the corner of the eye
while scrolling, without a box around every row. -->
<span v-if="selected" class="absolute inset-y-0 left-0 w-[3px] bg-accent" />

<div class="flex items-baseline gap-2">
<span class="min-w-0 flex-1 truncate font-semibold text-rail">{{ name }}</span>
<span v-if="pop.tier > 0" :title="pop.label" class="shrink-0 text-[10px] text-amber-400">
{{ pop.stars }}
</span>
<!-- Le chiffre sur lequel on tranche vraiment : Lyon et Perpignan se ressemblent
dans une liste, 1h56 et 5h07 ne se ressemblent pas. En navy, pas en teal :
la couleur d'accent est réservée à l'état interactif. -->
<!-- Navy, not teal: the accent colour is reserved for interactive state. -->
<span
v-if="fastest"
data-test="card-duration"
Expand Down
22 changes: 3 additions & 19 deletions app/components/DestinationPopover.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,10 @@
import type { Destination, ReturnDatesResult, SearchMode } from '~~/shared/types'
import { prettyLabel } from '~~/shared/stations'

/**
* Fiche d'une destination, ouverte au clic sur son marqueur. Rassemble tout ce qu'on
* sait déjà de la ville — horaires, durée, notoriété, dates de retour — au lieu de le
* laisser éparpillé entre la carte et le rail.
*/
const props = defineProps<{
destination: Destination
mode: SearchMode
/** Gare de référence de la recherche, pour libeller les trajets. */
/** Reference station of the search, used to label the legs. */
originLabel: string
originSlug?: string
returnsLoading?: boolean
Expand All @@ -26,7 +21,7 @@ const pop = computed(() => 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))
Expand Down Expand Up @@ -57,7 +52,6 @@ function formatDate(iso: string): string {
role="dialog"
:aria-label="`Détails pour ${name}`"
>
<!-- En-tête -->
<div class="flex items-start justify-between gap-2 border-b border-slate-100 px-3.5 pb-2.5 pt-3">
<div class="min-w-0">
<h3 class="truncate text-base font-bold leading-tight text-rail">{{ name }}</h3>
Expand All @@ -79,12 +73,10 @@ function formatDate(iso: string): string {
</div>

<div class="max-h-[22rem] overflow-y-auto px-3.5 py-3">
<!-- Résumé du trajet -->
<p class="text-xs text-rail-soft">
{{ fromName }} <span class="text-slate-300">→</span> {{ toName }}
</p>

<!-- Mode plage de dates : jours joignables -->
<template v-if="mode === 'range' && destination.availableDates">
<p class="mt-2 text-sm font-semibold text-rail">
Joignable {{ destination.availableDates.length }} jour(s)
Expand All @@ -101,7 +93,6 @@ function formatDate(iso: string): string {
</template>

<template v-else>
<!-- Ce qu'on sait du trajet : nombre d'options, amplitude, durée la plus courte -->
<div class="mt-2 flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span class="text-sm font-semibold text-rail">
{{ outbound.length }} {{ outbound.length > 1 ? 'trains' : 'train' }}
Expand All @@ -114,7 +105,6 @@ function formatDate(iso: string): string {
Départs de {{ window.first }} à {{ window.last }}
</p>

<!-- Horaires détaillés -->
<ul class="mt-2.5 flex flex-col gap-1">
<li
v-for="t in outbound"
Expand All @@ -132,7 +122,6 @@ function formatDate(iso: string): string {
</li>
</ul>

<!-- Mode aller-retour : le retour, dans l'autre sens -->
<template v-if="mode === 'roundtrip' && destination.returnTrains?.length">
<p class="mt-3 text-xs font-semibold uppercase tracking-wide text-coral-strong">
Retour vers {{ originName }}
Expand All @@ -155,7 +144,6 @@ function formatDate(iso: string): string {
</template>
</template>

<!-- Dates de retour à la demande, comme dans le rail -->
<template v-if="mode === 'from'">
<button
v-if="!returns"
Expand Down Expand Up @@ -190,11 +178,7 @@ function formatDate(iso: string): string {
</template>
</div>

<!--
Réservation. Chaque revendeur porte sa propre couleur : on sort du site, autant
savoir où l'on arrive. Deux moitiés égales — `basis-1/2` et non `flex-1`, dont la
base `auto` répartirait selon la longueur du libellé.
-->
<!-- `basis-1/2` and not `flex-1`, whose `auto` basis would split by label length. -->
<div v-if="legSlugs" class="border-t border-slate-100 bg-slate-50/60 px-3.5 py-2.5">
<p class="text-[11px] font-medium text-rail-soft">Réserver ce trajet</p>
<div class="mt-1.5 flex items-stretch gap-2">
Expand Down
8 changes: 3 additions & 5 deletions app/components/GithubLink.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
<script setup lang="ts">
/**
* Lien vers le dépôt. L'adresse est écrite ici et nulle part ailleurs : le projet étant
* sous AGPL-3.0, elle doit rester accessible depuis chaque écran.
*/
/** The repo URL is written here and nowhere else: under AGPL-3.0 it has to stay reachable
* from every screen. */
defineProps<{
/** Texte à côté de la marque. Absent : lien en icône seule (barre d'application). */
/** Text next to the mark. Absent: icon-only link (app bar). */
label?: string
}>()

Expand Down
18 changes: 4 additions & 14 deletions app/components/HeroSearch.vue
Original file line number Diff line number Diff line change
@@ -1,26 +1,17 @@
<script setup lang="ts">
import { todayISO, lastBookableISO } from '~~/shared/window'

/**
* Accroche de la page d'accueil : la barre de recherche elle-même, plutôt qu'un bouton
* qui mène à elle. Un champ qui ressemble à une recherche doit chercher — un décor
* cliquable mais impossible à remplir se lit comme une panne.
*
* La saisie n'est pas obligatoire : sans gare, on ouvre l'application avec la date déjà
* posée. Le formulaire n'a donc jamais d'état invalide qui bloque le clic.
*/
/** No field is required: without a station, /app opens with just the date. The form has
* no invalid state that blocks the click. */
const from = ref('')
const date = ref('')
const dateId = useId()

const today = todayISO()
const lastBookable = lastBookableISO()

/**
* La date par défaut est posée au montage : calculée pendant le rendu serveur, ce serait
* celle du serveur (UTC en production), qui désigne encore la veille entre minuit et 2 h
* du matin en France — soit une divergence d'hydratation sur la valeur du champ.
*/
// Default date set on mount: computed during SSR it would be the server's (UTC), which still
// reads as yesterday between midnight and 2am in France — a hydration mismatch on the field.
onMounted(() => (date.value = todayISO()))

function submit() {
Expand All @@ -37,7 +28,6 @@ function submit() {
class="w-full max-w-lg rounded-2xl bg-white p-2 shadow-2xl shadow-rail/40"
@submit.prevent="submit"
>
<!-- Même objet que dans l'application : un cadre unique découpé en cellules -->
<div class="rounded-xl border border-slate-200">
<StationInput
v-model="from"
Expand Down
Loading
Loading