Skip to content
Open
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
7 changes: 6 additions & 1 deletion docs/user-flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,12 @@ the destination is reachable, with its calendar open.
with driving alone and flying, from ADEME factors over the straight-line distance
(`src/util/eco.ts`).
- **Settings** — theme, MAX Jeune/Senior, comfortable/compact, and Low-end mode (map off +
reduced motion + compact) with a one-time nudge on weak devices; language.
reduced motion + compact) with a one-time nudge on weak devices; language. "Interrail
reservation fees" (off by default) adds each trip's pass-holder reservation fee, 2nd class,
to the Interrail exact-trip list (connections add up their trains), a "Cheapest
reservation" sort and a note linking Interrail's fee page. The fees come from
`src/data/fees.ts`, the same table train-api publishes; a train with no published fee
(SNCF coaches) shows none and sorts last.
- **Mobile** — the form is a sheet that collapses to a search bar (a long route wraps it to
two lines); results are a bottom-sheet drawer with peek / half / full detents measured
below that bar, so the full sheet never covers it. Back navigation preserves form state
Expand Down
38 changes: 36 additions & 2 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { findHiddenTrains } from "./core/hidden";
import { addDays, dayIndex, formatDuration, minutesToHHMM } from "./util/time";
import { haversineKm } from "./util/geo";
import { tripCo2 } from "./util/eco";
import { journeyFee, type Fee } from "./data/fees";
import { el, clear, isTouch } from "./ui/dom";
import { buildShell, applyTheme, applyDensity, applyReduceMotion, applyMap, closeHeaderMenu } from "./ui/shell";
import { showPostcard } from "./ui/toast";
Expand All @@ -53,6 +54,7 @@ import {
MAX_SENIOR_URL,
INTERRAIL_FRANCE_URL,
INTERRAIL_PASS_URL,
INTERRAIL_FEES_URL,
ADEME_CO2_URL,
GITHUB_URL,
GITHUB_ISSUES_URL,
Expand Down Expand Up @@ -929,6 +931,8 @@ function cityInfoUrl(id: string): string {
function ctx(): RenderCtx {
return {
label: (id) => deps.registry.label(id),
// Interrail reservation fees: an opt-in setting, and only for the Interrail pass.
fee: settings.fees && !deps.profile.seatKnown ? journeyFee : undefined,
formatDate,
formatWeekday,
// Deep-link to SNCF Connect with the trip pre-filled (clean station names, the
Expand Down Expand Up @@ -1730,6 +1734,7 @@ const SORT_LABEL = {
name: "sort_name",
arrival: "sort_arrival",
departure: "sort_departure",
cheapest: "sort_cheapest",
} as const;

/** Build the sort-picker option list for a mode from its applicable keys. */
Expand All @@ -1751,6 +1756,7 @@ interface SortAccessors<T> {
durationMin?: (x: T) => number;
arriveMin?: (x: T) => number; // absolute arrival minutes (earliest first)
departMin?: (x: T) => number; // departure minutes (earliest first)
fee?: (x: T) => Fee | undefined; // Interrail reservation fees (cheapest first, unknown last)
}

/**
Expand Down Expand Up @@ -1784,6 +1790,14 @@ function applySort<T>(items: T[], acc: SortAccessors<T>): T[] {
case "name":
arr.sort((a, b) => acc.name(a).localeCompare(acc.name(b)));
break;
case "cheapest":
if (acc.fee) {
const fee = acc.fee;
const lo = (x: T): number => fee(x)?.min ?? Infinity;
const hi = (x: T): number => fee(x)?.max ?? Infinity;
arr.sort((a, b) => lo(a) - lo(b) || hi(a) - hi(b));
}
break;
}
return arr;
}
Expand Down Expand Up @@ -2690,6 +2704,18 @@ function appendNearbyAlternatives(
* journey list booked on click, hidden-city trains and radius alternatives. Round /
* day trips take the separate {@link runTripSearch} 2-leg accordion instead.
*/
/** Under an exact trip's list when fees are on: they are indicative, with their source. */
function feeNoteEl(): HTMLElement {
return el("p", { class: "muted small fee-note" }, [
el("span", { text: `${t("fee_note")} ` }),
el("a", {
href: INTERRAIL_FEES_URL,
text: t("fee_link"),
attrs: { target: "_blank", rel: "noopener noreferrer" },
}),
]);
}

function runOdSearch(c: RenderCtx): void {
const { trains, registry } = deps;
if (!query.origin || !query.destination) {
Expand Down Expand Up @@ -2761,6 +2787,7 @@ function runOdSearch(c: RenderCtx): void {
durationMin: (j) => j.totalDurationMin,
arriveMin: (j) => (flex > 0 ? dayIndex(j.date) * 1440 : 0) + journeyArriveAbs(j),
departMin: (j) => (flex > 0 ? dayIndex(j.date) * 1440 : 0) + j.departMin,
fee: c.fee,
},
);

Expand All @@ -2786,11 +2813,12 @@ function runOdSearch(c: RenderCtx): void {
refs.results.append(
render.listToolbarEl(
t("badge_trains", { n: journeys.length }),
query.sort ?? "rec",
sortOptions(["rec", "arrival", "departure", "fastest"]),
query.sort === "cheapest" && !c.fee ? "rec" : query.sort ?? "rec",
sortOptions(c.fee ? ["rec", "arrival", "departure", "fastest", "cheapest"] : ["rec", "arrival", "departure", "fastest"]),
onSort,
),
);
if (c.fee) refs.results.append(feeNoteEl());
for (const j of journeys)
refs.results.append(
render.journeyEl(j, c, { bookOnClick: true, dateLabel: flex > 0 ? formatDate(j.date) : undefined }),
Expand Down Expand Up @@ -3530,6 +3558,7 @@ function openSettings(): void {
reduceMotion: settings.reduceMotion,
map: settings.map,
compact: settings.density === "compact",
fees: settings.fees,
onReduceMotion: (v) => {
settings = { ...settings, reduceMotion: v };
store.saveSettings(settings);
Expand All @@ -3549,6 +3578,11 @@ function openSettings(): void {
store.saveSettings(settings);
applyDensity(density);
},
onFees: (v) => {
settings = { ...settings, fees: v };
store.saveSettings(settings);
refreshInPlace();
},
// Master "Low-end mode": flip all three savers together. ON = the light experience
// (no motion, no map, compact); OFF = the full experience (motion, map, comfortable).
onLowEnd: (v) => {
Expand Down
3 changes: 3 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ export const MAX_SENIOR_URL = "https://www.sncf-connect.com/catalogue/descriptio
export const INTERRAIL_FRANCE_URL = "https://www.interrail.com/en/plan-your-trip/reservations/reservations-france";
/** Interrail Global Pass conditions: who can buy it, and the country-of-residence rule. */
export const INTERRAIL_PASS_URL = "https://www.interrail.com/en/interrail-passes/global-pass";
/** Interrail's pass-holder reservation fees, the source of src/data/fees.ts. */
export const INTERRAIL_FEES_URL =
"https://www.interrail.com/en/book-reservations/reservation-fees/domestic-train-reservation-fees";
/** ADEME's CO₂ comparator, the source of the per-km factors in src/util/eco.ts. */
export const ADEME_CO2_URL = "https://impactco2.fr/outils/transport";

Expand Down
93 changes: 93 additions & 0 deletions src/data/fees.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type { Journey } from "../types";

/**
* What an Interrail pass holder pays to reserve a seat on SNCF trains, in euros, 2nd
* class. The same table train-api publishes as v1/sncf/interrail/fees.json (see its
* src/operators/interrail-fees.ts), hand-entered from Interrail's fee pages and checked
* on 2026-10-12. Indicative: the app says so and links the source.
*
* A stop abroad sets the international fee for that country; otherwise the train's axe
* sets the domestic one.
*/

/** A pass-holder reservation in euros; `min === max` for a fixed fee. */
export interface Fee {
min: number;
max: number;
}

type Country = "BE" | "CH" | "DE" | "ES" | "IT" | "LU";

/** Whole words of SNCF stop names abroad, by country (accents and case ignored). */
const FOREIGN_STOPS: Record<Country, string[]> = {
BE: ["bruxelles", "brussel", "liege", "anvers", "antwerpen"],
CH: ["geneve", "lausanne", "zurich", "basel", "bale", "bern", "vallorbe", "neuchatel"],
DE: [
"mannheim", "saarbrucken", "saarbruecken", "kaiserslautern", "frankfurt", "karlsruhe",
"stuttgart", "augsburg", "munchen", "baden baden", "ulm", "freiburg", "koln", "dortmund",
],
ES: ["barcelona", "girona", "figueres", "madrid"],
IT: ["torino", "milano", "oulx", "oux"],
LU: ["luxembourg"],
};

const BY_COUNTRY: Record<Exclude<Country, "ES">, Fee> = {
BE: { min: 20, max: 35 }, // TGV to Brussels
CH: { min: 25, max: 25 }, // TGV Lyria
DE: { min: 19, max: 19 }, // ICE & TGV France–Germany
IT: { min: 31, max: 31 }, // TGV France–Italy
LU: { min: 10, max: 20 }, // TGV to Luxembourg
};
const SPAIN_FROM_PARIS: Fee = { min: 35, max: 35 };
const SPAIN_OTHER: Fee = { min: 25, max: 25 };

const BY_AXE: Record<string, Fee> = {
"SUD EST": { min: 10, max: 20 }, // TGV INOUI
EST: { min: 10, max: 20 },
ATLANTIQUE: { min: 10, max: 20 },
NORD: { min: 10, max: 20 },
INTERNATIONAL: { min: 10, max: 20 }, // a stretch inside France
"IC SRO": { min: 10, max: 20 }, // Intercités, reservation compulsory
"IC ARO": { min: 10, max: 20 },
"IC NUIT": { min: 10, max: 25 }, // seat €10, couchette €19–25
};

function fold(s: string): string {
return ` ${s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, " ")} `;
}

function foreignCountry(stop: string): Country | undefined {
const f = fold(stop);
for (const [country, names] of Object.entries(FOREIGN_STOPS) as [Country, string[]][]) {
if (names.some((n) => f.includes(` ${n} `))) return country;
}
return undefined;
}

/** The fee for one SNCF train between two stops, or undefined when no fee page covers it. */
export function interrailFee(axe: string | undefined, origin: string, destination: string): Fee | undefined {
const abroad = foreignCountry(origin) ?? foreignCountry(destination);
if (abroad === "ES") return [origin, destination].some((s) => fold(s).includes(" paris ")) ? SPAIN_FROM_PARIS : SPAIN_OTHER;
if (abroad) return BY_COUNTRY[abroad];
return axe ? BY_AXE[axe] : undefined;
}

/** The fees of every train in a journey added up, or undefined when one train has none. */
export function journeyFee(j: Journey): Fee | undefined {
let min = 0;
let max = 0;
for (const leg of j.legs) {
const fee = interrailFee(leg.axe, leg.origin, leg.destination);
if (!fee) return undefined;
min += fee.min;
max += fee.max;
}
return { min, max };
}

/** "€19" or "€10–20", in the reader's locale. */
export function formatFee(fee: Fee, lang: string): string {
const eur = (v: number): string =>
new Intl.NumberFormat(lang, { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(v);
return fee.min === fee.max ? eur(fee.min) : `${eur(fee.min)}–${eur(fee.max)}`;
}
6 changes: 6 additions & 0 deletions src/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ export const ar: Dict = {
set_show_map_hint: "أوقفها لتطبيق أخفّ وأسرع على الهواتف الضعيفة.",
set_compact: "نتائج مضغوطة",
set_compact_hint: "مزيد من القطارات في الشاشة.",
set_fees: "رسوم حجز إنتررييل",
set_fees_hint: "يعرض تكلفة الحجز الإلزامي لكل قطار، في الدرجة الثانية.",
set_lowend: "جهاز ضعيف",
set_lowend_hint: "تقليل الحركة وإخفاء الخريطة وضغط القائمة بضغطة واحدة.",
lowend_prompt_title: "تسريع التطبيق؟",
Expand Down Expand Up @@ -250,6 +252,7 @@ export const ar: Dict = {
sort_fastest: "الأسرع",
sort_arrival: "الوصول الأبكر",
sort_departure: "المغادرة الأبكر",
sort_cheapest: "أرخص حجز",
sort_name: "أ–ي",

mode_oneway: "ذهاب فقط",
Expand Down Expand Up @@ -302,6 +305,9 @@ export const ar: Dict = {
how_eco: "البيئة: رحلة بقطار TGV تُصدر نحو 50 مرة أقل من ثاني أكسيد الكربون مقارنة بالسيارة لشخص واحد، و100 مرة أقل من الطائرة (أرقام ADEME).",
eco_trip: "بالقطار، تُصدر هذه الرحلة نحو {train} كغ من مكافئ CO₂، مقابل {car} كغ بالسيارة لشخص واحد و{plane} كغ بالطائرة (ADEME، {km} كم في خط مستقيم).",
eco_link: "أرقام CO₂ من ADEME",
fee_chip: "الحجز {fee}",
fee_note: "رسوم حجز إنتررييل تقريبية، الدرجة الثانية.",
fee_link: "رسوم إنتررييل",
tagline_ir: "قطارات SNCF بتذكرة Interrail.",
how_note_ir: "تعرض هذه الأداة القطارات التي تسير (البيانات المفتوحة لـ SNCF)؛ ولا تعرف إن بقي مقعد لحاملي التذكرة. تحقق على SNCF Connect.",
multi_incomplete_ir: "بعض المقاطع لا يوجد فيها قطار — هذا المسار غير مكتمل.",
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ export const de: Dict = {
set_show_map_hint: "Aus für eine leichtere, schnellere App auf schwachen Handys.",
set_compact: "Kompakte Ergebnisse",
set_compact_hint: "Mehr Züge pro Bildschirm.",
set_fees: "Interrail-Reservierungsgebühren",
set_fees_hint: "Zeigt, was die Pflichtreservierung jedes Zuges in der 2. Klasse kostet.",
set_lowend: "Schwaches Gerät",
set_lowend_hint: "Bewegung reduzieren, Karte ausblenden und Liste kompakt — mit einem Tipp.",
lowend_prompt_title: "App beschleunigen?",
Expand Down Expand Up @@ -251,6 +253,7 @@ export const de: Dict = {
sort_fastest: "Schnellste",
sort_arrival: "Früheste Ankunft",
sort_departure: "Früheste Abfahrt",
sort_cheapest: "Günstigste Reservierung",
sort_name: "A–Z",

mode_oneway: "Einfache Fahrt",
Expand Down Expand Up @@ -303,6 +306,9 @@ export const de: Dict = {
how_eco: "Ökologie: Eine TGV-Fahrt verursacht etwa 50-mal weniger CO₂ als allein mit dem Auto und 100-mal weniger als mit dem Flugzeug (ADEME-Zahlen).",
eco_trip: "Mit dem Zug verursacht diese Fahrt etwa {train} kg CO₂e, gegenüber {car} kg allein im Auto und {plane} kg im Flugzeug (ADEME, {km} km Luftlinie).",
eco_link: "CO₂-Zahlen der ADEME",
fee_chip: "Reservierung {fee}",
fee_note: "Richtwerte für Interrail-Reservierungsgebühren, 2. Klasse.",
fee_link: "Interrail-Gebühren",
tagline_ir: "SNCF-Züge mit einem Interrail-Pass.",
how_note_ir: "Dieses Tool zeigt fahrende Züge (offene SNCF-Daten); es sieht nicht, ob noch ein Pass-Platz frei ist. Prüfe auf SNCF Connect.",
multi_incomplete_ir: "Für einige Abschnitte gibt es keinen Zug — diese Reise ist unvollständig.",
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ export const en: Dict = {
set_show_map_hint: "Turn off for a lighter, faster app on low-end phones.",
set_compact: "Compact results",
set_compact_hint: "More trains per screen.",
set_fees: "Interrail reservation fees",
set_fees_hint: "Shows what each train's compulsory reservation costs, 2nd class.",
set_lowend: "Low-end device",
set_lowend_hint: "Reduce motion, hide the map and compact the list, in one tap.",
lowend_prompt_title: "Speed up the app?",
Expand Down Expand Up @@ -263,6 +265,7 @@ export const en: Dict = {
sort_fastest: "Fastest",
sort_arrival: "Earliest arrival",
sort_departure: "Earliest departure",
sort_cheapest: "Cheapest reservation",
sort_name: "A–Z",

mode_oneway: "One-way",
Expand Down Expand Up @@ -315,6 +318,9 @@ export const en: Dict = {
how_eco: "Ecology: a TGV trip emits about 50 times less CO₂ than driving alone and 100 times less than flying (ADEME figures).",
eco_trip: "By train, this trip emits about {train} kg CO₂e, against {car} kg driving alone and {plane} kg flying (ADEME, {km} km as the crow flies).",
eco_link: "ADEME CO₂ figures",
fee_chip: "Reservation {fee}",
fee_note: "Indicative Interrail reservation fees, 2nd class.",
fee_link: "Interrail fees",
tagline_ir: "SNCF trains on an Interrail pass.",
how_note_ir: "This tool shows trains that run (SNCF open data); it can't see whether a pass-holder seat is left. Check SNCF Connect.",
multi_incomplete_ir: "Some legs have no train — this itinerary is incomplete.",
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ export const es: Dict = {
set_show_map_hint: "Desactívalo para una app más ligera y rápida en móviles básicos.",
set_compact: "Resultados compactos",
set_compact_hint: "Más trenes por pantalla.",
set_fees: "Suplementos de reserva Interrail",
set_fees_hint: "Muestra el precio de la reserva obligatoria de cada tren, en 2.ª clase.",
set_lowend: "Dispositivo básico",
set_lowend_hint: "Reduce animaciones, oculta el mapa y compacta la lista, de una vez.",
lowend_prompt_title: "¿Acelerar la app?",
Expand Down Expand Up @@ -251,6 +253,7 @@ export const es: Dict = {
sort_fastest: "Más rápido",
sort_arrival: "Llegada más temprana",
sort_departure: "Salida más temprana",
sort_cheapest: "Reserva más barata",
sort_name: "A–Z",

mode_oneway: "Solo ida",
Expand Down Expand Up @@ -303,6 +306,9 @@ export const es: Dict = {
how_eco: "Ecología: un viaje en TGV emite unas 50 veces menos CO₂ que en coche solo y 100 veces menos que en avión (cifras de ADEME).",
eco_trip: "En tren, este viaje emite unos {train} kg de CO₂e, frente a {car} kg en coche solo y {plane} kg en avión (ADEME, {km} km en línea recta).",
eco_link: "Cifras de CO₂ de ADEME",
fee_chip: "Reserva {fee}",
fee_note: "Suplementos de reserva Interrail orientativos, en 2.ª clase.",
fee_link: "Tarifas Interrail",
tagline_ir: "Trenes SNCF con un pase Interrail.",
how_note_ir: "Esta herramienta muestra los trenes que circulan (datos abiertos de SNCF); no sabe si queda plaza de pase. Verifica en SNCF Connect.",
multi_incomplete_ir: "Algunos trayectos no tienen tren — este itinerario está incompleto.",
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ export const fr = {
set_show_map_hint: "Désactivez-la pour une appli plus légère et rapide sur mobile bas de gamme.",
set_compact: "Résultats compacts",
set_compact_hint: "Plus de trains par écran.",
set_fees: "Frais de réservation Interrail",
set_fees_hint: "Affiche le prix de la réservation obligatoire de chaque train, en 2de classe.",
set_lowend: "Appareil bas de gamme",
set_lowend_hint: "Réduit les animations, masque la carte et compacte la liste, en un geste.",
lowend_prompt_title: "Accélérer l'appli ?",
Expand Down Expand Up @@ -261,6 +263,7 @@ export const fr = {
sort_fastest: "Plus rapide",
sort_arrival: "Arrivée la plus tôt",
sort_departure: "Départ le plus tôt",
sort_cheapest: "Réservation la moins chère",
sort_name: "A–Z",

mode_oneway: "Aller simple",
Expand Down Expand Up @@ -319,6 +322,9 @@ export const fr = {
how_eco: "Écologie : en TGV, un trajet émet environ 50 fois moins de CO₂ qu'en voiture seul et 100 fois moins qu'en avion (chiffres ADEME).",
eco_trip: "En train, ce trajet émet environ {train} kg de CO₂e, contre {car} kg en voiture seul et {plane} kg en avion (ADEME, {km} km à vol d'oiseau).",
eco_link: "Calculs CO₂ de l'ADEME",
fee_chip: "Résa {fee}",
fee_note: "Frais de réservation Interrail indicatifs, en 2de classe.",
fee_link: "Tarifs Interrail",
tagline_ir: "Trains SNCF avec un pass Interrail.",
how_note_ir: "Cet outil affiche les trains qui circulent (open data SNCF) ; il ne sait pas s'il reste une place pass. Vérifiez sur SNCF Connect.",
multi_incomplete_ir: "Certains trajets n'ont aucun train — cet itinéraire est incomplet.",
Expand Down
Loading
Loading