diff --git a/docs/user-flows.md b/docs/user-flows.md
index a737185..62578e9 100644
--- a/docs/user-flows.md
+++ b/docs/user-flows.md
@@ -17,6 +17,13 @@ search (`deriveMode` in `src/app.ts`; dispatch in `renderSearch`).
| **Multi-city** (`multi`) | legs or cities | `tour` | a multi-stop tour |
| **Ideas** (`ideas`) | From | `best` | best destinations, ranked |
+A typed or linked station resolves when it spells a station's name, id or alias (accents,
+case, hyphens, brackets and "St"/"Saint" read alike; a station's own name outranks another's
+alias, and the one with trains wins a tie), or when it matches exactly one station. A name
+matching several stations, or none, stops Search: the field turns red and a line under
+Search names it, so `pierre des` never runs from Tours and an unknown arrival never turns
+into a browse from the departure. Search with no departure names that step on the same line.
+
The **trip-type control** sits beside the date: an **`Aller simple` / `Aller-retour`**
(one-way / round trip) segmented toggle — the same sliding-pill style as the main tabs.
When *Aller-retour* is on, a **nights stepper** appears — `[ − ] N [ + ]` with a "Durée sur
@@ -126,6 +133,8 @@ span applied to the lists only, and a day could read green while its list was em
when the proposed return day has **no free-MAX return**, the return leg and its calendar
**open by default** (never a collapsed summary hiding an empty list), so the days that
*do* have a return are visible and one tap away. Pick a return →
+ - **Same day with no round trip that day** → under the empty outbound, a one-tap "Stay N
+ night(s) instead" offers the shortest stay (1–3 nights) that has one that day.
- **Trip modal** = booking recap: each leg's own **travel date** rides on the ticket header
(beside "Outbound" / "Return"), and an unmistakable per-leg action — "Book the outbound" /
"Book the return" (each deep-links SNCF Connect; a connecting leg opens the step modal)
@@ -133,10 +142,17 @@ span applied to the lists only, and a day could read green while its list was em
exits the flow (step-wise back).
3. **Only From, One-way** → browse (`runBrowse` "from"). Every destination reachable from the
station, ranked by how well-served it is, with availability. Tap a card → the exact trip.
-4. **Only From, a stay chosen** → discovery (`runGetaways`), "Where can you get away to?".
- Destinations ranked by **time at the destination** (hours on site if same-day is best,
- else nights) + a possible-start-days calendar. Tap a day → narrows to that day
- (auto-scrolls to results). Tap a destination → opens the round trip.
+ The list reads direct cards, then connection-only ("via") rows, then the radius "Stations
+ within N km" section, whatever the chunked rendering. A nearby row names the station its
+ free leg starts from ("from Massy TGV · 16 km"). An empty day offers a one-tap move to
+ the next day with seats.
+4. **Only From, a stay chosen** → discovery (`runGetaways`), titled with the origin, the day
+ and the stay ("Round trips from Paris — Tue, Sep 29 · Same day"; the mobile search bar
+ repeats it). Destinations ranked by **time at the destination** (hours on site if same-day
+ is best, else nights); the form's calendar shows the possible start days. Tap a day →
+ narrows to that day and scrolls the list into view when it sits below the fold. Tap a
+ destination → opens the round trip. An empty day offers the next day with a round trip
+ and a one-tap switch to one-way.
- Ranking: `sortGetaways` puts most hours-on-site first for same-day trips.
- A minimum-on-site gate exists in core (`minOnSiteMin`, default 4h); NOT yet exposed as
an Advanced control. (Open item.)
@@ -154,8 +170,12 @@ span applied to the lists only, and a day could read green while its list was em
## Ideas tab (best)
-Best free-MAX destinations from the origin, ranked (fastest / most-served / closest…),
-either an all-days overview or one specific day. Tap → open the route.
+Every free-MAX destination from the origin across the booking window (there is no one-day
+view), Recommended (fastest first) by default; the sort picker also ranks by most trains,
+most days, closest, fastest or A–Z.
+Each row shows the changes, the month's train count and the fastest time, plus the days
+reachable or the distance when the sort ranks by it. Tap → the exact trip on the first day
+the destination is reachable, with its calendar open.
## Cross-cutting (everywhere)
@@ -180,6 +200,7 @@ either an all-days overview or one specific day. Tap → open the route.
of an empty result. A malformed or out-of-window departure date falls back to today; a
malformed or out-of-window return or finish-by date, and a malformed time filter, are
dropped. The address bar is then corrected in place (`replaceState`) to match the screen.
+ A link without `card=` (a route page's "open in app") keeps the card saved in Settings.
- **PWA** — installable; a "new version — reload" postcard on updates.
## Known open items (see docs/trip-redesign.md for the audit plan)
diff --git a/package.json b/package.json
index 3be08d5..f4c0ac8 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,7 @@
"test:e2e": "node scripts/e2e.mjs",
"screenshot": "node scripts/screenshot.mjs",
"prerender": "node scripts/prerender.mjs",
- "generate-pages": "node scripts/generate-pages.mjs",
+ "generate-pages": "tsx scripts/generate-pages.mjs",
"build:mobile": "tsc --noEmit && vite build --mode capacitor",
"cap:sync": "npm run build:mobile && cap sync",
"cap:copy": "npm run build:mobile && cap copy",
diff --git a/scripts/generate-pages.mjs b/scripts/generate-pages.mjs
index f184fac..3eae3ee 100644
--- a/scripts/generate-pages.mjs
+++ b/scripts/generate-pages.mjs
@@ -1,9 +1,9 @@
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { marked } from "marked";
+import { SITE_URL as SITE, GITHUB_URL } from "../src/config.ts";
const DIST = join(process.cwd(), "dist");
-const SITE = "https://davd-gzl.github.io/MAX-Finder/";
const TOP_ROUTES = 150;
if (!existsSync(join(DIST, "index.html"))) {
@@ -108,7 +108,7 @@ ${body}
(Licence Ouverte), instantané du ${esc(lastmod)}. Disponibilités indicatives, rafraîchies ~1×/jour ;
la réservation se fait sur SNCF Connect.
Projet libre et indépendant, non affilié à la SNCF —
- code source.
+ code source.
Rechercher un trajet · FAQ ·
diff --git a/src/app.ts b/src/app.ts
index a414d9b..a24a15c 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -10,7 +10,7 @@ import {
} from "./core/destinations";
import { filterTrains, isNightTrain, type FilterOptions } from "./core/search";
import { bestTripsAcrossWindow, stationsOnDate, reachableBest, type BestTrip, type ReachTrip } from "./core/best";
-import { getawayIdeas, reverseGetawayIdeas, stayCalendar } from "./core/getaways";
+import { bestGetawayTo, getawayIdeas, reverseGetawayIdeas, stayCalendar } from "./core/getaways";
import { planTours, planTourInOrder, planTourGreedy, arrivalDate, type Tour } from "./core/tour";
import { findJourneys, bestJourney, reachableJourneys, journeyArriveAbs, toJourney, MAX_RESULTS } from "./core/connections";
import type { ConnectionOptions } from "./core/connections";
@@ -73,7 +73,6 @@ let refs: Refs;
let formApi: FormHandle;
let mapPromise: Promise | null = null;
let mapInstance: RouteMap | null = null;
-let labelToId: Map;
// Today (YYYY-MM-DD). MAX seats are only bookable ~30 days out, so the calendar
// and the date picker stay anchored to a today..today+30 window.
let today = "";
@@ -155,7 +154,7 @@ let odReturnDate: string | null = null;
// rt_return_moved notice on the next render, then clears itself.
let returnMoved = false;
-/** Set (or clear with "") the inline status next to the "surprise" button. */
+/** Set (or clear with "") the inline status under the Search and Surprise buttons. */
function setSurpriseMsg(text: string): void {
formApi?.setSurpriseMsg(text);
}
@@ -615,7 +614,6 @@ 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]));
- labelToId = new Map(registry.list().map((s) => [s.label.toLowerCase(), s.id]));
today = new Date().toISOString().slice(0, 10);
query = store.urlHasQuery()
@@ -700,6 +698,8 @@ function queryFromUrl(): SearchQuery {
const params = new URLSearchParams(location.search);
const linked = params.toString();
const q = store.queryFromParams(params, today);
+ // A link without card= (a shared link, a route page) keeps the card the user saved.
+ if (!params.has("card")) q.card = settings.card;
const lastBookable = addDays(today, BOOKING_WINDOW_DAYS - 1);
const inWindow = (d: string): boolean => d >= today && d <= lastBookable;
if (!inWindow(q.date)) q.date = today;
@@ -788,15 +788,7 @@ let searchHeldBack = false;
// --- station resolution -----------------------------------------------------
function resolveStation(text: string): string | undefined {
- const norm = text.trim();
- if (!norm) return undefined;
- const byLabel = labelToId.get(norm.toLowerCase());
- if (byLabel) return byLabel;
- // Strict: no fuzzy/raw fallback. Text matching no real station resolves to
- // undefined, so a typo or made-up name reads as invalid instead of being kept as
- // a phantom origin / city that silently matches nothing.
- const hit = deps.registry.search(norm, 1)[0];
- return hit ? hit.id : undefined;
+ return deps.registry.resolve(text);
}
// --- formatting / context ---------------------------------------------------
@@ -846,14 +838,13 @@ function ctx(): RenderCtx {
// Drop any "via" carried over from a previous exact-trip search: drilling into
// a specific route (often a connecting one) shouldn't be filtered through an
// unrelated hub, which would force it through a station it doesn't pass and
- // show nothing. A getaway idea passes its own start day (open.date) so the round
- // trip opens on a day it's actually feasible — not today, which may have only an
- // outbound and no return (the "click a same-day idea, get no round trip" dead end).
+ // show nothing. A getaway or Ideas row passes a day it's feasible (open.date) — not
+ // today, which may have no train, or an outbound and no return.
const date = open?.date ?? query.date;
const returnDate = open?.date && query.returnDate ? undefined : query.returnDate;
- // Opening a ONE-WAY idea (Ideas list, no stay, no dated getaway) → reveal the dates:
- // the exact-trip page shows its availability calendar open instead of today's trains.
- openOdCalendar = query.mode === "best" && !query.stay && !open?.date;
+ // Opening a ONE-WAY idea (Ideas list, no stay) → reveal the dates: the exact-trip
+ // page shows its availability calendar open, on the idea's first day.
+ openOdCalendar = query.mode === "best" && !query.stay;
query = { ...query, mode: "od", origin, destination, via: undefined, date, returnDate };
syncFormFromQuery();
// Push a browser entry marked as a DETAIL page: the list stays one Back away, and the
@@ -1531,7 +1522,7 @@ function pickFormDay(date: string): void {
// day, the calendar selection doesn't update the map"). Only a truly empty query stays
// staged.
if (queryIsRenderable(query)) {
- if (sameRoute) refreshInPlace();
+ if (sameRoute) refreshInPlace(true);
else applyAndRun();
}
repaintFormCalendar();
@@ -1982,16 +1973,24 @@ function runBrowse(c: RenderCtx, dir: "from" | "to"): void {
? nearbyBrowse(anchor, dir, query.date, query.radiusKm, filterOpts(), already)
: [];
- if (total === 0) {
- // Suppress the empty message when nearby alternatives will fill the gap below.
- if (nearby.length === 0) {
- refs.results.append(render.emptyEl(t("res_none")), render.hintEl(t("res_none_hint")));
- showMap(anchor, []);
- return;
- }
- } else {
+ if (total === 0 && nearby.length === 0) {
+ refs.results.append(render.emptyEl(t("res_none")));
+ // Point at the next day the form calendar marks green, when there is one.
+ const window = dateRange(today, BOOKING_WINDOW_DAYS);
+ const opts = { ...filterOpts(), maxConnections: query.maxConnections };
+ const next = nextAvailableDay(reachableCountCalendar(trains, anchor, window, opts, dir));
+ if (next) refs.results.append(nextDayEl(next));
+ refs.results.append(render.hintEl(t("res_none_hint")));
+ showMap(anchor, []);
+ return;
+ }
+
+ // One chunked list in reading order: direct cards, then via rows, then the nearby
+ // section, so a later batch of direct cards never lands below the other two.
+ const rows: (() => Node)[] = [];
+ if (total > 0) {
// Sort applies to the direct destinations (the rich cards); "rec" keeps the
- // most-served default. Via rows stay appended after, in their duration order.
+ // most-served default. Via rows follow, in their duration order.
const sortedGroups = applySort(groups, {
name: (g) => registry.label(g.station),
distanceKm: (g) => stationDistanceKm(anchor, g.station),
@@ -2005,23 +2004,32 @@ function runBrowse(c: RenderCtx, dir: "from" | "to"): void {
onSort,
),
);
- appendInChunks(refs.results, sortedGroups, (g) =>
- render.groupCardEl(g, dir, anchor, c, dayCount.get(g.station) ?? 0, stats.get(g.station), flex),
- );
- for (const tr of connecting) refs.results.append(render.reachTripRowEl(tr.station, tr.journey, c));
+ for (const g of sortedGroups)
+ rows.push(() => render.groupCardEl(g, dir, anchor, c, dayCount.get(g.station) ?? 0, stats.get(g.station), flex));
+ for (const tr of connecting) rows.push(() => render.reachTripRowEl(tr.station, tr.journey, c));
}
// Nearby paid-hop alternatives: a station near your anchor reaches (or is reached
- // by) somewhere your exact anchor doesn't. Each row opens that nearby leg's route.
- if (query.radiusKm) {
- const sec = el("section", { class: "nearby" }, [
- el("h3", { text: t("nearby_title", { km: query.radiusKm }) }),
- el("p", { class: "muted small", text: t(dir === "from" ? "nearby_browse_from" : "nearby_browse_to") }),
- ]);
- if (nearby.length === 0) sec.append(render.emptyEl(t("nearby_none")));
- else for (const n of nearby) sec.append(render.nearbyTripRowEl(n.station, Math.round(n.km), n.journey, c));
- refs.results.append(sec);
+ // by) somewhere your exact anchor doesn't. Each row names that station and opens its route.
+ const radiusKm = query.radiusKm;
+ if (radiusKm) {
+ rows.push(() => {
+ const sec = el("section", { class: "nearby" }, [
+ el("h3", { text: t("nearby_title", { km: radiusKm }) }),
+ el("p", { class: "muted small", text: t(dir === "from" ? "nearby_browse_from" : "nearby_browse_to") }),
+ ]);
+ if (nearby.length === 0) sec.append(render.emptyEl(t("nearby_none")));
+ for (const n of nearby) {
+ const note = t(dir === "from" ? "nearby_from_km" : "nearby_to_km", {
+ station: registry.label(n.via),
+ km: Math.round(n.km),
+ });
+ sec.append(render.nearbyTripRowEl(n.station, note, n.journey, c));
+ }
+ return sec;
+ });
}
+ appendInChunks(refs.results, rows, (row) => row());
// Tint map pins by how many changes each place takes: direct = green, each
// extra connection pushes toward red (see RouteMap.reachColor).
@@ -2049,8 +2057,13 @@ function runBrowse(c: RenderCtx, dir: "from" | "to"): void {
* clicking a destination row drops into the with-destination flow for that place.
*/
function runGetaways(c: RenderCtx, origin: string): void {
- const { trains } = deps;
- refs.title.textContent = t("rt_finder_title");
+ const { trains, registry } = deps;
+ const nights = query.stay ? stayNights(query.stay) : null;
+ refs.title.textContent = t("rt_finder_title", {
+ station: registry.label(origin),
+ date: formatDate(query.date),
+ stay: nights == null ? t("stay_flexible") : nights === 0 ? t("nights_sameday") : t("getaway_nights", { n: nights }),
+ });
// DAY-SCOPED: list the round trips you can start on the chosen day, so the count matches
// the "When to leave?" calendar's number for that day (pick another day → that day's list).
// A window-wide union would say "65 possible" while the calendar cell says "8 that day".
@@ -2058,6 +2071,10 @@ function runGetaways(c: RenderCtx, origin: string): void {
const shown = trips;
if (shown.length === 0) {
refs.results.append(render.emptyEl(t("getaway_none")));
+ // Never a dead end: the next day with a round trip, and the one-way list.
+ const next = nextAvailableDay(getawayIdeas(trains, origin, dateRange(today, BOOKING_WINDOW_DAYS), getawayOpts()).perDay);
+ if (next) refs.results.append(nextDayEl(next));
+ refs.results.append(actionEl(t("getaway_oneway_btn"), () => applyTripShape("oneway")));
showMap(origin, []);
return;
}
@@ -2386,7 +2403,9 @@ function runBestSearch(c: RenderCtx): void {
onSort,
),
);
- appendInChunks(refs.results, sorted, (tr) => render.bestTripRowEl(tr, c, stats.get(tr.destination)?.trains));
+ appendInChunks(refs.results, sorted, (tr) =>
+ render.bestTripRowEl(tr, c, stats.get(tr.destination)?.trains, query.sort),
+ );
showMap(
query.origin,
sorted.map((tr) => tr.destination),
@@ -2456,11 +2475,13 @@ function appendNearbyAlternatives(
} else {
if (alt.fromOrigin.length) {
sec.append(el("h4", { class: "nearby-sub", text: t("nearby_from_origin", { station: registry.label(query.origin) }) }));
- for (const a of alt.fromOrigin) sec.append(render.nearbyTripRowEl(a.id, Math.round(a.km), a.journey, c));
+ for (const a of alt.fromOrigin)
+ sec.append(render.nearbyTripRowEl(a.id, t("nearby_km", { km: Math.round(a.km) }), a.journey, c));
}
if (alt.toDest.length) {
sec.append(el("h4", { class: "nearby-sub", text: t("nearby_to_dest", { station: registry.label(query.destination) }) }));
- for (const a of alt.toDest) sec.append(render.nearbyTripRowEl(a.id, Math.round(a.km), a.journey, c));
+ for (const a of alt.toDest)
+ sec.append(render.nearbyTripRowEl(a.id, t("nearby_km", { km: Math.round(a.km) }), a.journey, c));
}
if (alt.bothEnds.length) {
sec.append(el("h4", { class: "nearby-sub", text: t("nearby_both") }));
@@ -2512,7 +2533,11 @@ function runOdSearch(c: RenderCtx): void {
// availability, mirroring the round-trip outbound calendar's collapse pattern.
// Opened from an Ideas one-way tap → show the days you can go up front (calendar open);
// otherwise it's collapsed behind a one-tap "Départ : … · Changer" summary as usual.
- const odCal = render.collapsibleCalendar(render.calendarEl(cal, c, query.date), "cal-collapsible", openOdCalendar);
+ const odCal = render.collapsibleCalendar(
+ render.calendarEl(cal, c, query.date),
+ "cal-collapsible",
+ openOdCalendar,
+ );
openOdCalendar = false;
odCal.setLabel(t("outbound_change", { date: formatDate(query.date) }));
refs.results.append(odCal.host);
@@ -3094,6 +3119,11 @@ function runTripSearch(c: RenderCtx): void {
};
if (outJourneys.length === 0) {
body0.append(render.emptyEl(t("res_none")), render.hintEl(t("res_none_hint")));
+ // No same-day round trip that day: offer the shortest stay that has one.
+ const n = isSameDayTrip
+ ? [1, 2, 3].find((k) => bestGetawayTo(trains, origin, destination, query.date, { ...outCalOpts, nights: k }))
+ : undefined;
+ if (n) body0.append(actionEl(t("try_nights", { n }), () => applyTripShape(stayFromNights(n))));
} else {
for (const j of outJourneys)
body0.append(
@@ -3159,6 +3189,28 @@ function runTripSearch(c: RenderCtx): void {
);
}
}
+
+/** A one-tap way forward under an empty list: a ghost button on its own centred row. */
+function actionEl(text: string, onClick: () => void): HTMLElement {
+ const button = el("button", { class: "btn btn-ghost", type: "button", text, on: { click: onClick } });
+ return el("div", { class: "od-comeback" }, [button]);
+}
+
+/** The first day after the chosen one, else the first in the window, that `cal` marks
+ * bookable on the query's card. */
+function nextAvailableDay(cal: CalendarDay[]): string | undefined {
+ const open = cal.filter((d) => d.available && d.date !== query.date);
+ return (open.find((d) => d.date > query.date) ?? open[0])?.date;
+}
+
+/** Move an empty list to `date`, a day its calendar marks green. */
+function nextDayEl(date: string): HTMLElement {
+ return actionEl(t("day_with_seats", { date: formatDate(date) }), () => {
+ query = { ...query, date };
+ refreshInPlace();
+ });
+}
+
/** Coarse pointer ≈ touch/phone. */
function showHint(input: HTMLInputElement): void {
// Empty state: no nagging prompt — just a blank heading and a ready cursor.
@@ -3242,15 +3294,34 @@ function cycleTripShape(): void {
repaintFormCalendar(); // the shape now means one-way vs round → recolour the days
}
+/** The departure or arrival field the active surface reads whose text names no station. */
+function unknownStationField(): HTMLInputElement | undefined {
+ const fields =
+ tripType === "simple" || (tripType === "multi" && formApi.getMultiMode() === "plan")
+ ? [refs.origin, refs.destination]
+ : tripType === "ideas"
+ ? [refs.origin]
+ : [];
+ return fields.find((f) => f.value.trim() !== "" && !resolveStation(f.value));
+}
+
/** Run a fresh search from the current form (submit or "g" shortcut). */
function runFromForm(): void {
+ // A name that matches no station, or several, stops here in red: searching without it
+ // would run a different search than the one typed.
+ const unknown = unknownStationField();
+ if (unknown) {
+ unknown.classList.add("is-invalid");
+ setSurpriseMsg(t("err_station", { station: unknown.value.trim() }));
+ return;
+ }
+ setSurpriseMsg("");
query = readQueryFromForm();
applyAndRun();
// Only swap the phone to the results view when there's something real to show. An
- // incomplete query (no origin, etc.) stays on the form with its field flagged, rather
- // than teleporting to a blank results screen. (applyAndRun's showHint already keeps
- // the form open; this stops the old unconditional flip from overriding it.)
+ // incomplete query stays on the form, with the missing step named under Search.
if (queryIsRenderable(query)) setMobileForm(false);
+ else setSurpriseMsg(t(query.mode === "tour" && formApi.getMultiMode() === "legs" ? "multi_hint" : "need_origin"));
}
/** Shift the chosen date by `delta` days, clamped to the bookable window. */
diff --git a/src/core/best.ts b/src/core/best.ts
index 3c6c8c4..66989eb 100644
--- a/src/core/best.ts
+++ b/src/core/best.ts
@@ -6,6 +6,8 @@ export interface BestTrip {
journey: Journey;
/** Days in the window this destination is reachable (set by the all-days view). */
days?: number;
+ /** The first day in the window it is reachable (set by the all-days view). */
+ firstDate?: string;
}
/** Distinct stations that appear (as origin or destination) on a given date. */
@@ -46,7 +48,7 @@ export function bestTripsAcrossWindow(
const cur = found.get(destination);
// Keep the fastest journey across the whole window, not the earliest day's.
if (!cur || journey.totalDurationMin < cur.journey.totalDurationMin) {
- found.set(destination, { destination, journey });
+ found.set(destination, { destination, journey, firstDate: cur?.firstDate ?? date });
}
}
}
diff --git a/src/data/stations.ts b/src/data/stations.ts
index e0a0378..5ffa668 100644
--- a/src/data/stations.ts
+++ b/src/data/stations.ts
@@ -2,12 +2,20 @@ import type { Station } from "../types";
import { CITY_REFERENCE } from "./cities";
import { NON_BOOKABLE_PATTERNS } from "../config";
-/** Lowercase, strip accents/diacritics for tolerant matching. */
+/**
+ * Match key for station and city names, typed, linked or stored: accents and case folded,
+ * hyphens, brackets and commas read as spaces, and "St"/"Ste" as "Saint"/"Sainte", so
+ * "St-Pierre" and "saint pierre" agree, as do "ESSLINGEN(NECKAR)" and "Esslingen Neckar".
+ */
export function normalizeText(s: string): string {
return s
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
+ .replace(/[-(),]/g, " ")
+ .replace(/\bst\b/g, "saint")
+ .replace(/\bste\b/g, "sainte")
+ .replace(/\s+/g, " ")
.trim();
}
@@ -33,21 +41,6 @@ export function isAirportStation(id: string): boolean {
return AIRPORT_MARKERS.some((m) => u.includes(m));
}
-/**
- * Tolerant match key for city resolution: accent/case-folded, hyphens → spaces,
- * "St"/"Ste" → "Saint"/"Sainte". Lets "ST MALO", "Saint-Malo" and "SAINT MALO"
- * all resolve to the same city.
- */
-export function matchNorm(s: string): string {
- return normalizeText(s)
- .replace(/[(),]/g, " ") // e.g. "ESSLINGEN(NECKAR)" -> "esslingen neckar"
- .replace(/-/g, " ")
- .replace(/\bst\b/g, "saint")
- .replace(/\bste\b/g, "sainte")
- .replace(/\s+/g, " ")
- .trim();
-}
-
/** Fallback display name for a station id not present in the registry. */
export function prettyLabel(id: string): string {
return id
@@ -78,6 +71,8 @@ export class StationRegistry {
private cityKeys: { key: string; info: CityInfo }[] = [];
// Ids actually present in the loaded dataset (i.e. bookable).
private present = new Set();
+ // Normalised name → station, for resolve(); rebuilt after addMissing().
+ private exact: Map | null = null;
constructor(stations: Station[]) {
for (const s of stations) this.add(s);
@@ -85,7 +80,7 @@ export class StationRegistry {
const refMap = new Map();
const addRef = (name: string | undefined, info: CityInfo): void => {
if (!name) return;
- const k = matchNorm(name);
+ const k = normalizeText(name);
if (k && !refMap.has(k)) refMap.set(k, info);
};
// Seed from curated stations (authoritative coords), then the supplementary
@@ -117,7 +112,7 @@ export class StationRegistry {
/** Best city reference matching a station id (whole-word, longest match). */
private matchCity(id: string): CityInfo | undefined {
- const n = matchNorm(id);
+ const n = normalizeText(id);
if (!n) return undefined;
for (const { key, info } of this.cityKeys) {
if (n === key || n.startsWith(`${key} `) || n.endsWith(` ${key}`) || n.includes(` ${key} `)) {
@@ -134,6 +129,7 @@ export class StationRegistry {
* Every id passed here is also recorded as "present" (bookable).
*/
addMissing(ids: Iterable): void {
+ this.exact = null;
for (const id of ids) {
if (!id) continue;
this.present.add(id);
@@ -154,19 +150,6 @@ export class StationRegistry {
return this.byId.get(id);
}
- /** The id a linked name means: a bookable id as is, else the id, label or alias equal to
- * it ignoring case, accents and "St"/hyphen spelling, preferring one with trains. */
- resolve(name: string): string | undefined {
- if (this.present.has(name)) return name;
- const n = matchNorm(name);
- let best: Station | undefined;
- for (const { station } of this.index) {
- const names = [station.id, station.label, ...(station.aliases ?? [])];
- if (names.some((s) => matchNorm(s) === n) && (!best || this.better(station, best))) best = station;
- }
- return best?.id;
- }
-
/** Every registered station (may contain label duplicates). */
all(): Station[] {
return [...this.byId.values()];
@@ -200,6 +183,41 @@ export class StationRegistry {
return s && Number.isFinite(s.lat) && Number.isFinite(s.lng) ? [s.lat, s.lng] : undefined;
}
+ /**
+ * The station a name means, typed or linked: a bookable id as is; else the station whose
+ * id, label or alias it spells under {@link normalizeText}; else the only station it
+ * matches. Several matches, or none, leave it unresolved, never a guess.
+ */
+ resolve(name: string): string | undefined {
+ if (this.present.has(name)) return name;
+ const q = normalizeText(name);
+ if (!q) return undefined;
+ this.exact ??= this.exactNames();
+ const exact = this.exact.get(q);
+ if (exact) return exact.id;
+ const hits = this.search(q, 2);
+ return hits.length === 1 ? hits[0]!.id : undefined;
+ }
+
+ /** Every bookable station by its normalised names: an id or label outranks another
+ * station's alias, and among equal names the station with trains wins. */
+ private exactNames(): Map {
+ const out = new Map();
+ const stations = this.all().filter((s) => !isNonBookable(s.id));
+ const tiers = [(s: Station) => s.aliases ?? [], (s: Station) => [s.id, s.label]];
+ for (const names of tiers) {
+ const tier = new Map();
+ for (const s of stations)
+ for (const n of names(s)) {
+ const k = normalizeText(n);
+ const cur = tier.get(k);
+ if (!cur || this.better(s, cur)) tier.set(k, s);
+ }
+ for (const [k, s] of tier) out.set(k, s); // later tiers overwrite earlier ones
+ }
+ return out;
+ }
+
/** Accent-insensitive, word-prefix-aware autocomplete (deduped by label). */
search(query: string, limit = 8): Station[] {
const q = normalizeText(query);
diff --git a/src/i18n/ar.ts b/src/i18n/ar.ts
index f4d984a..3bb32fb 100644
--- a/src/i18n/ar.ts
+++ b/src/i18n/ar.ts
@@ -75,6 +75,8 @@ export const ar: Dict = {
nearby_to_dest: "الوصول قرب {station}",
nearby_both: "الانطلاق والوصول من محطات قريبة",
nearby_km: "على بعد {km} كم",
+ nearby_from_km: "من {station} · {km} كم",
+ nearby_to_km: "إلى {station} · {km} كم",
nearby_none: "لا توجد محطة قريبة بها مقعد MAX لهذا المسار.",
nearby_browse_from: "وجهات إضافية يمكنك الوصول إليها بدفع رحلة قصيرة إلى محطة قريبة.",
nearby_browse_to: "نقاط انطلاق إضافية تصل إلى محطة قرب وجهتك — ادفع رحلة قصيرة من هناك.",
@@ -115,6 +117,7 @@ export const ar: Dict = {
res_destinations: "{n} وجهة",
res_origins: "{n} نقطة انطلاق",
badge_trains: "{n} قطار",
+ badge_days: "{n} يوم/أيام",
lbl_direct: "مباشر",
lbl_airport: "مطار",
lbl_via: "عبر {hub}",
@@ -212,6 +215,9 @@ export const ar: Dict = {
getaway_count: "{n} رحلة ذهاب وإياب ممكنة",
getaway_nights: "{n} ليلة/ليالٍ هناك",
getaway_none: "لا توجد رحلات MAX ذهابًا وإيابًا بهذه المعايير.",
+ getaway_oneway_btn: "عرض وجهات الذهاب فقط",
+ day_with_seats: "عرض {date} (مقاعد متاحة)",
+ try_nights: "ابقَ {n} ليلة/ليالٍ بدلًا من ذلك",
act_save: "حفظ",
act_saved: "محفوظ",
@@ -264,7 +270,7 @@ export const ar: Dict = {
cal_legend_return: "العدد = قطارات العودة",
res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}",
rt_return_leg: "العودة",
- rt_finder_title: "إلى أين تهرب؟",
+ rt_finder_title: "رحلات ذهاب وعودة من {station} — {date} · {stay}",
rt_need_origin: "أضف نقطة انطلاق لمعرفة أين يمكنك الذهاب والعودة.",
rt_reverse_title: "من أين يمكنك الذهاب والعودة للوصول إلى {station}؟",
rt_reverse_count: "{n} نقطة انطلاق ممكنة",
@@ -274,6 +280,7 @@ export const ar: Dict = {
senior_weekend_return_warn: "MAX SENIOR صالحة أيام الأسبوع فقط — عودتك في نهاية الأسبوع ولا يمكن حجزها مجانًا.",
form_cal_title: "متى تسافر؟",
form_cal_hint: "اختر محطة انطلاق لعرض الأيام المتاحة.",
+ need_origin: "اختر محطة المغادرة.",
form_cal_departed: "المغادرة: {date}",
form_cal_flex_hint: "اختر الذهاب ثم العودة",
form_cal_range: "الذهاب: {from} ← العودة: {to}",
diff --git a/src/i18n/de.ts b/src/i18n/de.ts
index ed5ef37..04313b1 100644
--- a/src/i18n/de.ts
+++ b/src/i18n/de.ts
@@ -75,6 +75,8 @@ export const de: Dict = {
nearby_to_dest: "Ankunft nahe {station}",
nearby_both: "Abfahrt und Ankunft in der Nähe",
nearby_km: "{km} km entfernt",
+ nearby_from_km: "ab {station} · {km} km",
+ nearby_to_km: "bis {station} · {km} km",
nearby_none: "Kein naher Bahnhof hat einen MAX-Platz für diese Strecke.",
nearby_browse_from: "Zusätzliche Ziele, die du mit einem kurzen bezahlten Sprung zu einem nahen Bahnhof erreichst.",
nearby_browse_to: "Zusätzliche Startbahnhöfe, die einen Bahnhof nahe deinem Ziel erreichen — von dort ein kurzer bezahlter Sprung.",
@@ -115,6 +117,7 @@ export const de: Dict = {
res_destinations: "{n} Ziel(e)",
res_origins: "{n} Ausgangsort(e)",
badge_trains: "{n} Zug/Züge",
+ badge_days: "{n} Tag(e)",
lbl_direct: "Direkt",
lbl_airport: "Flughafen",
lbl_via: "über {hub}",
@@ -213,6 +216,9 @@ export const de: Dict = {
getaway_count: "{n} Hin- und Rückfahrt(en) möglich",
getaway_nights: "{n} Nacht/Nächte vor Ort",
getaway_none: "Keine MAX-Hin-und-Rückfahrten für diese Kriterien.",
+ getaway_oneway_btn: "Ziele für die einfache Fahrt ansehen",
+ day_with_seats: "{date} ansehen (freie Plätze)",
+ try_nights: "Stattdessen {n} Nacht/Nächte bleiben",
act_save: "Speichern",
act_saved: "Gespeichert",
@@ -265,7 +271,7 @@ export const de: Dict = {
cal_legend_return: "Zahl = Rückfahrten",
res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}",
rt_return_leg: "Rückfahrt",
- rt_finder_title: "Wohin verreisen?",
+ rt_finder_title: "Hin und zurück ab {station} — {date} · {stay}",
rt_need_origin: "Gib einen Abfahrtsort ein, um Hin- und Rückfahrten zu sehen.",
rt_reverse_title: "Von wo aus erreichst du {station} per Hin- und Rückfahrt?",
rt_reverse_count: "{n} mögliche(r) Abfahrtsort(e)",
@@ -275,6 +281,7 @@ export const de: Dict = {
senior_weekend_return_warn: "MAX SENIOR gilt nur werktags — Ihre Rückfahrt fällt auf ein Wochenende und ist nicht kostenlos buchbar.",
form_cal_title: "Wann losfahren?",
form_cal_hint: "Wählen Sie einen Abfahrtsbahnhof, um verfügbare Tage zu sehen.",
+ need_origin: "Wähle einen Abfahrtsbahnhof.",
form_cal_departed: "Abfahrt: {date}",
form_cal_flex_hint: "Erst die Hinfahrt, dann die Rückfahrt wählen",
form_cal_range: "Hin: {from} → Rück: {to}",
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index 0f52150..0d3c49e 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -79,6 +79,8 @@ export const en: Dict = {
nearby_to_dest: "Arrive near {station}",
nearby_both: "Leave from and arrive near",
nearby_km: "{km} km away",
+ nearby_from_km: "from {station} · {km} km",
+ nearby_to_km: "to {station} · {km} km",
nearby_none: "No nearby station has a free MAX seat for this route.",
nearby_browse_from: "Extra destinations you can reach by paying a short hop to a nearby station.",
nearby_browse_to: "Extra origins that reach a station near your destination — pay a short hop from there.",
@@ -119,6 +121,7 @@ export const en: Dict = {
res_destinations: "{n} destination(s)",
res_origins: "{n} origin(s)",
badge_trains: "{n} train(s)",
+ badge_days: "{n} day(s)",
lbl_direct: "Direct",
lbl_airport: "Airport",
@@ -225,6 +228,9 @@ export const en: Dict = {
getaway_count: "{n} round trip(s) possible",
getaway_nights: "{n} night(s) there",
getaway_none: "No MAX round trips for these criteria.",
+ getaway_oneway_btn: "See one-way destinations",
+ day_with_seats: "See {date} (free seats)",
+ try_nights: "Stay {n} night(s) instead",
act_save: "Save",
act_saved: "Saved",
@@ -277,7 +283,7 @@ export const en: Dict = {
cal_legend_return: "number = return trains",
res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}",
rt_return_leg: "Return",
- rt_finder_title: "Where can you get away to?",
+ rt_finder_title: "Round trips from {station} — {date} · {stay}",
rt_need_origin: "Add a departure to see where you can go and come back.",
rt_reverse_title: "Where can you round-trip from to reach {station}?",
rt_reverse_count: "{n} possible departure point(s)",
@@ -287,6 +293,7 @@ export const en: Dict = {
senior_weekend_return_warn: "MAX SENIOR is weekday-only — your return lands on a weekend and can't be booked free.",
form_cal_title: "When to leave?",
form_cal_hint: "Choose a departure station to see which days are available.",
+ need_origin: "Choose a departure station.",
form_cal_departed: "Departure: {date}",
form_cal_flex_hint: "Pick the outbound, then the return",
form_cal_range: "Departure: {from} → Return: {to}",
diff --git a/src/i18n/es.ts b/src/i18n/es.ts
index cf36eb3..3d77426 100644
--- a/src/i18n/es.ts
+++ b/src/i18n/es.ts
@@ -75,6 +75,8 @@ export const es: Dict = {
nearby_to_dest: "Llegar cerca de {station}",
nearby_both: "Salir y llegar cerca",
nearby_km: "a {km} km",
+ nearby_from_km: "desde {station} · {km} km",
+ nearby_to_km: "hacia {station} · {km} km",
nearby_none: "Ninguna estación cercana tiene plaza MAX para este trayecto.",
nearby_browse_from: "Destinos adicionales a los que puedes llegar pagando un trayecto corto hasta una estación cercana.",
nearby_browse_to: "Orígenes adicionales que llegan a una estación cerca de tu destino — paga un trayecto corto desde allí.",
@@ -115,6 +117,7 @@ export const es: Dict = {
res_destinations: "{n} destino(s)",
res_origins: "{n} origen(es)",
badge_trains: "{n} tren(es)",
+ badge_days: "{n} día(s)",
lbl_direct: "Directo",
lbl_airport: "Aeropuerto",
lbl_via: "vía {hub}",
@@ -213,6 +216,9 @@ export const es: Dict = {
getaway_count: "{n} ida(s) y vuelta(s) posible(s)",
getaway_nights: "{n} noche(s) allí",
getaway_none: "Ningún viaje MAX de ida y vuelta para estos criterios.",
+ getaway_oneway_btn: "Ver destinos solo ida",
+ day_with_seats: "Ver el {date} (plazas libres)",
+ try_nights: "Quedarse {n} noche(s)",
act_save: "Guardar",
act_saved: "Guardado",
@@ -265,7 +271,7 @@ export const es: Dict = {
cal_legend_return: "número = trenes de vuelta",
res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}",
rt_return_leg: "Vuelta",
- rt_finder_title: "¿A dónde escaparse?",
+ rt_finder_title: "Idas y vueltas desde {station} — {date} · {stay}",
rt_need_origin: "Añade una salida para ver a dónde ir y volver.",
rt_reverse_title: "¿Desde dónde puedes ir y volver a {station}?",
rt_reverse_count: "{n} punto(s) de salida posible(s)",
@@ -275,6 +281,7 @@ export const es: Dict = {
senior_weekend_return_warn: "MAX SENIOR es solo entre semana — tu vuelta cae en fin de semana y no puede reservarse gratis.",
form_cal_title: "¿Cuándo salir?",
form_cal_hint: "Elige una estación de salida para ver qué días hay disponibles.",
+ need_origin: "Elige una estación de salida.",
form_cal_departed: "Salida: {date}",
form_cal_flex_hint: "Elige la ida y luego la vuelta",
form_cal_range: "Ida: {from} → Vuelta: {to}",
diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts
index 5fb6061..7768b0c 100644
--- a/src/i18n/fr.ts
+++ b/src/i18n/fr.ts
@@ -77,6 +77,8 @@ export const fr = {
nearby_to_dest: "Arriver près de {station}",
nearby_both: "Partir et arriver près",
nearby_km: "à {km} km",
+ nearby_from_km: "depuis {station} · {km} km",
+ nearby_to_km: "vers {station} · {km} km",
nearby_none: "Aucune gare voisine n'a de place MAX pour ce trajet.",
nearby_browse_from: "Des destinations en plus, en payant un court trajet jusqu'à une gare voisine.",
nearby_browse_to: "Des points de départ en plus qui atteignent une gare proche de votre arrivée — payez un court trajet depuis là.",
@@ -117,6 +119,7 @@ export const fr = {
res_destinations: "{n} destination(s)",
res_origins: "{n} origine(s)",
badge_trains: "{n} train(s)",
+ badge_days: "{n} jour(s)",
lbl_direct: "Direct",
lbl_airport: "Aéroport",
@@ -223,6 +226,9 @@ export const fr = {
getaway_count: "{n} aller(s)-retour(s) possible(s)",
getaway_nights: "{n} nuit(s) sur place",
getaway_none: "Aucun aller-retour MAX pour ces critères.",
+ getaway_oneway_btn: "Voir les destinations en aller simple",
+ day_with_seats: "Voir le {date} (places libres)",
+ try_nights: "Rester {n} nuit(s) à la place",
act_save: "Enregistrer",
act_saved: "Enregistré",
@@ -279,7 +285,7 @@ export const fr = {
cal_legend_return: "nombre = trains au retour",
res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}",
rt_return_leg: "Retour",
- rt_finder_title: "Où s'évader ?",
+ rt_finder_title: "Allers-retours depuis {station} — {date} · {stay}",
rt_need_origin: "Ajoutez un départ pour voir où aller et revenir.",
rt_reverse_title: "D’où partir pour rejoindre {station} ?",
rt_reverse_count: "{n} point(s) de départ possible(s)",
@@ -290,6 +296,7 @@ export const fr = {
// The reactive availability calendar on the home form (the Trip tab's date picker).
form_cal_title: "Quand partir ?",
form_cal_hint: "Choisissez une gare de départ pour voir les jours disponibles.",
+ need_origin: "Choisissez une gare de départ.",
form_cal_departed: "Départ : {date}",
// Flexible: the inline calendar becomes a departure→return range picker.
form_cal_flex_hint: "Choisissez l'aller, puis le retour",
diff --git a/src/i18n/it.ts b/src/i18n/it.ts
index 2c45630..0f6fe99 100644
--- a/src/i18n/it.ts
+++ b/src/i18n/it.ts
@@ -75,6 +75,8 @@ export const it: Dict = {
nearby_to_dest: "Arrivo vicino a {station}",
nearby_both: "Partenza e arrivo nelle vicinanze",
nearby_km: "a {km} km",
+ nearby_from_km: "da {station} · {km} km",
+ nearby_to_km: "verso {station} · {km} km",
nearby_none: "Nessuna stazione vicina ha un posto MAX per questo tragitto.",
nearby_browse_from: "Destinazioni in più raggiungibili pagando un breve tratto fino a una stazione vicina.",
nearby_browse_to: "Partenze in più che raggiungono una stazione vicino al tuo arrivo — paga un breve tratto da lì.",
@@ -115,6 +117,7 @@ export const it: Dict = {
res_destinations: "{n} destinazione/i",
res_origins: "{n} origine/i",
badge_trains: "{n} treno/i",
+ badge_days: "{n} giorno/i",
lbl_direct: "Diretto",
lbl_airport: "Aeroporto",
lbl_via: "via {hub}",
@@ -213,6 +216,9 @@ export const it: Dict = {
getaway_count: "{n} andata/e e ritorno possibile/i",
getaway_nights: "{n} notte/notti sul posto",
getaway_none: "Nessun andata e ritorno MAX per questi criteri.",
+ getaway_oneway_btn: "Vedi le destinazioni solo andata",
+ day_with_seats: "Vedi {date} (posti liberi)",
+ try_nights: "Resta {n} notte/notti",
act_save: "Salva",
act_saved: "Salvato",
@@ -265,7 +271,7 @@ export const it: Dict = {
cal_legend_return: "numero = treni di ritorno",
res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}",
rt_return_leg: "Ritorno",
- rt_finder_title: "Dove evadere?",
+ rt_finder_title: "Andata e ritorno da {station} — {date} · {stay}",
rt_need_origin: "Aggiungi una partenza per vedere dove andare e tornare.",
rt_reverse_title: "Da dove puoi fare andata e ritorno per {station}?",
rt_reverse_count: "{n} punto/i di partenza possibile/i",
@@ -275,6 +281,7 @@ export const it: Dict = {
senior_weekend_return_warn: "MAX SENIOR vale nei giorni feriali — il tuo ritorno cade nel weekend e non è prenotabile gratis.",
form_cal_title: "Quando partire?",
form_cal_hint: "Scegli una stazione di partenza per vedere i giorni disponibili.",
+ need_origin: "Scegli una stazione di partenza.",
form_cal_departed: "Partenza: {date}",
form_cal_flex_hint: "Scegli l'andata, poi il ritorno",
form_cal_range: "Andata: {from} → Ritorno: {to}",
diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts
index 04a7754..d28318b 100644
--- a/src/i18n/ja.ts
+++ b/src/i18n/ja.ts
@@ -75,6 +75,8 @@ export const ja: Dict = {
nearby_to_dest: "{station} 付近に到着",
nearby_both: "近くで出発して近くに到着",
nearby_km: "{km} km",
+ nearby_from_km: "{station} 発 · {km} km",
+ nearby_to_km: "{station} 着 · {km} km",
nearby_none: "この経路に MAX 席のある近隣駅はありません。",
nearby_browse_from: "近くの駅まで短い区間を支払えば行ける、追加の目的地。",
nearby_browse_to: "目的地の近くの駅に着く追加の出発地 — そこから短い区間だけ支払います。",
@@ -115,6 +117,7 @@ export const ja: Dict = {
res_destinations: "目的地 {n} 件",
res_origins: "出発地 {n} 件",
badge_trains: "列車 {n} 本",
+ badge_days: "{n} 日",
lbl_direct: "直通",
lbl_airport: "空港",
lbl_via: "{hub} 経由",
@@ -212,6 +215,9 @@ export const ja: Dict = {
getaway_count: "往復 {n} 件が可能",
getaway_nights: "{n}泊滞在",
getaway_none: "この条件では MAX 往復がありません。",
+ getaway_oneway_btn: "片道の行き先を見る",
+ day_with_seats: "{date} を見る(空席あり)",
+ try_nights: "代わりに {n} 泊する",
act_save: "保存",
act_saved: "保存済み",
@@ -264,7 +270,7 @@ export const ja: Dict = {
cal_legend_return: "数字 = 復路の列車",
res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}",
rt_return_leg: "復路",
- rt_finder_title: "どこへ出かけますか?",
+ rt_finder_title: "{station} 発の往復 — {date} · {stay}",
rt_need_origin: "出発地を入力すると往復できる場所が表示されます。",
rt_reverse_title: "{station} へ往復できる出発地は?",
rt_reverse_count: "出発地 {n} か所",
@@ -274,6 +280,7 @@ export const ja: Dict = {
senior_weekend_return_warn: "MAX SENIOR は平日限定です — 復路が週末のため無料予約できません。",
form_cal_title: "いつ出発?",
form_cal_hint: "出発駅を選ぶと空いている日が表示されます。",
+ need_origin: "出発駅を選んでください。",
form_cal_departed: "出発:{date}",
form_cal_flex_hint: "往路を選び、次に復路を選択",
form_cal_range: "往路:{from} → 復路:{to}",
diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts
index 1519624..b71b0d5 100644
--- a/src/i18n/ko.ts
+++ b/src/i18n/ko.ts
@@ -75,6 +75,8 @@ export const ko: Dict = {
nearby_to_dest: "{station} 근처에 도착",
nearby_both: "근처에서 출발하고 도착",
nearby_km: "{km} km",
+ nearby_from_km: "{station} 출발 · {km} km",
+ nearby_to_km: "{station} 도착 · {km} km",
nearby_none: "이 경로에 MAX 좌석이 있는 인근 역이 없습니다.",
nearby_browse_from: "가까운 역까지 짧은 구간을 결제하면 갈 수 있는 추가 목적지.",
nearby_browse_to: "도착지 근처 역에 닿는 추가 출발지 — 거기서 짧은 구간만 결제하세요.",
@@ -115,6 +117,7 @@ export const ko: Dict = {
res_destinations: "목적지 {n}곳",
res_origins: "출발지 {n}곳",
badge_trains: "열차 {n}대",
+ badge_days: "{n}일",
lbl_direct: "직통",
lbl_airport: "공항",
lbl_via: "{hub} 경유",
@@ -213,6 +216,9 @@ export const ko: Dict = {
getaway_count: "왕복 {n}건 가능",
getaway_nights: "{n}박 체류",
getaway_none: "이 조건으로 가능한 MAX 왕복이 없습니다.",
+ getaway_oneway_btn: "편도 목적지 보기",
+ day_with_seats: "{date} 보기 (빈자리 있음)",
+ try_nights: "대신 {n}박 머물기",
act_save: "저장",
act_saved: "저장됨",
@@ -265,7 +271,7 @@ export const ko: Dict = {
cal_legend_return: "숫자 = 귀환 열차",
res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}",
rt_return_leg: "돌아오기",
- rt_finder_title: "어디로 떠나볼까요?",
+ rt_finder_title: "{station} 출발 왕복 — {date} · {stay}",
rt_need_origin: "출발지를 입력하면 왕복 가능한 곳이 표시됩니다.",
rt_reverse_title: "{station}까지 왕복할 수 있는 출발지는?",
rt_reverse_count: "가능한 출발지 {n}곳",
@@ -275,6 +281,7 @@ export const ko: Dict = {
senior_weekend_return_warn: "MAX SENIOR는 평일 전용입니다 — 돌아오는 날이 주말이라 무료 예약이 안 됩니다.",
form_cal_title: "언제 떠날까요?",
form_cal_hint: "출발역을 선택하면 이용 가능한 날짜가 표시됩니다.",
+ need_origin: "출발역을 선택하세요.",
form_cal_departed: "출발: {date}",
form_cal_flex_hint: "가는 날을 고른 뒤 오는 날을 선택",
form_cal_range: "가는 날: {from} → 오는 날: {to}",
diff --git a/src/i18n/nl.ts b/src/i18n/nl.ts
index 12a5193..008b444 100644
--- a/src/i18n/nl.ts
+++ b/src/i18n/nl.ts
@@ -75,6 +75,8 @@ export const nl: Dict = {
nearby_to_dest: "Aankomst bij {station} in de buurt",
nearby_both: "Vertrek en aankomst in de buurt",
nearby_km: "{km} km",
+ nearby_from_km: "vanaf {station} · {km} km",
+ nearby_to_km: "naar {station} · {km} km",
nearby_none: "Geen nabijgelegen station heeft een MAX-plaats voor deze route.",
nearby_browse_from: "Extra bestemmingen die je bereikt door een korte hop naar een nabijgelegen station te betalen.",
nearby_browse_to: "Extra vertrekpunten die een station bij je bestemming bereiken — betaal daar een korte hop.",
@@ -115,6 +117,7 @@ export const nl: Dict = {
res_destinations: "{n} bestemming(en)",
res_origins: "{n} vertrekpunt(en)",
badge_trains: "{n} trein(en)",
+ badge_days: "{n} dag(en)",
lbl_direct: "Rechtstreeks",
lbl_airport: "Luchthaven",
lbl_via: "via {hub}",
@@ -212,6 +215,9 @@ export const nl: Dict = {
getaway_count: "{n} retour(s) mogelijk",
getaway_nights: "{n} nacht(en) ter plaatse",
getaway_none: "Geen MAX-retours voor deze criteria.",
+ getaway_oneway_btn: "Bekijk bestemmingen voor enkele reis",
+ day_with_seats: "Bekijk {date} (vrije plaatsen)",
+ try_nights: "Blijf {n} nacht(en)",
act_save: "Opslaan",
act_saved: "Opgeslagen",
@@ -264,7 +270,7 @@ export const nl: Dict = {
cal_legend_return: "aantal = terugtreinen",
res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}",
rt_return_leg: "Terugreis",
- rt_finder_title: "Waar kun je heen ontsnappen?",
+ rt_finder_title: "Retours vanaf {station} — {date} · {stay}",
rt_need_origin: "Voeg een vertrekpunt toe om te zien waar je heen en terug kunt.",
rt_reverse_title: "Vanwaar kun je heen en terug naar {station}?",
rt_reverse_count: "{n} mogelijk(e) vertrekpunt(en)",
@@ -274,6 +280,7 @@ export const nl: Dict = {
senior_weekend_return_warn: "MAX SENIOR geldt alleen doordeweeks — je terugreis valt in het weekend en is niet gratis te boeken.",
form_cal_title: "Wanneer vertrekken?",
form_cal_hint: "Kies een vertrekstation om te zien welke dagen beschikbaar zijn.",
+ need_origin: "Kies een vertrekstation.",
form_cal_departed: "Vertrek: {date}",
form_cal_flex_hint: "Kies de heenreis, dan de terugreis",
form_cal_range: "Heen: {from} → Terug: {to}",
diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts
index 7475064..ce4c382 100644
--- a/src/i18n/pt.ts
+++ b/src/i18n/pt.ts
@@ -75,6 +75,8 @@ export const pt: Dict = {
nearby_to_dest: "Chegar perto de {station}",
nearby_both: "Partir e chegar perto",
nearby_km: "a {km} km",
+ nearby_from_km: "a partir de {station} · {km} km",
+ nearby_to_km: "até {station} · {km} km",
nearby_none: "Nenhuma estação próxima tem lugar MAX para este trajeto.",
nearby_browse_from: "Destinos extra que pode alcançar pagando um pequeno trajeto até uma estação próxima.",
nearby_browse_to: "Origens extra que chegam a uma estação perto do seu destino — pague um pequeno trajeto a partir daí.",
@@ -115,6 +117,7 @@ export const pt: Dict = {
res_destinations: "{n} destino(s)",
res_origins: "{n} origem(ns)",
badge_trains: "{n} trem(ns)",
+ badge_days: "{n} dia(s)",
lbl_direct: "Direto",
lbl_airport: "Aeroporto",
lbl_via: "via {hub}",
@@ -212,6 +215,9 @@ export const pt: Dict = {
getaway_count: "{n} ida(s) e volta(s) possível(is)",
getaway_nights: "{n} noite(s) no local",
getaway_none: "Nenhuma ida e volta MAX para estes critérios.",
+ getaway_oneway_btn: "Ver destinos só de ida",
+ day_with_seats: "Ver {date} (lugares livres)",
+ try_nights: "Ficar {n} noite(s)",
act_save: "Guardar",
act_saved: "Guardado",
@@ -264,7 +270,7 @@ export const pt: Dict = {
cal_legend_return: "número = comboios de volta",
res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}",
rt_return_leg: "Volta",
- rt_finder_title: "Para onde escapar?",
+ rt_finder_title: "Idas e voltas a partir de {station} — {date} · {stay}",
rt_need_origin: "Adicione uma origem para ver para onde ir e voltar.",
rt_reverse_title: "De onde pode ir e voltar até {station}?",
rt_reverse_count: "{n} ponto(s) de partida possível(is)",
@@ -274,6 +280,7 @@ export const pt: Dict = {
senior_weekend_return_warn: "MAX SENIOR é só em dias úteis — a sua volta cai no fim de semana e não pode ser reservada de graça.",
form_cal_title: "Quando partir?",
form_cal_hint: "Escolha uma estação de partida para ver os dias disponíveis.",
+ need_origin: "Escolha uma estação de partida.",
form_cal_departed: "Partida: {date}",
form_cal_flex_hint: "Escolha a ida e depois a volta",
form_cal_range: "Ida: {from} → Volta: {to}",
diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts
index 5c633ac..ee2ea1b 100644
--- a/src/i18n/zh.ts
+++ b/src/i18n/zh.ts
@@ -75,6 +75,8 @@ export const zh: Dict = {
nearby_to_dest: "到达{station}附近",
nearby_both: "就近出发并就近到达",
nearby_km: "{km} 公里",
+ nearby_from_km: "从 {station} 出发 · {km} 公里",
+ nearby_to_km: "到 {station} · {km} 公里",
nearby_none: "附近没有车站为此线路提供 MAX 座位。",
nearby_browse_from: "支付一小段车程到邻近车站,即可抵达的额外目的地。",
nearby_browse_to: "可到达你目的地附近车站的额外出发地——从那里再付一小段车程。",
@@ -115,6 +117,7 @@ export const zh: Dict = {
res_destinations: "{n} 个目的地",
res_origins: "{n} 个出发地",
badge_trains: "{n} 趟列车",
+ badge_days: "{n} 天",
lbl_direct: "直达",
lbl_airport: "机场",
lbl_via: "经 {hub}",
@@ -212,6 +215,9 @@ export const zh: Dict = {
getaway_count: "可行往返 {n} 个",
getaway_nights: "停留 {n} 晚",
getaway_none: "在这些条件下没有 MAX 往返。",
+ getaway_oneway_btn: "查看单程目的地",
+ day_with_seats: "查看 {date}(有空位)",
+ try_nights: "改为停留 {n} 晚",
act_save: "保存",
act_saved: "已保存",
@@ -264,7 +270,7 @@ export const zh: Dict = {
cal_legend_return: "数字 = 返程列车",
res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}",
rt_return_leg: "返程",
- rt_finder_title: "去哪里度假?",
+ rt_finder_title: "从 {station} 出发的往返 — {date} · {stay}",
rt_need_origin: "填写出发地,即可查看可往返的目的地。",
rt_reverse_title: "从哪里可以往返到达 {station}?",
rt_reverse_count: "{n} 个可能的出发地",
@@ -274,6 +280,7 @@ export const zh: Dict = {
senior_weekend_return_warn: "MAX SENIOR 仅限工作日——您的返程在周末,无法免费预订。",
form_cal_title: "何时出发?",
form_cal_hint: "选择出发车站即可查看可用的日期。",
+ need_origin: "请选择出发车站。",
form_cal_departed: "出发:{date}",
form_cal_flex_hint: "先选去程,再选返程",
form_cal_range: "去程:{from} → 返程:{to}",
diff --git a/src/styles.css b/src/styles.css
index 0e3c4ec..0dd27b2 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1672,12 +1672,9 @@ a {
font-weight: 560;
font-variant-numeric: tabular-nums;
}
-/* Two chips side by side on an Ideas row (train count + days reachable). */
+/* An Ideas row's chips wrap one by one inside the row's meta line. */
.row-chips {
- display: inline-flex;
- align-items: center;
- gap: 0.35rem;
- flex: none;
+ display: contents;
}
.chev {
margin-left: auto;
diff --git a/src/ui/render.ts b/src/ui/render.ts
index 6d89e1c..ba20014 100644
--- a/src/ui/render.ts
+++ b/src/ui/render.ts
@@ -502,7 +502,7 @@ export function reachTripRowEl(
j: Journey,
ctx: RenderCtx,
extra?: HTMLElement,
- opts?: { hideMeta?: boolean; hideVia?: boolean },
+ opts?: { hideVia?: boolean; openDate?: string },
): HTMLElement {
const route: RoutePair = { origin: j.origin, destination: j.destination };
const via = j.legs.length > 1;
@@ -525,7 +525,7 @@ export function reachTripRowEl(
const metaChips: HTMLElement[] = [
...viaChip,
...(extra ? [extra] : []),
- ...(opts?.hideMeta ? [] : [el("bdi", { text: formatDuration(j.totalDurationMin) })]),
+ el("bdi", { text: formatDuration(j.totalDurationMin) }),
];
const main = el(
"button",
@@ -533,12 +533,12 @@ export function reachTripRowEl(
class: "dest-main dest-main-stacked",
type: "button",
attrs: { "aria-label": aria },
- on: { click: () => ctx.onOpenRoute(j.origin, j.destination) },
+ on: { click: () => ctx.onOpenRoute(j.origin, j.destination, opts?.openDate ? { date: opts.openDate } : undefined) },
},
[
el("div", { class: "dest-body" }, [
stationNameEl("dest-name", station, ctx.label(station)),
- ...(metaChips.length ? [el("span", { class: "dest-meta", attrs: { "aria-hidden": "true" } }, metaChips)] : []),
+ el("span", { class: "dest-meta", attrs: { "aria-hidden": "true" } }, metaChips),
]),
el("span", { class: "chev", attrs: { "aria-hidden": "true" } }, [icon(I.arrow)]),
],
@@ -726,15 +726,11 @@ export function listToolbarEl(
}
/**
- * A ranked best-trip row ("best" mode). Shows the month-long train count for the
- * destination (like the "Where to?" list) plus, in the all-days view, how many
- * days it's reachable.
+ * A ranked best-trip row ("best" mode): the changes it takes, the month-long train count,
+ * the fastest travel time, and the figure the active `sort` ranks by when it is another
+ * one (days reachable, distance). Opens on the first day the destination is reachable.
*/
-export function bestTripRowEl(trip: BestTrip, ctx: RenderCtx, trains?: number): HTMLElement {
- // Ideas is a discovery list: the city name is the point, so it always leads. A single
- // "N trains" chip (the ranking signal — how well-served the route is across the month)
- // rides alongside it; the duration meta is dropped so a long city name never gets
- // squeezed out of the row. Both figures still live in the chip's tooltip.
+export function bestTripRowEl(trip: BestTrip, ctx: RenderCtx, trains?: number, sort?: SortKey): HTMLElement {
const chips: HTMLElement[] = [];
// How many changes it takes to get there — the point of the Ideas list: Direct, or
// "N correspondance(s)". Colour-matched to the map pins (green direct / amber 1 / red 2+).
@@ -753,16 +749,19 @@ export function bestTripRowEl(trip: BestTrip, ctx: RenderCtx, trains?: number):
}),
);
}
+ if (sort === "days" && trip.days) chips.push(el("span", { class: "stat-chip", text: t("badge_days", { n: trip.days }) }));
+ const km = sort === "closest" ? ctx.distanceKm(trip.journey.origin, trip.destination) : Infinity;
+ if (Number.isFinite(km)) chips.push(el("span", { class: "stat-chip", text: t("nearby_km", { km: Math.round(km) }) }));
const extra = el("span", { class: "row-chips" }, chips);
- return reachTripRowEl(trip.destination, trip.journey, ctx, extra, { hideMeta: true, hideVia: true });
+ return reachTripRowEl(trip.destination, trip.journey, ctx, extra, { hideVia: true, openDate: trip.firstDate });
}
/**
- * A nearby paid-connection alternative (radius search): the nearby station, how
- * far it is, and the free-MAX journey it offers. Clicking opens that free route;
- * the user covers the short hop to/from the exact endpoint themselves.
+ * A nearby paid-connection alternative (radius search): the station, a `note` naming the
+ * nearby station and its distance, and the free-MAX journey it offers. Clicking opens that
+ * free route; the user covers the short hop to/from the exact endpoint themselves.
*/
-export function nearbyTripRowEl(station: string, km: number, j: Journey, ctx: RenderCtx): HTMLElement {
+export function nearbyTripRowEl(station: string, note: string, j: Journey, ctx: RenderCtx): HTMLElement {
const via = j.legs.length > 1;
const viaChip = via
? [el("span", { class: "chip chip-via", text: t("lbl_via", { hub: j.hubs.map((h) => ctx.label(h)).join(", ") }) })]
@@ -770,17 +769,19 @@ export function nearbyTripRowEl(station: string, km: number, j: Journey, ctx: Re
const main = el(
"button",
{
- class: "dest-main",
+ class: "dest-main dest-main-stacked",
type: "button",
- attrs: { "aria-label": `${ctx.label(station)} — ${t("nearby_km", { km })} — ${formatDuration(j.totalDurationMin)}` },
+ attrs: { "aria-label": `${ctx.label(station)} — ${note} — ${formatDuration(j.totalDurationMin)}` },
on: { click: () => ctx.onOpenRoute(j.origin, j.destination) },
},
[
- stationNameEl("dest-name", station, ctx.label(station)),
- el("span", { class: "chip chip-soft km-chip", text: t("nearby_km", { km }) }),
- ...viaChip,
- el("span", { class: "dest-meta", attrs: { "aria-hidden": "true" } }, [
- el("bdi", { text: formatDuration(j.totalDurationMin) }),
+ el("div", { class: "dest-body" }, [
+ stationNameEl("dest-name", station, ctx.label(station)),
+ el("span", { class: "dest-meta", attrs: { "aria-hidden": "true" } }, [
+ el("span", { class: "chip chip-soft km-chip", text: note }),
+ ...viaChip,
+ el("bdi", { text: formatDuration(j.totalDurationMin) }),
+ ]),
]),
el("span", { class: "chev", attrs: { "aria-hidden": "true" } }, [icon(I.arrow)]),
],
@@ -806,20 +807,22 @@ export function nearbyBothRowEl(
const main = el(
"button",
{
- class: "dest-main",
+ class: "dest-main dest-main-stacked",
type: "button",
attrs: { "aria-label": `${ctx.label(fromId)} → ${ctx.label(toId)} — ${formatDuration(j.totalDurationMin)}` },
on: { click: () => ctx.onOpenRoute(j.origin, j.destination) },
},
[
- el("span", { class: "dest-name" }, [
- el("bdi", { text: ctx.label(fromId) }),
- el("span", { class: "muted", text: " → " }),
- el("bdi", { text: ctx.label(toId) }),
- ]),
- el("span", { class: "chip chip-soft km-chip", text: t("nearby_km", { km: Math.max(fromKm, toKm) }) }),
- el("span", { class: "dest-meta", attrs: { "aria-hidden": "true" } }, [
- el("bdi", { text: formatDuration(j.totalDurationMin) }),
+ el("div", { class: "dest-body" }, [
+ el("span", { class: "dest-name" }, [
+ el("bdi", { text: ctx.label(fromId) }),
+ el("span", { class: "muted", text: " → " }),
+ el("bdi", { text: ctx.label(toId) }),
+ ]),
+ el("span", { class: "dest-meta", attrs: { "aria-hidden": "true" } }, [
+ el("span", { class: "chip chip-soft km-chip", text: t("nearby_km", { km: Math.max(fromKm, toKm) }) }),
+ el("bdi", { text: formatDuration(j.totalDurationMin) }),
+ ]),
]),
el("span", { class: "chev", attrs: { "aria-hidden": "true" } }, [icon(I.arrow)]),
],
diff --git a/tests/lists-links.test.ts b/tests/lists-links.test.ts
new file mode 100644
index 0000000..e610d40
--- /dev/null
+++ b/tests/lists-links.test.ts
@@ -0,0 +1,279 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { readFileSync } from "node:fs";
+
+// Leaflet needs a real browser canvas; stub the map module so the UI runs under jsdom.
+vi.mock("../src/ui/map", () => ({
+ RouteMap: class {
+ onSelect: ((id: string) => void) | null = null;
+ show(): void {}
+ route(): void {}
+ radius(): void {}
+ base(): void {}
+ highlight(): void {}
+ invalidate(): void {}
+ focus(): void {}
+ setInfo(): void {}
+ },
+}));
+
+import type { RawRecord, Station, DataMeta } from "../src/types";
+import { normalizeRecords } from "../src/data/dataset";
+import { StationRegistry } from "../src/data/stations";
+import { initApp } from "../src/app";
+import { t, getLang } from "../src/i18n";
+import { formatDuration } from "../src/util/time";
+import stations from "../data/stations.json";
+
+const meta: DataMeta = { updatedAt: "", source: "sample", recordCount: 0, isSample: true };
+const P = "PARIS (intramuros)";
+const enc = encodeURIComponent;
+
+/** One free-MAX train. */
+function tr(date: string, from: string, to: string, dep: string, arr: string, no: string): RawRecord {
+ return {
+ date,
+ origine: from,
+ destination: to,
+ heure_depart: dep,
+ heure_arrivee: arr,
+ train_no: no,
+ od_happy_card: "OUI",
+ axe: "",
+ } as RawRecord;
+}
+
+// rAF callbacks wait here until flush(), as in a browser, so chunked lists render in batches.
+let frames: FrameRequestCallback[] = [];
+function flush(): void {
+ while (frames.length) frames.shift()!(0);
+}
+
+function setup(search: string, records: RawRecord[], saved?: object): HTMLElement {
+ localStorage.clear();
+ if (saved) localStorage.setItem("mj.settings", JSON.stringify(saved));
+ document.body.innerHTML = '';
+ const root = document.getElementById("app") as HTMLElement;
+ history.replaceState(null, "", `/${search}`);
+ initApp(root, { trains: normalizeRecords(records), meta }, new StationRegistry(stations as Station[]));
+ flush();
+ return root;
+}
+
+const cardSelect = (root: HTMLElement): HTMLSelectElement =>
+ root.querySelector('select option[value="senior"]')!.parentElement as HTMLSelectElement;
+const originInput = (root: HTMLElement): HTMLInputElement =>
+ root.querySelectorAll('.od-fields input[list="station-list"]')[0]!;
+const destinationInput = (root: HTMLElement): HTMLInputElement =>
+ root.querySelectorAll('.od-fields input[list="station-list"]')[1]!;
+function search(root: HTMLElement): void {
+ root.querySelector(".search-form .form-actions button.btn-primary")!.click();
+ flush();
+}
+/** A day as the app writes it. */
+const formatDate = (iso: string): string =>
+ new Intl.DateTimeFormat(getLang(), { weekday: "short", day: "numeric", month: "short" }).format(
+ new Date(`${iso}T00:00:00`),
+ );
+const button = (root: HTMLElement, text: string): HTMLButtonElement | undefined =>
+ [...root.querySelectorAll(".results button")].find((b) => b.textContent === text);
+
+beforeEach(() => {
+ // 2026-06-25 is a Thursday: the 27th is a Saturday.
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(new Date("2026-06-25T12:00:00Z"));
+ frames = [];
+ globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
+ frames.push(cb);
+ return frames.length;
+ }) as typeof requestAnimationFrame;
+ globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame;
+ Element.prototype.scrollIntoView = function scrollIntoView(): void {};
+ window.scrollTo = (() => {}) as typeof window.scrollTo;
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+});
+
+describe("generated pages", () => {
+ it("take the site and repository URLs from src/config.ts", () => {
+ const src = readFileSync("scripts/generate-pages.mjs", "utf8");
+ expect(src).toContain('from "../src/config.ts"');
+ expect(src).not.toMatch(/https:\/\/[\w-]+\.github\.(io|com)/);
+ });
+});
+
+describe("the card", () => {
+ const lyon = [
+ tr("2026-06-26", P, "LYON (intramuros)", "08:00", "10:00", "1"),
+ tr("2026-06-27", P, "LYON (intramuros)", "08:00", "10:00", "2"),
+ ];
+ const od = `?mode=od&from=${enc(P)}&to=${enc("LYON (intramuros)")}&date=2026-06-26`;
+
+ it("a link without card= keeps the saved MAX SENIOR, and the address bar adds none", () => {
+ const root = setup(od.replace(enc(P), "paris"), lyon, { card: "senior" });
+ expect(cardSelect(root).value).toBe("senior");
+ const params = new URLSearchParams(location.search);
+ expect(params.get("from")).toBe(P); // the write-back ran
+ expect(params.has("card")).toBe(false);
+ });
+});
+
+describe("a typed station", () => {
+ const trains = [
+ tr("2026-06-25", P, "LYON (intramuros)", "08:00", "10:00", "1"),
+ tr("2026-06-25", "TOURS", P, "08:00", "10:00", "2"),
+ tr("2026-06-25", "ST PIERRE DES CORPS", P, "08:00", "10:00", "3"),
+ ];
+
+ it("that matches several stations stops Search in red with a message", () => {
+ const root = setup("", trains);
+ originInput(root).value = "pierre des";
+ search(root);
+ expect(location.search).not.toContain("from=");
+ expect(originInput(root).classList.contains("is-invalid")).toBe(true);
+ expect(root.querySelector(".surprise-msg")!.textContent).toBe(t("err_station", { station: "pierre des" }));
+ });
+
+ it("an unknown destination does not turn into a browse from the origin", () => {
+ const root = setup("", trains);
+ originInput(root).value = "Paris";
+ destinationInput(root).value = "Lyonzz";
+ search(root);
+ expect(location.search).not.toContain("mode=from");
+ expect(destinationInput(root).classList.contains("is-invalid")).toBe(true);
+ });
+
+ it("Search with no departure names what is missing", () => {
+ const root = setup("?mode=best", trains);
+ search(root);
+ expect(root.querySelector(".surprise-msg")!.textContent).toBe(t("need_origin"));
+ });
+});
+
+describe("browse", () => {
+ // 21 direct destinations (past the first chunk of 18), one reachable only via Lyon, and
+ // Le Mans reachable only from Massy TGV, 16 km from Paris.
+ const direct = Array.from({ length: 20 }, (_, i) => tr("2026-06-25", P, `ZZ D${i + 10}`, "08:00", "10:00", `d${i}`));
+ const trains = [
+ ...direct,
+ tr("2026-06-25", P, "LYON (intramuros)", "08:00", "10:00", "l1"),
+ tr("2026-06-25", "LYON (intramuros)", "ZZ VIA", "11:00", "12:00", "v1"),
+ tr("2026-06-25", "MASSY TGV", "LE MANS", "09:00", "10:00", "m1"),
+ ];
+
+ it("lists direct cards, then via rows, then the nearby section", () => {
+ const root = setup(`?mode=from&from=${enc(P)}&date=2026-06-25&rad=40`, trains);
+ const kids = [...root.querySelector(".results")!.children];
+ const station = (k: Element): string => (k as HTMLElement).dataset.station ?? "";
+ const lastDirect = Math.max(...kids.flatMap((k, i) => (/^ZZ D|^LYON/.test(station(k)) ? [i] : [])));
+ const via = kids.findIndex((k) => station(k) === "ZZ VIA");
+ const nearby = kids.findIndex((k) => k.classList.contains("nearby"));
+ expect(lastDirect).toBeGreaterThan(18);
+ expect(via).toBeGreaterThan(lastDirect);
+ expect(nearby).toBeGreaterThan(via);
+ });
+
+ it("names the nearby station a nearby row starts from", () => {
+ const root = setup(`?mode=from&from=${enc(P)}&date=2026-06-25&rad=40`, trains);
+ const row = root.querySelector('.nearby .group-card[data-station="LE MANS"]')!;
+ expect(row.textContent).toContain(t("nearby_from_km", { station: "Massy TGV", km: 16 }));
+ expect(row.querySelector(".dest-main-stacked .dest-body")).not.toBeNull();
+ });
+
+ it("an empty day points at the next day with seats", () => {
+ const root = setup(`?mode=from&from=${enc(P)}&date=2026-06-25`, [
+ tr("2026-06-27", P, "LYON (intramuros)", "08:00", "10:00", "1"),
+ ]);
+ expect(root.querySelector(".results .empty")).not.toBeNull();
+ const date = formatDate("2026-06-27");
+ const next = button(root, t("day_with_seats", { date }));
+ expect(next).toBeDefined();
+ next!.click();
+ flush();
+ expect(location.search).toContain("date=2026-06-27");
+ expect(root.querySelector('.results .group-card[data-station="LYON (intramuros)"]')).not.toBeNull();
+ });
+});
+
+describe("round-trip discovery", () => {
+ const trains = [
+ tr("2026-06-25", "GAP", "LYON (intramuros)", "07:00", "09:00", "1"),
+ tr("2026-06-27", "GAP", "LYON (intramuros)", "07:00", "09:00", "2"),
+ tr("2026-06-27", "LYON (intramuros)", "GAP", "18:00", "20:00", "3"),
+ ];
+
+ it("names the origin, the day and the stay", () => {
+ const root = setup(`?mode=from&from=GAP&date=2026-06-27&stay=day`, trains);
+ const title = root.querySelector("#results-title")!.textContent!;
+ expect(title).toContain("Gap");
+ expect(title).toContain(t("nights_sameday"));
+ });
+
+ it("an empty day offers the next day with a round trip and the one-way list", () => {
+ const root = setup(`?mode=from&from=GAP&date=2026-06-25&stay=day`, trains);
+ expect(button(root, t("getaway_oneway_btn"))).toBeDefined();
+ const date = formatDate("2026-06-27");
+ button(root, t("day_with_seats", { date }))!.click();
+ flush();
+ expect(root.querySelector('.results .group-card[data-station="LYON (intramuros)"]')).not.toBeNull();
+ });
+
+ it("a day tapped on the form calendar brings the refreshed list into view", () => {
+ const root = setup(`?mode=from&from=GAP&date=2026-06-25&stay=day`, trains);
+ vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({ top: 5000, bottom: 5040 } as DOMRect);
+ const scrolled = vi.spyOn(Element.prototype, "scrollIntoView");
+ root.querySelector('.form-cal-mount .cal-cell[data-date="2026-06-27"]')!.click();
+ flush();
+ expect(scrolled).toHaveBeenCalled();
+ });
+});
+
+describe("a same-day round trip with none that day", () => {
+ it("offers the shortest stay that has one", () => {
+ const root = setup(
+ `?mode=od&from=${enc("MARSEILLE ST CHARLES")}&to=${enc("BORDEAUX ST JEAN")}&date=2026-06-25&stay=day`,
+ [
+ tr("2026-06-25", "MARSEILLE ST CHARLES", "BORDEAUX ST JEAN", "08:00", "14:00", "1"),
+ tr("2026-06-26", "BORDEAUX ST JEAN", "MARSEILLE ST CHARLES", "09:00", "15:00", "2"),
+ ],
+ );
+ button(root, t("try_nights", { n: 1 }))!.click();
+ flush();
+ expect(location.search).toContain("stay=1");
+ expect(root.querySelector(".results .od-return .journey")).not.toBeNull();
+ });
+});
+
+describe("Ideas", () => {
+ const trains = [
+ tr("2026-06-25", P, "LYON (intramuros)", "08:00", "10:00", "1"),
+ tr("2026-06-26", P, "LILLE", "08:00", "09:00", "2"),
+ tr("2026-06-27", P, "PERPIGNAN", "21:25", "08:00", "3"),
+ ];
+ const ideas = `?mode=best&from=${enc(P)}`;
+
+ it("keeps Recommended as the default sort, and shows the durations", () => {
+ const root = setup(ideas, trains);
+ const sort = root.querySelector(".sort-select")!;
+ expect([...sort.options].map((o) => o.value)).toContain("rec");
+ expect(sort.value).toBe("rec");
+ expect(root.querySelector('.group-card[data-station="LILLE"]')!.textContent).toContain(formatDuration(60));
+ });
+
+ it("shows the figure a Most days or Closest sort ranks by", () => {
+ const days = setup(`${ideas}&sort=days`, trains);
+ expect(days.querySelector('.group-card[data-station="LILLE"]')!.textContent).toContain(t("badge_days", { n: 1 }));
+ const closest = setup(`${ideas}&sort=closest`, trains);
+ expect(closest.querySelector('.group-card[data-station="LILLE"]')!.textContent).toMatch(/\d+ km/);
+ });
+
+ it("opens a destination on the first day it is reachable", () => {
+ const root = setup(ideas, trains);
+ root.querySelector('.group-card[data-station="PERPIGNAN"] .dest-main')!.click();
+ flush();
+ expect(location.search).toContain("date=2026-06-27");
+ expect(root.querySelector(".results .journey")).not.toBeNull();
+ });
+});
diff --git a/tests/stations.test.ts b/tests/stations.test.ts
index 2c9affe..0582cc7 100644
--- a/tests/stations.test.ts
+++ b/tests/stations.test.ts
@@ -114,3 +114,47 @@ describe("StationRegistry lookups", () => {
expect(registry.label("SOME UNKNOWN GARE")).toBe("Some Unknown Gare");
});
});
+
+describe("StationRegistry.resolve", () => {
+ const r = new StationRegistry(stationData as Station[]);
+ r.addMissing([
+ "PARIS (intramuros)",
+ "PAU",
+ "TOURS",
+ "ST PIERRE DES CORPS",
+ "AIX EN PROVENCE TGV",
+ "AIX LES BAINS LE REVARD",
+ "MORLAIX",
+ "MARNE LA VALLEE CHESSY",
+ ]);
+
+ it("reads hyphens, accents, case and St/Saint alike", () => {
+ expect(normalizeText("St-Pierre")).toBe(normalizeText("saint pierre"));
+ expect(normalizeText("Ste-Foy")).toBe("sainte foy");
+ });
+
+ it("resolves a name spelling an id, label or alias, however it is written", () => {
+ expect(r.resolve("Paris")).toBe("PARIS (intramuros)");
+ expect(r.resolve("St-Pierre-des-Corps")).toBe("ST PIERRE DES CORPS");
+ expect(r.resolve("aix")).toBe("AIX EN PROVENCE TGV"); // a curated alias
+ });
+
+ it("prefers a station's own name to another station's alias", () => {
+ // TOURS carries the alias "saint pierre des corps"; the station itself wins.
+ expect(r.resolve("saint pierre des corps")).toBe("ST PIERRE DES CORPS");
+ });
+
+ it("resolves the only station a partial name matches", () => {
+ expect(r.resolve("disneyland")).toBe("MARNE LA VALLEE CHESSY");
+ expect(r.resolve("aix en provence")).toBe("AIX EN PROVENCE TGV");
+ expect(r.resolve("aix les")).toBe("AIX LES BAINS LE REVARD");
+ });
+
+ it("leaves a name matching several stations, or none, unresolved", () => {
+ expect(r.resolve("pierre des")).toBeUndefined();
+ expect(r.resolve("saint pierre")).toBeUndefined();
+ expect(r.resolve("pa")).toBeUndefined();
+ expect(r.resolve("ai")).toBeUndefined();
+ expect(r.resolve("zzz")).toBeUndefined();
+ });
+});