diff --git a/docs/user-flows.md b/docs/user-flows.md
index df8e0cb..7c7a9c5 100644
--- a/docs/user-flows.md
+++ b/docs/user-flows.md
@@ -149,8 +149,9 @@ span applied to the lists only, and a day could read green while its list was em
- **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)
- — plus Save the whole trip. Back inside the accordion re-opens the outbound before it
- exits the flow (step-wise back).
+ — plus Save the whole trip, and "See all dates", which closes the modal and opens both
+ legs and both calendars in place. Back inside the accordion re-opens the outbound before
+ it 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.
The list reads direct cards, then connection-only ("via") rows, then the radius "Stations
@@ -196,18 +197,40 @@ the destination is reachable, with its calendar open.
- **Map** — full-bleed behind a results drawer on mobile, side panel on desktop. Markers per
destination, hover/selection synced with the list; route line for exact trips; auto-fits
above the drawer on mobile.
-- **Saved & Favorites** — star a route / save a trip, from the header menu. (The two overlap
- — a known cleanup item.)
+- **Saved & Favorites** — star a route / save a trip. Both cards sit in the results column;
+ on a phone, where the search form hides that column, the header menu's "Saved trips &
+ favorites" entry opens the saved page (every saved trip, the favorites card below it)
+ from any screen. (The two overlap — a known cleanup item.) A saved trip opens its ticket
+ modal, whose "See all dates" opens the route page; a favorite prefills the Trip tab with
+ that route alone.
- **Settings** — theme, MAX Jeune/Senior, comfortable/compact, and Low-end mode (map off +
reduced motion + compact) with a one-time nudge on weak devices; language.
-- **Mobile** — the form is a sheet that collapses to a search bar; 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
- page) pushes **one** history entry carrying a form snapshot, so browser Back returns to the
- prior page with the form intact. Refining the current view — the Aller simple/retour toggle,
- the nights stepper, the Flexible pill, picking a calendar day — updates **in place**
- (`replaceState`), never pushing a new entry. So repeated toggling can't pile up duplicate
- entries (the old bug where Back needed ~10 presses and the form appeared wiped).
+- **Mobile** — the form is a sheet that collapses to a search bar (a long route wraps it to
+ two lines); results are a bottom-sheet drawer with peek / half / full detents measured
+ below that bar, so the full sheet never covers it. Back navigation preserves form state
+ and never lands on a dead screen.
+- **History model** — a genuine navigation (Search, a tab switch, drilling into a route,
+ opening the saved page) pushes **one** history entry carrying a snapshot of the form, tab
+ included, so browser Back and Forward return to each page with **its own** form intact,
+ never the latest one. Refining the current view — the Aller simple/retour toggle, the nights
+ stepper, the Flexible pill, picking a calendar day — updates **in place** (`replaceState`),
+ never pushing a new entry. So repeated toggling can't pile up duplicate entries (the old bug
+ where Back needed ~10 presses and the form appeared wiped). Details of the model:
+ - The **bare landing URL never shows results**: Back to it restores the form as it was left
+ and shows "press Search". A refinement made on the landing form still pushes one entry
+ (so there is a form to Back to), and since the user never left the form, Back to the
+ landing keeps what was built on that entry (a Flexible range included).
+ - The **saved-trips page** is its own entry: Forward onto it shows it again.
+ - Leaving a list for a route stamps the list's **scroll position**; Back restores it (the
+ drawer on a phone, the main column on a desktop).
+ - An open **dialog** owns one entry: Back closes it and leaves the page under it as it
+ was. Closing it with its own button, Escape or the backdrop pops that entry, so no stray
+ Back step is left behind.
+ - A **Search with nothing to search** (no station) adds no entry: a hint under the button
+ says what is missing.
+ - The `1/2/3` shortcuts never move focus into a field, so they keep working after landing
+ on an empty tab: an empty field gets a ready cursor only while nothing else holds focus,
+ as on the first load.
- **Deep links** — every search is a shareable URL; legacy `?rdate=` / `?rt=` links still work.
A station may be named in any case or accent (`from=paris`, `to=LILLE`) and resolves to
the station that has trains; a name matching no station shows "Unknown station" instead
@@ -223,4 +246,3 @@ the destination is reachable, with its calendar open.
- Collapse the two save systems (favorite star + Save bookmark) into one.
- One `openRoute()` primitive (list cards / favorites / map pins behave consistently).
- One home for the availability calendar (form popover vs results).
-- Mobile browser-Back should close detail pages via history.
diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs
index 4cb0e3f..a4662f8 100644
--- a/scripts/e2e.mjs
+++ b/scripts/e2e.mjs
@@ -557,6 +557,185 @@ await scenario(
{ viewport: { width: 390, height: 844, isMobile: true, hasTouch: true } },
);
+const MOBILE = { width: 390, height: 844, isMobile: true, hasTouch: true };
+const urlParam = (page, key) => new URL(page.url()).searchParams.get(key);
+const fields = (page) => page.$$eval(".search-form .od-fields input", (els) => els.map((e) => e.value).join("|"));
+async function fillOd(page, from, to) {
+ await page.evaluate(
+ (vals) => {
+ const inputs = document.querySelectorAll(".search-form .od-fields input");
+ vals.forEach((v, i) => {
+ inputs[i].value = v;
+ inputs[i].dispatchEvent(new Event("input", { bubbles: true }));
+ inputs[i].dispatchEvent(new Event("change", { bubbles: true }));
+ });
+ },
+ [from, to],
+ );
+}
+const clickSearch = (page) => page.click(".search-form .form-actions button.btn-primary");
+
+// 16. Regression: each history entry restores its OWN form and tab. Search Paris → Lyon from
+// the landing, open Ideas, Back twice: the landing comes back on the Trip tab with Paris →
+// Lyon filled (not the Ideas form built after it), and its bare URL shows no results.
+await scenario(
+ "history: Back to the landing restores its own form and tab, with no results under it",
+ BASE,
+ async (page) => {
+ await fillOd(page, P, L);
+ await clickSearch(page);
+ assert(await until(async () => (await $count(page, "article.journey")) > 0), "precondition: no Paris → Lyon results");
+ await page.click('.mode-tab[data-trip="ideas"]');
+ assert(await until(async () => urlParam(page, "mode") === "best"), "precondition: Ideas did not open");
+ await page.goBack();
+ await page.goBack();
+ assert(await until(async () => !new URL(page.url()).search), `two Backs did not reach the landing (${page.url()})`);
+ const landed = await until(async () =>
+ (await activeTrip(page)) === "simple" && (await $count(page, ".results .empty")) > 0 ? true : null,
+ );
+ assert(landed, `landing shows tab "${await activeTrip(page)}", results "${(await $text(page, ".results"))?.slice(0, 60)}"`);
+ const filled = await fields(page);
+ assert(/paris/i.test(filled) && /lyon/i.test(filled), `landing form is not its own (got "${filled}")`);
+ const rows = await $count(page, ".results .group-card, .results article.journey");
+ assert(rows === 0, `the bare landing URL shows ${rows} result rows`);
+ },
+);
+
+// 17. Regression: the tab follows the entry, not the last form. Legs link → Ideas (no origin)
+// → Multi-city → Back must land on the Ideas tab its URL names, without the legs results.
+await scenario(
+ "history: Back to an Ideas entry shows the Ideas tab, not the Multi-city built after it",
+ `${BASE}?mode=tour&legs=${enc(`${P}>${L}@${DATE}`)}&date=${DATE}`,
+ async (page) => {
+ assert(await until(async () => (await $count(page, ".mc-result")) > 0), "precondition: legs did not render");
+ await page.click('.mode-tab[data-trip="ideas"]');
+ assert(await until(async () => urlParam(page, "mode") === "best"), "precondition: Ideas did not open");
+ await page.click('.mode-tab[data-trip="multi"]');
+ assert(await until(async () => urlParam(page, "mode") === "tour"), "precondition: Multi-city did not open");
+ await page.goBack();
+ const back = await until(async () =>
+ urlParam(page, "mode") === "best" && (await activeTrip(page)) === "ideas" && (await $count(page, ".mc-result")) === 0,
+ );
+ assert(back, `Back to ?mode=best shows tab "${await activeTrip(page)}" with ${await $count(page, ".mc-result")} legs`);
+ },
+);
+
+// 18. Regression: the saved-trips page is its own history entry. See all → Back → Forward
+// must land on the saved page again, not on the list under it.
+await scenario(
+ "history: Forward returns to the saved-trips page",
+ `${BASE}?mode=od&from=${enc(P)}&to=${enc(L)}&date=${RT_DATE}`,
+ async (page) => {
+ assert(await until(async () => (await $count(page, "article.journey button[aria-pressed]")) > 0), "precondition: no train to save");
+ await page.click("article.journey button[aria-pressed]");
+ await page.click(".saved-see-all");
+ assert(await until(async () => (await $count(page, ".saved-page-card")) > 0), "the saved-trips page did not open");
+ await page.goBack();
+ assert(await until(async () => (await $count(page, "article.journey")) > 0), "Back did not return to the list");
+ await page.goForward();
+ assert(
+ await until(async () => (await $count(page, ".saved-page-card")) > 0 && (await $count(page, "article.journey")) === 0),
+ `Forward shows "${await $text(page, "#results-title")}" instead of the saved-trips page`,
+ );
+ },
+);
+
+// 19. Regression: a dialog owns one history entry. At 390px, Back with a dialog open closes
+// it and stays on the results; a dialog closed by its own button leaves no stray entry,
+// so the next Back reaches the form.
+await scenario(
+ "history: Back closes an open dialog first; a dialog closed by its button leaves no entry",
+ BASE,
+ async (page) => {
+ await fillOd(page, P, L);
+ await clickSearch(page);
+ assert(await until(async () => (await $count(page, "article.journey")) > 0), "precondition: no Paris → Lyon results");
+ const openDialog = async () => {
+ await page.keyboard.press("?");
+ assert(await until(async () => (await $count(page, "dialog[open]")) === 1), "the help dialog did not open");
+ };
+ await openDialog();
+ await page.goBack();
+ assert(await until(async () => (await $count(page, "dialog[open]")) === 0), "Back left the dialog open");
+ assert(urlParam(page, "from"), "Back with a dialog open left the results page");
+ assert((await $count(page, "article.journey")) > 0, "Back with a dialog open re-rendered the page empty");
+ await openDialog();
+ await page.click("dialog[open] .modal-close");
+ assert(await until(async () => (await $count(page, "dialog[open]")) === 0), "the Close button did not close the dialog");
+ await page.goBack();
+ assert(await until(async () => !urlParam(page, "from")), `one Back after closing did not reach the form (${page.url()})`);
+ },
+ { viewport: MOBILE },
+);
+
+// 20. Regression: Back from a drilled-in route puts the list back where it was scrolled, in
+// the drawer at 390px and in the main column at 1366px.
+for (const [viewport, label] of [[MOBILE, "390px drawer"], [undefined, "1366px column"]]) {
+ await scenario(
+ `history: Back from a route restores the list's scroll (${label})`,
+ `${BASE}?mode=from&from=${enc(P)}&date=${DATE}`,
+ async (page) => {
+ assert(await until(async () => (await $count(page, ".results .group-card")) > 30), "precondition: short list");
+ const scrollTop = () =>
+ page.evaluate(() => {
+ const el = [".drawer-scroll", ".main-col"]
+ .map((s) => document.querySelector(s))
+ .find((e) => e && /auto|scroll/.test(getComputedStyle(e).overflowY));
+ return (el ?? document.scrollingElement).scrollTop;
+ });
+ await page.evaluate(() => document.querySelectorAll(".results .group-card")[25].scrollIntoView());
+ const before = await scrollTop();
+ assert(before > 500, `precondition: the list did not scroll (${before}px)`);
+ await page.evaluate(() => {
+ const card = document.querySelectorAll(".results .group-card")[25];
+ (card.querySelector(".dest-main") || card).click();
+ });
+ assert(await until(async () => (await $count(page, ".back-btn")) > 0), "the route did not open");
+ await page.goBack();
+ const restored = await until(async () => Math.abs((await scrollTop()) - before) <= 2);
+ assert(restored, `Back put the list at ${await scrollTop()}px, it was at ${before}px`);
+ },
+ viewport && { viewport },
+ );
+}
+
+// 21. Regression: "See all dates" in the trip dialog shows the dates in place — both
+// calendars open, same page — instead of re-running the route as a new detail page.
+await scenario(
+ "trip dialog: 'See all dates' opens the calendars in place, with no new page",
+ `${BASE}?mode=od&from=${enc(P)}&to=${enc(L)}&date=${RT_DATE}&rdate=${RT_DATE2}`,
+ async (page) => {
+ assert(await until(async () => page.$eval(".rt-view-ticket", (b) => !b.hidden).catch(() => false)), "precondition: no ticket");
+ const url = page.url();
+ await page.click(".rt-view-ticket");
+ assert(await until(async () => (await $count(page, "dialog[open] .trip-more")) === 1), "the trip dialog did not open");
+ await page.click("dialog[open] .trip-more");
+ const opened = await until(async () =>
+ (await $count(page, "dialog[open]")) === 0 && (await $count(page, '.results .cal-toggle[aria-expanded="true"]')) >= 2,
+ );
+ assert(opened, `calendars open: ${await $count(page, '.results .cal-toggle[aria-expanded="true"]')} of 2`);
+ assert(page.url() === url, `the URL changed to ${page.url()}`);
+ assert((await $count(page, ".back-btn")) === 0, "'See all dates' opened a new detail page");
+ },
+);
+
+// 22. Regression: the tab shortcuts keep working after one lands on an empty tab. Landing
+// on the empty Trip tab must not pull focus into its field, where "3" would be typed.
+await scenario(
+ "keyboard: 1/2/3 keep switching tabs after landing on an empty one",
+ `${BASE}?mode=tour&date=${DATE}`,
+ async (page) => {
+ await page.keyboard.press("1");
+ const settled = await until(async () => (await activeTrip(page)) === "simple" && (await $count(page, ".results .loading")) === 0);
+ assert(settled, "'1' did not open the Trip tab");
+ await page.keyboard.press("3");
+ const ideas = await until(async () => (await activeTrip(page)) === "ideas");
+ assert(ideas, `'3' did not open Ideas; the fields read "${await fields(page)}"`);
+ await page.keyboard.press("?");
+ assert(await until(async () => (await $count(page, "dialog[open]")) === 1), "'?' did not open the shortcuts help");
+ },
+);
+
// 12. PWA manifest is served and parseable, icon reference resolves.
await scenario("pwa: manifest is served and valid JSON", BASE, async (page) => {
const manifestHref = await page.$eval('link[rel="manifest"]', (el) => el.getAttribute("href"));
diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs
index 6f49bb7..244cf6f 100644
--- a/scripts/prerender.mjs
+++ b/scripts/prerender.mjs
@@ -41,6 +41,9 @@ const page = await browser.newPage();
await page.evaluateOnNewDocument(() => {
Object.defineProperty(navigator, "language", { get: () => "fr-FR" });
Object.defineProperty(navigator, "languages", { get: () => ["fr-FR", "fr"] });
+ // Map off: the snapshot must not carry Leaflet's DOM, a tile or the map chunk's
+ // preload links, or every visitor (low-end mode included) downloads them.
+ localStorage.setItem("mj.settings", JSON.stringify({ map: false }));
});
await page.goto(BASE, { waitUntil: "networkidle2", timeout: 60000 });
await page.waitForFunction(
@@ -65,6 +68,7 @@ server.close();
// fallback background); hydration re-adds the pill, correctly placed for the real
// viewport, with no initial animation.
html = html
+ .replace(/(]*?)\s+data-map="off"/, "$1")
.replace(/(]*?)\s+style="[^"]*"/g, "$1")
.replace(/(class="[^"]*?)\s+has-thumb\b/g, "$1")
.replace(/(class="[^"]*?)\s+animate-thumb\b/g, "$1");
@@ -75,6 +79,7 @@ if (!html.includes(' errors.push(e.message));
+ page.on("console", (m) => {
+ if (m.type() === "error" && m.text().includes("Content Security Policy")) errors.push(m.text().slice(0, 160));
+ });
// Ignore cross-origin (map tile) failures — they're expected and harmless.
page.on("requestfailed", (r) => {
const u = r.url();
@@ -76,6 +81,119 @@ for (const { name, url } of pages) {
await page.close();
}
+// Low-end mode must not fetch the map chunk or a tile; with the map on, Leaflet still mounts.
+for (const map of [false, true]) {
+ const page = await browser.newPage();
+ await page.evaluateOnNewDocument((s) => localStorage.setItem("mj.settings", s), JSON.stringify({ map }));
+ const mapRequests = [];
+ page.on("request", (r) => {
+ if (/\/assets\/map-|tile\.openstreetmap/.test(r.url())) mapRequests.push(r.url());
+ });
+ await page.goto(pages[1].url, { waitUntil: "load", timeout: 45000 });
+ await page.waitForSelector(".results article.journey, .results .empty", { timeout: 45000 });
+ if (map) {
+ const mounted = await page.waitForSelector(".leaflet-container", { timeout: 20000 }).catch(() => null);
+ if (!mounted) failures.push("[map on] Leaflet never mounted");
+ } else {
+ await page.waitForNetworkIdle({ idleTime: 500, timeout: 20000 }).catch(() => {});
+ if (mapRequests.length) failures.push(`[map off] fetched map assets: ${mapRequests.join(", ")}`);
+ }
+ console.log(` map ${map ? "on" : "off"}: map requests=${mapRequests.length}`);
+ await page.close();
+}
+
+// Zero truncated text (product rule 2): every visible text node on these screens must fit,
+// never ellipsized, line-clamped, clipped by an overflow:hidden box or pushed off screen.
+const L = (id) => encodeURIComponent(id);
+const CDG = "AEROPORT ROISSY CDG 2 TGV", VAL = "VALENCE TGV AUVERGNE RHONE ALPES", SPC = "ST PIERRE DES CORPS";
+const DATE2 = new Date(Date.now() + 6 * 86_400_000).toISOString().slice(0, 10);
+// The first day the snapshot runs both legs of a connection through `hub`, so connecting
+// cards (via chips, long leg names) render; DATE when the data has none.
+const snapshot = JSON.parse(readFileSync(join(DIST, "data", "tgvmax.json"), "utf-8"));
+const runs = (o, d) =>
+ new Set(snapshot.filter((t) => t.origine === o && t.destination === d && t.date >= DATE).map((t) => t.date));
+const viaDate = (o, hub, d) => [...runs(o, hub)].filter((day) => runs(hub, d).has(day)).sort()[0] ?? DATE;
+const PARIS = "PARIS (intramuros)", LYON = "LYON (intramuros)", BDX = "BORDEAUX ST JEAN", TLS = "TOULOUSE MATABIAU";
+const textPages = [
+ {
+ name: "exact-trip",
+ width: 390,
+ url: `${BASE}?mode=od&from=${P}&to=${T}&date=${viaDate(PARIS, BDX, TLS)}`,
+ must: ".msearch-text",
+ },
+ {
+ name: "long-names",
+ width: 360,
+ url: `${BASE}?mode=od&from=${L(CDG)}&to=${L(VAL)}&date=${viaDate(CDG, LYON, VAL)}`,
+ must: ".msearch-text",
+ },
+ {
+ name: "tour-legs",
+ width: 390,
+ url: `${BASE}?mode=tour&legs=${L(`${SPC}>${VAL}@${DATE}~${VAL}>${CDG}@${DATE2}`)}&date=${DATE}`,
+ must: ".mc-route",
+ },
+ // Desktop too: an airport name beside its ✈ badge was cut here.
+ {
+ name: "browse",
+ width: 1100,
+ url: `${BASE}?mode=from&from=${L(VAL)}&date=${[...runs(VAL, CDG)].sort()[0] ?? DATE}`,
+ must: ".stn-airport",
+ },
+];
+for (const { name, width, url, must } of textPages) {
+ const page = await browser.newPage();
+ await page.setViewport({ width, height: 844, isMobile: width < 861, hasTouch: width < 861 });
+ // Reduced motion: no view transition, so the screen is final once the results render.
+ await page.evaluateOnNewDocument(() => localStorage.setItem("mj.settings", '{"lang":"en","reduceMotion":true}'));
+ await page.goto(url, { waitUntil: "load", timeout: 45000 });
+ await page.waitForFunction(
+ (sel) =>
+ document.querySelector(sel)?.textContent.trim() &&
+ document.querySelector(".results")?.children.length &&
+ !document.querySelector(".results .loading"),
+ { timeout: 45000 },
+ must,
+ ).catch(() => failures.push(`[${name}@${width}] ${must} never rendered`));
+ const cut = await page.evaluate(() => {
+ const out = [];
+ for (const el of document.querySelectorAll("#app *")) {
+ const texts = [...el.childNodes].filter((c) => c.nodeType === 3 && c.textContent.trim());
+ if (!texts.length || el.closest(".sr-only, .leaflet-container, [aria-hidden='true'], select")) continue;
+ const cs = getComputedStyle(el);
+ const box = el.getBoundingClientRect();
+ if (cs.visibility !== "visible" || box.width < 2 || box.height < 2) continue; // hidden or visually hidden
+ const label = `${el.className || el.tagName} "${el.textContent.trim().slice(0, 40)}"`;
+ const overflows = el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1;
+ if ((cs.textOverflow === "ellipsis" || cs.webkitLineClamp !== "none") && overflows) {
+ out.push(`${label} ellipsized`);
+ continue;
+ }
+ const range = document.createRange();
+ range.selectNodeContents(texts[0]);
+ const r = range.getBoundingClientRect();
+ if (r.right > document.documentElement.clientWidth + 1 || r.left < -1) {
+ out.push(`${label} off screen`);
+ continue;
+ }
+ for (let a = el; a && a.id !== "app"; a = a.parentElement) {
+ const o = getComputedStyle(a).overflowX;
+ if (o === "auto" || o === "scroll") break;
+ const ar = a.getBoundingClientRect();
+ if ((o === "hidden" || o === "clip") && (r.right > ar.right + 1 || r.left < ar.left - 1)) {
+ out.push(`${label} clipped`);
+ break;
+ }
+ }
+ }
+ return out;
+ });
+ if (cut.length) failures.push(`[${name}@${width}] cut text: ${cut.slice(0, 5).join(" | ")}`);
+ const vias = await page.$$eval(".results .chip-via", (n) => n.length);
+ console.log(` ${name}@${width}: cut text=${cut.length}, via chips=${vias}`);
+ await page.close();
+}
+
await browser.close();
server.close();
@@ -84,4 +202,4 @@ if (failures.length) {
for (const f of failures) console.error(" ✗ " + f);
process.exit(1);
}
-console.log("\nRender verification passed — the app mounts on every checked page.");
+console.log("\nRender verification passed — the app mounts, low-end mode skips the map, no text is cut.");
diff --git a/src/app.ts b/src/app.ts
index cfab858..06a6efa 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -41,6 +41,7 @@ import {
showMultiTripModal,
showTourModal,
showSettingsModal,
+ modalPopstate,
} from "./ui/modals";
import { generateBookingUrl } from "./util/booking";
import { t, setLang, getLang, isLang } from "./i18n";
@@ -661,33 +662,21 @@ export function initApp(root: HTMLElement, dataset: Dataset, registry: StationRe
// single back-stack; the entry's `detail` flag (read by renderSearch) keeps the in-app
// "Retour" in step with where the history now sits.
window.addEventListener("popstate", (ev) => {
+ if (modalPopstate(ev.state)) return; // Back closed a dialog; the page under it stays
const searched = queryFromUrl();
- // Restore the FORM from the snapshot stashed on this history entry (staged edits —
- // departure, destination, filters — survive the round trip), then the RESULTS from
- // the URL. Falling back to the URL query keeps older entries (no snapshot) working.
- const snap = formStateFrom(ev.state);
- let formQuery = snap ?? searched;
- // The URL decides the SCREEN: a renderable query is a COMMITTED search (results page); a
- // bare, non-renderable URL is the home/form screen. Only the home entry can carry a
- // snapshot frozen mid-build — stamped the instant "Aller-retour" was toggled (a same-day
- // round trip), before Flexible + the return were picked — OR no snapshot at all. So ONLY
- // there do we restore the full form the user last assembled, keeping the whole build
- // (departure, Flexible range, filters) across a Back instead of a wiped/partial form.
- // A committed search entry always keeps its OWN snapshot, so Backing through several
- // distinct searches restores each one faithfully (never the latest build). goHome() nulls
- // lastBuiltForm, so the logo/reset path lands on a genuinely empty home.
- const onResults = queryIsRenderable(searched);
- if (!onResults && lastBuiltForm && !isBlankForm(lastBuiltForm)) {
- formQuery = lastBuiltForm;
- }
+ const state = entryState();
+ // Restore the FORM and tab from this entry's snapshot (the bare landing's is landingForm
+ // when set), then the RESULTS from the URL; an entry with no snapshot uses the URL.
+ const formQuery = (!store.urlHasQuery() && landingForm) || state.form || searched;
+ // The URL decides the SCREEN: a renderable query, or the saved-trips page, is a results
+ // page; anything else is the form screen, where renderSearch shows no results.
+ const onResults = queryIsRenderable(searched) || Boolean(state.saved);
query = formQuery;
syncFormFromQuery();
query = onResults ? searched : formQuery;
- runSearch();
+ runSearch(state.scroll);
// On mobile the form and the results are two different screens. Back/Forward must
- // move between them too: a URL with no search is the initial (form) screen, one
- // with a search is the results screen. Follow the URL (onResults), not `query` — on the
- // home entry `query` now carries the restored build, but the screen is still the form.
+ // move between them too, following the URL (onResults), not `query`.
setMobileForm(!onResults);
});
}
@@ -898,7 +887,6 @@ function ctx(): RenderCtx {
store.toggleTrip(buildSavedTrip(out, inb));
renderSavedTrips();
},
- onShowTrip: (out, inb) => showTripModal(out, ctx(), { inbound: inb, onShare: shareCurrentUrl }),
isTourSaved: (tour) => store.isTripSaved(store.tourId(tour)),
onToggleTour: (tour) => {
store.toggleTrip(buildSavedTour(tour));
@@ -1148,34 +1136,41 @@ interface HistoryState {
* the in-app "Retour" shows and Back returns to the underlying list. The browser history
* is the single back-stack — this flag just marks which entries are drill-ins. */
detail?: boolean;
+ /** The saved-trips page: renderSearch shows it in place of the search under it. */
+ saved?: boolean;
+ /** Pushed off the bare landing form by an in-place refinement (the trip-type toggle, the
+ * nights, Flexible): the user is still on the form, so it is the landing's form too. */
+ landing?: boolean;
+ /** The list's scroll offset, stamped when a navigation leaves the entry. */
+ scroll?: number;
+}
+/** The current history entry's state, empty on an entry the app never stamped. */
+function entryState(): Partial {
+ const s: unknown = history.state;
+ return s && typeof s === "object" ? (s as Partial) : {};
}
/** Whether the current history entry is a drilled-in detail page. */
function currentDetail(): boolean {
- const s = history.state;
- return Boolean(s && typeof s === "object" && (s as { detail?: unknown }).detail);
-}
-// The last form the user actually built (origin/destination/legs filled). Kept so a Back
-// that lands on the bare home entry — whose snapshot predates the finished build (e.g. it
-// was stamped the moment Round trip was toggled, before Flexible + the return were picked) —
-// restores the WHOLE form the user assembled instead of wiping it. "Keep all data of the
-// initial form across every screen."
-let lastBuiltForm: SearchQuery | null = null;
-/** A form with no route yet — nothing worth preserving across a Back. */
-function isBlankForm(q: SearchQuery): boolean {
- return !q.origin && !q.destination && !(q.legs && q.legs.length > 0);
-}
-function formSnapshot(detail = false): HistoryState {
- const form = readQueryFromForm();
- if (!isBlankForm(form)) lastBuiltForm = form; // remember the richest form we've seen
- return detail ? { form, detail: true } : { form };
-}
-/** Read a form snapshot back off a popstate `event.state`, if one is present. */
-function formStateFrom(state: unknown): SearchQuery | null {
- if (state && typeof state === "object" && "form" in state) {
- const form = (state as { form?: unknown }).form;
- if (form && typeof form === "object") return form as SearchQuery;
+ return Boolean(entryState().detail);
+}
+// The latest form of the entry pushed off the bare landing by an in-place refinement, which
+// a Back to the landing restores; null when a Search or a drill-in left the landing instead.
+let landingForm: SearchQuery | null = null;
+/** Stamp the current entry with `query` and the live form, keeping its other flags. */
+function restamp(): void {
+ const state = { ...entryState(), form: readQueryFromForm() };
+ if (state.landing) landingForm = state.form;
+ store.updateUrl(query, state);
+}
+/** Stamp the entry a navigation leaves: the list's scroll offset, which Back restores, and
+ * on the bare landing the form as the user left it. */
+function leaveEntry(): void {
+ const state = { ...entryState(), scroll: listScroller().scrollTop };
+ if (!store.urlHasQuery()) {
+ state.form = readQueryFromForm();
+ landingForm = null;
}
- return null;
+ history.replaceState(state, "", location.href);
}
/** Parse a day-count input into 1..14, falling back to `fallback`. */
@@ -1216,27 +1211,21 @@ function applyAndRun(push = true, detail = false): void {
// replace in place instead of pushing a duplicate.
const alreadyShown = store.urlHasQuery() && store.queryToParams(query).toString() === location.search.replace(/^\?/, "");
if ((push || leavingBareForm) && !alreadyShown) {
- // If we're leaving the bare home/form page — no query in the URL and no form snapshot on
- // the entry yet — stamp it (same URL, we only add state) with the staged form so a
- // browser Back returns with the departure/destination/filters still filled instead of a
- // wiped form ("even if you come back it gets deleted"). Guard on BOTH: an entry with a
- // query in its URL owns a real page (a deep-linked or prior search) whose form Back must
- // restore verbatim — stamping it with the form we're switching TO would corrupt it. The
- // results entry pushed below carries its own snapshot for Forward.
- if (leavingBareForm && !formStateFrom(history.state)) {
- history.replaceState(formSnapshot(), "", location.href);
- }
+ leaveEntry();
// Push a browser history entry so the native Back button returns to the prior page,
// stashing a snapshot of the live form on the entry so a gesture-Back / popstate can
// restore the exact form that produced this page instead of wiping it. `detail` marks a
// drilled-in page (route from a list) so renderSearch shows the in-app Retour.
- store.pushUrl(query, formSnapshot(detail));
+ const form = readQueryFromForm();
+ const landing = leavingBareForm && !push;
+ if (landing) landingForm = form;
+ store.pushUrl(query, { form, detail, landing });
} else {
// In-place refinement of the view already on screen: REPLACE the current entry (still
// stamping the live form snapshot, so Back restores the filled form) so a run of
- // toggles adds zero history entries. Preserve the detail flag — a refine stays on the
+ // toggles adds zero history entries. The entry keeps its flags — a refine stays on the
// same (possibly drilled-in) page.
- store.updateUrl(query, formSnapshot(currentDetail()));
+ restamp();
}
settings = { ...settings, card: query.card };
store.saveSettings(settings);
@@ -1258,6 +1247,32 @@ function resultsScroller(): HTMLElement | null {
return drawer && drawer.scrollHeight > drawer.clientHeight + 1 ? drawer : null;
}
+/** The element the result list scrolls in: the drawer on a phone, the main column on a
+ * desktop, else the page. */
+function listScroller(): Element {
+ const scrolls = (e: Element | null): e is Element => Boolean(e && /auto|scroll/.test(getComputedStyle(e).overflowY));
+ return (
+ [".drawer-scroll", ".main-col"].map((s) => document.querySelector(s)).find(scrolls) ??
+ document.scrollingElement ??
+ document.documentElement
+ );
+}
+
+/** Scroll the list to `top`, following it while its chunks render in, until it gets there
+ * or stops growing. */
+function scrollListTo(top: number): void {
+ const gen = renderGen;
+ const scroller = listScroller();
+ let height = -1;
+ const step = (): void => {
+ if (gen !== renderGen || scroller.scrollHeight === height) return;
+ height = scroller.scrollHeight;
+ scroller.scrollTop = top;
+ if (scroller.scrollTop < top - 1) requestAnimationFrame(step);
+ };
+ step();
+}
+
/**
* Gently reveal an element that sits BELOW the current fold — and only then. A calendar
* tap must never jerk the page/drawer UP (David: "clicking a date scrolls up, why?"), so
@@ -1286,10 +1301,9 @@ function revealResults(): void {
function refreshInPlace(reveal = false): void {
// Restamp the entry with a FRESH form snapshot (not just the URL): an in-place refine —
// completing a Flexible range, moving the return — changes the form, and a Back must
- // restore that latest form, not the snapshot frozen before the refine. formSnapshot()
- // also refreshes lastBuiltForm, so the home-entry fallback stays current. Preserve the
- // detail flag: an in-place refresh (calendar day, moving the return) stays on the same page.
- store.updateUrl(query, formSnapshot(currentDetail()));
+ // restore that latest form, not the snapshot frozen before the refine. The entry keeps
+ // its flags: an in-place refresh (calendar day, moving the return) stays on the same page.
+ restamp();
const scroller = resultsScroller();
const scrollY = scroller ? scroller.scrollTop : window.scrollY;
// A calendar-day pick is usually what triggers an in-place refresh. If a day cell had
@@ -1534,8 +1548,12 @@ function pickFormDay(date: string): void {
/** Run the form's picked day(s): refresh the results for the same route, else search. */
function commitFormPick(): void {
const fq = readQueryFromForm();
+ // The bare landing shows no results to refresh, so a pick there is a navigation.
const sameRoute =
- query.origin === fq.origin && query.destination === fq.destination && (query.mode === "od" || tripIsRound());
+ store.urlHasQuery() &&
+ query.origin === fq.origin &&
+ query.destination === fq.destination &&
+ (query.mode === "od" || tripIsRound());
query = fq;
// Run as soon as the query is searchable — an exact route OR a one-ended discovery
// (origin-only "from"/"best", destination-only "to") — so tapping a day refreshes the
@@ -1703,7 +1721,8 @@ function cancelLoading(): boolean {
return true;
}
-function runSearch(): void {
+/** Run the current query and render it; `scrollTo` puts the list back at that offset. */
+function runSearch(scrollTo?: number): void {
searchToken++;
const token = searchToken;
searchLoading = true;
@@ -1729,6 +1748,7 @@ function runSearch(): void {
searchLoading = false;
clear(refs.results);
renderSearch();
+ if (scrollTo !== undefined) scrollListTo(scrollTo);
});
});
};
@@ -1820,9 +1840,18 @@ function appendInChunks(
function renderSearch(): void {
renderGen++;
activeStepBack = null; // each render re-registers its own step-back (if any)
+ rootRef.dataset.detail = currentDetail() ? "on" : "";
+ if (entryState().saved) return renderSavedPage();
+ // A bare URL is the landing form: a filled form restored there waits for Search.
+ if (!store.urlHasQuery() && queryIsRenderable(query)) {
+ document.title = APP_TITLE;
+ refs.title.textContent = "";
+ showSearchPrompt();
+ updateSearchBar();
+ return;
+ }
const c = ctx();
updateDocTitle();
- rootRef.dataset.detail = currentDetail() ? "on" : "";
// NB: the map is drawn by exactly ONE call per render — the mode's own show()/
// route(), or showBaseMap() on an empty state (via showHint / a "nothing to plot"
@@ -2150,9 +2179,7 @@ function runArmedPrompt(): void {
refs.title.textContent = "";
refs.results.append(render.emptyEl(t("rt_need_origin")));
showBaseMap();
- // Focus the origin so typing one immediately runs discovery (no extra click) — but
- // NOT on phones, where it springs the on-screen keyboard behind the results drawer.
- if (!isTouch()) refs.origin.focus({ preventScroll: true });
+ offerFocus(refs.origin);
}
function runMultiCity(c: RenderCtx): void {
@@ -2779,9 +2806,16 @@ function runTripSearch(c: RenderCtx): void {
b.body.style.display = collapsed ? "none" : "";
refreshSummary(i);
};
+ // "See all dates" in the ticket: both legs and both calendars open right here.
+ const showAllDates = (): void => {
+ setCollapsed(0, false);
+ setCollapsed(1, false);
+ outCalUI.setOpen(true);
+ retCalUI.setOpen(true);
+ };
const openTripModal = (): void => {
if (chosenOutbound && boxes[1]?.chosen) {
- showTripModal(chosenOutbound, c, { inbound: boxes[1].chosen, onShare: shareCurrentUrl });
+ showTripModal(chosenOutbound, c, { inbound: boxes[1].chosen, onShare: shareCurrentUrl, onMoreDates: showAllDates });
}
};
// Reopen the ticket on demand ("View ticket"): use the chosen legs when set, else the
@@ -2790,7 +2824,7 @@ function runTripSearch(c: RenderCtx): void {
const openTripModalBest = (): void => {
const out = chosenOutbound ?? outJourneys[0];
const ret = boxes[1]?.chosen ?? returnJourneys(odReturnDate ?? proposed).list[0];
- if (out && ret) showTripModal(out, c, { inbound: ret, onShare: shareCurrentUrl });
+ if (out && ret) showTripModal(out, c, { inbound: ret, onShare: shareCurrentUrl, onMoreDates: showAllDates });
};
const pickReturn = (j: Journey): void => {
if (boxes[1]) boxes[1].chosen = j;
@@ -2974,7 +3008,7 @@ function runTripSearch(c: RenderCtx): void {
// a long pick shows the real fixed count).
formApi.setStayNights(nights);
}
- store.updateUrl(query, formSnapshot(currentDetail()));
+ restamp();
paintReturn(retDate);
deferFormCalRepaint(); // the form's range, header and stay grading follow the new return
// The return list updates IN PLACE right where the calendar is — no scroll jump (a
@@ -3235,9 +3269,13 @@ function showHint(input: HTMLInputElement): void {
// empty — a confusing "why am I here?" page. Send the phone back to the search form
// instead, which is the real entry point.
setMobileForm(true);
- // On phones, don't auto-focus the field: it pops the keyboard + the station
- // suggestion dropdown over the whole UI on entry. Let the user tap it first.
- if (!isTouch()) input.focus({ preventScroll: true });
+ offerFocus(input);
+}
+
+/** Put a ready cursor in the empty field while nothing holds focus (a navigation leaves it on
+ * the heading, where shortcuts work); never on a phone, where it pops the keyboard. */
+function offerFocus(input: HTMLInputElement): void {
+ if (!isTouch() && document.activeElement === document.body) input.focus({ preventScroll: true });
}
function goBack(): void {
@@ -3251,7 +3289,6 @@ function goBack(): void {
/** Reset to the landing state (clicking the logo). Keeps language/theme/card. */
function goHome(): void {
- lastBuiltForm = null; // an explicit reset — don't let a later Back resurrect the old form
query = { mode: "from", date: today, card: settings.card, maxConnections: 1, hidden: true };
syncFormFromQuery();
applyAndRun();
@@ -3327,13 +3364,18 @@ function runFromForm(): void {
setSurpriseMsg(t("err_station", { station: unknown.value.trim() }));
return;
}
+ const next = readQueryFromForm();
+ // Nothing to search yet: name the missing step under Search, and leave the page and the
+ // history as they are rather than committing an empty search.
+ if (!queryIsRenderable(next)) {
+ const legs = tripType === "multi" && formApi.getMultiMode() === "legs";
+ setSurpriseMsg(t(legs ? "multi_hint" : "need_origin"));
+ return;
+ }
setSurpriseMsg("");
- query = readQueryFromForm();
+ query = next;
applyAndRun();
- // Only swap the phone to the results view when there's something real to show. An
- // 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"));
+ setMobileForm(false);
}
/** Shift the chosen date by `delta` days, clamped to the bookable window. */
@@ -3823,6 +3865,11 @@ function buildLayout(root: HTMLElement): void {
onInstall: () => void promptInstall(),
onShortcuts: showShortcutsHelp,
onSettings: openSettings,
+ onSaved: () => {
+ closeHeaderMenu();
+ openSavedPage();
+ setMobileForm(false);
+ },
onOpenMobileForm: () => setMobileForm(true),
onSelect: (id) => markSelected(id),
onPeek: (id) => mapInstance?.peek(id),
@@ -3935,8 +3982,9 @@ function setMobileForm(open: boolean): void {
const mq = (q: string): boolean => typeof matchMedia === "function" && matchMedia(q).matches;
const doc = document as Document & { startViewTransition?: (cb: () => void) => unknown };
// Morph the collapsed search bar into the full form (and back) on phones, via a
- // shared view-transition-name; instant everywhere it isn't supported.
+ // shared view-transition-name; instant when the screen stays the same or it isn't supported.
if (
+ rootRef.dataset.mform !== (open ? "form" : "results") &&
mq("(max-width: 860px)") &&
!mq("(prefers-reduced-motion: reduce)") &&
!settings.reduceMotion &&
@@ -3963,7 +4011,9 @@ function fillRoute(origin: string, destination: string): void {
// Clear any stale "via" so a saved route isn't filtered through an unrelated hub.
query = { ...query, mode: "od", origin, destination, via: undefined };
syncFormFromQuery();
- store.updateUrl(query); // keep the URL in step with the prefilled route
+ // Re-read the Trip tab's form, which drops what only another tab carries (tour cities, legs).
+ query = readQueryFromForm();
+ restamp(); // keep the URL and the entry's form in step with the prefilled route
// Favorites live in the results drawer, but the form they prefill is a different
// screen on mobile (display:none in results view). Bring the form sheet forward so
// the prefilled route is actually visible — otherwise tapping a favorite did
@@ -4030,7 +4080,12 @@ function savedTripInfo(trip: store.SavedTrip): { label: string; when: string; op
return {
label: `${deps.registry.label(out.origin)} ${inb ? "⇄" : "→"} ${deps.registry.label(out.destination)}`,
when: inb ? `${formatDate(out.date)} – ${formatDate(inb.date)}` : formatDate(out.date),
- open: () => showTripModal(out, ctx(), { inbound: inb, onShare: shareCurrentUrl }),
+ open: () =>
+ showTripModal(out, ctx(), {
+ inbound: inb,
+ onShare: shareCurrentUrl,
+ onMoreDates: () => ctx().onOpenRoute(out.origin, out.destination),
+ }),
};
}
@@ -4088,18 +4143,14 @@ function renderSavedTrips(): void {
/** Open the dedicated saved-trips page (full list), remembering where we were. */
function openSavedPage(): void {
- // Push a browser history entry marked as a detail page (carrying the form snapshot) so a
- // gesture / browser Back closes the saved page coherently — popping back to the underlying
- // search — instead of skipping past it, and returns with the form intact.
- store.pushUrl(query, formSnapshot(true));
- if (pendingRaf) cancelAnimationFrame(pendingRaf);
- pendingRaf = 0;
- // Enter the full-page detail layout (like drilling into a route) so this isn't
- // crammed into the 30vh bottom sheet with the map behind it on mobile. On the way back,
- // renderSearch reads the (now non-detail) entry and clears this.
- rootRef.dataset.detail = "on";
- clear(refs.results);
- renderSavedPage();
+ // Push a detail entry flagged `saved`, carrying the form snapshot: Back returns to the
+ // search under it with the form intact, and renderSearch shows this page on Forward.
+ leaveEntry();
+ store.pushUrl(query, { form: readQueryFromForm(), detail: true, saved: true });
+ cancelLoading(); // a search still in flight must not paint over the page
+ // renderSearch enters the full-page detail layout (like drilling into a route) so this
+ // isn't crammed into the 30vh bottom sheet with the map behind it on mobile.
+ renderSearch();
refs.title.focus({ preventScroll: true });
refs.title.scrollIntoView({ behavior: "smooth", block: "start" });
}
diff --git a/src/i18n/ar.ts b/src/i18n/ar.ts
index ea6a7c3..2e8a137 100644
--- a/src/i18n/ar.ts
+++ b/src/i18n/ar.ts
@@ -223,6 +223,7 @@ export const ar: Dict = {
act_saved: "محفوظ",
act_unsave: "إزالة من الرحلات المحفوظة",
saved_title: "الرحلات المحفوظة",
+ menu_saved: "الرحلات المحفوظة والمفضلة",
saved_see_all: "عرض الكل ({n})",
saved_count: "{n} رحلة محفوظة",
saved_none: "لا توجد رحلات محفوظة بعد.",
diff --git a/src/i18n/de.ts b/src/i18n/de.ts
index df9fdc4..073eaba 100644
--- a/src/i18n/de.ts
+++ b/src/i18n/de.ts
@@ -224,6 +224,7 @@ export const de: Dict = {
act_saved: "Gespeichert",
act_unsave: "Aus gespeicherten Reisen entfernen",
saved_title: "Gespeicherte Reisen",
+ menu_saved: "Gespeicherte Reisen & Favoriten",
saved_see_all: "Alle anzeigen ({n})",
saved_count: "{n} gespeicherte Reise(n)",
saved_none: "Noch keine gespeicherten Reisen.",
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index a1b6cee..7240a33 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -236,6 +236,7 @@ export const en: Dict = {
act_saved: "Saved",
act_unsave: "Remove from saved trips",
saved_title: "Saved trips",
+ menu_saved: "Saved trips & favorites",
saved_see_all: "See all ({n})",
saved_count: "{n} saved trip(s)",
saved_none: "No saved trips yet.",
diff --git a/src/i18n/es.ts b/src/i18n/es.ts
index fa74da6..6ccfc0a 100644
--- a/src/i18n/es.ts
+++ b/src/i18n/es.ts
@@ -224,6 +224,7 @@ export const es: Dict = {
act_saved: "Guardado",
act_unsave: "Quitar de viajes guardados",
saved_title: "Viajes guardados",
+ menu_saved: "Viajes guardados y favoritos",
saved_see_all: "Ver todos ({n})",
saved_count: "{n} viaje(s) guardado(s)",
saved_none: "Aún no hay viajes guardados.",
diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts
index 5cb492e..32e92dd 100644
--- a/src/i18n/fr.ts
+++ b/src/i18n/fr.ts
@@ -234,6 +234,7 @@ export const fr = {
act_saved: "Enregistré",
act_unsave: "Retirer des trajets enregistrés",
saved_title: "Trajets enregistrés",
+ menu_saved: "Trajets enregistrés et favoris",
saved_see_all: "Voir tout ({n})",
saved_count: "{n} trajet(s) enregistré(s)",
saved_none: "Aucun trajet enregistré pour l'instant.",
diff --git a/src/i18n/it.ts b/src/i18n/it.ts
index eb5067a..5e150cd 100644
--- a/src/i18n/it.ts
+++ b/src/i18n/it.ts
@@ -224,6 +224,7 @@ export const it: Dict = {
act_saved: "Salvato",
act_unsave: "Rimuovi dai viaggi salvati",
saved_title: "Viaggi salvati",
+ menu_saved: "Viaggi salvati e preferiti",
saved_see_all: "Mostra tutti ({n})",
saved_count: "{n} viaggio/i salvato/i",
saved_none: "Nessun viaggio salvato per ora.",
diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts
index 65c15e4..cc8943a 100644
--- a/src/i18n/ja.ts
+++ b/src/i18n/ja.ts
@@ -223,6 +223,7 @@ export const ja: Dict = {
act_saved: "保存済み",
act_unsave: "保存した旅程から削除",
saved_title: "保存した旅程",
+ menu_saved: "保存した旅程とお気に入り",
saved_see_all: "すべて表示 ({n})",
saved_count: "保存した旅程 {n} 件",
saved_none: "保存した旅程はまだありません。",
diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts
index 40c4d0a..c3dcd93 100644
--- a/src/i18n/ko.ts
+++ b/src/i18n/ko.ts
@@ -224,6 +224,7 @@ export const ko: Dict = {
act_saved: "저장됨",
act_unsave: "저장한 여행에서 제거",
saved_title: "저장한 여행",
+ menu_saved: "저장한 여행 및 즐겨찾기",
saved_see_all: "전체 보기 ({n})",
saved_count: "저장한 여행 {n}개",
saved_none: "아직 저장한 여행이 없습니다.",
diff --git a/src/i18n/nl.ts b/src/i18n/nl.ts
index 0a702d1..c113d4a 100644
--- a/src/i18n/nl.ts
+++ b/src/i18n/nl.ts
@@ -223,6 +223,7 @@ export const nl: Dict = {
act_saved: "Opgeslagen",
act_unsave: "Verwijderen uit opgeslagen reizen",
saved_title: "Opgeslagen reizen",
+ menu_saved: "Opgeslagen reizen & favorieten",
saved_see_all: "Alles bekijken ({n})",
saved_count: "{n} opgeslagen reis(zen)",
saved_none: "Nog geen opgeslagen reizen.",
diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts
index a310c00..28d6fa4 100644
--- a/src/i18n/pt.ts
+++ b/src/i18n/pt.ts
@@ -223,6 +223,7 @@ export const pt: Dict = {
act_saved: "Guardado",
act_unsave: "Remover das viagens guardadas",
saved_title: "Viagens guardadas",
+ menu_saved: "Viagens guardadas e favoritos",
saved_see_all: "Ver tudo ({n})",
saved_count: "{n} viagem(ns) guardada(s)",
saved_none: "Ainda não há viagens guardadas.",
diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts
index 730952b..30d4398 100644
--- a/src/i18n/zh.ts
+++ b/src/i18n/zh.ts
@@ -223,6 +223,7 @@ export const zh: Dict = {
act_saved: "已保存",
act_unsave: "从已保存行程中移除",
saved_title: "已保存行程",
+ menu_saved: "已保存行程和收藏",
saved_see_all: "查看全部 ({n})",
saved_count: "{n} 个已保存行程",
saved_none: "暂无已保存行程。",
diff --git a/src/styles.css b/src/styles.css
index 23cd241..2d9264c 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -34,7 +34,6 @@
font-synthesis: none;
}
-:root[data-theme="dark"],
:root[data-theme="auto"] {
color-scheme: light dark;
}
@@ -66,6 +65,7 @@
}
}
:root[data-theme="dark"] {
+ color-scheme: dark;
--bg: #181613;
--surface: #211f1b;
--surface-2: #26241f;
@@ -251,7 +251,11 @@ a {
display: contents;
}
.install-btn .install-label {
- margin-left: 0.4rem;
+ margin-inline-start: 0.4rem;
+}
+/* Phone menu only: on desktop the saved + favorites cards sit in the results column. */
+.saved-btn {
+ display: none;
}
.ctl {
background: var(--surface);
@@ -486,11 +490,11 @@ a {
display: block;
}
.input.has-clear {
- padding-right: 2rem;
+ padding-inline-end: 2rem;
}
.input-clear {
position: absolute;
- right: 0.45rem;
+ inset-inline-end: 0.45rem;
top: 50%;
transform: translateY(-50%);
width: 1.4rem;
@@ -560,7 +564,7 @@ a {
/* A checkbox nested under another (e.g. "only night trains" under "night trains"):
indented a little and with a subtler label, so it reads as a sub-option. */
.field-sub {
- margin-left: 1.25rem;
+ margin-inline-start: 1.25rem;
}
.field-sub .field-label {
color: var(--muted);
@@ -1229,7 +1233,7 @@ a {
/* "Only night trains" is a sub-option of "night trains", but side by side in the
toggle band its indent would just read as a column out of alignment. */
.advanced-toggles .field-sub {
- margin-left: 0;
+ margin-inline-start: 0;
}
.day-badge {
font-size: 0.68rem;
@@ -1440,7 +1444,8 @@ a {
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
- padding: 0.2rem 1.5rem 0.2rem 0.5rem;
+ padding-block: 0.2rem;
+ padding-inline: 0.5rem 1.5rem;
font: inherit;
font-size: 0.82rem;
cursor: pointer;
@@ -1651,9 +1656,6 @@ a {
.dest-name {
font-weight: 600;
font-size: 0.95rem;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
flex: 1 1 auto;
min-width: 0;
}
@@ -1773,15 +1775,9 @@ a {
background: var(--surface-2);
color: var(--text);
border-color: var(--border-strong);
- /* When the row is tight (long hub name / narrow screen) the chip yields
- first: it shrinks and ellipsizes so the destination name keeps priority. */
- display: inline-block;
+ /* In a tight row the chip shrinks first and its hub names wrap in full. */
min-width: 0;
- flex: 0 1 auto;
- max-width: 14ch;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
+ white-space: normal;
line-height: 1.5;
}
.chip-soft {
@@ -1972,13 +1968,10 @@ a {
font-size: 0.93rem;
flex-wrap: wrap;
}
-/* Let long station names shrink/ellipsize instead of overflowing the card on
- narrow phones; the arrow icon, day-badge and airport flag keep their size. */
+/* Long station names shrink and wrap inside the card on narrow phones; the arrow
+ icon, day-badge and airport flag keep their size. */
.leg-route > span:not(.day-badge):not(.airport-badge) {
min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
}
/* A small ✈ marking an airport station, next to its name in lists and journeys. */
@@ -1994,7 +1987,7 @@ a {
width: 13px;
height: 13px;
}
-/* Airport name + flag: the name ellipsizes, the ✈ stays put (never clipped). */
+/* Airport name + flag: the name wraps, the ✈ stays beside it. */
.stn-airport {
display: inline-flex;
align-items: center;
@@ -2002,9 +1995,6 @@ a {
max-width: 100%;
}
.stn-airport .stn-text {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
min-width: 0;
}
.layover {
@@ -2068,11 +2058,7 @@ a {
row-gap: 0.3rem;
}
.dest-name {
- flex: 1 1 auto;
order: 1;
- white-space: normal;
- overflow: visible;
- text-overflow: clip;
}
.dest-main > .chev {
order: 2;
@@ -2085,20 +2071,7 @@ a {
}
.dest-meta .stat-chip,
.dest-meta .chip,
- .dest-meta .chip-onsite,
- .dest-meta .chip-via {
- max-width: none;
- overflow: visible;
- text-overflow: clip;
- white-space: normal;
- }
- /* Airport names keep their ✈ badge but the inner text wraps instead of ellipsizing. */
- .dest-name.stn-airport {
- white-space: normal;
- }
- .dest-name .stn-text {
- overflow: visible;
- text-overflow: clip;
+ .dest-meta .chip-onsite {
white-space: normal;
}
}
@@ -2831,13 +2804,16 @@ a {
select.input {
appearance: none;
-webkit-appearance: none;
- padding-right: 2rem;
+ padding-inline-end: 2rem;
background-image: var(--chev);
background-repeat: no-repeat;
background-position: right 0.6rem center;
background-size: 13px;
cursor: pointer;
}
+[dir="rtl"] select.input {
+ background-position: left 0.6rem center;
+}
select.input:hover {
border-color: var(--border-strong);
}
@@ -3319,9 +3295,6 @@ select.input::-ms-expand {
}
.dest-main > .dest-body > .dest-name {
flex: none;
- white-space: normal; /* phones never truncate a name — a long one wraps in full */
- overflow: visible;
- text-overflow: clip;
}
.dest-main > .dest-body > .dest-meta {
flex: none;
@@ -3588,9 +3561,7 @@ select.input::-ms-expand {
}
.dp-value-text {
font-weight: 600;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
+ min-width: 0;
}
.dp-value-badge[hidden] {
display: none;
@@ -3845,10 +3816,7 @@ select.input::-ms-expand {
font-family: var(--font-display);
font-weight: 600;
font-size: 1.02rem;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
+ flex: 1 1 0; /* fills the row beside the badge and date, wrapping a long route */
}
.mc-date {
margin-inline-start: auto;
@@ -4275,9 +4243,7 @@ select.input::-ms-expand {
}
[data-mform="results"] .msearch-text {
min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
+ text-wrap: balance; /* a long route wraps to two even lines, not a lone "oct." */
}
[data-mform="results"] .results-drawer {
display: flex;
@@ -4448,6 +4414,13 @@ select.input::-ms-expand {
font-size: 1rem;
font-weight: 600;
}
+ .header-ctls .saved-btn {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ padding-inline: 0.75rem;
+ text-align: start;
+ }
.header-ctls .menu-actions .ctl {
display: flex;
align-items: center;
diff --git a/src/ui/icons.ts b/src/ui/icons.ts
index 5c3c999..36b2321 100644
--- a/src/ui/icons.ts
+++ b/src/ui/icons.ts
@@ -23,6 +23,9 @@ export const LOGO_SVG =
export const GITHUB_SVG =
'';
+export const BOOKMARK_SVG =
+ '';
+
export const SEARCH_SVG =
'';
diff --git a/src/ui/map.ts b/src/ui/map.ts
index 7cbd8b7..cc0bd5e 100644
--- a/src/ui/map.ts
+++ b/src/ui/map.ts
@@ -201,7 +201,10 @@ export class RouteMap {
}
const drawer = document.querySelector(".results-drawer");
const bottom = Math.round((drawer?.clientHeight ?? window.innerHeight * 0.3) + 12);
- map.fitBounds(bounds, { paddingTopLeft: [24, 76], paddingBottomRight: [24, bottom] });
+ // The bar wraps to two lines on a long route, so reserve its measured height.
+ const bar = document.querySelector(".msearch-bar");
+ const top = bar ? bar.offsetTop + bar.offsetHeight + 17 : 76;
+ map.fitBounds(bounds, { paddingTopLeft: [24, top], paddingBottomRight: [24, bottom] });
}
/** Render a hub station linked to each of `others`. Unknown coords are skipped. */
diff --git a/src/ui/modals.ts b/src/ui/modals.ts
index 661541e..13a6253 100644
--- a/src/ui/modals.ts
+++ b/src/ui/modals.ts
@@ -6,18 +6,76 @@ import * as render from "./render";
import { t } from "../i18n";
import { APP_VERSION, APP_BUILD } from "../config";
+/* ── history ── */
+
+// An open modal owns one history entry, the page's own state plus `dialog`, so the
+// browser Back closes the modal instead of leaving the page under it.
+let modalBase = ""; // the URL of the page under the modal
+let popping = false; // the modal's own entry is being popped
+let afterPop: (() => void) | null = null;
+
+function isModalEntry(state: unknown): boolean {
+ return Boolean(state && typeof state === "object" && (state as { dialog?: unknown }).dialog);
+}
+
+function runAfterPop(): void {
+ const fn = afterPop;
+ afterPop = null;
+ fn?.();
+}
+
+/** Once the last open modal has closed, pop its entry, then run `afterPop`. */
+function releaseEntry(): void {
+ if (document.querySelector("dialog[open]")) return; // a modal reopened over it keeps the entry
+ if (isModalEntry(history.state)) {
+ popping = true;
+ history.back();
+ } else {
+ runAfterPop();
+ }
+}
+
+/**
+ * Handle a popstate that belongs to a modal; true means the page under it stays as it is.
+ * Back closes an open modal, and Forward onto the entry of a closed one steps off it.
+ * @param state the popstate event's state.
+ */
+export function modalPopstate(state: unknown): boolean {
+ if (popping) {
+ popping = false;
+ runAfterPop();
+ return true;
+ }
+ const open = document.querySelectorAll("dialog[open]");
+ if (open.length > 0) {
+ for (const d of open) d.close();
+ return location.href === modalBase;
+ }
+ if (!isModalEntry(state)) return false;
+ popping = true;
+ history.back();
+ return true;
+}
+
/* ── internal helpers ── */
/**
* Wire the shared dialog lifecycle: remove from the DOM once closed, close on a
- * backdrop click, then mount and open it.
+ * backdrop click, give it a history entry, then mount and open it.
* @param dialog the dialog element to mount and open.
*/
function mountModal(dialog: HTMLDialogElement): void {
- dialog.addEventListener("close", () => dialog.remove());
+ dialog.addEventListener("close", () => {
+ dialog.remove();
+ releaseEntry();
+ });
dialog.addEventListener("click", (e) => {
if (e.target === dialog) dialog.close();
});
+ if (!isModalEntry(history.state)) {
+ modalBase = location.href;
+ history.pushState({ ...history.state, dialog: true }, "");
+ }
document.body.append(dialog);
dialog.showModal();
}
@@ -191,16 +249,17 @@ export function showBookingModal(journey: Journey, ctx: RenderCtx): void {
/**
* The whole trip on one page: a single journey or a round trip, with both legs
- * bookable, a share action, and a shortcut to the route's full calendar. Map
- * actions are neutralised — there's no map behind the dialog to draw on.
+ * bookable, a share action, and a shortcut to the route's dates. Map actions are
+ * neutralised — there's no map behind the dialog to draw on.
* @param outbound the outbound journey.
* @param ctx render context for the trip card.
- * @param opts optional inbound leg and a share handler.
+ * @param opts optional inbound leg, a share handler, and what "See all dates" does once
+ * the dialog and its history entry are gone.
*/
export function showTripModal(
outbound: Journey,
ctx: RenderCtx,
- opts: { inbound?: Journey; onShare?: (onCopied: () => void) => void } = {},
+ opts: { inbound?: Journey; onShare?: (onCopied: () => void) => void; onMoreDates: () => void },
): void {
const { inbound, onShare } = opts;
const dialog = el("dialog", { class: "modal trip-modal" }) as HTMLDialogElement;
@@ -210,8 +269,8 @@ export function showTripModal(
text: t("trip_more_dates"),
on: {
click: () => {
+ afterPop = opts.onMoreDates;
dialog.close();
- ctx.onOpenRoute(outbound.origin, outbound.destination);
},
},
});
diff --git a/src/ui/render.ts b/src/ui/render.ts
index ba20014..7b16e1d 100644
--- a/src/ui/render.ts
+++ b/src/ui/render.ts
@@ -42,8 +42,6 @@ export interface RenderCtx {
isTripSaved: (outbound: Journey, inbound?: Journey) => boolean;
/** Save the trip if absent, else remove it. */
onToggleTrip: (outbound: Journey, inbound?: Journey) => void;
- /** Open the consolidated one-page view of a trip (round trip when `inbound` is set). */
- onShowTrip: (outbound: Journey, inbound?: Journey) => void;
/** Whether this multi-city tour is saved. */
isTourSaved: (tour: Tour) => boolean;
/** Save the tour if absent, else remove it. */
diff --git a/src/ui/shell.ts b/src/ui/shell.ts
index e4e7b00..f8286b3 100644
--- a/src/ui/shell.ts
+++ b/src/ui/shell.ts
@@ -11,6 +11,7 @@ import {
LOGO_SVG,
GITHUB_SVG,
SEARCH_SVG,
+ BOOKMARK_SVG,
themeSvg,
} from "./icons";
@@ -32,6 +33,7 @@ export interface ShellProps {
onInstall: () => void;
onShortcuts: () => void;
onSettings: () => void;
+ onSaved: () => void;
onOpenMobileForm: () => void;
onSelect: (id: string) => void;
onPeek: (id: string | null) => void;
@@ -93,10 +95,10 @@ let teardownDrawer: (() => void) | null = null;
* between peek / half / full detents. A no-op where matchMedia is unavailable.
* @param drawer the drawer element to size.
* @param handle the grab handle that drives the drag.
- * @param mapSection the map behind the drawer, used to measure available height.
- * @returns a cleanup that removes the media-query/resize listeners it installed.
+ * @param bar the floating search bar; the drawer's full height stops just below it.
+ * @returns a cleanup that removes the listeners and observer it installed.
*/
-function setupDrawer(drawer: HTMLElement, handle: HTMLElement, mapSection: HTMLElement): () => void {
+function setupDrawer(drawer: HTMLElement, handle: HTMLElement, bar: HTMLElement): () => void {
const mq = typeof window.matchMedia === "function" ? window.matchMedia("(max-width: 860px)") : null;
if (!mq) return () => {};
const order = ["peek", "half", "full"] as const;
@@ -107,8 +109,7 @@ function setupDrawer(drawer: HTMLElement, handle: HTMLElement, mapSection: HTMLE
let state: Detent = "half";
const sizes = (): Record => {
- const mapTop = mapSection.getBoundingClientRect().top;
- const full = Math.max(240, Math.round(window.innerHeight - mapTop - 6));
+ const full = Math.max(240, Math.round(window.innerHeight - bar.getBoundingClientRect().bottom - 8));
const handleH = handle.offsetHeight || 46;
return {
peek: Math.max(handleH + 92, Math.round(full * 0.24)),
@@ -120,7 +121,8 @@ function setupDrawer(drawer: HTMLElement, handle: HTMLElement, mapSection: HTMLE
const snap = (s: Detent): void => {
state = s;
drawer.dataset.state = s;
- if (mq.matches) drawer.style.height = `${sizes()[s]}px`;
+ // The bar only shows in the results view; measured while hidden, every detent is wrong.
+ if (mq.matches && bar.getClientRects().length) drawer.style.height = `${sizes()[s]}px`;
};
let dragging = false;
@@ -184,7 +186,11 @@ function setupDrawer(drawer: HTMLElement, handle: HTMLElement, mapSection: HTMLE
const sync = (): void => {
if (mq.matches) {
+ // A layout change resizes the sheet at once; only a tap or a drag animates it.
+ drawer.style.transition = "none";
snap(state);
+ void drawer.offsetHeight;
+ drawer.style.transition = "";
} else {
drawer.style.height = "";
drawer.style.transition = "";
@@ -192,13 +198,11 @@ function setupDrawer(drawer: HTMLElement, handle: HTMLElement, mapSection: HTMLE
};
mq.addEventListener("change", sync);
window.addEventListener("resize", sync);
- // The initial sync must wait until the layout is attached: buildShell runs before
- // buildLayout appends the shell, so a synchronous measure here reads a detached
- // mapSection (top = 0) and snaps to a too-tall drawer. Defer one frame so `full`
- // is measured against the real viewport position.
- if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => sync());
- else sync();
+ // Re-measure each time the bar appears (entering the results view) or changes height.
+ const ro = typeof ResizeObserver === "function" ? new ResizeObserver(() => sync()) : null;
+ ro?.observe(bar);
return () => {
+ ro?.disconnect();
mq.removeEventListener("change", sync);
window.removeEventListener("resize", sync);
window.removeEventListener("pointermove", onMove);
@@ -291,6 +295,15 @@ function buildHeader(props: ShellProps): { header: HTMLElement; cardSelect: HTML
});
});
+ // Saved trips + favorites live in the results column, which a phone hides on the
+ // form screen; this entry opens the saved page from anywhere.
+ const savedBtn = el("button", {
+ class: "ctl saved-btn",
+ type: "button",
+ html: `${BOOKMARK_SVG}${t("menu_saved")}`,
+ on: { click: () => props.onSaved() },
+ });
+
const ghLink = el("a", {
class: "ctl icon-ctl gh-link",
html: GITHUB_SVG,
@@ -303,7 +316,7 @@ function buildHeader(props: ShellProps): { header: HTMLElement; cardSelect: HTML
// theme, share, shortcuts — lives behind the hamburger to keep the mobile header compact.
const quickCtls = el("div", { class: "header-quick" }, [cardSel, installBtn, settingsBtn]);
const headerCtls = el("div", { class: "header-ctls" }, [
- el("div", { class: "menu-selects" }, [langSel]),
+ el("div", { class: "menu-selects" }, [langSel, savedBtn]),
el("div", { class: "menu-actions" }, [ghLink, keysBtn, themeBtn, shareBtn]),
]);
const menuBtn = el("button", {
@@ -421,7 +434,7 @@ export function buildShell(props: ShellProps): ShellHandles {
el("div", { class: "side-col" }, [mapSection]),
]);
- teardownDrawer = setupDrawer(resultsDrawer, drawerHandle, mapSection);
+ teardownDrawer = setupDrawer(resultsDrawer, drawerHandle, msearchBar);
results.addEventListener("click", (ev) => {
const card = (ev.target as HTMLElement).closest("[data-station]");
diff --git a/tests/history.test.ts b/tests/history.test.ts
new file mode 100644
index 0000000..312eb60
--- /dev/null
+++ b/tests/history.test.ts
@@ -0,0 +1,81 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+// Leaflet needs a real browser canvas; stub the map module (as the smoke suite does).
+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 * as store from "../src/state/store";
+import sample from "../data/tgvmax.sample.json";
+import stations from "../data/stations.json";
+
+const P = "PARIS (intramuros)";
+const L = "LYON (intramuros)";
+const enc = encodeURIComponent;
+const meta: DataMeta = { updatedAt: "", source: "sample", recordCount: 0, isSample: true };
+
+/** Mount the app on `search`; `seed` runs on the cleared storage before it loads. */
+function setup(search: string, seed?: () => void): HTMLElement {
+ localStorage.clear();
+ seed?.();
+ document.body.innerHTML = '';
+ const root = document.getElementById("app") as HTMLElement;
+ history.replaceState(null, "", `/${search}`);
+ initApp(root, { trains: normalizeRecords(sample as RawRecord[]), meta }, new StationRegistry(stations as Station[]));
+ return root;
+}
+
+beforeEach(() => {
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(new Date("2026-06-25T12:00:00Z"));
+ globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
+ cb(0);
+ return 0;
+ }) as typeof requestAnimationFrame;
+ Element.prototype.scrollIntoView = function scrollIntoView(): void {};
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("history and navigation", () => {
+ it("opens a favorite from a tour page with a clean Trip query (no cities= left over)", () => {
+ const root = setup(`?mode=tour&from=${enc(P)}&cities=${enc(L)}&date=2026-06-25`, () =>
+ store.toggleFavorite({ origin: P, destination: L }),
+ );
+ root.querySelector(".fav-row:not(.trip-row) .fav-open")!.click();
+ const params = new URLSearchParams(location.search);
+ expect(params.get("mode")).toBe("od");
+ expect(params.get("to")).toBe(L);
+ expect(params.has("cities")).toBe(false);
+ });
+
+ it("an empty Search adds no history entry and says what is missing", () => {
+ const root = setup(`?mode=od&from=${enc(P)}&to=${enc(L)}&date=2026-06-25`);
+ const [origin, destination] = root.querySelectorAll(".search-form .od-fields input");
+ for (const input of [origin!, destination!]) {
+ input.value = "";
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ }
+ const before = { length: history.length, search: location.search };
+ root.querySelector(".search-form .form-actions button.btn-primary")!.click();
+ expect(history.length).toBe(before.length);
+ expect(location.search).toBe(before.search);
+ expect(root.querySelector(".surprise-msg")?.textContent).not.toBe("");
+ });
+});
diff --git a/vite.config.ts b/vite.config.ts
index 857bf53..56c2a8a 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -22,6 +22,8 @@ export default defineConfig(({ command, mode }) => ({
build: {
target: "es2022",
sourcemap: true,
+ // Fonts always ship as files: the page CSP (font-src falls back to 'self') blocks data: fonts.
+ assetsInlineLimit: (file: string) => (/\.woff2?$/.test(file) ? false : undefined),
},
test: {
environment: "jsdom",