From 167060799d41cbbfd9f90ee85f97dde8b6f9a0d8 Mon Sep 17 00:00:00 2001
From: davd-gzl <60177543+davd-gzl@users.noreply.github.com>
Date: Tue, 29 Sep 2026 15:55:15 +0900
Subject: [PATCH 1/3] Lists: strict station names, ordered rows, a way out of
empty lists
Generated pages read the site and repository URLs from src/config.ts,
so the sitemap, canonicals and route pages point at offware-apps.
A link without card= keeps the saved card, the address bar correction
adds none, and every MAX SENIOR calendar greys its weekends.
One resolver serves the form and links: a bookable id as is, else the
station whose id, label or alias the name spells (its own name over
another's alias, the one with trains on a tie), else the only station
it matches. Otherwise Search stops with the field in red and a line
naming it. Hyphens, brackets and St/Saint read alike.
Browse lists direct cards, then via rows, then the nearby section,
and a nearby row names the station its free leg starts from.
Ideas drops the Recommended sort that repeated Fastest, shows the
figure it ranks by, and opens a row on its first reachable day.
Discovery titles name the origin, day and stay, a form day tap
reveals the list, and empty browse, discovery and same-day lists
offer the next day with seats, one-way, or the shortest stay.
---
docs/user-flows.md | 35 ++++-
package.json | 2 +-
scripts/generate-pages.mjs | 4 +-
src/app.ts | 196 +++++++++++++++++--------
src/core/best.ts | 4 +-
src/data/stations.ts | 80 +++++++----
src/i18n/ar.ts | 11 +-
src/i18n/de.ts | 11 +-
src/i18n/en.ts | 11 +-
src/i18n/es.ts | 11 +-
src/i18n/fr.ts | 11 +-
src/i18n/it.ts | 11 +-
src/i18n/ja.ts | 11 +-
src/i18n/ko.ts | 11 +-
src/i18n/nl.ts | 11 +-
src/i18n/pt.ts | 11 +-
src/i18n/zh.ts | 11 +-
src/styles.css | 7 +-
src/ui/render.ts | 69 ++++-----
tests/lists-links.test.ts | 288 +++++++++++++++++++++++++++++++++++++
tests/stations.test.ts | 44 ++++++
21 files changed, 689 insertions(+), 161 deletions(-)
create mode 100644 tests/lists-links.test.ts
diff --git a/docs/user-flows.md b/docs/user-flows.md
index a737185..db09608 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,11 @@ 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), fastest first; the sort picker also ranks by most trains, most days, closest 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)
@@ -165,7 +184,8 @@ either an all-days overview or one specific day. Tap → open the route.
- **Saved & Favorites** — star a route / save a trip, from the header menu. (The two overlap
— a known cleanup item.)
- **Settings** — theme, MAX Jeune/Senior, comfortable/compact, and Low-end mode (map off +
- reduced motion + compact) with a one-time nudge on weak devices; language.
+ reduced motion + compact) with a one-time nudge on weak devices; language. MAX SENIOR is
+ weekday-only, so every calendar greys its weekends (a weekend list keeps its notice).
- **Mobile** — the form is a sheet that collapses to a search bar; results are a bottom-sheet
drawer with detents. Back navigation preserves form state and never lands on a dead screen.
- **History model** — a genuine navigation (Search, drilling into a route, opening the saved
@@ -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..7f4aa90 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 ---------------------------------------------------
@@ -821,6 +813,12 @@ function isWeekend(iso: string): boolean {
return day === 0 || day === 6;
}
+/** A calendar as the card sees it: MAX SENIOR is weekday-only, so a weekend is never bookable. */
+function gradeForCard(days: CalendarDay[], card: SearchQuery["card"]): CalendarDay[] {
+ if (card !== "senior") return days;
+ return days.map((d) => (isWeekend(d.date) ? { date: d.date, available: false, count: 0 } : d));
+}
+
// Wikivoyage language editions that exist; others (e.g. ko) fall back to English.
const WIKIVOYAGE_LANGS = new Set(["fr", "en", "es", "de", "it", "zh"]);
@@ -846,14 +844,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
@@ -1510,7 +1507,7 @@ function repaintFormCalendar(): void {
if (rangeOpt && calOpts) {
calOpts = { ...calOpts, range: rangeOpt, hint: t("form_cal_flex_hint") };
}
- mount.append(render.calendarEl(cal, calCtx, selected, calOpts));
+ mount.append(render.calendarEl(gradeForCard(cal, fq.card), calCtx, selected, calOpts));
}
/**
@@ -1531,7 +1528,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 +1979,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 +2010,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 +2063,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 +2077,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;
}
@@ -2241,7 +2264,7 @@ function runMultiCity(c: RenderCtx): void {
// handy when you left the date blank. Clicking a day sets it and re-runs.
const legCal = availabilityCalendar(trains, leg.from, leg.to, windowDates, opts);
const legCtx: RenderCtx = { ...c, onSelectDay: (d) => setLegDate(i, d) };
- const calEl = journeys.length ? render.calendarEl(legCal, legCtx, leg.date) : null;
+ const calEl = journeys.length ? render.calendarEl(gradeForCard(legCal, query.card), legCtx, leg.date) : null;
if (calEl) sec.append(calEl);
const cards: HTMLElement[] = [];
if (journeys.length === 0) sec.append(render.emptyEl(t("res_none")));
@@ -2369,7 +2392,7 @@ function runBestSearch(c: RenderCtx): void {
// Month-long train count per destination (same figure as the "Where to?" list),
// so an idea shows how well-served it is before you drill in.
const stats = windowStats(trains, query.origin, "from", filterOpts());
- // Sort by trains / days reachable / distance / name; "rec" keeps fastest-first.
+ // Fastest first (bestTripsAcrossWindow's own order) unless another key is picked.
const origin = query.origin;
const sorted = applySort(trips, {
name: (tr) => registry.label(tr.destination),
@@ -2381,12 +2404,14 @@ function runBestSearch(c: RenderCtx): void {
refs.results.append(
render.listToolbarEl(
t("res_destinations", { n: trips.length }),
- query.sort ?? "rec",
- sortOptions(["rec", "trains", "days", "closest", "fastest", "name"]),
+ query.sort ?? "fastest",
+ sortOptions(["fastest", "trains", "days", "closest", "name"]),
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 +2481,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 +2539,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(gradeForCard(cal, query.card), c, query.date),
+ "cal-collapsible",
+ openOdCalendar,
+ );
openOdCalendar = false;
odCal.setLabel(t("outbound_change", { date: formatDate(query.date) }));
refs.results.append(odCal.host);
@@ -2894,7 +2925,7 @@ function runTripSearch(c: RenderCtx): void {
const refocus = retCalHost.contains(document.activeElement);
clear(retCalHost);
retCalHost.append(
- render.calendarEl(retCal, retCtx, retDate, {
+ render.calendarEl(gradeForCard(retCal, query.card), retCtx, retDate, {
title: t("rt_inbound"),
// First cell is same-day (hours on site); every later cell is nights away.
count: (n, day) => (day.date === query.date ? t("daytrip_cal_hours", { h: n }) : t("getaway_nights", { n })),
@@ -2991,7 +3022,7 @@ function runTripSearch(c: RenderCtx): void {
refreshInPlace();
};
const outCalCtx: RenderCtx = { ...c, onSelectDay: onOutboundDay };
- const outCalEl = render.calendarEl(outCal, outCalCtx, query.date, {
+ const outCalEl = render.calendarEl(gradeForCard(outCal, query.card), outCalCtx, query.date, {
title: t("getaway_cal_title"),
count: (n) => (isSameDayTrip ? t("daytrip_cal_hours", { h: n }) : t("getaway_nights", { n })),
countLegend: isSameDayTrip ? t("cal_legend_hours") : t("cal_legend_nights"),
@@ -3094,6 +3125,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 +3195,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 = gradeForCard(cal, query.card).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 +3300,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. */
@@ -3700,7 +3777,7 @@ function buildLayout(root: HTMLElement): void {
// connection-aware) instead of the old direct-only undercount.
cal = reachableCountCalendar(deps.trains, o, dates, opts, "from");
}
- if (cal) for (const c of cal) map.set(c.date, c.available ? c.count : 0);
+ if (cal) for (const c of gradeForCard(cal, query.card)) map.set(c.date, c.available ? c.count : 0);
return map;
},
onSwitchTab: switchTab,
@@ -3735,6 +3812,7 @@ function buildLayout(root: HTMLElement): void {
query = { ...query, card };
store.updateUrl(query);
runSearch();
+ repaintFormCalendar(); // a Senior calendar greys its weekends
},
onShare: (onCopied) => void shareCurrentUrl(onCopied),
onInstall: () => void promptInstall(),
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..6aa02ea 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: "نقاط انطلاق إضافية تصل إلى محطة قرب وجهتك — ادفع رحلة قصيرة من هناك.",
@@ -87,7 +89,7 @@ export const ar: Dict = {
desc_simple: "اعثر على رحلة محددة، أو تصفّح كل الوجهات انطلاقًا من محطة (أو كل نقاط الانطلاق نحو محطة).",
desc_multi_plan: "اذكر المدن التي تريد زيارتها: نرتّبها ونحدّد تواريخها في جولة قابلة للتنفيذ.",
desc_multi_legs: "كوّن كل رحلة يدويًا، محطة بمحطة وتاريخًا بتاريخ.",
- desc_ideas: "اكتشف إلى أين يمكنك الذهاب من محطة، مرتّبًا حسب عدد القطارات المجانية.",
+ desc_ideas: "اكتشف إلى أين يمكنك الذهاب من محطة، الأسرع أولًا.",
hint_od_optional: "اختياري: املأ المغادرة أو الوصول أو كليهما.",
radius_hint: "يوسّع البحث ليشمل المحطات القريبة لإيجاد مزيد من المقاعد المجانية.",
act_swap: "تبديل المغادرة والوصول",
@@ -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..caeb4d2 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.",
@@ -87,7 +89,7 @@ export const de: Dict = {
desc_simple: "Finde eine genaue Verbindung oder durchstöbere alle Ziele ab einem Bahnhof (oder alle Startorte zu einem).",
desc_multi_plan: "Liste die Städte auf: Wir ordnen und datieren sie zu einer machbaren Rundreise.",
desc_multi_legs: "Stelle jede Etappe selbst zusammen, Bahnhof für Bahnhof und Tag für Tag.",
- desc_ideas: "Entdecke, wohin du ab einem Bahnhof fahren kannst — sortiert nach Anzahl freier Züge.",
+ desc_ideas: "Entdecke, wohin du ab einem Bahnhof fahren kannst — die schnellsten zuerst.",
hint_od_optional: "Optional: Fülle Abfahrt, Ankunft oder beides aus.",
radius_hint: "Erweitert die Suche auf nahe Bahnhöfe, um mehr freie Plätze zu finden.",
act_swap: "Abfahrt und Ankunft tauschen",
@@ -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..e2232ae 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.",
@@ -91,7 +93,7 @@ export const en: Dict = {
desc_simple: "Find an exact trip, or browse every destination from a station (or every origin to one).",
desc_multi_plan: "List the cities to visit: we order and date them into a feasible tour.",
desc_multi_legs: "Build each leg by hand, station by station and date by date.",
- desc_ideas: "Discover where to go from a station, ranked by how many free trains run.",
+ desc_ideas: "Discover where to go from a station, fastest first.",
hint_od_optional: "Optional: fill the départ, the arrivée, or both.",
radius_hint: "Widen the search to nearby stations to surface more free seats.",
act_swap: "Swap départ and arrivée",
@@ -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..cb1c769 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í.",
@@ -87,7 +89,7 @@ export const es: Dict = {
desc_simple: "Encuentra un trayecto exacto, o explora todos los destinos desde una estación (o todos los orígenes hacia una).",
desc_multi_plan: "Enumera las ciudades a visitar: las ordenamos y fechamos en un circuito viable.",
desc_multi_legs: "Compón cada trayecto a mano, estación por estación y fecha por fecha.",
- desc_ideas: "Descubre adónde ir desde una estación, ordenado por cuántos trenes gratuitos hay.",
+ desc_ideas: "Descubre adónde ir desde una estación, los más rápidos primero.",
hint_od_optional: "Opcional: rellena la salida, la llegada, o ambas.",
radius_hint: "Amplía la búsqueda a estaciones cercanas para encontrar más plazas gratuitas.",
act_swap: "Intercambiar salida y llegada",
@@ -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..101e39f 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à.",
@@ -89,7 +91,7 @@ export const fr = {
desc_simple: "Trouvez un trajet précis, ou parcourez toutes les destinations au départ d'une gare (ou toutes les origines vers une gare).",
desc_multi_plan: "Listez les villes à visiter : on les ordonne et les date en un périple faisable.",
desc_multi_legs: "Composez chaque trajet à la main, gare par gare et date par date.",
- desc_ideas: "Découvrez où partir depuis une gare, classé par nombre de trains gratuits.",
+ desc_ideas: "Découvrez où partir depuis une gare, les plus rapides d'abord.",
hint_od_optional: "Optionnel : remplissez le départ, l'arrivée, ou les deux.",
radius_hint: "Élargit la recherche aux gares voisines pour trouver plus de places gratuites.",
act_swap: "Intervertir départ et arrivée",
@@ -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..7185431 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ì.",
@@ -87,7 +89,7 @@ export const it: Dict = {
desc_simple: "Trova un viaggio preciso, o esplora tutte le destinazioni da una stazione (o tutte le origini verso una).",
desc_multi_plan: "Elenca le città da visitare: le ordiniamo e le datiamo in un tour fattibile.",
desc_multi_legs: "Componi ogni tratta a mano, stazione per stazione e data per data.",
- desc_ideas: "Scopri dove andare da una stazione, ordinato per numero di treni gratuiti.",
+ desc_ideas: "Scopri dove andare da una stazione, prima i più veloci.",
hint_od_optional: "Facoltativo: compila la partenza, l'arrivo, o entrambi.",
radius_hint: "Allarga la ricerca alle stazioni vicine per trovare più posti gratuiti.",
act_swap: "Inverti partenza e arrivo",
@@ -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..d0dbcd9 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: "目的地の近くの駅に着く追加の出発地 — そこから短い区間だけ支払います。",
@@ -87,7 +89,7 @@ export const ja: Dict = {
desc_simple: "正確な経路を探すか、ある駅からの全目的地(またはある駅への全出発地)を見て回れます。",
desc_multi_plan: "訪れる都市を並べると、実行可能な周遊として順番と日付を決めます。",
desc_multi_legs: "駅ごと・日付ごとに各区間を手動で組み立てます。",
- desc_ideas: "ある駅から行ける場所を、無料列車の本数順で見つけます。",
+ desc_ideas: "ある駅から行ける場所を、速い順に見つけます。",
hint_od_optional: "任意:出発地、到着地、またはその両方を入力してください。",
radius_hint: "検索を近くの駅まで広げ、より多くの無料席を見つけます。",
act_swap: "出発地と到着地を入れ替える",
@@ -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..5292123 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: "도착지 근처 역에 닿는 추가 출발지 — 거기서 짧은 구간만 결제하세요.",
@@ -87,7 +89,7 @@ export const ko: Dict = {
desc_simple: "정확한 여정을 찾거나, 한 역에서 갈 수 있는 모든 목적지(또는 한 역으로 오는 모든 출발지)를 둘러보세요.",
desc_multi_plan: "방문할 도시를 나열하면 실행 가능한 여정으로 순서와 날짜를 정해 드립니다.",
desc_multi_legs: "역과 날짜를 하나씩 직접 지정해 각 구간을 구성하세요.",
- desc_ideas: "한 역에서 갈 수 있는 곳을, 무료 열차 수 기준으로 정렬해 살펴보세요.",
+ desc_ideas: "한 역에서 갈 수 있는 곳을 빠른 순으로 살펴보세요.",
hint_od_optional: "선택 사항: 출발지, 도착지 또는 둘 다 입력하세요.",
radius_hint: "검색을 인근 역까지 넓혀 더 많은 무료 좌석을 찾습니다.",
act_swap: "출발지와 도착지 바꾸기",
@@ -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..9641b8b 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.",
@@ -87,7 +89,7 @@ export const nl: Dict = {
desc_simple: "Vind een exacte reis, of blader door alle bestemmingen vanaf een station (of alle vertrekpunten naar één).",
desc_multi_plan: "Noem de te bezoeken steden: we ordenen en dateren ze tot een haalbare rondreis.",
desc_multi_legs: "Stel elk traject zelf samen, station voor station en dag voor dag.",
- desc_ideas: "Ontdek waar je vanaf een station heen kunt, gerangschikt op aantal gratis treinen.",
+ desc_ideas: "Ontdek waar je vanaf een station heen kunt, snelste eerst.",
hint_od_optional: "Optioneel: vul vertrek, aankomst, of beide in.",
radius_hint: "Verbreedt de zoekopdracht naar nabijgelegen stations om meer gratis plaatsen te vinden.",
act_swap: "Vertrek en aankomst omwisselen",
@@ -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..85eb61e 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í.",
@@ -87,7 +89,7 @@ export const pt: Dict = {
desc_simple: "Encontre um trajeto exato, ou explore todos os destinos a partir de uma estação (ou todas as origens para uma).",
desc_multi_plan: "Liste as cidades a visitar: ordenamo-las e datamo-las num circuito viável.",
desc_multi_legs: "Componha cada trajeto à mão, estação a estação e data a data.",
- desc_ideas: "Descubra para onde ir a partir de uma estação, ordenado pelo número de comboios gratuitos.",
+ desc_ideas: "Descubra para onde ir a partir de uma estação, os mais rápidos primeiro.",
hint_od_optional: "Opcional: preencha a partida, a chegada, ou ambas.",
radius_hint: "Alarga a pesquisa a estações próximas para encontrar mais lugares gratuitos.",
act_swap: "Trocar partida e chegada",
@@ -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..af25314 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: "可到达你目的地附近车站的额外出发地——从那里再付一小段车程。",
@@ -87,7 +89,7 @@ export const zh: Dict = {
desc_simple: "查找精确行程,或浏览从某车站出发的所有目的地(或前往某车站的所有出发地)。",
desc_multi_plan: "列出要游览的城市:我们把它们排序并排期成可行的环游。",
desc_multi_legs: "逐站、逐日手动编排每一段行程。",
- desc_ideas: "发现从某车站可以去哪里,按免费列车数量排序。",
+ desc_ideas: "发现从某车站可以去哪里,最快的排在前面。",
hint_od_optional: "可选:填写出发地、到达地,或两者都填。",
radius_hint: "将搜索扩展到邻近车站,以发现更多免费座位。",
act_swap: "交换出发地和到达地",
@@ -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..dfc6188
--- /dev/null
+++ b/tests/lists-links.test.ts
@@ -0,0 +1,288 @@
+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);
+ });
+
+ it("a Senior calendar never marks a weekend bookable", () => {
+ const cell = (root: HTMLElement, date: string) => root.querySelector(`.results .cal-cell[data-date="${date}"]`)!;
+ const senior = setup(`${od}&card=senior`, lyon);
+ expect(cell(senior, "2026-06-26").classList.contains("ok")).toBe(true);
+ expect(cell(senior, "2026-06-27").classList.contains("ok")).toBe(false);
+ const jeune = setup(`${od}&card=jeune`, lyon);
+ expect(cell(jeune, "2026-06-27").classList.contains("ok")).toBe(true);
+ });
+});
+
+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("offers no Recommended sort that repeats Fastest, and shows the durations it ranks by", () => {
+ const root = setup(ideas, trains);
+ const sort = root.querySelector(".sort-select")!;
+ expect([...sort.options].map((o) => o.value)).not.toContain("rec");
+ expect(sort.value).toBe("fastest");
+ 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();
+ });
+});
From 0c44373764614989c8c2b61a5ff6754e689157cb Mon Sep 17 00:00:00 2001
From: davd-gzl <60177543+davd-gzl@users.noreply.github.com>
Date: Tue, 29 Sep 2026 16:30:15 +0900
Subject: [PATCH 2/3] Lists: keep the Recommended sort and Senior weekend days
as on main
Ideas keeps Recommended as its default sort and its description in
every locale; rows still show the duration, and the days or distance
under their sorts. MAX SENIOR calendars no longer grey weekends. A link
without card= still keeps the saved card.
---
docs/user-flows.md | 6 +++---
src/app.ts | 27 ++++++++++-----------------
src/i18n/ar.ts | 2 +-
src/i18n/de.ts | 2 +-
src/i18n/en.ts | 2 +-
src/i18n/es.ts | 2 +-
src/i18n/fr.ts | 2 +-
src/i18n/it.ts | 2 +-
src/i18n/ja.ts | 2 +-
src/i18n/ko.ts | 2 +-
src/i18n/nl.ts | 2 +-
src/i18n/pt.ts | 2 +-
src/i18n/zh.ts | 2 +-
tests/lists-links.test.ts | 15 +++------------
14 files changed, 27 insertions(+), 43 deletions(-)
diff --git a/docs/user-flows.md b/docs/user-flows.md
index db09608..62578e9 100644
--- a/docs/user-flows.md
+++ b/docs/user-flows.md
@@ -171,7 +171,8 @@ span applied to the lists only, and a day could read green while its list was em
## Ideas tab (best)
Every free-MAX destination from the origin across the booking window (there is no one-day
-view), fastest first; the sort picker also ranks by most trains, most days, closest or A–Z.
+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.
@@ -184,8 +185,7 @@ the destination is reachable, with its calendar open.
- **Saved & Favorites** — star a route / save a trip, from the header menu. (The two overlap
— a known cleanup item.)
- **Settings** — theme, MAX Jeune/Senior, comfortable/compact, and Low-end mode (map off +
- reduced motion + compact) with a one-time nudge on weak devices; language. MAX SENIOR is
- weekday-only, so every calendar greys its weekends (a weekend list keeps its notice).
+ reduced motion + compact) with a one-time nudge on weak devices; language.
- **Mobile** — the form is a sheet that collapses to a search bar; results are a bottom-sheet
drawer with detents. Back navigation preserves form state and never lands on a dead screen.
- **History model** — a genuine navigation (Search, drilling into a route, opening the saved
diff --git a/src/app.ts b/src/app.ts
index 7f4aa90..a24a15c 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -813,12 +813,6 @@ function isWeekend(iso: string): boolean {
return day === 0 || day === 6;
}
-/** A calendar as the card sees it: MAX SENIOR is weekday-only, so a weekend is never bookable. */
-function gradeForCard(days: CalendarDay[], card: SearchQuery["card"]): CalendarDay[] {
- if (card !== "senior") return days;
- return days.map((d) => (isWeekend(d.date) ? { date: d.date, available: false, count: 0 } : d));
-}
-
// Wikivoyage language editions that exist; others (e.g. ko) fall back to English.
const WIKIVOYAGE_LANGS = new Set(["fr", "en", "es", "de", "it", "zh"]);
@@ -1507,7 +1501,7 @@ function repaintFormCalendar(): void {
if (rangeOpt && calOpts) {
calOpts = { ...calOpts, range: rangeOpt, hint: t("form_cal_flex_hint") };
}
- mount.append(render.calendarEl(gradeForCard(cal, fq.card), calCtx, selected, calOpts));
+ mount.append(render.calendarEl(cal, calCtx, selected, calOpts));
}
/**
@@ -2264,7 +2258,7 @@ function runMultiCity(c: RenderCtx): void {
// handy when you left the date blank. Clicking a day sets it and re-runs.
const legCal = availabilityCalendar(trains, leg.from, leg.to, windowDates, opts);
const legCtx: RenderCtx = { ...c, onSelectDay: (d) => setLegDate(i, d) };
- const calEl = journeys.length ? render.calendarEl(gradeForCard(legCal, query.card), legCtx, leg.date) : null;
+ const calEl = journeys.length ? render.calendarEl(legCal, legCtx, leg.date) : null;
if (calEl) sec.append(calEl);
const cards: HTMLElement[] = [];
if (journeys.length === 0) sec.append(render.emptyEl(t("res_none")));
@@ -2392,7 +2386,7 @@ function runBestSearch(c: RenderCtx): void {
// Month-long train count per destination (same figure as the "Where to?" list),
// so an idea shows how well-served it is before you drill in.
const stats = windowStats(trains, query.origin, "from", filterOpts());
- // Fastest first (bestTripsAcrossWindow's own order) unless another key is picked.
+ // Sort by trains / days reachable / distance / name; "rec" keeps fastest-first.
const origin = query.origin;
const sorted = applySort(trips, {
name: (tr) => registry.label(tr.destination),
@@ -2404,8 +2398,8 @@ function runBestSearch(c: RenderCtx): void {
refs.results.append(
render.listToolbarEl(
t("res_destinations", { n: trips.length }),
- query.sort ?? "fastest",
- sortOptions(["fastest", "trains", "days", "closest", "name"]),
+ query.sort ?? "rec",
+ sortOptions(["rec", "trains", "days", "closest", "fastest", "name"]),
onSort,
),
);
@@ -2540,7 +2534,7 @@ function runOdSearch(c: RenderCtx): void {
// 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(gradeForCard(cal, query.card), c, query.date),
+ render.calendarEl(cal, c, query.date),
"cal-collapsible",
openOdCalendar,
);
@@ -2925,7 +2919,7 @@ function runTripSearch(c: RenderCtx): void {
const refocus = retCalHost.contains(document.activeElement);
clear(retCalHost);
retCalHost.append(
- render.calendarEl(gradeForCard(retCal, query.card), retCtx, retDate, {
+ render.calendarEl(retCal, retCtx, retDate, {
title: t("rt_inbound"),
// First cell is same-day (hours on site); every later cell is nights away.
count: (n, day) => (day.date === query.date ? t("daytrip_cal_hours", { h: n }) : t("getaway_nights", { n })),
@@ -3022,7 +3016,7 @@ function runTripSearch(c: RenderCtx): void {
refreshInPlace();
};
const outCalCtx: RenderCtx = { ...c, onSelectDay: onOutboundDay };
- const outCalEl = render.calendarEl(gradeForCard(outCal, query.card), outCalCtx, query.date, {
+ const outCalEl = render.calendarEl(outCal, outCalCtx, query.date, {
title: t("getaway_cal_title"),
count: (n) => (isSameDayTrip ? t("daytrip_cal_hours", { h: n }) : t("getaway_nights", { n })),
countLegend: isSameDayTrip ? t("cal_legend_hours") : t("cal_legend_nights"),
@@ -3205,7 +3199,7 @@ function actionEl(text: string, onClick: () => void): HTMLElement {
/** 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 = gradeForCard(cal, query.card).filter((d) => d.available && d.date !== query.date);
+ const open = cal.filter((d) => d.available && d.date !== query.date);
return (open.find((d) => d.date > query.date) ?? open[0])?.date;
}
@@ -3777,7 +3771,7 @@ function buildLayout(root: HTMLElement): void {
// connection-aware) instead of the old direct-only undercount.
cal = reachableCountCalendar(deps.trains, o, dates, opts, "from");
}
- if (cal) for (const c of gradeForCard(cal, query.card)) map.set(c.date, c.available ? c.count : 0);
+ if (cal) for (const c of cal) map.set(c.date, c.available ? c.count : 0);
return map;
},
onSwitchTab: switchTab,
@@ -3812,7 +3806,6 @@ function buildLayout(root: HTMLElement): void {
query = { ...query, card };
store.updateUrl(query);
runSearch();
- repaintFormCalendar(); // a Senior calendar greys its weekends
},
onShare: (onCopied) => void shareCurrentUrl(onCopied),
onInstall: () => void promptInstall(),
diff --git a/src/i18n/ar.ts b/src/i18n/ar.ts
index 6aa02ea..3bb32fb 100644
--- a/src/i18n/ar.ts
+++ b/src/i18n/ar.ts
@@ -89,7 +89,7 @@ export const ar: Dict = {
desc_simple: "اعثر على رحلة محددة، أو تصفّح كل الوجهات انطلاقًا من محطة (أو كل نقاط الانطلاق نحو محطة).",
desc_multi_plan: "اذكر المدن التي تريد زيارتها: نرتّبها ونحدّد تواريخها في جولة قابلة للتنفيذ.",
desc_multi_legs: "كوّن كل رحلة يدويًا، محطة بمحطة وتاريخًا بتاريخ.",
- desc_ideas: "اكتشف إلى أين يمكنك الذهاب من محطة، الأسرع أولًا.",
+ desc_ideas: "اكتشف إلى أين يمكنك الذهاب من محطة، مرتّبًا حسب عدد القطارات المجانية.",
hint_od_optional: "اختياري: املأ المغادرة أو الوصول أو كليهما.",
radius_hint: "يوسّع البحث ليشمل المحطات القريبة لإيجاد مزيد من المقاعد المجانية.",
act_swap: "تبديل المغادرة والوصول",
diff --git a/src/i18n/de.ts b/src/i18n/de.ts
index caeb4d2..04313b1 100644
--- a/src/i18n/de.ts
+++ b/src/i18n/de.ts
@@ -89,7 +89,7 @@ export const de: Dict = {
desc_simple: "Finde eine genaue Verbindung oder durchstöbere alle Ziele ab einem Bahnhof (oder alle Startorte zu einem).",
desc_multi_plan: "Liste die Städte auf: Wir ordnen und datieren sie zu einer machbaren Rundreise.",
desc_multi_legs: "Stelle jede Etappe selbst zusammen, Bahnhof für Bahnhof und Tag für Tag.",
- desc_ideas: "Entdecke, wohin du ab einem Bahnhof fahren kannst — die schnellsten zuerst.",
+ desc_ideas: "Entdecke, wohin du ab einem Bahnhof fahren kannst — sortiert nach Anzahl freier Züge.",
hint_od_optional: "Optional: Fülle Abfahrt, Ankunft oder beides aus.",
radius_hint: "Erweitert die Suche auf nahe Bahnhöfe, um mehr freie Plätze zu finden.",
act_swap: "Abfahrt und Ankunft tauschen",
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index e2232ae..0d3c49e 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -93,7 +93,7 @@ export const en: Dict = {
desc_simple: "Find an exact trip, or browse every destination from a station (or every origin to one).",
desc_multi_plan: "List the cities to visit: we order and date them into a feasible tour.",
desc_multi_legs: "Build each leg by hand, station by station and date by date.",
- desc_ideas: "Discover where to go from a station, fastest first.",
+ desc_ideas: "Discover where to go from a station, ranked by how many free trains run.",
hint_od_optional: "Optional: fill the départ, the arrivée, or both.",
radius_hint: "Widen the search to nearby stations to surface more free seats.",
act_swap: "Swap départ and arrivée",
diff --git a/src/i18n/es.ts b/src/i18n/es.ts
index cb1c769..3d77426 100644
--- a/src/i18n/es.ts
+++ b/src/i18n/es.ts
@@ -89,7 +89,7 @@ export const es: Dict = {
desc_simple: "Encuentra un trayecto exacto, o explora todos los destinos desde una estación (o todos los orígenes hacia una).",
desc_multi_plan: "Enumera las ciudades a visitar: las ordenamos y fechamos en un circuito viable.",
desc_multi_legs: "Compón cada trayecto a mano, estación por estación y fecha por fecha.",
- desc_ideas: "Descubre adónde ir desde una estación, los más rápidos primero.",
+ desc_ideas: "Descubre adónde ir desde una estación, ordenado por cuántos trenes gratuitos hay.",
hint_od_optional: "Opcional: rellena la salida, la llegada, o ambas.",
radius_hint: "Amplía la búsqueda a estaciones cercanas para encontrar más plazas gratuitas.",
act_swap: "Intercambiar salida y llegada",
diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts
index 101e39f..7768b0c 100644
--- a/src/i18n/fr.ts
+++ b/src/i18n/fr.ts
@@ -91,7 +91,7 @@ export const fr = {
desc_simple: "Trouvez un trajet précis, ou parcourez toutes les destinations au départ d'une gare (ou toutes les origines vers une gare).",
desc_multi_plan: "Listez les villes à visiter : on les ordonne et les date en un périple faisable.",
desc_multi_legs: "Composez chaque trajet à la main, gare par gare et date par date.",
- desc_ideas: "Découvrez où partir depuis une gare, les plus rapides d'abord.",
+ desc_ideas: "Découvrez où partir depuis une gare, classé par nombre de trains gratuits.",
hint_od_optional: "Optionnel : remplissez le départ, l'arrivée, ou les deux.",
radius_hint: "Élargit la recherche aux gares voisines pour trouver plus de places gratuites.",
act_swap: "Intervertir départ et arrivée",
diff --git a/src/i18n/it.ts b/src/i18n/it.ts
index 7185431..0f6fe99 100644
--- a/src/i18n/it.ts
+++ b/src/i18n/it.ts
@@ -89,7 +89,7 @@ export const it: Dict = {
desc_simple: "Trova un viaggio preciso, o esplora tutte le destinazioni da una stazione (o tutte le origini verso una).",
desc_multi_plan: "Elenca le città da visitare: le ordiniamo e le datiamo in un tour fattibile.",
desc_multi_legs: "Componi ogni tratta a mano, stazione per stazione e data per data.",
- desc_ideas: "Scopri dove andare da una stazione, prima i più veloci.",
+ desc_ideas: "Scopri dove andare da una stazione, ordinato per numero di treni gratuiti.",
hint_od_optional: "Facoltativo: compila la partenza, l'arrivo, o entrambi.",
radius_hint: "Allarga la ricerca alle stazioni vicine per trovare più posti gratuiti.",
act_swap: "Inverti partenza e arrivo",
diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts
index d0dbcd9..d28318b 100644
--- a/src/i18n/ja.ts
+++ b/src/i18n/ja.ts
@@ -89,7 +89,7 @@ export const ja: Dict = {
desc_simple: "正確な経路を探すか、ある駅からの全目的地(またはある駅への全出発地)を見て回れます。",
desc_multi_plan: "訪れる都市を並べると、実行可能な周遊として順番と日付を決めます。",
desc_multi_legs: "駅ごと・日付ごとに各区間を手動で組み立てます。",
- desc_ideas: "ある駅から行ける場所を、速い順に見つけます。",
+ desc_ideas: "ある駅から行ける場所を、無料列車の本数順で見つけます。",
hint_od_optional: "任意:出発地、到着地、またはその両方を入力してください。",
radius_hint: "検索を近くの駅まで広げ、より多くの無料席を見つけます。",
act_swap: "出発地と到着地を入れ替える",
diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts
index 5292123..b71b0d5 100644
--- a/src/i18n/ko.ts
+++ b/src/i18n/ko.ts
@@ -89,7 +89,7 @@ export const ko: Dict = {
desc_simple: "정확한 여정을 찾거나, 한 역에서 갈 수 있는 모든 목적지(또는 한 역으로 오는 모든 출발지)를 둘러보세요.",
desc_multi_plan: "방문할 도시를 나열하면 실행 가능한 여정으로 순서와 날짜를 정해 드립니다.",
desc_multi_legs: "역과 날짜를 하나씩 직접 지정해 각 구간을 구성하세요.",
- desc_ideas: "한 역에서 갈 수 있는 곳을 빠른 순으로 살펴보세요.",
+ desc_ideas: "한 역에서 갈 수 있는 곳을, 무료 열차 수 기준으로 정렬해 살펴보세요.",
hint_od_optional: "선택 사항: 출발지, 도착지 또는 둘 다 입력하세요.",
radius_hint: "검색을 인근 역까지 넓혀 더 많은 무료 좌석을 찾습니다.",
act_swap: "출발지와 도착지 바꾸기",
diff --git a/src/i18n/nl.ts b/src/i18n/nl.ts
index 9641b8b..008b444 100644
--- a/src/i18n/nl.ts
+++ b/src/i18n/nl.ts
@@ -89,7 +89,7 @@ export const nl: Dict = {
desc_simple: "Vind een exacte reis, of blader door alle bestemmingen vanaf een station (of alle vertrekpunten naar één).",
desc_multi_plan: "Noem de te bezoeken steden: we ordenen en dateren ze tot een haalbare rondreis.",
desc_multi_legs: "Stel elk traject zelf samen, station voor station en dag voor dag.",
- desc_ideas: "Ontdek waar je vanaf een station heen kunt, snelste eerst.",
+ desc_ideas: "Ontdek waar je vanaf een station heen kunt, gerangschikt op aantal gratis treinen.",
hint_od_optional: "Optioneel: vul vertrek, aankomst, of beide in.",
radius_hint: "Verbreedt de zoekopdracht naar nabijgelegen stations om meer gratis plaatsen te vinden.",
act_swap: "Vertrek en aankomst omwisselen",
diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts
index 85eb61e..ce4c382 100644
--- a/src/i18n/pt.ts
+++ b/src/i18n/pt.ts
@@ -89,7 +89,7 @@ export const pt: Dict = {
desc_simple: "Encontre um trajeto exato, ou explore todos os destinos a partir de uma estação (ou todas as origens para uma).",
desc_multi_plan: "Liste as cidades a visitar: ordenamo-las e datamo-las num circuito viável.",
desc_multi_legs: "Componha cada trajeto à mão, estação a estação e data a data.",
- desc_ideas: "Descubra para onde ir a partir de uma estação, os mais rápidos primeiro.",
+ desc_ideas: "Descubra para onde ir a partir de uma estação, ordenado pelo número de comboios gratuitos.",
hint_od_optional: "Opcional: preencha a partida, a chegada, ou ambas.",
radius_hint: "Alarga a pesquisa a estações próximas para encontrar mais lugares gratuitos.",
act_swap: "Trocar partida e chegada",
diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts
index af25314..ee2ea1b 100644
--- a/src/i18n/zh.ts
+++ b/src/i18n/zh.ts
@@ -89,7 +89,7 @@ export const zh: Dict = {
desc_simple: "查找精确行程,或浏览从某车站出发的所有目的地(或前往某车站的所有出发地)。",
desc_multi_plan: "列出要游览的城市:我们把它们排序并排期成可行的环游。",
desc_multi_legs: "逐站、逐日手动编排每一段行程。",
- desc_ideas: "发现从某车站可以去哪里,最快的排在前面。",
+ desc_ideas: "发现从某车站可以去哪里,按免费列车数量排序。",
hint_od_optional: "可选:填写出发地、到达地,或两者都填。",
radius_hint: "将搜索扩展到邻近车站,以发现更多免费座位。",
act_swap: "交换出发地和到达地",
diff --git a/tests/lists-links.test.ts b/tests/lists-links.test.ts
index dfc6188..e610d40 100644
--- a/tests/lists-links.test.ts
+++ b/tests/lists-links.test.ts
@@ -118,15 +118,6 @@ describe("the card", () => {
expect(params.get("from")).toBe(P); // the write-back ran
expect(params.has("card")).toBe(false);
});
-
- it("a Senior calendar never marks a weekend bookable", () => {
- const cell = (root: HTMLElement, date: string) => root.querySelector(`.results .cal-cell[data-date="${date}"]`)!;
- const senior = setup(`${od}&card=senior`, lyon);
- expect(cell(senior, "2026-06-26").classList.contains("ok")).toBe(true);
- expect(cell(senior, "2026-06-27").classList.contains("ok")).toBe(false);
- const jeune = setup(`${od}&card=jeune`, lyon);
- expect(cell(jeune, "2026-06-27").classList.contains("ok")).toBe(true);
- });
});
describe("a typed station", () => {
@@ -263,11 +254,11 @@ describe("Ideas", () => {
];
const ideas = `?mode=best&from=${enc(P)}`;
- it("offers no Recommended sort that repeats Fastest, and shows the durations it ranks by", () => {
+ 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)).not.toContain("rec");
- expect(sort.value).toBe("fastest");
+ 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));
});
From 0e0ba3896650f8d94e8ac6e674c803fbcfe1612d Mon Sep 17 00:00:00 2001
From: David <60177543+davd-gzl@users.noreply.github.com>
Date: Sun, 4 Oct 2026 06:59:21 +0200
Subject: [PATCH 3/3] Calendars: green days and counts match the trip they open
(#72)
* Calendars: green days and counts match the trip they open
A fixed 2 or 3 night stay graded days by any stay up to that length, so
green days led to an empty return. The stay calendar now keeps the exact
length unless the stay is flexible.
Reverse same-day discovery now pairs the earliest arrival with the
latest return home, as the forward search does, so no origin is lost
and the hours on site match the trip. A train listed at two stations of
one group keeps its shortest row.
Browse and Ideas count trains from today only, and a card's time is the
chosen day's. Discovery cards and trip rows show the exact time on site,
and a stay's first return is the one the card counts.
The form calendar follows days picked under the results. In Flexible,
every range tap runs at once, so the header, date pill, URL and results
agree; days before the departure are not offered as a return, the inert
stepper reads the range, and the same-day minimum stays in place.
* Calendars: keep main's return order and two-tap Flexible pick
A stay's return list is fastest first again, and its first stays the
default return. A stay's discovery card now counts that return, so its
travel time matches the trip it opens.
The first Flexible range tap stages the departure on the form again and
runs nothing. It no longer rewrites the query, so the URL and the results
keep the range on screen while the form header reads the departure as
pending. The return tap runs the range.
* History: Back and Forward land on the page the user left (#73)
* History: Back and Forward land on the page the user left
Each history entry now restores its own form and tab. The bare landing
URL never shows results; Back to it restores the form as it was left
and asks for Search, keeping a build made on the entry a refinement
pushed off it. The saved-trips page has its own restorable entry, and
Back restores the list's scroll after a drill-in.
An open dialog owns one history entry: Back closes it, and closing it
by its button pops that entry. "See all dates" opens both calendars
in place, an empty Search adds no entry and shows a hint, the tab
shortcuts no longer pull focus into an empty field, and a favorite
opened from a tour page drops the tour's cities from the URL.
* Mobile layout: no text is cut, low-end mode skips the map, saved trips open from the menu (#74)
Via chips, the phone search bar, tour routes, leg names, airport names
and the date pill wrap in full instead of ending in an ellipsis; the
search bar takes two balanced lines and the map fit reserves its
height.
The prerendered page no longer carries the live map, so low-end mode
downloads no map code and no tile. Fonts ship as files, which clears
the 8 CSP errors every load logged.
The phone header menu opens the saved page, favorites included, from
the search form. The drawer measures its detents below the search bar,
so the first half detent matches later ones and the full one stops
under the bar; landing runs no view transition. Controls use logical
sides in Arabic, and the dark theme sets color-scheme to dark.
npm run verify now fails on CSP errors, map requests in low-end mode
and cut text at 360, 390 and 1100px.
---
docs/user-flows.md | 84 +++++--
scripts/e2e.mjs | 179 +++++++++++++++
scripts/prerender.mjs | 5 +
scripts/verify-render.mjs | 124 +++++++++-
src/app.ts | 429 ++++++++++++++++++++---------------
src/core/connections.ts | 52 +++--
src/core/destinations.ts | 32 +--
src/core/getaways.ts | 42 ++--
src/i18n/ar.ts | 4 +-
src/i18n/de.ts | 4 +-
src/i18n/en.ts | 4 +-
src/i18n/es.ts | 4 +-
src/i18n/fr.ts | 4 +-
src/i18n/it.ts | 4 +-
src/i18n/ja.ts | 4 +-
src/i18n/ko.ts | 4 +-
src/i18n/nl.ts | 4 +-
src/i18n/pt.ts | 4 +-
src/i18n/zh.ts | 4 +-
src/styles.css | 104 ++++-----
src/ui/form.ts | 7 +-
src/ui/icons.ts | 3 +
src/ui/map.ts | 5 +-
src/ui/modals.ts | 73 +++++-
src/ui/render.ts | 2 -
src/ui/shell.ts | 41 ++--
tests/calendar-truth.test.ts | 284 +++++++++++++++++++++++
tests/history.test.ts | 81 +++++++
vite.config.ts | 2 +
29 files changed, 1232 insertions(+), 361 deletions(-)
create mode 100644 tests/calendar-truth.test.ts
create mode 100644 tests/history.test.ts
diff --git a/docs/user-flows.md b/docs/user-flows.md
index 62578e9..7c7a9c5 100644
--- a/docs/user-flows.md
+++ b/docs/user-flows.md
@@ -34,7 +34,9 @@ round trip out of fixed-nights mode so you pick the **exact departure and return
Trip-tab calendar** (Ulysse-style). While Flexible is active the fixed-nights stepper stays
**in place but inert (dimmed, buttons disabled)** rather than being removed — so toggling
Flexible never moves the "Durée sur place" label or reflows the row (no layout jump); only
-the pill lights up. The form's stay becomes `flexible`. Tapping the stepper or a segment leaves
+the pill lights up. The inert stepper reads the nights of the range on screen, and the
+same-day "Temps minimum sur place" field likewise stays in place, inert. Switching to
+Flexible keeps the current stay as the range. The form's stay becomes `flexible`. Tapping the stepper or a segment leaves
Flexible again. The stepper (and the pill) are hidden for one-way. `r` toggles one-way ↔
round trip (keeping the nights count, never Flexible); `1/2/3` switch tabs. Toggling,
stepping, or picking Flexible re-runs in place (no second Search tap when origin +
@@ -47,8 +49,10 @@ return date on `query.returnDate` (URL `stay=flex` + `rdate`).
The **Trip tab's date picker is a live availability calendar on the form itself**
(`repaintFormCalendar` in `src/app.ts`, painted into `refs.formCalendar` via
`render.calendarEl`). It sits under the date + trip-shape row behind a one-tap
-**"When to leave?"** header (which also shows the picked departure) and is **collapsed by
-default** so the form stays short on a phone — one tap opens the month to change the day.
+**"When to leave?"** header (which also shows the picked departure, on its own line so the
+header keeps one height in every trip shape) and is **collapsed by default** so the form
+stays short on a phone — one tap opens the month to change the day. A day picked under the
+results (either leg's calendar) moves it too.
The header names the calendar once (the in-body `