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 every SNCF train a pass holder can take, international stops included, and Renfe's long-distance trains across Spain, joined at Barcelona so Paris → Madrid works with one change. |

**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
7 changes: 6 additions & 1 deletion docs/user-flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,12 @@ the destination is reachable, with its calendar open.
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
each one takes a pass-holder reservation, international stops included. It also reads
Renfe's long-distance trains from train-api (`src/data/operators.ts`; there is no
snapshot of them, so without train-api Interrail is SNCF only). Renfe's stations that SNCF
also serves take SNCF's id, and Barcelona Sants and Spain's main stations are interchange
hubs, so Paris → Madrid changes at Barcelona. A Renfe train books on renfe.com, and the
reservation-fee estimate leaves out journeys with one. 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
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 SNCF train plus Renfe's long-distance trains from train-api, seat unknown; SNCF 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
14 changes: 12 additions & 2 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { loadDataset, type Dataset } from "./data/dataset";
import { SNCF_PROFILE, parseCard, profileForCard, type DatasetProfile } from "./data/profile";
import { operatorById } from "./data/operators";
import { StationRegistry } from "./data/stations";
import type { CardType, SearchQuery, SearchMode, MaxTrain, Journey, SortKey, CalendarDay, StayChoice } from "./types";
import { stayNights, stayFromNights } from "./core/roundtrip";
Expand All @@ -20,7 +21,7 @@ import {
type Getaway,
} from "./core/getaways";
import { planTours, planTourInOrder, planTourGreedy, arrivalDate, type Tour } from "./core/tour";
import { findJourneys, bestJourney, reachableJourneys, journeyArriveAbs, catchableAfter, toJourney, MAX_RESULTS } from "./core/connections";
import { findJourneys, bestJourney, reachableJourneys, journeyArriveAbs, catchableAfter, toJourney, setNetworkHubs, MAX_RESULTS } from "./core/connections";
import type { ConnectionOptions } from "./core/connections";
import { availabilityCalendar, reachableCountCalendar, dateRange } from "./core/calendar";
import { findHiddenTrains } from "./core/hidden";
Expand Down Expand Up @@ -394,6 +395,7 @@ function tourAddCount(): number {
function growTour(mode: "nearest" | "random", count: number): void {
setSurpriseMsg("");
const avail = deps.trains.filter((tr) => tr.available);
setNetworkHubs(avail, deps.profile.hubs); // same interchange stations as the full timetable
const inRegion = (id: string): boolean =>
!query.region || deps.registry.get(id)?.region === query.region;

Expand Down Expand Up @@ -642,6 +644,7 @@ export function initApp(root: HTMLElement, dataset: Dataset, registry: StationRe
// Every station present in the dataset becomes searchable (the curated registry
// only covers map coordinates for the major ones).
registry.addMissing(dataset.trains.flatMap((t) => [t.origin, t.destination]));
addOperatorAliases();

today = new Date().toISOString().slice(0, 10);
query = store.urlHasQuery()
Expand Down Expand Up @@ -769,12 +772,18 @@ function useDataset(dataset: Dataset): void {
};
setSeatUnknown(!deps.profile.seatKnown);
deps.registry.setPresent(dataset.trains.flatMap((tr) => [tr.origin, tr.destination]));
addOperatorAliases();
formApi.setStationLabels(deps.registry.list().map((s) => s.label));
const updated = rootRef.querySelector(".updated");
if (updated) updated.textContent = footerUpdatedText();
deferFormCalRepaint();
}

/** Let "Madrid" and the like name the main station of the pass's other operators. */
function addOperatorAliases(): void {
for (const op of deps.profile.operators ?? []) deps.registry.addAliases(op.aliases);
}

/** Dim the page's results under a spinner while another pass's timetable loads and searches. */
function setPassLoading(on: boolean): void {
rootRef.classList.toggle("pass-loading", on);
Expand Down Expand Up @@ -937,7 +946,8 @@ function ctx(): RenderCtx {
formatWeekday,
// Deep-link to SNCF Connect with the trip pre-filled (clean station names, the
// journey date, and the departure time). Connecting trips book per-leg instead.
bookUrl: (origin, destination, date, time) =>
bookUrl: (origin, destination, date, time, operator) =>
operatorById(operator)?.bookingUrl ??
generateBookingUrl(deps.registry.label(origin), deps.registry.label(destination), date, time),
cityInfoUrl,
onOpenRoute: (origin, destination, open) => {
Expand Down
23 changes: 19 additions & 4 deletions src/core/connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,21 @@ export interface ConnectionOptions {
export const MAX_RESULTS = 200;
const MAX_DFS_EXPANSIONS = 400_000;

// Interchange stations of each dataset (its profile's hubs), keyed by its trains array:
// Interrail adds Barcelona and Spain's hubs to the French ones. Unregistered = HUB_STATIONS.
const networkHubs = new WeakMap<MaxTrain[], string[]>();

/** Set the stations where journeys over `trains` may change trains. */
export function setNetworkHubs(trains: MaxTrain[], hubs: string[]): void {
networkHubs.set(trains, hubs);
}

/** The interchange stations for a search: the dataset's hubs, plus any the query adds (a via). */
function hubSetFor(trains: MaxTrain[], opts: ConnectionOptions): Set<string> {
const base = networkHubs.get(trains) ?? HUB_STATIONS;
return new Set(opts.hubs ? [...base, ...opts.hubs] : base);
}

// Cache of available trains grouped by date, keyed by the (stable, loaded-once)
// trains array. Avoids re-scanning the whole dataset on every journey lookup —
// matters when callers run findJourneys many times (30-day calendar, best mode).
Expand Down Expand Up @@ -170,7 +185,7 @@ export function findJourneys(
opts: ConnectionOptions = {},
): Journey[] {
const maxConn = opts.maxConnections ?? 1;
const hubSet = new Set(opts.hubs ?? HUB_STATIONS);
const hubSet = hubSetFor(trains, opts);
const minC = opts.minConnectionMin ?? MIN_CONNECTION_MIN;
// Pool `span` calendar days (≥2). A multi-day span also raises the layover
// ceiling to the span, so a hop can wait days at a hub, not just hours.
Expand Down Expand Up @@ -308,7 +323,7 @@ export function reachableJourneys(
arriveCeil?: number,
): Map<string, Journey> {
const maxConn = opts.maxConnections ?? 1;
const hubSet = new Set(opts.hubs ?? HUB_STATIONS);
const hubSet = hubSetFor(trains, opts);
const minC = opts.minConnectionMin ?? MIN_CONNECTION_MIN;
const span = Math.max(2, Math.floor(opts.spanDays ?? 2));
const baseMaxC = opts.maxConnectionMin ?? MAX_CONNECTION_MIN;
Expand Down Expand Up @@ -436,7 +451,7 @@ export function latestReturns(
opts: ConnectionOptions = {},
): Map<string, Journey> {
const maxConn = opts.maxConnections ?? 1;
const hubSet = new Set(opts.hubs ?? HUB_STATIONS);
const hubSet = hubSetFor(trains, opts);
const minC = opts.minConnectionMin ?? MIN_CONNECTION_MIN;
const span = Math.max(2, Math.floor(opts.spanDays ?? 2));
const baseMaxC = opts.maxConnectionMin ?? MAX_CONNECTION_MIN;
Expand Down Expand Up @@ -549,7 +564,7 @@ export function reachableInto(
opts: ConnectionOptions = {},
): Map<string, Journey> {
const maxConn = opts.maxConnections ?? 1;
const hubSet = new Set(opts.hubs ?? HUB_STATIONS);
const hubSet = hubSetFor(trains, opts);
const minC = opts.minConnectionMin ?? MIN_CONNECTION_MIN;
const span = Math.max(2, Math.floor(opts.spanDays ?? 2));
const baseMaxC = opts.maxConnectionMin ?? MAX_CONNECTION_MIN;
Expand Down
38 changes: 38 additions & 0 deletions src/data/cities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,44 @@ export const CITY_REFERENCE: CityRef[] = [
{ name: "Figueres", lat: 42.267, lng: 2.962, region: "España", aliases: ["figueres vilafant"] },
{ name: "Madrid", lat: 40.407, lng: -3.691, region: "España", aliases: ["madrid atocha"] },
{ name: "Zaragoza", lat: 41.659, lng: -0.911, region: "España", aliases: ["saragosse", "zaragoza delicias"] },
// Renfe's network (Interrail): the main cities its long-distance trains serve.
{ name: "Tarragona", lat: 41.111, lng: 1.253, region: "España", aliases: ["camp de tarragona"] },
{ name: "Lleida", lat: 41.62, lng: 0.633, region: "España", aliases: ["lerida", "lleida pirineus"] },
{ name: "Valencia", lat: 39.459, lng: -0.383, region: "España", aliases: ["valence espagne", "valencia joaquin sorolla"] },
{ name: "Valencia de Alcántara", lat: 39.413, lng: -7.243, region: "España" },
{ name: "Alicante", lat: 38.344, lng: -0.495, region: "España", aliases: ["alacant", "alicante alacant"] },
{ name: "Murcia", lat: 37.976, lng: -1.13, region: "España" },
{ name: "Córdoba", lat: 37.888, lng: -4.789, region: "España", aliases: ["cordoue"] },
{ name: "Sevilla", lat: 37.392, lng: -5.975, region: "España", aliases: ["seville"] },
{ name: "Málaga", lat: 36.712, lng: -4.432, region: "España" },
{ name: "Granada", lat: 37.184, lng: -3.61, region: "España", aliases: ["grenade"] },
{ name: "Cádiz", lat: 36.529, lng: -6.288, region: "España" },
{ name: "Huelva", lat: 37.258, lng: -6.947, region: "España" },
{ name: "Almería", lat: 36.835, lng: -2.455, region: "España" },
{ name: "Toledo", lat: 39.862, lng: -4.016, region: "España", aliases: ["tolede"] },
{ name: "Ciudad Real", lat: 38.979, lng: -3.912, region: "España" },
{ name: "Albacete", lat: 38.995, lng: -1.851, region: "España" },
{ name: "Cuenca", lat: 40.025, lng: -2.157, region: "España" },
{ name: "Valladolid", lat: 41.644, lng: -4.729, region: "España" },
{ name: "Segovia", lat: 40.943, lng: -4.112, region: "España", aliases: ["segovie"] },
{ name: "Salamanca", lat: 40.97, lng: -5.672, region: "España", aliases: ["salamanque"] },
{ name: "Burgos", lat: 42.351, lng: -3.69, region: "España" },
{ name: "León", lat: 42.596, lng: -5.578, region: "España" },
{ name: "Oviedo", lat: 43.368, lng: -5.857, region: "España" },
{ name: "Gijón", lat: 43.536, lng: -5.669, region: "España" },
{ name: "Santander", lat: 43.461, lng: -3.81, region: "España" },
{ name: "Bilbao", lat: 43.26, lng: -2.927, region: "España" },
{ name: "Vitoria", lat: 42.842, lng: -2.673, region: "España", aliases: ["vitoria gasteiz"] },
{ name: "San Sebastián", lat: 43.318, lng: -1.976, region: "España", aliases: ["donostia", "saint sebastien"] },
{ name: "Pamplona", lat: 42.824, lng: -1.661, region: "España", aliases: ["pampelune", "iruna"] },
{ name: "Logroño", lat: 42.461, lng: -2.441, region: "España" },
{ name: "Huesca", lat: 42.135, lng: -0.409, region: "España" },
{ name: "A Coruña", lat: 43.355, lng: -8.408, region: "España", aliases: ["la corogne", "coruna"] },
{ name: "Santiago de Compostela", lat: 42.871, lng: -8.544, region: "España", aliases: ["saint jacques de compostelle"] },
{ name: "Vigo", lat: 42.233, lng: -8.711, region: "España" },
{ name: "Ourense", lat: 42.351, lng: -7.874, region: "España" },
{ name: "Badajoz", lat: 38.88, lng: -6.97, region: "España" },
{ name: "Castellón", lat: 39.987, lng: -0.052, region: "España", aliases: ["castello", "castellon de la plana"] },
{ name: "Milano", lat: 45.486, lng: 9.204, region: "Italia", aliases: ["milan", "milano centrale"] },
{ name: "Torino", lat: 45.063, lng: 7.678, region: "Italia", aliases: ["turin", "torino porta susa"] },
// --- additional French stations (coverage gaps from the real dataset) ------
Expand Down
79 changes: 73 additions & 6 deletions src/data/dataset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import type { RawRecord, MaxTrain, DataMeta } from "../types";
import { parseTimeToMinutes, minutesToHHMM } from "../util/time";
import { normalizeText } from "./stations";
import { SNCF_PROFILE, type DatasetProfile, type RawSourceRecord } from "./profile";
import { decodeCompact, isCompact } from "./compact";
import { decodeCompact, isCompact, type CompactSnapshot } from "./compact";
import type { Operator } from "./operators";
import { setNetworkHubs } from "../core/connections";
import sampleData from "../../data/tgvmax.sample.json";

/** Accent-insensitive substring match of a station name against a pattern list. */
Expand Down Expand Up @@ -62,7 +64,7 @@ export interface Dataset {
meta: DataMeta;
/** The {@link DatasetProfile} these trains were read with (omitted = SNCF MAX). */
profile?: DatasetProfile;
/** The train-api base the trains came from; empty when they came from the snapshot. */
/** The train-api base the trains (or some of them) came from; empty when all came from the snapshot. */
apiBase?: string;
}

Expand Down Expand Up @@ -122,12 +124,77 @@ async function fromTrainApi(profile: DatasetProfile, base: string, now: Date): P
}

/**
* Load a {@link DatasetProfile}'s trains (default: SNCF MAX): from train-api at
* `apiBase` when it answers with a fresh timetable, else the committed daily snapshot,
* else a stale train-api timetable (offline, from the service worker's cache), else the
* bundled sample fixture, so the app always has something to show.
* One other operator's timetable for a pass from train-api, or null when it is off,
* unreachable, malformed or entirely in the past. There is no snapshot to fall back to:
* without it, the pass shows SNCF's trains alone.
*/
async function operatorFromTrainApi(op: Operator, pass: string, base: string, now: Date): Promise<CompactSnapshot | null> {
try {
const json = await fetchJson<unknown>(`${base}/${op.id}/${pass}/all.json`, TRAIN_API_TIMEOUT_MS);
if (!isCompact(json)) return null;
const lastDate = json.dates.reduce((a, d) => (d > a ? d : a), "");
return lastDate >= now.toISOString().slice(0, 10) ? json : null;
} catch {
return null;
}
}

/**
* Another operator's trains, normalized like SNCF's and tagged with the operator. A
* station SNCF also serves takes SNCF's id (`sncfIds`), by the operator's own mapping or
* by name with accents, case and punctuation aside, so journeys can change trains there.
*/
export function operatorTrains(
json: CompactSnapshot,
op: Operator,
profile: DatasetProfile,
sncfIds: Iterable<string>,
): MaxTrain[] {
const byName = new Map<string, string>();
for (const id of sncfIds) byName.set(normalizeText(id), id);
const stations = json.stations.map((name) => op.sncfNames[name] ?? byName.get(normalizeText(name)) ?? name);
const rows = decodeCompact({ ...json, stations }) as unknown as RawRecord[];
const trains = normalizeRecords(rows, profile);
for (const tr of trains) tr.operator = op.id;
return trains;
}

/**
* Load a {@link DatasetProfile}'s trains (default: SNCF MAX), see {@link loadSncf}, with
* the trains of the profile's other operators from train-api added when they answer. A
* train both feeds carry (same stations, date and times) is kept once, as SNCF's.
*/
export async function loadDataset(profile: DatasetProfile = SNCF_PROFILE, apiBase = "", now = new Date()): Promise<Dataset> {
const pass = profile.trainApiPass;
const others = Promise.all(
(apiBase && pass ? profile.operators ?? [] : []).map(async (op) => ({ op, json: await operatorFromTrainApi(op, pass ?? "", apiBase, now) })),
);
const dataset = await loadSncf(profile, apiBase, now);
let trains = dataset.trains;
if (!dataset.meta.isSample) {
const sncfIds = new Set(trains.flatMap((tr) => [tr.origin, tr.destination]));
const key = (tr: MaxTrain): string => `${tr.date} ${tr.origin} ${tr.destination} ${tr.depart} ${tr.arrive}`;
const seen = new Set(trains.map(key));
for (const { op, json } of await others) {
if (!json) continue;
const extra = operatorTrains(json, op, profile, sncfIds).filter((tr) => !seen.has(key(tr)));
trains = trains.concat(extra);
}
}
// The profile's interchange stations, for every search over these trains.
setNetworkHubs(trains, profile.hubs);
if (trains === dataset.trains) return dataset;
// Some trains came from train-api, so the search worker must read it too to hold the same.
return { ...dataset, trains, apiBase, meta: { ...dataset.meta, recordCount: trains.length } };
}

/**
* Load a profile's SNCF trains: from train-api at `apiBase` when it answers with a fresh
* timetable, else the committed daily snapshot, else a stale train-api timetable
* (offline, from the service worker's cache), else the bundled sample fixture, so the
* app always has something to show.
*/
async function loadSncf(profile: DatasetProfile, apiBase: string, now: Date): Promise<Dataset> {
const api = await fromTrainApi(profile, apiBase, now);
const fromApi = (a: ApiTimetable): Dataset => ({ trains: normalizeRecords(a.rows, profile), meta: a.meta, profile, apiBase });
if (api && !api.stale) return fromApi(api);
Expand Down
Loading
Loading