diff --git a/docs/user-flows.md b/docs/user-flows.md index 5f43767..d2eb3f2 100644 --- a/docs/user-flows.md +++ b/docs/user-flows.md @@ -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 diff --git a/src/app.ts b/src/app.ts index ca99433..2cd577a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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"; @@ -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, @@ -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 @@ -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. */ @@ -1751,6 +1756,7 @@ interface SortAccessors { 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) } /** @@ -1784,6 +1790,14 @@ function applySort(items: T[], acc: SortAccessors): 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; } @@ -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) { @@ -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, }, ); @@ -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 }), @@ -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); @@ -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) => { diff --git a/src/config.ts b/src/config.ts index f3bdb91..25eb2f3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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"; diff --git a/src/data/fees.ts b/src/data/fees.ts new file mode 100644 index 0000000..d7ac8fb --- /dev/null +++ b/src/data/fees.ts @@ -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 = { + 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, 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 = { + "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)}`; +} diff --git a/src/i18n/ar.ts b/src/i18n/ar.ts index 7ca09be..cbfbc8d 100644 --- a/src/i18n/ar.ts +++ b/src/i18n/ar.ts @@ -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: "تسريع التطبيق؟", @@ -250,6 +252,7 @@ export const ar: Dict = { sort_fastest: "الأسرع", sort_arrival: "الوصول الأبكر", sort_departure: "المغادرة الأبكر", + sort_cheapest: "أرخص حجز", sort_name: "أ–ي", mode_oneway: "ذهاب فقط", @@ -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: "بعض المقاطع لا يوجد فيها قطار — هذا المسار غير مكتمل.", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 3cd507b..b54f21e 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -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?", @@ -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", @@ -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.", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 526210a..227e5ef 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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?", @@ -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", @@ -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.", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 5d37612..16954c9 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -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?", @@ -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", @@ -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.", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index 1b85ca8..021e793 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -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 ?", @@ -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", @@ -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.", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index c8bc72e..5f1270e 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -108,6 +108,8 @@ export const it: Dict = { set_show_map_hint: "Disattivala per un'app più leggera e veloce su telefoni economici.", set_compact: "Risultati compatti", set_compact_hint: "Più treni per schermata.", + set_fees: "Supplementi di prenotazione Interrail", + set_fees_hint: "Mostra quanto costa la prenotazione obbligatoria di ogni treno, in 2ª classe.", set_lowend: "Dispositivo economico", set_lowend_hint: "Riduce animazioni, nasconde la mappa e compatta la lista, con un tocco.", lowend_prompt_title: "Velocizzare l'app?", @@ -251,6 +253,7 @@ export const it: Dict = { sort_fastest: "Più veloce", sort_arrival: "Arrivo più presto", sort_departure: "Partenza più presto", + sort_cheapest: "Prenotazione più economica", sort_name: "A–Z", mode_oneway: "Solo andata", @@ -303,6 +306,9 @@ export const it: Dict = { how_eco: "Ecologia: un viaggio in TGV emette circa 50 volte meno CO₂ che in auto da soli e 100 volte meno che in aereo (dati ADEME).", eco_trip: "In treno, questo viaggio emette circa {train} kg di CO₂e, contro {car} kg in auto da soli e {plane} kg in aereo (ADEME, {km} km in linea d'aria).", eco_link: "Dati CO₂ dell'ADEME", + fee_chip: "Prenotazione {fee}", + fee_note: "Supplementi di prenotazione Interrail indicativi, 2ª classe.", + fee_link: "Tariffe Interrail", tagline_ir: "Treni SNCF con un pass Interrail.", how_note_ir: "Questo strumento mostra i treni che circolano (dati aperti SNCF); non sa se resta un posto pass. Verifica su SNCF Connect.", multi_incomplete_ir: "Alcune tratte non hanno treni — questo itinerario è incompleto.", diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 6ec1d4a..f856d09 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -108,6 +108,8 @@ export const ja: Dict = { set_show_map_hint: "低スペック端末では、オフにして軽く速く。", set_compact: "コンパクト表示", set_compact_hint: "1画面により多くの列車。", + set_fees: "インターレイル予約料金", + set_fees_hint: "各列車の必須予約にかかる料金を2等車で表示します。", set_lowend: "低スペック端末", set_lowend_hint: "モーション削減・地図非表示・リスト圧縮をまとめて。", lowend_prompt_title: "アプリを軽くしますか?", @@ -250,6 +252,7 @@ export const ja: Dict = { sort_fastest: "速い順", sort_arrival: "到着が早い順", sort_departure: "出発が早い順", + sort_cheapest: "予約料金が安い順", sort_name: "A–Z", mode_oneway: "片道", @@ -302,6 +305,9 @@ export const ja: Dict = { how_eco: "環境:TGVでの移動のCO₂排出は、1人で車に乗る場合の約50分の1、飛行機の約100分の1です(ADEMEの数値)。", eco_trip: "列車なら、この旅のCO₂e排出は約{train} kgです。1人で車なら{car} kg、飛行機なら{plane} kgです(ADEME、直線距離{km} km)。", eco_link: "ADEMEのCO₂データ", + fee_chip: "予約 {fee}", + fee_note: "インターレイルの予約料金(2等車)は目安です。", + fee_link: "インターレイル料金", tagline_ir: "Interrail パスで乗る SNCF の列車。", how_note_ir: "このツールは運行中の列車(SNCF オープンデータ)を表示します。パス席が残っているかは分かりません。SNCF Connect でご確認ください。", multi_incomplete_ir: "一部の区間に列車がありません — この行程は不完全です。", diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index 3b43ac4..7ead45a 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -108,6 +108,8 @@ export const ko: Dict = { set_show_map_hint: "저사양 폰에서 더 가볍고 빠르게 하려면 끄세요.", set_compact: "좁게 보기", set_compact_hint: "화면당 더 많은 열차.", + set_fees: "인터레일 예약 요금", + set_fees_hint: "각 열차의 필수 예약 요금을 2등석 기준으로 보여 줍니다.", set_lowend: "저사양 기기", set_lowend_hint: "모션 줄이기, 지도 숨김, 목록 압축을 한 번에.", lowend_prompt_title: "앱을 더 빠르게 할까요?", @@ -251,6 +253,7 @@ export const ko: Dict = { sort_fastest: "빠른 순", sort_arrival: "가장 빠른 도착", sort_departure: "가장 빠른 출발", + sort_cheapest: "예약 요금 낮은 순", sort_name: "가나다순", mode_oneway: "편도", @@ -303,6 +306,9 @@ export const ko: Dict = { how_eco: "환경: TGV 여행은 혼자 자동차로 갈 때보다 CO₂를 약 50배, 비행기보다 약 100배 적게 배출합니다(ADEME 수치).", eco_trip: "기차로 이 여정은 약 {train} kg CO₂e를 배출합니다. 혼자 자동차로는 {car} kg, 비행기로는 {plane} kg입니다(ADEME, 직선거리 {km} km).", eco_link: "ADEME CO₂ 수치", + fee_chip: "예약 {fee}", + fee_note: "인터레일 예약 요금(2등석)은 참고용입니다.", + fee_link: "인터레일 요금", tagline_ir: "Interrail 패스로 타는 SNCF 열차.", how_note_ir: "이 도구는 운행하는 열차(SNCF 공개 데이터)를 표시하며, 패스 좌석이 남았는지는 알 수 없습니다. SNCF Connect에서 확인하세요.", multi_incomplete_ir: "일부 구간에 열차가 없습니다 — 이 일정은 미완성입니다.", diff --git a/src/i18n/nl.ts b/src/i18n/nl.ts index 7a55135..7db40bf 100644 --- a/src/i18n/nl.ts +++ b/src/i18n/nl.ts @@ -108,6 +108,8 @@ export const nl: Dict = { set_show_map_hint: "Uit voor een lichtere, snellere app op zwakke telefoons.", set_compact: "Compacte resultaten", set_compact_hint: "Meer treinen per scherm.", + set_fees: "Interrail-reserveringskosten", + set_fees_hint: "Toont wat de verplichte reservering van elke trein kost, 2e klas.", set_lowend: "Zwak apparaat", set_lowend_hint: "Beweging beperken, kaart verbergen en lijst compact — in één tik.", lowend_prompt_title: "App sneller maken?", @@ -250,6 +252,7 @@ export const nl: Dict = { sort_fastest: "Snelste", sort_arrival: "Vroegste aankomst", sort_departure: "Vroegste vertrek", + sort_cheapest: "Goedkoopste reservering", sort_name: "A–Z", mode_oneway: "Enkele reis", @@ -302,6 +305,9 @@ export const nl: Dict = { how_eco: "Ecologie: een TGV-reis stoot ongeveer 50 keer minder CO₂ uit dan alleen met de auto en 100 keer minder dan vliegen (cijfers van ADEME).", eco_trip: "Met de trein stoot deze reis ongeveer {train} kg CO₂e uit, tegen {car} kg alleen met de auto en {plane} kg met het vliegtuig (ADEME, {km} km hemelsbreed).", eco_link: "CO₂-cijfers van ADEME", + fee_chip: "Reservering {fee}", + fee_note: "Indicatieve Interrail-reserveringskosten, 2e klas.", + fee_link: "Interrail-tarieven", tagline_ir: "SNCF-treinen met een Interrail-pas.", how_note_ir: "Deze tool toont treinen die rijden (open data SNCF); hij ziet niet of er nog een passplaats vrij is. Controleer op SNCF Connect.", multi_incomplete_ir: "Sommige trajecten hebben geen trein — deze reis is onvolledig.", diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts index 4111bcd..e0c15f2 100644 --- a/src/i18n/pt.ts +++ b/src/i18n/pt.ts @@ -108,6 +108,8 @@ export const pt: Dict = { set_show_map_hint: "Desative para uma app mais leve e rápida em telemóveis modestos.", set_compact: "Resultados compactos", set_compact_hint: "Mais comboios por ecrã.", + set_fees: "Taxas de reserva Interrail", + set_fees_hint: "Mostra quanto custa a reserva obrigatória de cada comboio, em 2.ª classe.", set_lowend: "Dispositivo modesto", set_lowend_hint: "Reduz o movimento, oculta o mapa e compacta a lista, num toque.", lowend_prompt_title: "Acelerar o app?", @@ -250,6 +252,7 @@ export const pt: Dict = { sort_fastest: "Mais rápido", sort_arrival: "Chegada mais cedo", sort_departure: "Partida mais cedo", + sort_cheapest: "Reserva mais barata", sort_name: "A–Z", mode_oneway: "Só ida", @@ -302,6 +305,9 @@ export const pt: Dict = { how_eco: "Ecologia: uma viagem de TGV emite cerca de 50 vezes menos CO₂ do que de carro sozinho e 100 vezes menos do que de avião (dados da ADEME).", eco_trip: "De comboio, esta viagem emite cerca de {train} kg de CO₂e, contra {car} kg de carro sozinho e {plane} kg de avião (ADEME, {km} km em linha reta).", eco_link: "Dados de CO₂ da ADEME", + fee_chip: "Reserva {fee}", + fee_note: "Taxas de reserva Interrail indicativas, 2.ª classe.", + fee_link: "Taxas Interrail", tagline_ir: "Comboios SNCF com um passe Interrail.", how_note_ir: "Esta ferramenta mostra os comboios que circulam (dados abertos da SNCF); não sabe se ainda há lugar de passe. Verifique no SNCF Connect.", multi_incomplete_ir: "Alguns trajetos não têm comboio — este itinerário está incompleto.", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index d9500ac..5097400 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -108,6 +108,8 @@ export const zh: Dict = { set_show_map_hint: "在低端手机上关闭可让应用更轻更快。", set_compact: "紧凑结果", set_compact_hint: "每屏显示更多班次。", + set_fees: "Interrail 订座费", + set_fees_hint: "显示每趟列车必须订座的费用(二等座)。", set_lowend: "低端设备", set_lowend_hint: "一键减少动效、隐藏地图并压缩列表。", lowend_prompt_title: "让应用更快?", @@ -250,6 +252,7 @@ export const zh: Dict = { sort_fastest: "最快", sort_arrival: "最早到达", sort_departure: "最早出发", + sort_cheapest: "订座费最低", sort_name: "A–Z", mode_oneway: "单程", @@ -302,6 +305,9 @@ export const zh: Dict = { how_eco: "环保:乘坐 TGV 出行的二氧化碳排放约为独自驾车的五十分之一、乘飞机的百分之一(ADEME 数据)。", eco_trip: "乘火车,这段行程约排放 {train} kg CO₂e,独自驾车为 {car} kg,乘飞机为 {plane} kg(ADEME,直线距离 {km} km)。", eco_link: "ADEME 二氧化碳数据", + fee_chip: "订座 {fee}", + fee_note: "Interrail 订座费仅供参考(二等座)。", + fee_link: "Interrail 费用", tagline_ir: "持 Interrail 通票乘坐的 SNCF 列车。", how_note_ir: "本工具显示运行中的列车(SNCF 开放数据),无法得知是否还有通票座位。请在 SNCF Connect 上核实。", multi_incomplete_ir: "部分行程没有列车——此行程不完整。", diff --git a/src/state/store.ts b/src/state/store.ts index 58ffe83..4e707bd 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -40,6 +40,8 @@ export interface Settings { /** Show the interactive map. Off = no Leaflet/tiles at all: the biggest saving on * low-end devices and slow connections; results go full-width. */ map: boolean; + /** Show Interrail pass-holder reservation fees on each train (off by default). */ + fees: boolean; } export type Density = "comfortable" | "compact"; @@ -87,6 +89,7 @@ export function loadSettings(): Settings { density: s.density === "compact" ? "compact" : "comfortable", reduceMotion: s.reduceMotion === true, map: s.map !== false, // default on + fees: s.fees === true, }; } @@ -307,7 +310,7 @@ export function queryToParams(q: SearchQuery): URLSearchParams { return p; } -const SORT_KEYS = ["rec", "trains", "days", "closest", "fastest", "name"] as const; +const SORT_KEYS = ["rec", "trains", "days", "closest", "fastest", "name", "cheapest"] as const; export function queryFromParams(p: URLSearchParams, fallbackDate: string): SearchQuery { const rawMode = p.get("mode") ?? "from"; diff --git a/src/styles.css b/src/styles.css index 2c04e49..e19b933 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1992,6 +1992,17 @@ a { color: var(--muted); font-size: 0.83rem; } +/* The route chip and, when turned on, the Interrail reservation fee beside it. */ +.journey-tags { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; + min-width: 0; +} +.chip-fee { + color: var(--text); +} .legs { display: flex; flex-direction: column; diff --git a/src/types.ts b/src/types.ts index 4e5e679..9a81abb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -163,7 +163,7 @@ export interface SearchQuery { } /** Result ordering for the destination / ideas lists. */ -export type SortKey = "rec" | "trains" | "days" | "closest" | "fastest" | "name" | "arrival" | "departure"; +export type SortKey = "rec" | "trains" | "days" | "closest" | "fastest" | "name" | "arrival" | "departure" | "cheapest"; /** A direct or multi-leg (1..3 legs) journey. */ export interface Journey { diff --git a/src/ui/modals.ts b/src/ui/modals.ts index d082c20..1d2b852 100644 --- a/src/ui/modals.ts +++ b/src/ui/modals.ts @@ -134,9 +134,12 @@ export function showSettingsModal(opts: { reduceMotion: boolean; map: boolean; compact: boolean; + /** Interrail reservation fees on each train (off by default). */ + fees: boolean; onReduceMotion: (v: boolean) => void; onMap: (v: boolean) => void; onCompact: (v: boolean) => void; + onFees: (v: boolean) => void; /** Master "Low-end mode": ON = all three savers on, OFF = all three off. */ onLowEnd: (v: boolean) => void; }): void { @@ -175,6 +178,12 @@ export function showSettingsModal(opts: { el("p", { class: "modal-text muted small", text: t("set_perf") }), el("div", { class: "set-rows set-master" }, [master]), body, + el("div", { class: "set-rows" }, [ + settingSwitch(t("set_fees"), t("set_fees_hint"), opts.fees, (v) => { + opts.onFees(v); + rebuild({ fees: v }); + }), + ]), el("p", { class: "muted small set-version", text: `Railmap v${APP_VERSION}${APP_BUILD ? ` · ${APP_BUILD}` : ""}`, diff --git a/src/ui/render.ts b/src/ui/render.ts index 0bcb8b0..2b54468 100644 --- a/src/ui/render.ts +++ b/src/ui/render.ts @@ -9,7 +9,8 @@ import { el } from "./dom"; import { isNightTrain } from "../core/search"; import { isAirportStation } from "../data/stations"; import { formatDuration, dayIndex, addDays } from "../util/time"; -import { t } from "../i18n"; +import { t, getLang } from "../i18n"; +import { formatFee, type Fee } from "../data/fees"; // Monotonic id source for wiring aria-labelledby (a calendar's

to its grid). let uidSeq = 0; @@ -36,6 +37,8 @@ export interface RenderCtx { onIcs: (journey: Journey) => void; /** Open the step-by-step "book each train" modal for a connecting journey. */ onBookSteps: (journey: Journey) => void; + /** A journey's Interrail reservation fees, set only when the traveller turned them on. */ + fee?: (journey: Journey) => Fee | undefined; isFavorite: (route: RoutePair) => boolean; onToggleFavorite: (route: RoutePair) => void; /** Whether this trip (one-way, or a round trip with `inbound`) is saved. */ @@ -592,12 +595,18 @@ function journeyBodyEl(j: Journey, ctx: RenderCtx, label?: string, dateLabel?: s class: "chip chip-via", text: t("lbl_via", { hub: j.hubs.map((h) => ctx.label(h)).join(", ") }), }); + const fee = ctx.fee?.(j); const head = el("div", { class: "journey-head" }, [ ...(label ? [el("h3", { class: "trip-leg-title", text: label })] : []), // With flexible dates the list spans several days, so each card carries its own // date (otherwise which day a proposition is for is lost). Leads the head. ...(dateLabel ? [el("span", { class: "chip chip-date", text: dateLabel })] : []), - tag, + fee + ? el("span", { class: "journey-tags" }, [ + tag, + el("span", { class: "chip chip-fee", text: t("fee_chip", { fee: formatFee(fee, getLang()) }) }), + ]) + : tag, el("span", { class: "journey-total" }, [icon(I.clock), el("span", { text: formatDuration(j.totalDurationMin) })]), ]); return el("div", { class: "journey-body" }, [head, legs]); diff --git a/tests/fees.test.ts b/tests/fees.test.ts new file mode 100644 index 0000000..3c73e65 --- /dev/null +++ b/tests/fees.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { formatFee, interrailFee, journeyFee } from "../src/data/fees"; +import type { Journey, MaxTrain } from "../src/types"; + +const leg = (origin: string, destination: string, axe?: string): MaxTrain => ({ + date: "2026-10-19", + origin, + destination, + depart: "08:00", + arrive: "10:00", + departMin: 480, + arriveMin: 600, + durationMin: 120, + trainNo: "6201", + available: true, + axe, +}); + +describe("Interrail reservation fees", () => { + it("prices a train by the country abroad, else by its axe", () => { + expect(interrailFee("SUD EST", "PARIS (intramuros)", "MARSEILLE ST CHARLES")).toEqual({ min: 10, max: 20 }); + expect(interrailFee("INTERNATIONAL", "PARIS (intramuros)", "STUTTGART HBF")).toEqual({ min: 19, max: 19 }); + expect(interrailFee("INTERNATIONAL", "PARIS (intramuros)", "ZURICH HB")).toEqual({ min: 25, max: 25 }); + expect(interrailFee("INTERNATIONAL", "LYON (intramuros)", "TORINO PORTA SUSA")).toEqual({ min: 31, max: 31 }); + expect(interrailFee("INTERNATIONAL", "PARIS (intramuros)", "DIJON VILLE")).toEqual({ min: 10, max: 20 }); + expect(interrailFee("IC NUIT", "PARIS (intramuros)", "BRIANCON")).toEqual({ min: 10, max: 25 }); + expect(interrailFee("AUTOCAR SNCF", "PARIS (intramuros)", "ROUEN RIVE DROITE")).toBeUndefined(); + // Whole words only: Bernay is in Normandy, not Bern. + expect(interrailFee("NORD", "PARIS (intramuros)", "BERNAY")).toEqual({ min: 10, max: 20 }); + }); + + it("adds up a connecting journey's fees, and gives none when one train has none", () => { + const j = (legs: MaxTrain[]) => ({ legs }) as unknown as Journey; + expect(journeyFee(j([leg("PARIS (intramuros)", "LYON (intramuros)", "SUD EST"), leg("LYON (intramuros)", "TORINO PORTA SUSA", "INTERNATIONAL")]))).toEqual({ min: 41, max: 51 }); + expect(journeyFee(j([leg("PARIS (intramuros)", "ROUEN RIVE DROITE", "AUTOCAR SNCF")]))).toBeUndefined(); + }); + + it("writes a fixed fee as one price and a range as two", () => { + expect(formatFee({ min: 19, max: 19 }, "en")).toBe("€19"); + expect(formatFee({ min: 10, max: 20 }, "en")).toBe("€10–€20"); + }); +});