From 5b891de5088b3b3ed067388efb40ec1571281c53 Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:33:49 +0000 Subject: [PATCH] Give each operator its own Interrail booking site and reservation rules The index sent Interrail holders to SNCF Connect for every operator and said every train needs a paid reservation, which is wrong for ICE within Germany, Dutch domestic trains, Swiss and Irish trains and more. Each operator's index entry now carries its own booking site and a note on which of its trains need a reservation; an operator without one falls back to general rules pointing at Interrail's reservations page. --- docs/format.md | 6 +++-- src/api.ts | 4 ++- src/format.ts | 6 +++-- src/operators/interrail.ts | 53 ++++++++++++++++++++++++++++++++++++++ tests/api.test.ts | 13 ++++++++++ 5 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 src/operators/interrail.ts diff --git a/docs/format.md b/docs/format.md index 14044f9..ef2aeda 100644 --- a/docs/format.md +++ b/docs/format.md @@ -20,8 +20,10 @@ publishes it per pass in the compact snapshot format (`src/compact.ts`). The SNCF feed has one MAX flag for both MAX passes (they share one quota), so `max-jeune` and `max-senior` differ only by MAX SENIOR's own rules. Its weekday rule is applied; its off-peak limits (Friday afternoon and evening, Monday morning, eves of holidays) are not in -the data, so `index.json` carries them as the pass's `note`. Interrail's `note` carries -its residence rule, which no timetable can check. +the data, so `index.json` carries them as the pass's `note`. For Interrail, each operator's +entry carries its own booking site (`bookingUrl`) and reservation rules (`note`): whether a +pass holder needs a reservation depends on the operator and the train (none on ICE within +Germany or on Dutch domestic trains; a paid one on TGV INOUI, Frecciarossa or AVE). ## Passes diff --git a/src/api.ts b/src/api.ts index ecf4cdd..8c61cfe 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,5 +1,6 @@ import { encodeCompact } from "./compact"; import { PASSES, PASS_INFO, type PassId, type PassInfo, type Train } from "./format"; +import { INTERRAIL_BY_OPERATOR } from "./operators/interrail"; /** * The static API: plain JSON files, so it can be hosted for free and never needs a @@ -69,7 +70,8 @@ export function buildOperatorApi( for (const pass of PASSES) { const ok = bookableFor(trains, pass); if (ok.length === 0) continue; - passes.push({ ...PASS_INFO[pass], trainCount: ok.length }); + const own = pass === "interrail" ? INTERRAIL_BY_OPERATOR[operator] : undefined; + passes.push({ ...PASS_INFO[pass], ...own, trainCount: ok.length }); files.push({ path: `v1/${operator}/${pass}/all.json`, body: encodeCompact(ok) }); const byOrigin = new Map(); for (const t of ok) { diff --git a/src/format.ts b/src/format.ts index 7102f73..a658e46 100644 --- a/src/format.ts +++ b/src/format.ts @@ -65,7 +65,9 @@ export const PASS_INFO: Record = { id: "interrail", name: "Interrail", seatKnown: false, - bookingUrl: "https://www.sncf-connect.com/", - note: "Every train here needs a paid pass-holder reservation. A Global Pass covers only one outbound and one return trip inside your country of residence.", + // Each operator's index entry carries its own booking site and reservation rules + // (src/operators/interrail.ts); these are the fallback. + bookingUrl: "https://www.interrail.eu/en/plan-your-trip/reservations", + note: "Reservation rules depend on the operator and the train. A Global Pass covers only one outbound and one return trip inside your country of residence.", }, }; diff --git a/src/operators/interrail.ts b/src/operators/interrail.ts new file mode 100644 index 0000000..c59d678 --- /dev/null +++ b/src/operators/interrail.ts @@ -0,0 +1,53 @@ +import type { PassInfo } from "../format"; + +/** + * Where an Interrail holder books each operator's trains, and whether they need a + * reservation. Reservation rules come from the operators' own pass pages and change; + * clients should show the note, not decide for the traveller. + */ +export const INTERRAIL_BY_OPERATOR: Record> = { + sncf: { + bookingUrl: "https://www.sncf-connect.com/en-en/", + note: "Every TGV INOUI, Intercités, night and international train here needs a paid pass-holder reservation. A Global Pass covers only one outbound and one return trip inside your country of residence.", + }, + renfe: { + bookingUrl: "https://www.renfe.com/es/en", + note: "High-speed and long-distance trains (AVE, Alvia, Euromed, Intercity) need a paid pass-holder reservation; medium-distance trains need a free one.", + }, + nl: { + bookingUrl: "https://www.ns.nl/en", + note: "Domestic trains need no reservation. Eurostar needs a paid pass-holder reservation; ICE and Intercity direct don't within the Netherlands.", + }, + de: { + bookingUrl: "https://int.bahn.de/en", + note: "ICE, IC and EC trains within Germany need no reservation (one is optional). Trains to France and some night trains need a paid reservation.", + }, + it: { + bookingUrl: "https://www.trenitalia.com/en.html", + note: "Frecciarossa, Frecciargento, Frecciabianca, Intercity and night trains need a paid pass-holder reservation. Regionale and Regionale Veloce trains don't.", + }, + ch: { + bookingUrl: "https://www.sbb.ch/en", + note: "Most trains need no reservation. TGV Lyria and some international EuroCity and night trains need a paid one.", + }, + no: { + bookingUrl: "https://www.vy.no/en", + note: "Long-distance trains (F lines and SJ) need a paid seat reservation. Regional trains don't.", + }, + fi: { + bookingUrl: "https://www.vr.fi/en", + note: "InterCity, Pendolino and night trains need a paid seat reservation. Regional trains don't.", + }, + ie: { + bookingUrl: "https://www.irishrail.ie/en-ie/", + note: "No reservation needed.", + }, + se: { + bookingUrl: "https://www.sj.se/en", + note: "SJ high-speed, long-distance and night trains need a paid pass-holder reservation. Regional trains don't.", + }, + gb: { + bookingUrl: "https://www.nationalrail.co.uk/", + note: "No reservation needed, except on the Caledonian Sleeper.", + }, +}; diff --git a/tests/api.test.ts b/tests/api.test.ts index 7d95b5d..a99476f 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -116,6 +116,19 @@ describe("static API", () => { expect(decodeCompact(all)).toHaveLength(trains.length); }); + it("sends Interrail holders to each operator's own booking site, with its reservation rules", () => { + const sncf = entry.passes.find((p) => p.id === "interrail"); + expect(sncf?.bookingUrl).toContain("sncf-connect.com"); + const interrail = { interrail: { bookable: true, seat: "unknown" as const } }; + const train = { operator: "de", date: "2026-10-05", origin: "Köln Hbf", destination: "Berlin Hbf", depart: "08:00", arrive: "12:30", trainNo: "ICE 1", passes: interrail }; + const de = buildOperatorApi("de", "test", [train]).entry.passes[0]; + expect(de?.bookingUrl).toContain("bahn.de"); + expect(de?.note).toMatch(/need no reservation/); + // An operator without its own entry falls back to the general rules. + const other = buildOperatorApi("xx", "test", [{ ...train, operator: "xx" }]).entry.passes[0]; + expect(other?.bookingUrl).toContain("interrail.eu"); + }); + it("answers a search by origin, destination and date from one origin file", () => { const stations = file("v1/sncf/stations.json") as { id: string; name: string }[]; const paris = stations.find((s) => s.name === "PARIS (intramuros)");