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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ It also handles connections (up to 6 changes, an optional stopover city, overnig
| --- | --- | --- |
| **[MAX JEUNE](https://www.sncf-connect.com/catalogue/description/max-jeune)** | 16 to 27, €79/month, 12-month minimum | €0 seats in 2nd class on TGV INOUI, OUIGO high-speed and Intercités with reservation, in France plus Luxembourg and Freiburg. Book up to 30 days ahead, 6 trips at a time, one €0 trip a day from the same station, confirm 1 to 2 days before. No €0 seats on Friday and Sunday afternoons and evenings or holiday eves. Railmap shows the trains with a free MAX seat open for booking. |
| **[MAX SENIOR](https://www.sncf-connect.com/catalogue/description/max-senior)** | 60 and over, same price | Same €0 seats as MAX JEUNE. |
| **[Interrail](https://www.interrail.com/en/interrail-passes/global-pass)** | Residents of the 33 Interrail countries (everyone else buys Eurail). Youth up to 27, senior from 60, children 4 to 11 free with a Child Pass | Any train with a pass-holder reservation (€10 to 20 on TGV, limited seats). Living in France: a Global Pass covers only one trip out of France and one trip back in, and there is no One Country Pass for your own country. Railmap shows every SNCF train a pass holder can take, international stops included. |
| **[Interrail](https://www.interrail.com/en/interrail-passes/global-pass)** | Residents of the 33 Interrail countries (everyone else buys Eurail). Youth up to 27, senior from 60, children 4 to 11 free with a Child Pass | Any train with a pass-holder reservation (€10 to 20 on TGV, limited seats). Living in France: a Global Pass covers only one trip out of France and one trip back in, and there is no One Country Pass for your own country. Railmap shows the trains a pass holder can take across Europe (SNCF, Renfe, DB, NS, SBB and the other operators train-api publishes), with changes between them. |

**Lower carbon.** A TGV trip emits about 50 times less CO₂ than driving alone and 100 times less than flying ([ADEME](https://impactco2.fr/outils/transport)). Each exact trip shows its own figure.

Expand Down
21 changes: 21 additions & 0 deletions docs/user-flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,27 @@ the destination is reachable, with its calendar open.
back is instant. Switching between the two MAX passes re-runs straight away. Booking still opens SNCF Connect,
where pass-holder reservations are sold; "How does it work?" links Interrail's page on
French reservations. A `card=interrail` link loads the Interrail snapshot at boot.
**Interrail across Europe**: on Interrail, a background worker (`src/europe/`) downloads
train-api's `/v2/` index and every operator's station list (small) once per visit, and
matches the operators' stations into one directory. A search then downloads only the
trip bundles of the operators on the way between its two towns (operators that meet in a
town, on a shortest chain or one operator longer; explore: the origin's operators and
their neighbours), so Paris to Madrid loads SNCF and Renfe, never Finland, and the page
stays light as countries are added. It searches them with train-api's journey engine
(`src/europe/engine.ts`, a copy of train-api's `src/search.ts`). Settings has an opt-in
"Europe's timetables offline": it keeps every operator's files in Cache Storage
(`railmap-europe`, which `public/sw.js` leaves alone), refreshes them on a visit once
train-api has published newer ones, and the worker reads them whenever train-api can't
be reached; turning it off deletes them. Every station in the directory joins the
station fields and the map: a place SNCF also serves keeps SNCF's id, any other takes the engine's
(`amsterdam-centraal`). The exact trip (one way, round trip, flexible days) reads its
journeys from that network: the days shown first, then the rest of the bookable days in
the background, after which the calendars fill in. A search from or to a station covers
every station of its town. Explore from a station lists, after SNCF's direct
destinations, the 80 fastest of the 150 busiest towns reachable that day. A train of
another operator books on that operator's site ("Book on Renfe"), and carries no
reservation fee. When the bundles can't load, Interrail searches SNCF's trains alone, as
before. Ideas, arrive-at, multi-city and the planner still search SNCF's trains.
"How does it work?" lists who can use each pass and its rules (MAX JEUNE 16 to 27,
MAX SENIOR from 60, booking window and daily limits; Interrail for residents of the 33
countries, Eurail for everyone else) and the special case for people living in France (a
Expand Down
2 changes: 1 addition & 1 deletion public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Query params:
- `from`: origin station id ; `to`: destination station id (in `mode=tour`, the optional finish city; set `to`=`from` for a loop)
- `date`: `YYYY-MM-DD`
- `conn`: `0` | `1` | `2` | `3` | `6` (max changes; 6 = unlimited; default 1)
- `card`: `jeune` | `senior` | `interrail` (Interrail: every running train, seat unknown; data in `/data/interrail.json`, the compact format of `src/data/compact.ts`)
- `card`: `jeune` | `senior` | `interrail` (Interrail: every running train, seat unknown; SNCF's in `/data/interrail.json`, the compact format of `src/data/compact.ts`, and every operator's from train-api's `/v2/` trip bundles; `from`/`to` then also take the engine's station ids, such as `amsterdam-centraal`)
- `after` / `before`: `HH:MM` departure window ; `arrbefore`: `HH:MM` arrive-before cutoff ; `maxdur`: minutes ; `type`: axis
- `via`: station id to route through (`mode=od`)
- `flex`: `1` | `2` | `3` | `7` — also search ± N days around `date` (`mode=od/from/to/tour`)
Expand Down
22 changes: 17 additions & 5 deletions public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,21 @@
* - Navigation requests → network-first, fallback to cached shell
* - /data/*.json → network-first, fallback to cache
* - Same-origin GET → cache-first (hashed JS/CSS assets)
* - train-api *.json → network-first, fallback to cache (offline timetable)
* - train-api *.json → network-first, fallback to cache (offline timetable), except
* the v2 trip files: Interrail across Europe keeps those itself,
* only when the user turns offline mode on (src/europe/)
* - Other cross-origin / POST → passthrough, never cached
*/

// Bump on every release that changes the build output. A new value forces the SW to
// re-install and re-activate: the activate handler then deletes the previous cache
// and navigates any client stranded on a stale shell (the white-page failure mode)
// onto the freshly deployed one.
const CACHE_NAME = "maxjeune-v38";
const CACHE_NAME = "maxjeune-v39";

// Europe's timetables, kept by the page's Europe worker when the user turns offline mode
// on (OFFLINE_CACHE in src/europe/cache.ts). Not this worker's to delete.
const EUROPE_CACHE = "railmap-europe";

// Minimal app shell — paths relative to the SW's scope (/Railmap/)
// Vite injects a hashed index.html in the build output at the base path.
Expand Down Expand Up @@ -47,8 +53,9 @@ self.addEventListener("activate", (event) => {
const keys = await caches.keys();
// If an older cache exists, this is an UPGRADE: a client may be stranded on
// a stale shell pointing at a deleted bundle (the blank-page failure mode).
const wasUpgrade = keys.some((k) => k !== CACHE_NAME);
await Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)));
const old = keys.filter((k) => k !== CACHE_NAME && k !== EUROPE_CACHE);
const wasUpgrade = old.length > 0;
await Promise.all(old.map((k) => caches.delete(k)));
await self.clients.claim();
if (wasUpgrade) {
// Force any open window onto the fresh shell. The page's own JS can't
Expand Down Expand Up @@ -82,7 +89,12 @@ self.addEventListener("fetch", (event) => {

// The train-api timetable replaces /data/*.json for most visitors, so keep the last
// copy for offline use the same way (see trainApiBase in src/config.ts).
if (url.origin === TRAIN_API_ORIGIN && url.pathname.startsWith("/train-api/") && url.pathname.endsWith(".json")) {
if (
url.origin === TRAIN_API_ORIGIN &&
url.pathname.startsWith("/train-api/") &&
!url.pathname.startsWith("/train-api/v2/") &&
url.pathname.endsWith(".json")
) {
event.respondWith(networkFirstWithCacheFallback(request));
return;
}
Expand Down
121 changes: 120 additions & 1 deletion scripts/e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,64 @@ const TYPES = {
".txt": "text/plain", ".xml": "application/xml", ".ico": "image/x-icon",
};

// A stand-in train-api for Interrail across Europe (v2): SNCF from Paris to Barcelona, Renfe
// on to Madrid, and Finland's VR, which no Paris search should download, every day of the
// booking window. A page opened with e2e=europe in its URL reads it; every other page runs
// on the bundled snapshot alone. EUROPE_HITS records the files asked for; europeDown makes
// the stand-in fail like an unreachable network.
const EUROPE_DATES = Array.from({ length: 33 }, (_, i) => new Date(Date.now() + (i - 1) * 86_400_000).toISOString().slice(0, 10));
const EVERY_DAY = "f".repeat(Math.ceil(EUROPE_DATES.length / 4));
const EUROPE_OPERATORS = {
sncf: {
booking: "https://www.sncf-connect.com/en-en/",
stations: [["PARIS (intramuros)", null, null], ["BARCELONA SANTS", null, null]],
families: ["SUD EST"],
trips: [["9713", 0, EVERY_DAY, [0, 1], [480, 480, 870, 870]]],
},
renfe: {
booking: "https://www.renfe.com/es/en",
stations: [["Barcelona-Sants", 41.3793, 2.1404], ["Zaragoza Delicias", 41.6588, -0.9116], ["Madrid-Puerta de Atocha", 40.4066, -3.6892]],
families: ["AVE"],
trips: [["03063", 0, EVERY_DAY, [0, 1, 2], [930, 930, 1010, 1012, 1095, 1095]]],
},
fi: {
booking: "https://www.vr.fi/en",
stations: [["Helsinki", 60.1719, 24.9414], ["Tampere", 61.4981, 23.7731]],
families: ["IC"],
trips: [["IC1", 0, EVERY_DAY, [0, 1], [420, 420, 520, 520]]],
},
};
const EUROPE_API = {
"/europe-api/v2/index.json": {
version: 2,
updatedAt: new Date().toISOString(),
operators: Object.entries(EUROPE_OPERATORS).map(([id, o]) => ({
id,
passes: [{ id: "interrail", bookingUrl: o.booking, bytes: JSON.stringify(o.trips).length }],
})),
},
};
for (const [id, o] of Object.entries(EUROPE_OPERATORS)) {
EUROPE_API[`/europe-api/v2/${id}/stations.json`] = o.stations;
EUROPE_API[`/europe-api/v2/${id}/interrail.json`] = {
v: 2, operator: id, pass: "interrail", dates: EUROPE_DATES, stations: o.stations, families: o.families, trips: o.trips,
};
}
const EUROPE_HITS = new Set();
let europeDown = false;

// Serve dist, mirroring the GitHub-Pages /Railmap/ base-path rewrite.
const server = http.createServer((req, res) => {
const path = (req.url || "/").split("?")[0];
if (path.startsWith("/europe-api/")) {
EUROPE_HITS.add(path);
if (europeDown) return res.writeHead(503).end("down");
const json = EUROPE_API[path];
if (!json) return res.writeHead(404).end("not found");
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify(json));
}
const europe = /[?&]e2e=europe\b/.test(req.url || "");
let p = decodeURIComponent((req.url || "/").split("?")[0])
.replace(/^\/Railmap\//, "/")
.replace(/^\/+/, "");
Expand All @@ -47,7 +103,8 @@ const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": TYPES[extname(file)] || "application/octet-stream" });
// Runs on the bundled snapshot, never the live train-api (see trainApiBase in src/config.ts).
const body = readFileSync(file);
res.end(file.endsWith(".html") ? body.toString("utf-8").replace(/<meta name="train-api"[^>]*>/, "") : body);
const meta = europe ? `<meta name="train-api" content="${ORIGIN}/europe-api/v1" />` : "";
res.end(file.endsWith(".html") ? body.toString("utf-8").replace(/<meta name="train-api"[^>]*>/, meta) : body);
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const PORT = server.address().port;
Expand Down Expand Up @@ -832,6 +889,68 @@ await scenario(
},
);

// Interrail across Europe: the trip search changes from SNCF to Renfe in Barcelona, books
// the Renfe train on Renfe's site, and explore lists Madrid, which SNCF's trains never reach.
EUROPE_HITS.clear();
await scenario(
"interrail europe: Paris to Madrid changes to Renfe, and explore reaches Madrid",
`${BASE}?mode=od&from=${enc(P)}&to=madrid-puerta-de-atocha&date=${DATE}&card=interrail&e2e=europe`,
async (page) => {
const shown = await until(async () => ((await $text(page, ".results")) || "").includes("03063") || null, 30_000);
assert(shown, `no Paris to Madrid journey with the Renfe train (results "${(await $text(page, ".results"))?.slice(0, 120)}")`);
assert((await $count(page, "article.journey")) > 0, "no journey card");
assert(((await $text(page, ".results")) || "").includes("9713"), "the SNCF train to Barcelona is missing");
// The calendar fills in behind the list: another day shows a train too.
const days = await until(async () => ((await $count(page, ".results .cal-cell.ok")) > 1) || null, 30_000);
assert(days, "the possible-days calendar did not fill in");
// It scales by downloading only the operators on the way, and every station list.
assert(EUROPE_HITS.has("/europe-api/v2/fi/stations.json"), "Finland's stations were not listed");
assert(!EUROPE_HITS.has("/europe-api/v2/fi/interrail.json"), "Paris to Madrid downloaded Finland's timetable");
await page.goto(`${BASE}?mode=from&from=${enc(P)}&date=${DATE}&card=interrail&e2e=europe`, { waitUntil: "networkidle2" });
const madrid = await until(async () => ((await $text(page, ".results")) || "").includes("Madrid-Puerta de Atocha") || null, 30_000);
assert(madrid, `explore did not reach Madrid (results "${(await $text(page, ".results"))?.slice(0, 120)}")`);
},
);

// Offline mode (Settings, opt-in): every country's timetable is kept on the device, and
// a search still finds Paris to Madrid when train-api can't be reached.
await scenario(
"interrail europe: offline mode keeps every country and searches without train-api",
`${BASE}?mode=od&from=${enc(P)}&to=madrid-puerta-de-atocha&date=${DATE}&card=interrail&e2e=europe`,
async (page) => {
europeDown = false;
assert(await until(async () => ((await $text(page, ".results")) || "").includes("03063") || null, 30_000), "precondition: no Paris to Madrid journey");
await page.click("button.settings-btn");
assert(await until(async () => (await $count(page, ".settings-modal")) > 0 || null, 5_000), "the settings dialog did not open");
const turnedOn = await page.evaluate(() => {
const row = [...document.querySelectorAll(".settings-modal .set-row")].find((r) =>
/offline/i.test(r.querySelector(".set-row-label")?.textContent || ""),
);
const sw = row?.querySelector("button[role=switch]");
sw?.click();
return !!sw;
});
assert(turnedOn, "no offline switch in the settings");
const kept = await until(
() =>
page.evaluate(async () => {
const c = await caches.open("railmap-europe");
return (await c.keys()).filter((r) => r.url.includes("/interrail.json")).length === 3 || null;
}),
30_000,
);
assert(kept, "offline mode did not keep every country's timetable");
europeDown = true;
try {
await page.goto(`${BASE}?mode=od&from=${enc(P)}&to=madrid-puerta-de-atocha&date=${DATE2}&card=interrail&e2e=europe`, { waitUntil: "networkidle2" });
const offline = await until(async () => ((await $text(page, ".results")) || "").includes("03063") || null, 30_000);
assert(offline, `no journey with train-api unreachable (results "${(await $text(page, ".results"))?.slice(0, 120)}")`);
} finally {
europeDown = false;
}
},
);

// 12. PWA manifest is served and parseable, icon reference resolves.
await scenario("pwa: manifest is served and valid JSON", BASE, async (page) => {
const manifestHref = await page.$eval('link[rel="manifest"]', (el) => el.getAttribute("href"));
Expand Down
Loading
Loading