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",