From 3beaffb06226010fc40fabe72b827375973b9920 Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:43:45 +0000 Subject: [PATCH] feat(interrail): add Renfe's trains so Paris to Madrid works Interrail now reads Renfe's long-distance trains from train-api next to SNCF's. Renfe stations SNCF also serves take SNCF's id, and Barcelona Sants and Spain's main stations are interchange hubs, so Paris to Madrid comes out as a TGV to Barcelona and an AVE on. A Renfe train books on renfe.com, a city name like Madrid finds its main station, and the reservation-fee estimate leaves out journeys with a Renfe train. Without train-api, Interrail shows SNCF's trains alone, as before. --- README.md | 2 +- docs/user-flows.md | 7 ++- public/llms.txt | 2 +- src/app.ts | 14 ++++- src/core/connections.ts | 23 ++++++-- src/data/cities.ts | 38 +++++++++++++ src/data/dataset.ts | 79 ++++++++++++++++++++++++--- src/data/fees.ts | 6 ++- src/data/operators.ts | 61 +++++++++++++++++++++ src/data/profile.ts | 14 +++-- src/data/stations.ts | 20 ++++++- src/i18n/ar.ts | 5 +- src/i18n/de.ts | 5 +- src/i18n/en.ts | 5 +- src/i18n/es.ts | 5 +- src/i18n/fr.ts | 5 +- src/i18n/it.ts | 5 +- src/i18n/ja.ts | 5 +- src/i18n/ko.ts | 5 +- src/i18n/nl.ts | 5 +- src/i18n/pt.ts | 5 +- src/i18n/zh.ts | 5 +- src/types.ts | 2 + src/ui/modals.ts | 2 +- src/ui/render.ts | 20 ++++--- tests/spain.test.ts | 116 ++++++++++++++++++++++++++++++++++++++++ 26 files changed, 410 insertions(+), 51 deletions(-) create mode 100644 src/data/operators.ts create mode 100644 tests/spain.test.ts diff --git a/README.md b/README.md index e4219d8..cfff661 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/user-flows.md b/docs/user-flows.md index d2eb3f2..5998a6b 100644 --- a/docs/user-flows.md +++ b/docs/user-flows.md @@ -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 `_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 diff --git a/public/llms.txt b/public/llms.txt index 1eb1a40..5b9f513 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -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`) diff --git a/src/app.ts b/src/app.ts index 2cd577a..0e580fe 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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"; @@ -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"; @@ -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; @@ -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() @@ -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); @@ -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) => { diff --git a/src/core/connections.ts b/src/core/connections.ts index 97aae58..4bc7d18 100644 --- a/src/core/connections.ts +++ b/src/core/connections.ts @@ -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(); + +/** 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 { + 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). @@ -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. @@ -308,7 +323,7 @@ export function reachableJourneys( arriveCeil?: number, ): Map { 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; @@ -436,7 +451,7 @@ export function latestReturns( opts: ConnectionOptions = {}, ): Map { 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; @@ -549,7 +564,7 @@ export function reachableInto( opts: ConnectionOptions = {}, ): Map { 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; diff --git a/src/data/cities.ts b/src/data/cities.ts index 53c1832..119d963 100644 --- a/src/data/cities.ts +++ b/src/data/cities.ts @@ -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) ------ diff --git a/src/data/dataset.ts b/src/data/dataset.ts index fea3405..f7b670b 100644 --- a/src/data/dataset.ts +++ b/src/data/dataset.ts @@ -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. */ @@ -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; } @@ -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 { + try { + const json = await fetchJson(`${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, +): MaxTrain[] { + const byName = new Map(); + 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 { + 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 { 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); diff --git a/src/data/fees.ts b/src/data/fees.ts index d7ac8fb..2840802 100644 --- a/src/data/fees.ts +++ b/src/data/fees.ts @@ -72,11 +72,15 @@ export function interrailFee(axe: string | undefined, origin: string, destinatio return axe ? BY_AXE[axe] : undefined; } -/** The fees of every train in a journey added up, or undefined when one train has none. */ +/** + * The fees of every train in a journey added up, or undefined when one train has none. + * The table covers SNCF's trains only, so a Renfe train leaves the journey without one. + */ export function journeyFee(j: Journey): Fee | undefined { let min = 0; let max = 0; for (const leg of j.legs) { + if (leg.operator) return undefined; const fee = interrailFee(leg.axe, leg.origin, leg.destination); if (!fee) return undefined; min += fee.min; diff --git a/src/data/operators.ts b/src/data/operators.ts new file mode 100644 index 0000000..d798b8a --- /dev/null +++ b/src/data/operators.ts @@ -0,0 +1,61 @@ +/** + * Other operators whose trains an Interrail pass also takes, read from train-api next to + * SNCF's (`///all.json`). Their stations keep the operator's own names, + * except the ones SNCF also serves: those take SNCF's id, so a journey can change trains + * there (Paris → Barcelona on a TGV, then Barcelona → Madrid on an AVE). + */ +export interface Operator { + /** train-api's operator id. */ + id: string; + /** Its name, as on its booking site. */ + name: string; + /** Where its trains are booked. */ + bookingUrl: string; + /** Its stations where a journey may change trains, by the operator's own names. */ + hubs: string[]; + /** + * Its names for stations SNCF also serves, when they differ from SNCF's by more than + * accents, case and punctuation (those join on their own), mapped to SNCF's id. + */ + sncfNames: Record; + /** City names its main stations answer to in the station fields, by station id. */ + aliases: Record; +} + +/** Renfe: Spain's AVE, Alvia, Euromed, Avant and Intercity trains, and its trains into France. */ +export const RENFE: Operator = { + id: "renfe", + name: "Renfe", + bookingUrl: "https://www.renfe.com/es/en", + hubs: [ + "Madrid-Puerta de Atocha-Almudena Grandes", + "Madrid-Chamartín-Clara Campoamor", + "Zaragoza Delicias", + "Camp de Tarragona", + "València-Joaquín Sorolla", + "Córdoba-Julio Anguita", + "Sevilla-Santa Justa", + "Málaga María Zambrano", + ], + sncfNames: { + "Lyon Part Dieu": "LYON (intramuros)", + Nimes: "NIMES CENTRE", + "Valence TGV": "VALENCE TGV AUVERGNE RHONE ALPES", + }, + aliases: { + "BARCELONA SANTS": ["barcelona", "barcelone"], + "Madrid-Puerta de Atocha-Almudena Grandes": ["madrid"], + "Zaragoza Delicias": ["zaragoza", "saragosse"], + "València-Joaquín Sorolla": ["valencia"], + "Sevilla-Santa Justa": ["sevilla", "seville", "séville"], + "Córdoba-Julio Anguita": ["cordoba", "cordoue"], + "Málaga María Zambrano": ["malaga"], + }, +}; + +const OPERATORS: Operator[] = [RENFE]; + +/** An operator by its train-api id; undefined for SNCF and unknown ids. */ +export function operatorById(id: string | undefined): Operator | undefined { + return id ? OPERATORS.find((o) => o.id === id) : undefined; +} diff --git a/src/data/profile.ts b/src/data/profile.ts index 7fb2427..c70c1b2 100644 --- a/src/data/profile.ts +++ b/src/data/profile.ts @@ -9,6 +9,7 @@ import { } from "../config"; import type { CardType } from "../types"; import { decodeCompact, isCompact } from "./compact"; +import { RENFE, type Operator } from "./operators"; /** * A data-source PROFILE: everything about reading and judging ONE train dataset. @@ -56,6 +57,8 @@ export interface DatasetProfile { decode?: (json: unknown) => RawSourceRecord[] | null; /** This pass's id in train-api (`/sncf//all.json`); omitted = snapshot only. */ trainApiPass?: string; + /** Other operators whose trains this pass also takes, read from train-api alongside SNCF's. */ + operators?: Operator[]; } /** One raw record before normalization — shape varies per source, so it's untyped. */ @@ -106,14 +109,17 @@ export const SNCF_PROFILE: DatasetProfile = { }; /** - * SNCF trains for an Interrail pass holder. Every TGV INOUI, Intercités, night and - * international train in the feed takes a pass-holder reservation, so every train that - * runs counts. Nothing says whether a pass-holder seat is left (`seatKnown: false`), - * and international stops are fine (no exclusions). The snapshot is the compact file. + * Trains for an Interrail pass holder: SNCF's, plus Renfe's from train-api. Every TGV + * INOUI, Intercités, night and international train in the SNCF feed takes a pass-holder + * reservation, so every train that runs counts. Nothing says whether a pass-holder seat + * is left (`seatKnown: false`), and international stops are fine (no exclusions). The + * snapshot is the compact file, SNCF only. Barcelona joins the two networks. */ export const INTERRAIL_PROFILE: DatasetProfile = { ...SNCF_PROFILE, id: "sncf-interrail", + hubs: [...HUB_STATIONS, "BARCELONA SANTS", ...RENFE.hubs], + operators: [RENFE], dataUrl: INTERRAIL_DATA_URL, metaUrl: INTERRAIL_META_URL, isReservable: () => true, diff --git a/src/data/stations.ts b/src/data/stations.ts index 96873c7..fe4ae3b 100644 --- a/src/data/stations.ts +++ b/src/data/stations.ts @@ -43,8 +43,10 @@ export function isAirportStation(id: string): boolean { /** Fallback display name for a station id not present in the registry. */ export function prettyLabel(id: string): string { - return id - .replace(/\s*\(intramuros\)/i, "") + const name = id.replace(/\s*\(intramuros\)/i, ""); + // Another operator's names are already written properly ("Madrid-Puerta de Atocha"). + if (name !== name.toUpperCase()) return name; + return name .toLowerCase() .replace(/(^|[\s'-])([a-zà-ÿ])/g, (_m, p1: string, p2: string) => p1 + p2.toUpperCase()); } @@ -161,6 +163,20 @@ export class StationRegistry { this.addMissing(ids); } + /** + * Extra names stations answer to, by station id ("madrid" for Madrid-Puerta de Atocha), + * so a typed or linked city name resolves to its main station. Unknown ids are skipped. + */ + addAliases(aliases: Record): void { + this.exact = null; + for (const [id, names] of Object.entries(aliases)) { + const s = this.byId.get(id); + if (!s) continue; + this.index = this.index.filter((e) => e.station.id !== id); + this.add({ ...s, aliases: [...(s.aliases ?? []), ...names] }); + } + } + get(id: string): Station | undefined { return this.byId.get(id); } diff --git a/src/i18n/ar.ts b/src/i18n/ar.ts index cbfbc8d..fd90116 100644 --- a/src/i18n/ar.ts +++ b/src/i18n/ar.ts @@ -133,6 +133,7 @@ export const ar: Dict = { lbl_train: "القطار {no}", lbl_dayoffset: "+{n} ي", act_book: "احجز على SNCF Connect", + act_book_on: "احجز على {site}", act_next: "التالي", act_results: "النتائج", act_book_leg: "احجز القطار {n}", @@ -308,8 +309,8 @@ export const ar: Dict = { fee_chip: "الحجز {fee}", fee_note: "رسوم حجز إنتررييل تقريبية، الدرجة الثانية.", fee_link: "رسوم إنتررييل", - tagline_ir: "قطارات SNCF بتذكرة Interrail.", - how_note_ir: "تعرض هذه الأداة القطارات التي تسير (البيانات المفتوحة لـ SNCF)؛ ولا تعرف إن بقي مقعد لحاملي التذكرة. تحقق على SNCF Connect.", + tagline_ir: "قطارات SNCF وRenfe بتذكرة Interrail.", + how_note_ir: "تعرض هذه الأداة القطارات التي تسير (البيانات المفتوحة لـ SNCF وRenfe)؛ ولا تعرف إن بقي مقعد لحاملي التذكرة. تحقق عند الحجز.", multi_incomplete_ir: "بعض المقاطع لا يوجد فيها قطار — هذا المسار غير مكتمل.", tour_none_ir: "لا توجد جولة لهذه المدن والتواريخ.", res_none_ir: "لا يوجد قطار لهذه المعايير.", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index b54f21e..b802eec 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -133,6 +133,7 @@ export const de: Dict = { lbl_train: "Zug {no}", lbl_dayoffset: "+{n} T", act_book: "Auf SNCF Connect buchen", + act_book_on: "Bei {site} buchen", act_next: "Weiter", act_results: "Ergebnisse", act_book_leg: "Zug {n} buchen", @@ -309,8 +310,8 @@ export const de: Dict = { fee_chip: "Reservierung {fee}", fee_note: "Richtwerte für Interrail-Reservierungsgebühren, 2. Klasse.", fee_link: "Interrail-Gebühren", - tagline_ir: "SNCF-Züge mit einem Interrail-Pass.", - how_note_ir: "Dieses Tool zeigt fahrende Züge (offene SNCF-Daten); es sieht nicht, ob noch ein Pass-Platz frei ist. Prüfe auf SNCF Connect.", + tagline_ir: "SNCF- und Renfe-Züge mit einem Interrail-Pass.", + how_note_ir: "Dieses Tool zeigt fahrende Züge (offene Daten von SNCF und Renfe); es sieht nicht, ob noch ein Pass-Platz frei ist. Prüfe es beim Buchen.", multi_incomplete_ir: "Für einige Abschnitte gibt es keinen Zug — diese Reise ist unvollständig.", tour_none_ir: "Keine Route für diese Städte und Daten.", res_none_ir: "Keine Züge für diese Kriterien.", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 227e5ef..07afbd6 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -139,6 +139,7 @@ export const en: Dict = { lbl_dayoffset: "+{n} d", act_book: "Book on SNCF Connect", + act_book_on: "Book on {site}", act_next: "Next step", act_results: "Results", act_book_leg: "Book train {n}", @@ -321,8 +322,8 @@ export const en: Dict = { fee_chip: "Reservation {fee}", fee_note: "Indicative Interrail reservation fees, 2nd class.", fee_link: "Interrail fees", - tagline_ir: "SNCF trains on an Interrail pass.", - how_note_ir: "This tool shows trains that run (SNCF open data); it can't see whether a pass-holder seat is left. Check SNCF Connect.", + tagline_ir: "SNCF and Renfe trains on an Interrail pass.", + how_note_ir: "This tool shows trains that run (SNCF and Renfe open data); it can't see whether a pass-holder seat is left. Check when you book.", multi_incomplete_ir: "Some legs have no train — this itinerary is incomplete.", tour_none_ir: "No tour for these cities and dates.", res_none_ir: "No train for these criteria.", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 16954c9..a5deb81 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -133,6 +133,7 @@ export const es: Dict = { lbl_train: "Tren {no}", lbl_dayoffset: "+{n} d", act_book: "Reservar en SNCF Connect", + act_book_on: "Reservar en {site}", act_next: "Siguiente", act_results: "Resultados", act_book_leg: "Reservar el tren {n}", @@ -309,8 +310,8 @@ export const es: Dict = { fee_chip: "Reserva {fee}", fee_note: "Suplementos de reserva Interrail orientativos, en 2.ª clase.", fee_link: "Tarifas Interrail", - tagline_ir: "Trenes SNCF con un pase Interrail.", - how_note_ir: "Esta herramienta muestra los trenes que circulan (datos abiertos de SNCF); no sabe si queda plaza de pase. Verifica en SNCF Connect.", + tagline_ir: "Trenes SNCF y Renfe con un pase Interrail.", + how_note_ir: "Esta herramienta muestra los trenes que circulan (datos abiertos de SNCF y Renfe); no sabe si queda plaza de pase. Verifica al reservar.", multi_incomplete_ir: "Algunos trayectos no tienen tren — este itinerario está incompleto.", tour_none_ir: "Ningún itinerario para estas ciudades y fechas.", res_none_ir: "Ningún tren para estos criterios.", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index 021e793..3a30170 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -137,6 +137,7 @@ export const fr = { lbl_dayoffset: "+{n} j", act_book: "Réserver sur SNCF Connect", + act_book_on: "Réserver sur {site}", act_next: "Étape suivante", act_results: "Résultats", act_book_leg: "Réserver le train {n}", @@ -325,8 +326,8 @@ export const fr = { fee_chip: "Résa {fee}", fee_note: "Frais de réservation Interrail indicatifs, en 2de classe.", fee_link: "Tarifs Interrail", - tagline_ir: "Trains SNCF avec un pass Interrail.", - how_note_ir: "Cet outil affiche les trains qui circulent (open data SNCF) ; il ne sait pas s'il reste une place pass. Vérifiez sur SNCF Connect.", + tagline_ir: "Trains SNCF et Renfe avec un pass Interrail.", + how_note_ir: "Cet outil affiche les trains qui circulent (open data SNCF et Renfe) ; il ne sait pas s'il reste une place pass. Vérifiez en réservant.", multi_incomplete_ir: "Certains trajets n'ont aucun train — cet itinéraire est incomplet.", tour_none_ir: "Aucun itinéraire pour ces villes et dates.", res_none_ir: "Aucun train pour ces critères.", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index 5f1270e..acf27b4 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -133,6 +133,7 @@ export const it: Dict = { lbl_train: "Treno {no}", lbl_dayoffset: "+{n} g", act_book: "Prenota su SNCF Connect", + act_book_on: "Prenota su {site}", act_next: "Avanti", act_results: "Risultati", act_book_leg: "Prenota il treno {n}", @@ -309,8 +310,8 @@ export const it: Dict = { fee_chip: "Prenotazione {fee}", fee_note: "Supplementi di prenotazione Interrail indicativi, 2ª classe.", fee_link: "Tariffe Interrail", - tagline_ir: "Treni SNCF con un pass Interrail.", - how_note_ir: "Questo strumento mostra i treni che circolano (dati aperti SNCF); non sa se resta un posto pass. Verifica su SNCF Connect.", + tagline_ir: "Treni SNCF e Renfe con un pass Interrail.", + how_note_ir: "Questo strumento mostra i treni che circolano (dati aperti SNCF e Renfe); non sa se resta un posto pass. Verifica al momento della prenotazione.", multi_incomplete_ir: "Alcune tratte non hanno treni — questo itinerario è incompleto.", tour_none_ir: "Nessun itinerario per queste città e date.", res_none_ir: "Nessun treno per questi criteri.", diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index f856d09..72da9eb 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -133,6 +133,7 @@ export const ja: Dict = { lbl_train: "列車 {no}", lbl_dayoffset: "+{n}日", act_book: "SNCF Connect で予約", + act_book_on: "{site} で予約", act_next: "次へ", act_results: "結果", act_book_leg: "{n} 本目の列車を予約", @@ -308,8 +309,8 @@ export const ja: Dict = { fee_chip: "予約 {fee}", fee_note: "インターレイルの予約料金(2等車)は目安です。", fee_link: "インターレイル料金", - tagline_ir: "Interrail パスで乗る SNCF の列車。", - how_note_ir: "このツールは運行中の列車(SNCF オープンデータ)を表示します。パス席が残っているかは分かりません。SNCF Connect でご確認ください。", + tagline_ir: "Interrail パスで乗る SNCF・Renfe の列車。", + how_note_ir: "このツールは運行中の列車(SNCF・Renfe オープンデータ)を表示します。パス席が残っているかは分かりません。予約時にご確認ください。", multi_incomplete_ir: "一部の区間に列車がありません — この行程は不完全です。", tour_none_ir: "これらの都市・日付の旅程はありません。", res_none_ir: "条件に合う列車はありません。", diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index 7ead45a..2c3b1d9 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -133,6 +133,7 @@ export const ko: Dict = { lbl_train: "열차 {no}", lbl_dayoffset: "+{n}일", act_book: "SNCF Connect에서 예약", + act_book_on: "{site}에서 예약", act_next: "다음", act_results: "결과", act_book_leg: "{n}번 열차 예약", @@ -309,8 +310,8 @@ export const ko: Dict = { fee_chip: "예약 {fee}", fee_note: "인터레일 예약 요금(2등석)은 참고용입니다.", fee_link: "인터레일 요금", - tagline_ir: "Interrail 패스로 타는 SNCF 열차.", - how_note_ir: "이 도구는 운행하는 열차(SNCF 공개 데이터)를 표시하며, 패스 좌석이 남았는지는 알 수 없습니다. SNCF Connect에서 확인하세요.", + tagline_ir: "Interrail 패스로 타는 SNCF·Renfe 열차.", + how_note_ir: "이 도구는 운행하는 열차(SNCF·Renfe 공개 데이터)를 표시하며, 패스 좌석이 남았는지는 알 수 없습니다. 예약할 때 확인하세요.", multi_incomplete_ir: "일부 구간에 열차가 없습니다 — 이 일정은 미완성입니다.", tour_none_ir: "해당 도시와 날짜에 대한 일정이 없습니다.", res_none_ir: "조건에 맞는 열차가 없습니다.", diff --git a/src/i18n/nl.ts b/src/i18n/nl.ts index 7db40bf..3e606aa 100644 --- a/src/i18n/nl.ts +++ b/src/i18n/nl.ts @@ -133,6 +133,7 @@ export const nl: Dict = { lbl_train: "Trein {no}", lbl_dayoffset: "+{n} d", act_book: "Boeken op SNCF Connect", + act_book_on: "Boeken bij {site}", act_next: "Volgende", act_results: "Resultaten", act_book_leg: "Trein {n} boeken", @@ -308,8 +309,8 @@ export const nl: Dict = { fee_chip: "Reservering {fee}", fee_note: "Indicatieve Interrail-reserveringskosten, 2e klas.", fee_link: "Interrail-tarieven", - tagline_ir: "SNCF-treinen met een Interrail-pas.", - how_note_ir: "Deze tool toont treinen die rijden (open data SNCF); hij ziet niet of er nog een passplaats vrij is. Controleer op SNCF Connect.", + tagline_ir: "SNCF- en Renfe-treinen met een Interrail-pas.", + how_note_ir: "Deze tool toont treinen die rijden (open data van SNCF en Renfe); hij ziet niet of er nog een passplaats vrij is. Controleer het bij het boeken.", multi_incomplete_ir: "Sommige trajecten hebben geen trein — deze reis is onvolledig.", tour_none_ir: "Geen route voor deze steden en data.", res_none_ir: "Geen trein voor deze criteria.", diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts index e0c15f2..1ef1b88 100644 --- a/src/i18n/pt.ts +++ b/src/i18n/pt.ts @@ -133,6 +133,7 @@ export const pt: Dict = { lbl_train: "Trem {no}", lbl_dayoffset: "+{n} d", act_book: "Reservar no SNCF Connect", + act_book_on: "Reservar na {site}", act_next: "Seguinte", act_results: "Resultados", act_book_leg: "Reservar o comboio {n}", @@ -308,8 +309,8 @@ export const pt: Dict = { fee_chip: "Reserva {fee}", fee_note: "Taxas de reserva Interrail indicativas, 2.ª classe.", fee_link: "Taxas Interrail", - tagline_ir: "Comboios SNCF com um passe Interrail.", - how_note_ir: "Esta ferramenta mostra os comboios que circulam (dados abertos da SNCF); não sabe se ainda há lugar de passe. Verifique no SNCF Connect.", + tagline_ir: "Comboios SNCF e Renfe com um passe Interrail.", + how_note_ir: "Esta ferramenta mostra os comboios que circulam (dados abertos da SNCF e da Renfe); não sabe se ainda há lugar de passe. Verifique ao reservar.", multi_incomplete_ir: "Alguns trajetos não têm comboio — este itinerário está incompleto.", tour_none_ir: "Nenhum roteiro para estas cidades e datas.", res_none_ir: "Nenhum comboio para estes critérios.", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 5097400..a104944 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -133,6 +133,7 @@ export const zh: Dict = { lbl_train: "列车 {no}", lbl_dayoffset: "+{n} 天", act_book: "在 SNCF Connect 预订", + act_book_on: "在 {site} 预订", act_next: "下一步", act_results: "结果", act_book_leg: "预订第 {n} 趟列车", @@ -308,8 +309,8 @@ export const zh: Dict = { fee_chip: "订座 {fee}", fee_note: "Interrail 订座费仅供参考(二等座)。", fee_link: "Interrail 费用", - tagline_ir: "持 Interrail 通票乘坐的 SNCF 列车。", - how_note_ir: "本工具显示运行中的列车(SNCF 开放数据),无法得知是否还有通票座位。请在 SNCF Connect 上核实。", + tagline_ir: "持 Interrail 通票乘坐的 SNCF 和 Renfe 列车。", + how_note_ir: "本工具显示运行中的列车(SNCF 和 Renfe 开放数据),无法得知是否还有通票座位。请在预订时核实。", multi_incomplete_ir: "部分行程没有列车——此行程不完整。", tour_none_ir: "这些城市和日期没有行程。", res_none_ir: "没有符合条件的列车。", diff --git a/src/types.ts b/src/types.ts index 9a81abb..df1d920 100644 --- a/src/types.ts +++ b/src/types.ts @@ -48,6 +48,8 @@ export interface MaxTrain { trainNo: string; available: boolean; // bookable with the pass: MAX = od_happy_card "OUI"; Interrail = the train runs axe?: string; + /** train-api's operator id for trains not run by SNCF ("renfe"); omitted = SNCF. */ + operator?: string; } /** A station with coordinates for the map + autocomplete. */ diff --git a/src/ui/modals.ts b/src/ui/modals.ts index 1d2b852..c26f965 100644 --- a/src/ui/modals.ts +++ b/src/ui/modals.ts @@ -238,7 +238,7 @@ export function showBookingModal(journey: Journey, ctx: RenderCtx): void { ]), el("a", { class: "btn btn-primary book-step-btn", - href: ctx.bookUrl(leg.origin, leg.destination, leg.date, leg.depart), + href: ctx.bookUrl(leg.origin, leg.destination, leg.date, leg.depart, leg.operator), attrs: { target: "_blank", rel: "noopener noreferrer" }, text: t("act_book_leg", { n: i + 1 }), }), diff --git a/src/ui/render.ts b/src/ui/render.ts index 2b54468..e48adcd 100644 --- a/src/ui/render.ts +++ b/src/ui/render.ts @@ -11,16 +11,24 @@ import { isAirportStation } from "../data/stations"; import { formatDuration, dayIndex, addDays } from "../util/time"; import { t, getLang } from "../i18n"; import { formatFee, type Fee } from "../data/fees"; +import { operatorById } from "../data/operators"; // Monotonic id source for wiring aria-labelledby (a calendar's

to its grid). let uidSeq = 0; +/** "Book on SNCF Connect", or on the site of the operator that runs the train. */ +function bookOn(train: MaxTrain | undefined): string { + const op = operatorById(train?.operator); + return op ? t("act_book_on", { site: op.name }) : t("act_book"); +} + export interface RenderCtx { label: (id: string) => string; formatDate: (iso: string) => string; /** Narrow localized weekday name (e.g. "Sat"). */ formatWeekday: (iso: string) => string; - bookUrl: (origin: string, destination: string, date: string, time?: string) => string; + /** Where to book a train: SNCF Connect, or another operator's site (`operator`, see operators.ts). */ + bookUrl: (origin: string, destination: string, date: string, time?: string, operator?: string) => string; /** External travel-guide (Wikivoyage) URL for a station's city. */ cityInfoUrl: (id: string) => string; /** Open the exact O→D trip. A getaway idea passes `open.date` — its advertised start day — @@ -303,7 +311,7 @@ export function journeyEl( const connecting = j.legs.length > 1; const book = (): void => { if (connecting) ctx.onBookSteps(j); - else window.open(ctx.bookUrl(j.origin, j.destination, j.date, j.legs[0]?.depart), "_blank", "noopener,noreferrer"); + else window.open(ctx.bookUrl(j.origin, j.destination, j.date, j.legs[0]?.depart, j.legs[0]?.operator), "_blank", "noopener,noreferrer"); }; // A through-ticket can't be pinned to the exact free trains in a single SNCF @@ -334,7 +342,7 @@ export function journeyEl( ) : el("a", { class: "btn btn-primary book-leg", - href: ctx.bookUrl(j.origin, j.destination, j.date, j.legs[0]?.depart), + href: ctx.bookUrl(j.origin, j.destination, j.date, j.legs[0]?.depart, j.legs[0]?.operator), attrs: { target: "_blank", rel: "noopener noreferrer" }, text: opts.bookLabel, }) @@ -353,12 +361,12 @@ export function journeyEl( "a", { class: "book-arrow", - href: ctx.bookUrl(j.origin, j.destination, j.date, j.legs[0]?.depart), + href: ctx.bookUrl(j.origin, j.destination, j.date, j.legs[0]?.depart, j.legs[0]?.operator), attrs: { target: "_blank", rel: "noopener noreferrer", - "aria-label": t("act_book"), - title: t("act_book"), + "aria-label": bookOn(j.legs[0]), + title: bookOn(j.legs[0]), }, }, [icon(I.arrow), el("span", { class: "sr-only", text: t("link_newtab") })], diff --git a/tests/spain.test.ts b/tests/spain.test.ts new file mode 100644 index 0000000..bd5567b --- /dev/null +++ b/tests/spain.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; +import { encodeCompact, type TimetableRow } from "../src/data/compact"; +import { loadDataset, operatorTrains } from "../src/data/dataset"; +import { INTERRAIL_PROFILE, SNCF_PROFILE } from "../src/data/profile"; +import { RENFE, operatorById } from "../src/data/operators"; +import { findJourneys } from "../src/core/connections"; +import { journeyFee } from "../src/data/fees"; +import { prettyLabel } from "../src/data/stations"; + +const BASE = "https://api.test/v1"; +const NOW = new Date("2026-10-01T09:00:00Z"); +const DATE = "2026-10-02"; +const MADRID = "Madrid-Puerta de Atocha-Almudena Grandes"; + +const row = (origine: string, destination: string, dep: string, arr: string, no: string, axe?: string): TimetableRow => ({ + date: DATE, + origine, + destination, + heure_depart: dep, + heure_arrivee: arr, + train_no: no, + ...(axe ? { axe } : {}), +}); + +// SNCF: Paris → Barcelona by TGV. Renfe: Barcelona → Madrid by AVE, and a Renfe train +// into France that SNCF's feed also carries (kept once). +const sncf = encodeCompact([ + row("PARIS (intramuros)", "BARCELONA SANTS", "10:07", "16:36", "9713", "INTERNATIONAL"), + row("PARIS (intramuros)", "LYON (intramuros)", "08:00", "10:00", "6601", "SUD EST"), + row("BARCELONA SANTS", "LYON (intramuros)", "09:00", "14:00", "9742", "INTERNATIONAL"), +]); +const renfe = encodeCompact([ + row("Barcelona-Sants", MADRID, "17:30", "20:15", "03171", "AVE"), + row("Barcelona-Sants", "Lyon Part Dieu", "09:00", "14:00", "09742", "AVE INT"), + row("Barcelona-Sants", "Perpignan", "09:00", "10:20", "09742", "AVE INT"), +]); + +const json = (body: unknown): Response => new Response(JSON.stringify(body), { status: 200 }); +const stub = (renfeUp = true): unknown => + vi.stubGlobal("fetch", (url: string) => { + if (url === `${BASE}/sncf/interrail/all.json`) return Promise.resolve(json(sncf)); + if (url === `${BASE}/renfe/interrail/all.json`) + return renfeUp ? Promise.resolve(json(renfe)) : Promise.reject(new Error("offline")); + if (url === `${BASE}/index.json`) return Promise.resolve(json({ updatedAt: "2026-10-01T08:00:00Z" })); + return Promise.resolve(new Response("", { status: 404 })); + }); + +afterEach(() => vi.unstubAllGlobals()); + +describe("Renfe on Interrail", () => { + it("joins Renfe's stations SNCF also serves to SNCF's ids", () => { + const trains = operatorTrains(renfe, RENFE, INTERRAIL_PROFILE, ["BARCELONA SANTS", "LYON (intramuros)", "PERPIGNAN"]); + expect(trains.map((t) => [t.origin, t.destination])).toEqual([ + ["BARCELONA SANTS", MADRID], + ["BARCELONA SANTS", "LYON (intramuros)"], + ["BARCELONA SANTS", "PERPIGNAN"], + ]); + expect(trains.every((t) => t.operator === "renfe" && t.available)).toBe(true); + }); + + it("adds Renfe's trains to Interrail, keeping a train both feeds carry once", async () => { + stub(); + const d = await loadDataset(INTERRAIL_PROFILE, BASE, NOW); + expect(d.trains).toHaveLength(5); + expect(d.trains.filter((t) => t.origin === "BARCELONA SANTS" && t.destination === "LYON (intramuros)")).toHaveLength(1); + expect(d.trains.find((t) => t.destination === MADRID)?.operator).toBe("renfe"); + expect(d.meta.recordCount).toBe(5); + }); + + it("finds Paris → Madrid with a change at Barcelona", async () => { + stub(); + const d = await loadDataset(INTERRAIL_PROFILE, BASE, NOW); + const [j] = findJourneys(d.trains, "PARIS (intramuros)", MADRID, DATE); + expect(j?.hubs).toEqual(["BARCELONA SANTS"]); + expect(j?.legs.map((l) => l.operator)).toEqual([undefined, "renfe"]); + // The fee table covers SNCF only: no estimate for a journey with a Renfe train. + expect(j && journeyFee(j)).toBeUndefined(); + }); + + it("shows SNCF's trains alone when Renfe's file is out of reach", async () => { + stub(false); + const d = await loadDataset(INTERRAIL_PROFILE, BASE, NOW); + expect(d.trains).toHaveLength(3); + expect(d.trains.some((t) => t.operator)).toBe(false); + }); + + it("never asks Renfe for the MAX passes", async () => { + const urls: string[] = []; + vi.stubGlobal("fetch", (url: string) => { + urls.push(url); + return Promise.resolve(json({})); + }); + await loadDataset(SNCF_PROFILE, BASE, NOW); + expect(urls.some((u) => u.includes("/renfe/"))).toBe(false); + }); + + it("books a Renfe train on Renfe's site and keeps its station names", () => { + expect(operatorById("renfe")?.bookingUrl).toContain("renfe.com"); + expect(operatorById(undefined)).toBeUndefined(); + expect(prettyLabel(MADRID)).toBe(MADRID); + expect(prettyLabel("BARCELONA SANTS")).toBe("Barcelona Sants"); + }); +}); + +describe("Spanish station names", () => { + it("resolves a city name to its main station once Renfe's trains are in", async () => { + const { StationRegistry } = await import("../src/data/stations"); + const r = new StationRegistry([]); + r.addMissing([MADRID, "Madrid-Chamartín-Clara Campoamor", "BARCELONA SANTS", "Barcelona-Passeig de Gràcia"]); + expect(r.resolve("madrid")).toBeUndefined(); // two Madrid stations: ambiguous + r.addAliases(RENFE.aliases); + expect(r.resolve("Madrid")).toBe(MADRID); + expect(r.resolve("barcelone")).toBe("BARCELONA SANTS"); + expect(r.coords(MADRID)).toEqual([40.407, -3.691]); + }); +});