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 .github/workflows/update-data.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add public/data/tgvmax.json public/data/meta.json
git add public/data/tgvmax.json public/data/meta.json public/data/interrail.json public/data/interrail-meta.json
if git diff --cached --quiet; then
echo "No changes to commit."
else
Expand Down
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,13 @@ It also handles connections (up to 6 changes, an optional stopover city, overnig

## Supported passes

| Pass | Status | What Railmap shows |
| Pass | Who can use it | How it works |
| --- | --- | --- |
| MAX JEUNE (16 to 27) | Available | Trains with a free MAX seat open for booking |
| MAX SENIOR (60+) | Available | Trains with a free MAX seat open for booking |
| Interrail | In progress ([#83](https://github.com/offware-apps/Railmap/pull/83)) | Every SNCF train a pass holder can take, international stops included |
| **[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. |

**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.

Booking always happens on [SNCF Connect](https://www.sncf-connect.com/). Railmap is an independent project, not affiliated with SNCF, and does not sell tickets. Availability is refreshed about once a day, so confirm before you book.

Expand Down Expand Up @@ -101,9 +103,10 @@ specs/ guiding principles

SNCF publishes which trains have a free MAX seat as open data. Railmap is a static site on top of it:

1. A scheduled GitHub Action downloads the dataset each morning and keeps only the trains with a free seat (about 77 MB trimmed to about 6 MB).
2. Your browser downloads that file and runs every search on your device. It can query the SNCF API directly as a fallback.
3. Everything is served as static files from GitHub Pages. There is no backend, no database and no server cost.
1. [train-api](https://github.com/offware-apps/train-api) publishes each pass's timetable every morning as static JSON. The app reads it first.
2. A scheduled GitHub Action also snapshots the dataset into the app itself (about 77 MB trimmed to about 6 MB), which the app falls back to when train-api is unreachable or out of date.
3. Your browser downloads the timetable and runs every search on your device.
4. Everything is served as static files from GitHub Pages. There is no backend, no database and no server cost.

More detail:

Expand Down
24 changes: 24 additions & 0 deletions docs/user-flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,30 @@ the destination is reachable, with its calendar open.
from any screen. (The two overlap — a known cleanup item.) A saved trip opens its ticket
modal, whose "See all dates" opens the route page; a favorite prefills the Trip tab with
that route alone.
- **Pass** — the header's pass picker: **MAX JEUNE** (default), **MAX SENIOR**, or
**Interrail**. Timetables come from [train-api](https://github.com/offware-apps/train-api)
first (`<meta name="train-api">` in `index.html`, refreshed daily), and from the bundled
snapshots when it is off, unreachable, more than 36 hours old or entirely in the past (a
stale train-api copy still beats the sample offline; the service worker keeps the last
one). Both MAX passes search the MAX JEUNE
timetable (trains with a free MAX seat; `tgvmax.json` as fallback). Interrail searches
every train in the SNCF feed (`interrail.json` as fallback, see `src/data/compact.ts`):
each one takes a pass-holder reservation, international stops included. No open data says whether an Interrail seat is left, so every Interrail result
page opens with a notice saying so, and copy that would claim a free MAX seat reads its
`<key>_ir` variant instead ("green = at least one train"). Switching between a MAX pass
and Interrail loads the other timetable while the page stays up (the results dim under a
"Loading…" pill), then re-runs the search; each timetable loads once per visit, so switching
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.
"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
Global Pass covers one trip out and one trip back in), with links to the official pages.
The Interrail seat notice on results ends with that France residence rule. An exact
trip (Trip tab, origin and destination) shows one line comparing the trip's CO₂ by train
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.
- **Mobile** — the form is a sheet that collapses to a search bar (a long route wraps it to
Expand Down
5 changes: 3 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' https://ressources.data.sncf.com; worker-src 'self'; base-uri 'self'"
content="default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' https://ressources.data.sncf.com https://offware-apps.github.io; worker-src 'self'; base-uri 'self'"
/>
<title>Trains MAX JEUNE / MAX SENIOR (ex-TGVmax) réservables — Railmap</title>
<meta
Expand All @@ -32,13 +32,14 @@
content="Toutes les places MAX JEUNE / MAX SENIOR ouvertes à la réservation, mises à jour chaque jour depuis l'open data SNCF."
/>
<meta name="twitter:image" content="https://offware-apps.github.io/Railmap/icons/og.png" />
<!-- Where the timetables come from first (src/config.ts trainApiBase); empty = the bundled snapshot only. -->
<meta name="train-api" content="https://offware-apps.github.io/train-api/v1" />
<link rel="manifest" href="manifest.webmanifest" />
<meta name="theme-color" content="#0f7a52" />
<link rel="icon" type="image/svg+xml" href="icons/icon.svg" />
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png" />
<link rel="alternate" type="text/plain" href="llms.txt" title="LLM instructions" />
<link rel="alternate" type="application/json" href="api.json" title="Railmap machine API" />
<link rel="preload" href="%BASE_URL%data/tgvmax.json" as="fetch" crossorigin />
<script type="application/ld+json">
{
"@context": "https://schema.org",
Expand Down
2 changes: 1 addition & 1 deletion public/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
"to": "destination station id (in tour mode: optional finish city; = from for a loop)",
"date": "YYYY-MM-DD",
"conn": "0 | 1 | 2 | 3 | 6 (max changes; 6 = unlimited; default 1)",
"card": "jeune | senior",
"card": "jeune | senior | interrail",
"after": "HH:MM (depart after)",
"before": "HH:MM (depart before)",
"maxdur": "integer minutes (max total journey duration)",
Expand Down
6 changes: 6 additions & 0 deletions public/data/interrail-meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"updatedAt": "2026-10-05T05:15:43.172Z",
"source": "SNCF Open Data — tgvmax, all trains (Licence Ouverte)",
"recordCount": 358954,
"isSample": false
}
1 change: 1 addition & 0 deletions public/data/interrail.json

Large diffs are not rendered by default.

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`
- `card`: `jeune` | `senior` | `interrail` (Interrail: every running train, seat unknown; data in `/data/interrail.json`, the compact format of `src/data/compact.ts`)
- `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
17 changes: 14 additions & 3 deletions public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,24 @@
* - Navigation requests → network-first, fallback to cached shell
* - /data/*.json → network-first, fallback to cache
* - Same-origin GET → cache-first (hashed JS/CSS assets)
* - Cross-origin / POST → passthrough, never cached
* - train-api *.json → network-first, fallback to cache (offline timetable)
* - 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-v37";
const CACHE_NAME = "maxjeune-v38";

// 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.
// We store the scope root ("/Railmap/") as the shell fallback URL.
const SHELL_URL = self.registration.scope; // e.g. "https://host/Railmap/"

// Where the train-api timetable is served from (the `train-api` meta in index.html).
const TRAIN_API_ORIGIN = "https://offware-apps.github.io";

// ---------------------------------------------------------------------------
// Install — precache the app shell
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -76,7 +80,14 @@ self.addEventListener("fetch", (event) => {
const url = new URL(request.url);
const scope = new URL(self.registration.scope);

// Never intercept cross-origin requests (SNCF API, map tiles, etc.)
// 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")) {
event.respondWith(networkFirstWithCacheFallback(request));
return;
}

// Never intercept other cross-origin requests (SNCF API, map tiles, etc.)
if (url.origin !== scope.origin) return;

const isSameOrigin = url.origin === scope.origin;
Expand Down
26 changes: 26 additions & 0 deletions public/theme.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
*/
(function () {
var theme = "auto";
var card = "";
try {
var raw = localStorage.getItem("mj.settings");
if (raw) {
var s = JSON.parse(raw);
if (s && (s.theme === "light" || s.theme === "dark" || s.theme === "auto")) theme = s.theme;
if (s && typeof s.card === "string") card = s.card;
}
} catch (e) {
/* private mode / bad JSON — fall back to auto (follows the OS) */
Expand All @@ -33,4 +35,28 @@
// Paint the canvas background immediately (matches --bg light/dark), so there is no
// white/black flash before the CSS file loads. The stylesheet takes over once parsed.
root.style.backgroundColor = dark ? "#181613" : "#efece3";

// Start the dataset download now, before the bundle boots. The pass picks the file
// (see profileForCard in src/data/profile.ts): a link's card= wins, else the saved one.
try {
var linked = new URLSearchParams(location.search).get("card");
if (linked) card = linked;
} catch (e) {
/* no URLSearchParams — keep the saved pass */
}
// train-api first when the page names one (see trainApiBase in src/config.ts), else the
// bundled snapshot; both MAX cards read the MAX JEUNE timetable.
var apiMeta = document.querySelector('meta[name="train-api"]');
var api = apiMeta ? (apiMeta.getAttribute("content") || "").trim().replace(/\/+$/, "") : "";
var script = document.currentScript;
var base = script && script.src ? script.src.replace(/theme\.js(\?.*)?$/, "") : "";
var link = document.createElement("link");
link.rel = "preload";
link.as = "fetch";
link.crossOrigin = "anonymous";
link.href = api
? api + "/sncf/" + (card === "interrail" ? "interrail" : "max-jeune") + "/all.json"
: base + "data/" + (card === "interrail" ? "interrail.json" : "tgvmax.json");
link.setAttribute("data-dataset-preload", ""); // the prerender strips it (scripts/prerender.mjs)
document.head.appendChild(link);
})();
32 changes: 31 additions & 1 deletion scripts/e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ const server = http.createServer((req, res) => {
if (!existsSync(file) || statSync(file).isDirectory()) file = join(DIST, "index.html");
if (!existsSync(file)) return res.writeHead(404).end("not found");
res.writeHead(200, { "Content-Type": TYPES[extname(file)] || "application/octet-stream" });
res.end(readFileSync(file));
// 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);
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const PORT = server.address().port;
Expand Down Expand Up @@ -802,6 +804,34 @@ await scenario(
},
);

// Switching MAX → Interrail loads the other timetable with the page still up: no
// navigation, a "Loading…" pill over dimmed results, then the Interrail results.
await scenario(
"pass: switching to Interrail swaps the timetable in place, without reloading",
`${BASE}?mode=od&from=${enc(P)}&to=${enc(L)}&date=${RT_DATE}`,
async (page) => {
assert(await until(async () => (await $count(page, "article.journey")) > 0), "precondition: no MAX results");
await page.evaluate(() => {
window.__samePage = true;
});
await page.select(".header-quick select", "interrail");
assert(await until(async () => urlParam(page, "card") === "interrail"), "the URL did not take card=interrail");
const swapped = await until(async () =>
(await $count(page, ".pass-spinner")) === 0 &&
(await $count(page, ".results .notice")) > 0 &&
(await $count(page, "article.journey")) > 0
? true
: null,
);
assert(swapped, `Interrail results did not show (results "${(await $text(page, ".results"))?.slice(0, 80)}")`);
assert(await page.evaluate(() => window.__samePage === true), "the page reloaded");
// And back: the MAX timetable is already loaded, so it shows without a pill.
await page.select(".header-quick select", "jeune");
assert(await until(async () => (await $count(page, ".results .notice")) === 0 && (await $count(page, "article.journey")) > 0), "MAX results did not come back");
assert(await page.evaluate(() => window.__samePage === true), "the page reloaded going back to MAX");
},
);

// 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
29 changes: 26 additions & 3 deletions scripts/fetch-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
* scripts/fetch-data.ts
*
* Downloads the full SNCF tgvmax dataset via the Opendatasoft Explore API v2.1
* export endpoint and writes:
* data/tgvmax.json – full mapped record array (compact JSON)
* data/meta.json – freshness metadata
* export endpoint and writes, under public/data/:
* tgvmax.json – the MAX pass: trains with a free MAX seat (record array)
* meta.json – its freshness metadata
* interrail.json – the Interrail pass: every train in the feed, in the
* compact format of src/data/compact.ts
* interrail-meta.json – its freshness metadata
*
* Run via: npm run fetch-data (tsx scripts/fetch-data.ts)
*/
Expand All @@ -13,6 +16,7 @@
// fs, path, and process so we can import them without @types/node.
import * as fs from "fs";
import * as path from "path";
import { encodeCompact } from "../src/data/compact";

// ---------------------------------------------------------------------------
// Types
Expand Down Expand Up @@ -47,6 +51,8 @@ const MAX_RETRIES = 3;
const REPO_ROOT = process.cwd();
const OUT_DATA = path.resolve(REPO_ROOT, "public", "data", "tgvmax.json");
const OUT_META = path.resolve(REPO_ROOT, "public", "data", "meta.json");
const OUT_IR_DATA = path.resolve(REPO_ROOT, "public", "data", "interrail.json");
const OUT_IR_META = path.resolve(REPO_ROOT, "public", "data", "interrail-meta.json");

// ---------------------------------------------------------------------------
// Helpers
Expand Down Expand Up @@ -186,6 +192,23 @@ async function main(): Promise<void> {

fs.writeFileSync(OUT_META, JSON.stringify(meta, null, 2), "utf-8");
console.log(`[fetch-data] Wrote metadata → ${OUT_META}`);

// Interrail: every train that runs takes a pass-holder reservation, seat or not, so
// keep them all. Each distinct service is stored once with the dates it runs on, which
// keeps the whole feed a static file instead of ~65 MB of repeated rows.
const compact = encodeCompact(mapped.filter((r) => r.origine && r.destination && r.date));
const irMeta: Meta = {
updatedAt: meta.updatedAt,
source: "SNCF Open Data — tgvmax, all trains (Licence Ouverte)",
recordCount: mapped.length,
isSample: false,
};
fs.writeFileSync(OUT_IR_DATA, JSON.stringify(compact), "utf-8");
console.log(
`[fetch-data] Wrote ${compact.trains.length} services (${mapped.length} trains) → ${OUT_IR_DATA}`,
);
fs.writeFileSync(OUT_IR_META, JSON.stringify(irMeta, null, 2), "utf-8");
console.log(`[fetch-data] Wrote metadata → ${OUT_IR_META}`);
console.log(`[fetch-data] Done. updatedAt=${meta.updatedAt}`);
}

Expand Down
Loading
Loading