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
6 changes: 4 additions & 2 deletions docs/format.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion src/api.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<string, Train[]>();
for (const t of ok) {
Expand Down
6 changes: 4 additions & 2 deletions src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ export const PASS_INFO: Record<PassId, PassInfo> = {
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.",
},
};
53 changes: 53 additions & 0 deletions src/operators/interrail.ts
Original file line number Diff line number Diff line change
@@ -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<string, Pick<PassInfo, "bookingUrl" | "note">> = {
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.",
},
};
13 changes: 13 additions & 0 deletions tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)");
Expand Down
Loading