diff --git a/docs/user-flows.md b/docs/user-flows.md index a737185..7c7a9c5 100644 --- a/docs/user-flows.md +++ b/docs/user-flows.md @@ -17,6 +17,13 @@ search (`deriveMode` in `src/app.ts`; dispatch in `renderSearch`). | **Multi-city** (`multi`) | legs or cities | `tour` | a multi-stop tour | | **Ideas** (`ideas`) | From | `best` | best destinations, ranked | +A typed or linked station resolves when it spells a station's name, id or alias (accents, +case, hyphens, brackets and "St"/"Saint" read alike; a station's own name outranks another's +alias, and the one with trains wins a tie), or when it matches exactly one station. A name +matching several stations, or none, stops Search: the field turns red and a line under +Search names it, so `pierre des` never runs from Tours and an unknown arrival never turns +into a browse from the departure. Search with no departure names that step on the same line. + The **trip-type control** sits beside the date: an **`Aller simple` / `Aller-retour`** (one-way / round trip) segmented toggle — the same sliding-pill style as the main tabs. When *Aller-retour* is on, a **nights stepper** appears — `[ − ] N [ + ]` with a "Durée sur @@ -27,7 +34,9 @@ round trip out of fixed-nights mode so you pick the **exact departure and return Trip-tab calendar** (Ulysse-style). While Flexible is active the fixed-nights stepper stays **in place but inert (dimmed, buttons disabled)** rather than being removed — so toggling Flexible never moves the "Durée sur place" label or reflows the row (no layout jump); only -the pill lights up. The form's stay becomes `flexible`. Tapping the stepper or a segment leaves +the pill lights up. The inert stepper reads the nights of the range on screen, and the +same-day "Temps minimum sur place" field likewise stays in place, inert. Switching to +Flexible keeps the current stay as the range. The form's stay becomes `flexible`. Tapping the stepper or a segment leaves Flexible again. The stepper (and the pill) are hidden for one-way. `r` toggles one-way ↔ round trip (keeping the nights count, never Flexible); `1/2/3` switch tabs. Toggling, stepping, or picking Flexible re-runs in place (no second Search tap when origin + @@ -40,8 +49,10 @@ return date on `query.returnDate` (URL `stay=flex` + `rdate`). The **Trip tab's date picker is a live availability calendar on the form itself** (`repaintFormCalendar` in `src/app.ts`, painted into `refs.formCalendar` via `render.calendarEl`). It sits under the date + trip-shape row behind a one-tap -**"When to leave?"** header (which also shows the picked departure) and is **collapsed by -default** so the form stays short on a phone — one tap opens the month to change the day. +**"When to leave?"** header (which also shows the picked departure, on its own line so the +header keeps one height in every trip shape) and is **collapsed by default** so the form +stays short on a phone — one tap opens the month to change the day. A day picked under the +results (either leg's calendar) moves it too. The header names the calendar once (the in-body `

` is rendered `sr-only` via `calendarEl`'s `hideTitle`, so "When to leave?" isn't written twice). It **recomputes whenever anything it is derived from changes** — origin, destination, the Aller simple / Aller-retour @@ -66,8 +77,8 @@ the post-reload "press Search" prompt. |-------|------------------|-------------| | no origin yet | — (neutral month) | any day, tappable — with a "pick a departure station" hint | | origin + dest, one-way | `availabilityCalendar` | a departure exists that day (count = trains) | -| origin + dest, same day (0 nights) | `stayCalendar` (hours) | a same-day there-and-back works (count = hours on site) | -| origin + dest, N nights | `stayCalendar` (nights) | an N-night round trip is feasible (count = nights) | +| origin + dest, same day (0 nights) | `stayCalendar` (hours) | a same-day there-and-back works (count = whole hours on site, rounded down) | +| origin + dest, N nights | `stayCalendar` (nights) | a return exactly N nights later exists (count = N) | | origin only, one-way | `reachableCountCalendar` | you can leave that day (count = destinations) | | origin only, round / same day | `getawayIdeas().perDay` | a getaway is possible that day (count = destinations) | @@ -82,13 +93,18 @@ stays as the exact-date / ±flex keyboard entry for power users. departure** and arms the calendar for the return (`formRangeAwait`); the **next tap on/after it sets the return** — `query.returnDate` with `stay: "flexible"` — and (route complete) runs the flexible round trip in place, while an earlier tap just restarts. A **third tap begins a -fresh range**. The days between the two picked endpoints (both `.sel`) get a `.range` band, -and while the return is being chosen hovering previews the pending span (`.preview`). -Availability is shown exactly as the single-date calendar (round-trip feasibility). The +fresh range**. The first tap stays on the form: the header reads the departure as pending +("choose the return") while the URL and the results keep the range already on screen, and +the return tap runs the new range. A link with no `rdate` shows the departure + 2 its +results propose, in the header and the range too. The days between the two endpoints (both +`.sel`) get a `.range` band, and while the return is being chosen hovering previews the +pending span (`.preview`). The days count outbound trains, and while the return is awaited +they count return trains, the days before the departure greyed (a tap there restarts). The collapsed header spells out the two endpoints ("Aller: … → Retour: …", or a "choose the -return" prompt); `syncFormFromQuery` restores the highlighted range from `stay=flex` + -`rdate`. Fixed-nights and one-way modes keep the single-date departure picker; only Flexible -turns on range selection. The results-page return calendar still handles the return too — +return" prompt while the return is awaited and in discovery, which proposes no return); +`syncFormFromQuery` restores the highlighted range from `stay=flex` + `rdate`. Fixed-nights +and one-way modes keep the single-date departure picker; only Flexible turns on range +selection. The results-page return calendar still handles the return too — this only adds the pick on the **first page**. **Max correspondances** (0 / 1 / 2 / 3 / no limit) is a **main-form field**, not buried in @@ -118,6 +134,8 @@ span applied to the lists only, and a day could read green while its list was em - **Leg 2 Return** opens (gently revealed only if below the fold — a calendar tap never scrolls the drawer up) — a return calendar whose **first cell is the same day** (hours on site), later cells are nights at the destination, pre-selected to the stay's return. + A stay's list is fastest first, and a stay from the last bookable day keeps its return + past the window, where the return leg says there is none. For a **fixed** N-night stay the return is derived with no second question, so its calendar is **collapsed by default** behind a "Return: · Change" toggle (same `.cal-collapsible` / `.cal-toggle` / `.cal-panel` pattern as the outbound one). In @@ -126,18 +144,31 @@ span applied to the lists only, and a day could read green while its list was em when the proposed return day has **no free-MAX return**, the return leg and its calendar **open by default** (never a collapsed summary hiding an empty list), so the days that *do* have a return are visible and one tap away. Pick a return → + - **Same day with no round trip that day** → under the empty outbound, a one-tap "Stay N + night(s) instead" offers the shortest stay (1–3 nights) that has one that day. - **Trip modal** = booking recap: each leg's own **travel date** rides on the ticket header (beside "Outbound" / "Return"), and an unmistakable per-leg action — "Book the outbound" / "Book the return" (each deep-links SNCF Connect; a connecting leg opens the step modal) - — 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. -4. **Only From, a stay chosen** → discovery (`runGetaways`), "Where can you get away to?". - Destinations ranked by **time at the destination** (hours on site if same-day is best, - else nights) + a possible-start-days calendar. Tap a day → narrows to that day - (auto-scrolls to results). Tap a destination → opens the round trip. + The list reads direct cards, then connection-only ("via") rows, then the radius "Stations + within N km" section, whatever the chunked rendering. A nearby row names the station its + free leg starts from ("from Massy TGV · 16 km"). An empty day offers a one-tap move to + the next day with seats. +4. **Only From, a stay chosen** → discovery (`runGetaways`), titled with the origin, the day + and the stay ("Round trips from Paris — Tue, Sep 29 · Same day"; the mobile search bar + repeats it). Destinations ranked by **time at the destination** (hours on site if same-day + is best, else nights); the form's calendar shows the possible start days. Tap a day → + narrows to that day and scrolls the list into view when it sits below the fold. Tap a + destination → opens the round trip. An empty day offers the next day with a round trip + and a one-tap switch to one-way. - Ranking: `sortGetaways` puts most hours-on-site first for same-day trips. + - A card's travel time is the trip it opens: a stay counts that trip's default return, + the fastest on the return day (`asOpened`); a same-day card counts the latest return + home by midnight, as the trip does. - A minimum-on-site gate exists in core (`minOnSiteMin`, default 4h); NOT yet exposed as an Advanced control. (Open item.) 5. **Only To** → reverse browse (`runBrowse` "to"): where you can come *from* to reach the @@ -154,32 +185,59 @@ span applied to the lists only, and a day could read green while its list was em ## Ideas tab (best) -Best free-MAX destinations from the origin, ranked (fastest / most-served / closest…), -either an all-days overview or one specific day. Tap → open the route. +Every free-MAX destination from the origin across the booking window (there is no one-day +view), Recommended (fastest first) by default; the sort picker also ranks by most trains, +most days, closest, fastest or A–Z. +Each row shows the changes, the month's train count and the fastest time, plus the days +reachable or the distance when the sort ranks by it. Tap → the exact trip on the first day +the destination is reachable, with its calendar open. ## Cross-cutting (everywhere) - **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 of an empty result. A malformed or out-of-window departure date falls back to today; a malformed or out-of-window return or finish-by date, and a malformed time filter, are dropped. The address bar is then corrected in place (`replaceState`) to match the screen. + A link without `card=` (a route page's "open in app") keeps the card saved in Settings. - **PWA** — installable; a "new version — reload" postcard on updates. ## Known open items (see docs/trip-redesign.md for the audit plan) @@ -188,4 +246,3 @@ either an all-days overview or one specific day. Tap → open the route. - 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/package.json b/package.json index 3be08d5..f4c0ac8 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "test:e2e": "node scripts/e2e.mjs", "screenshot": "node scripts/screenshot.mjs", "prerender": "node scripts/prerender.mjs", - "generate-pages": "node scripts/generate-pages.mjs", + "generate-pages": "tsx scripts/generate-pages.mjs", "build:mobile": "tsc --noEmit && vite build --mode capacitor", "cap:sync": "npm run build:mobile && cap sync", "cap:copy": "npm run build:mobile && cap copy", diff --git a/scripts/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/generate-pages.mjs b/scripts/generate-pages.mjs index f184fac..3eae3ee 100644 --- a/scripts/generate-pages.mjs +++ b/scripts/generate-pages.mjs @@ -1,9 +1,9 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; import { join, dirname } from "node:path"; import { marked } from "marked"; +import { SITE_URL as SITE, GITHUB_URL } from "../src/config.ts"; const DIST = join(process.cwd(), "dist"); -const SITE = "https://davd-gzl.github.io/MAX-Finder/"; const TOP_ROUTES = 150; if (!existsSync(join(DIST, "index.html"))) { @@ -108,7 +108,7 @@ ${body} (Licence Ouverte), instantané du ${esc(lastmod)}. Disponibilités indicatives, rafraîchies ~1×/jour ; la réservation se fait sur SNCF Connect. Projet libre et indépendant, non affilié à la SNCF — - code source. + code source.

Rechercher un trajet · FAQ · diff --git a/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 a414d9b..06a6efa 100644 --- a/src/app.ts +++ b/src/app.ts @@ -10,7 +10,14 @@ import { } from "./core/destinations"; import { filterTrains, isNightTrain, type FilterOptions } from "./core/search"; import { bestTripsAcrossWindow, stationsOnDate, reachableBest, type BestTrip, type ReachTrip } from "./core/best"; -import { getawayIdeas, reverseGetawayIdeas, stayCalendar } from "./core/getaways"; +import { + bestGetawayTo, + getawayIdeas, + reverseGetawayIdeas, + sortGetaways, + stayCalendar, + type Getaway, +} from "./core/getaways"; import { planTours, planTourInOrder, planTourGreedy, arrivalDate, type Tour } from "./core/tour"; import { findJourneys, bestJourney, reachableJourneys, journeyArriveAbs, toJourney, MAX_RESULTS } from "./core/connections"; import type { ConnectionOptions } from "./core/connections"; @@ -34,6 +41,7 @@ import { showMultiTripModal, showTourModal, showSettingsModal, + modalPopstate, } from "./ui/modals"; import { generateBookingUrl } from "./util/booking"; import { t, setLang, getLang, isLang } from "./i18n"; @@ -73,7 +81,6 @@ let refs: Refs; let formApi: FormHandle; let mapPromise: Promise | null = null; let mapInstance: RouteMap | null = null; -let labelToId: Map; // Today (YYYY-MM-DD). MAX seats are only bookable ~30 days out, so the calendar // and the date picker stay anchored to a today..today+30 window. let today = ""; @@ -122,13 +129,12 @@ function returnForStay(stay: StayChoice, depart: string): string { return returnAfterNights(depart, n); } -/** Departure + N nights, clamped to the last bookable day. The nights stepper's return - * date for a round trip — used for any N (a fixed N-night stay is `` `n${N}` ``, fully - * decoupled from Flexible, so the explicit return day is departure + N for every N). */ +/** Departure + N nights: the nights stepper's return date for a round trip — used for any N + * (a fixed N-night stay is `` `n${N}` ``, fully decoupled from Flexible, so the explicit + * return day is departure + N for every N). Past the bookable window it has no return train, + * which the return leg says, rather than a shorter stay than the stepper reads. */ function returnAfterNights(depart: string, nights: number): string { - const last = addDays(today, BOOKING_WINDOW_DAYS - 1); - const d = addDays(depart, Math.max(0, nights)); - return d > last ? last : d; + return addDays(depart, Math.max(0, nights)); } @@ -155,7 +161,7 @@ let odReturnDate: string | null = null; // rt_return_moved notice on the next render, then clears itself. let returnMoved = false; -/** Set (or clear with "") the inline status next to the "surprise" button. */ +/** Set (or clear with "") the inline status under the Search and Surprise buttons. */ function setSurpriseMsg(text: string): void { formApi?.setSurpriseMsg(text); } @@ -615,7 +621,6 @@ export function initApp(root: HTMLElement, dataset: Dataset, registry: StationRe // Every station present in the dataset becomes searchable (the curated registry // only covers map coordinates for the major ones). registry.addMissing(dataset.trains.flatMap((t) => [t.origin, t.destination])); - labelToId = new Map(registry.list().map((s) => [s.label.toLowerCase(), s.id])); today = new Date().toISOString().slice(0, 10); query = store.urlHasQuery() @@ -657,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); }); } @@ -700,6 +693,8 @@ function queryFromUrl(): SearchQuery { const params = new URLSearchParams(location.search); const linked = params.toString(); const q = store.queryFromParams(params, today); + // A link without card= (a shared link, a route page) keeps the card the user saved. + if (!params.has("card")) q.card = settings.card; const lastBookable = addDays(today, BOOKING_WINDOW_DAYS - 1); const inWindow = (d: string): boolean => d >= today && d <= lastBookable; if (!inWindow(q.date)) q.date = today; @@ -788,15 +783,7 @@ let searchHeldBack = false; // --- station resolution ----------------------------------------------------- function resolveStation(text: string): string | undefined { - const norm = text.trim(); - if (!norm) return undefined; - const byLabel = labelToId.get(norm.toLowerCase()); - if (byLabel) return byLabel; - // Strict: no fuzzy/raw fallback. Text matching no real station resolves to - // undefined, so a typo or made-up name reads as invalid instead of being kept as - // a phantom origin / city that silently matches nothing. - const hit = deps.registry.search(norm, 1)[0]; - return hit ? hit.id : undefined; + return deps.registry.resolve(text); } // --- formatting / context --------------------------------------------------- @@ -846,14 +833,13 @@ function ctx(): RenderCtx { // Drop any "via" carried over from a previous exact-trip search: drilling into // a specific route (often a connecting one) shouldn't be filtered through an // unrelated hub, which would force it through a station it doesn't pass and - // show nothing. A getaway idea passes its own start day (open.date) so the round - // trip opens on a day it's actually feasible — not today, which may have only an - // outbound and no return (the "click a same-day idea, get no round trip" dead end). + // show nothing. A getaway or Ideas row passes a day it's feasible (open.date) — not + // today, which may have no train, or an outbound and no return. const date = open?.date ?? query.date; const returnDate = open?.date && query.returnDate ? undefined : query.returnDate; - // Opening a ONE-WAY idea (Ideas list, no stay, no dated getaway) → reveal the dates: - // the exact-trip page shows its availability calendar open instead of today's trains. - openOdCalendar = query.mode === "best" && !query.stay && !open?.date; + // Opening a ONE-WAY idea (Ideas list, no stay) → reveal the dates: the exact-trip + // page shows its availability calendar open, on the idea's first day. + openOdCalendar = query.mode === "best" && !query.stay; query = { ...query, mode: "od", origin, destination, via: undefined, date, returnDate }; syncFormFromQuery(); // Push a browser entry marked as a DETAIL page: the list stays one Back away, and the @@ -901,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)); @@ -1053,19 +1038,18 @@ function readQueryFromForm(): SearchQuery { ? rawStayMin : undefined; const outDate = refs.date.value || query.date; - // Flexible on an exact route keeps the return the user picked on the calendar (still in - // window and on/after the outbound), else leaves it unset so the results page proposes one. + // Flexible keeps the return the user picked on the calendar (still in window and on/after + // the outbound). const flexReturn = query.returnDate && query.returnDate >= outDate ? query.returnDate : undefined; + // A stay derives an explicit return for an exact route only (discovery derives its own from + // the sweep): departure + the stepper's nights, which in Flexible is the range's span. + const stayReturn = mode === "od" && formNights !== null ? returnAfterNights(outDate, formNights) : undefined; // Flexible carries the return the user picked on the calendar in EVERY stay-taking mode — // not just an exact route. Origin-only ("from") and destination-only ("to") discovery use // départ→retour as the getaway window (see getawayOptsFor), so dropping it here was what - // left the arrival un-pickable when only one endpoint was filled. A FIXED stay still only - // derives an explicit return for an exact route (discovery derives its own from the sweep). - const returnDate = formFlexible - ? flexReturn - : mode === "od" && formNights !== null - ? returnAfterNights(outDate, formNights) - : undefined; + // left the arrival un-pickable when only one endpoint was filled. With no pick yet, an exact + // route keeps the current span, so switching to Flexible or moving its departure keeps the stay. + const returnDate = formFlexible ? (flexReturn ?? stayReturn) : stayReturn; return { mode, origin: legsMode ? undefined : resolveStation(refs.origin.value), @@ -1152,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); + return Boolean(entryState().detail); } -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 }; +// 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); } -/** 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; +/** 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`. */ @@ -1220,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); @@ -1262,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 @@ -1290,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 @@ -1307,9 +1317,12 @@ function refreshInPlace(reveal = false): void { // whole form from `query` here: that clobbered a staged, not-yet-searched edit — a // ticked "Night trains", a tour city chip — because those live in the form (and // `tourCities`) but aren't folded into `query` until Search. Re-syncing silently - // reset them, which is the "my filter / cities disappeared" bug. + // reset them, which is the "my filter / cities disappeared" bug. A day picked under the + // results moves the form calendar's day too. + const dateMoved = refs.date.value !== query.date; refs.date.value = query.date; refs.departDate.setDate(query.date); + if (dateMoved) deferFormCalRepaint(); formApi.refreshTourEndDate(); clear(refs.results); renderSearch(); @@ -1413,20 +1426,25 @@ function repaintFormCalendar(): void { const nights = formApi.getStayNights(); // null = one-way, else 0..N const round = nights !== null; // Flexible → the inline month becomes a departure→return RANGE picker (requirement 2): - // `selected` is the departure, `query.returnDate` (in window, on/after it) the return. - // No return yet ⇒ awaiting the second tap. Other shapes keep the single-date picker. + // `selected` is the departure, the return is `query.returnDate` (in window, on/after it), + // else — a link with none — on an exact route the departure + 2 its results propose. While + // the return tap is awaited there is none yet. Other shapes keep the single-date picker. const flexRange = formApi.isFlexible(); const selected = refs.date.value || query.date; const windowDates = dateRange(today, BOOKING_WINDOW_DAYS); - const rangeEnd = flexRange && query.returnDate && query.returnDate >= selected ? query.returnDate : undefined; + const pickedReturn = query.returnDate && query.returnDate >= selected ? query.returnDate : undefined; + const rangeEnd = + flexRange && !formRangeAwait ? (pickedReturn ?? (o && d ? proposedReturn(selected) : undefined)) : undefined; const rangeOpt = flexRange ? { end: rangeEnd, awaiting: formRangeAwait } : undefined; // A query snapshot read purely to derive the SAME options the eventual search will use. const fq = readQueryFromForm(); // Keep the collapsed-header summary in step with the picked day — in Flexible it spells - // out the two endpoints ("Aller: … → Retour: …") or prompts for the return. + // out the two endpoints ("Aller: … → Retour: …") or prompts for the return, and the + // inert stepper reads the range's nights. if (flexRange) { const dep = selected ? formatDate(selected) : ""; + if (rangeEnd) formApi.setFlexible(dayIndex(rangeEnd) - dayIndex(selected)); refs.formCalPicked.textContent = rangeEnd ? t("form_cal_range", { from: dep, to: formatDate(rangeEnd) }) : t("form_cal_range_await", { from: dep }); @@ -1461,7 +1479,7 @@ function repaintFormCalendar(): void { calOpts = { title: t("form_cal_title"), hideTitle: true }; } else if (nights === 0 && !flexRange) { cal = stayCalendar(trains, o, d, windowDates, getawayOptsFor(fq), "hours"); - calOpts = { title: t("form_cal_title"), hideTitle: true, count: (h: number) => t("daytrip_cal_hours", { h }), countLegend: t("cal_legend_hours") }; + calOpts = { title: t("form_cal_title"), hideTitle: true, count: (h: number) => t("daytrip_cal_hours", { dur: formatDuration(h * 60) }), countLegend: t("cal_legend_hours") }; } else if (flexRange) { // Flexible: show the availability for the LEG being picked — the OUTBOUND (o→d) while // choosing the departure, the RETURN (d→o) once the departure is set and we're awaiting @@ -1469,8 +1487,11 @@ function repaintFormCalendar(): void { // you're picking right now" (David: "available trains per day for departure or return // depending on what you're choosing"). if (formRangeAwait) { + // A day before the departure is no return: a tap there restarts the range. const ret = odJourneyOptsFor(fq, d, o); - cal = availabilityCalendar(trains, d, o, windowDates, ret.journeyOpts, ret.accept); + cal = availabilityCalendar(trains, d, o, windowDates, ret.journeyOpts, ret.accept).map((day) => + day.date < selected ? { ...day, available: false, count: 0 } : day, + ); calOpts = { title: t("form_cal_title"), hideTitle: true, countLegend: t("cal_legend_return") }; } else { cal = availabilityCalendar(trains, o, d, windowDates, journeyOpts, accept); @@ -1521,9 +1542,18 @@ function repaintFormCalendar(): void { function pickFormDay(date: string): void { refs.date.value = date; refs.departDate.setDate(date); + commitFormPick(); +} + +/** 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 @@ -1531,7 +1561,7 @@ function pickFormDay(date: string): void { // day, the calendar selection doesn't update the map"). Only a truly empty query stays // staged. if (queryIsRenderable(query)) { - if (sameRoute) refreshInPlace(); + if (sameRoute) refreshInPlace(true); else applyAndRun(); } repaintFormCalendar(); @@ -1539,41 +1569,24 @@ function pickFormDay(date: string): void { /** * A day tapped on the Flexible Trip-tab calendar, which is a departure→return RANGE picker - * (requirement 2). The phase is derived from `query.returnDate`: with no return yet the - * calendar is AWAITING the second tap, so a tap on/after the departure sets the return - * (query.returnDate) with stay "flexible" and — if the route is complete — runs the - * flexible round trip; a tap before the departure just restarts. Any tap once the range is - * complete restarts from a new departure. The two taps stay on the form, so the range is - * built inline before any navigation, and a third tap begins a fresh range. + * (requirement 2). The first tap stages the departure on the form and arms the calendar for + * the return; the next tap on/after it sets the return (query.returnDate) with stay + * "flexible" and runs the range like a single-day pick, while an earlier tap restarts. A tap + * once the range is complete begins a fresh range. */ function pickFormRange(date: string): void { const out = refs.date.value || query.date; if (formRangeAwait && date >= out) { - // Second tap ≥ departure → the return: complete the range and run the flexible trip. formRangeAwait = false; query = { ...query, returnDate: date }; - const fq = readQueryFromForm(); // reads query.returnDate → carries it as the flexible return - const sameRoute = - query.origin === fq.origin && query.destination === fq.destination && (query.mode === "od" || tripIsRound()); - query = fq; - // Run as soon as the completed range yields something searchable — an exact route OR a - // one-ended discovery (origin-only "from", destination-only "to"), so picking départ → - // retour with only Paris filled shows the getaways for that window instead of just staging. - if (queryIsRenderable(query)) { - if (sameRoute) refreshInPlace(); - else applyAndRun(); - } - // else: nothing searchable yet (no endpoint) — the range stays staged on the form. - repaintFormCalendar(); + commitFormPick(); return; } - // First tap (or a restart, or a tap before an armed departure) → the departure: stage it, - // drop any prior return, and arm for the return tap. Stays on the form so the range is - // completed inline. + // The departure stays on the form, its header reading it as pending until the return + // tap; `query`, the URL and the results keep the range already on screen meanwhile. formRangeAwait = true; refs.date.value = date; refs.departDate.setDate(date); - query = { ...query, date, returnDate: undefined }; repaintFormCalendar(); } @@ -1708,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; @@ -1734,6 +1748,7 @@ function runSearch(): void { searchLoading = false; clear(refs.results); renderSearch(); + if (scrollTo !== undefined) scrollListTo(scrollTo); }); }); }; @@ -1825,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" @@ -1926,26 +1950,14 @@ function runBrowse(c: RenderCtx, dir: "from" | "to"): void { // bookable window. if (i === 0 || (d >= today && d <= lastBookable)) windowDates.push(d); } - const dayCount = new Map(); - for (const d of windowDates) { - const g = - dir === "from" - ? reachableDestinations(trains, anchor, d, filterOpts()) - : reachableOrigins(trains, anchor, d, filterOpts()); - for (const x of g) dayCount.set(x.station, (dayCount.get(x.station) ?? 0) + x.count); - } - - // Take the whole-window record for those reachable stations, so each card can show - // both the day/window count and the month total (richer card data: fastest time etc.). - const groups = reachableGroups(trains, anchor, dir, filterOpts()).filter( - (g) => (dayCount.get(g.station) ?? 0) > 0, - ); + // Each card's train count and fastest time cover that span, as its list does. + const groups = reachableGroups(trains, anchor, dir, windowDates, filterOpts()); const directStations = new Set(groups.map((g) => g.station)); - // Total MAX availability over the whole booking window, per destination, so each + // Total MAX availability over the booking window (today on), per destination, so each // card shows how many tickets exist before drilling into the exact-trip calendar. // Rank the list by that total (most-served first) — the "statistic" view. - const stats = windowStats(trains, anchor, dir, filterOpts()); + const stats = windowStats(trains, anchor, dir, dateRange(today, BOOKING_WINDOW_DAYS), filterOpts()); groups.sort( (a, b) => (stats.get(b.station)?.trains ?? 0) - (stats.get(a.station)?.trains ?? 0) || @@ -1982,16 +1994,24 @@ function runBrowse(c: RenderCtx, dir: "from" | "to"): void { ? nearbyBrowse(anchor, dir, query.date, query.radiusKm, filterOpts(), already) : []; - if (total === 0) { - // Suppress the empty message when nearby alternatives will fill the gap below. - if (nearby.length === 0) { - refs.results.append(render.emptyEl(t("res_none")), render.hintEl(t("res_none_hint"))); - showMap(anchor, []); - return; - } - } else { + if (total === 0 && nearby.length === 0) { + refs.results.append(render.emptyEl(t("res_none"))); + // Point at the next day the form calendar marks green, when there is one. + const window = dateRange(today, BOOKING_WINDOW_DAYS); + const opts = { ...filterOpts(), maxConnections: query.maxConnections }; + const next = nextAvailableDay(reachableCountCalendar(trains, anchor, window, opts, dir)); + if (next) refs.results.append(nextDayEl(next)); + refs.results.append(render.hintEl(t("res_none_hint"))); + showMap(anchor, []); + return; + } + + // One chunked list in reading order: direct cards, then via rows, then the nearby + // section, so a later batch of direct cards never lands below the other two. + const rows: (() => Node)[] = []; + if (total > 0) { // Sort applies to the direct destinations (the rich cards); "rec" keeps the - // most-served default. Via rows stay appended after, in their duration order. + // most-served default. Via rows follow, in their duration order. const sortedGroups = applySort(groups, { name: (g) => registry.label(g.station), distanceKm: (g) => stationDistanceKm(anchor, g.station), @@ -2005,23 +2025,32 @@ function runBrowse(c: RenderCtx, dir: "from" | "to"): void { onSort, ), ); - appendInChunks(refs.results, sortedGroups, (g) => - render.groupCardEl(g, dir, anchor, c, dayCount.get(g.station) ?? 0, stats.get(g.station), flex), - ); - for (const tr of connecting) refs.results.append(render.reachTripRowEl(tr.station, tr.journey, c)); + for (const g of sortedGroups) + rows.push(() => render.groupCardEl(g, dir, anchor, c, g.count, stats.get(g.station), flex)); + for (const tr of connecting) rows.push(() => render.reachTripRowEl(tr.station, tr.journey, c)); } // Nearby paid-hop alternatives: a station near your anchor reaches (or is reached - // by) somewhere your exact anchor doesn't. Each row opens that nearby leg's route. - if (query.radiusKm) { - const sec = el("section", { class: "nearby" }, [ - el("h3", { text: t("nearby_title", { km: query.radiusKm }) }), - el("p", { class: "muted small", text: t(dir === "from" ? "nearby_browse_from" : "nearby_browse_to") }), - ]); - if (nearby.length === 0) sec.append(render.emptyEl(t("nearby_none"))); - else for (const n of nearby) sec.append(render.nearbyTripRowEl(n.station, Math.round(n.km), n.journey, c)); - refs.results.append(sec); + // by) somewhere your exact anchor doesn't. Each row names that station and opens its route. + const radiusKm = query.radiusKm; + if (radiusKm) { + rows.push(() => { + const sec = el("section", { class: "nearby" }, [ + el("h3", { text: t("nearby_title", { km: radiusKm }) }), + el("p", { class: "muted small", text: t(dir === "from" ? "nearby_browse_from" : "nearby_browse_to") }), + ]); + if (nearby.length === 0) sec.append(render.emptyEl(t("nearby_none"))); + for (const n of nearby) { + const note = t(dir === "from" ? "nearby_from_km" : "nearby_to_km", { + station: registry.label(n.via), + km: Math.round(n.km), + }); + sec.append(render.nearbyTripRowEl(n.station, note, n.journey, c)); + } + return sec; + }); } + appendInChunks(refs.results, rows, (row) => row()); // Tint map pins by how many changes each place takes: direct = green, each // extra connection pushes toward red (see RouteMap.reachColor). @@ -2049,15 +2078,23 @@ function runBrowse(c: RenderCtx, dir: "from" | "to"): void { * clicking a destination row drops into the with-destination flow for that place. */ function runGetaways(c: RenderCtx, origin: string): void { - const { trains } = deps; - refs.title.textContent = t("rt_finder_title"); + const { trains, registry } = deps; + const nights = query.stay ? stayNights(query.stay) : null; + refs.title.textContent = t("rt_finder_title", { + station: registry.label(origin), + date: formatDate(query.date), + stay: nights == null ? t("stay_flexible") : nights === 0 ? t("nights_sameday") : t("getaway_nights", { n: nights }), + }); // DAY-SCOPED: list the round trips you can start on the chosen day, so the count matches // the "When to leave?" calendar's number for that day (pick another day → that day's list). // A window-wide union would say "65 possible" while the calendar cell says "8 that day". - const { trips } = getawayIdeas(trains, origin, [query.date], getawayOpts()); - const shown = trips; + const shown = asOpened(getawayIdeas(trains, origin, [query.date], getawayOpts()).trips); if (shown.length === 0) { refs.results.append(render.emptyEl(t("getaway_none"))); + // Never a dead end: the next day with a round trip, and the one-way list. + const next = nextAvailableDay(getawayIdeas(trains, origin, dateRange(today, BOOKING_WINDOW_DAYS), getawayOpts()).perDay); + if (next) refs.results.append(nextDayEl(next)); + refs.results.append(actionEl(t("getaway_oneway_btn"), () => applyTripShape("oneway"))); showMap(origin, []); return; } @@ -2068,7 +2105,7 @@ function runGetaways(c: RenderCtx, origin: string): void { // separate mode. const metric = trip.nights === 0 - ? t("daytrip_cal_hours", { h: Math.round((trip.onSiteMin ?? 0) / 60) }) + ? t("daytrip_cal_hours", { dur: formatDuration(trip.onSiteMin ?? 0) }) : t("getaway_nights", { n: trip.nights }); return render.getawayCityRowEl(trip, c, { metric }); }); @@ -2079,6 +2116,29 @@ function runGetaways(c: RenderCtx, origin: string): void { ); } +/** A stay's returns on a later day, fastest first: the trip page's return list, whose first + * is the return it picks by default. */ +function stayReturns(q: SearchQuery, origin: string, destination: string, retDate: string): Journey[] { + const { journeyOpts, accept } = odJourneyOptsFor(q, origin, destination); + return findJourneys(deps.trains, destination, origin, retDate, journeyOpts) + .filter(accept) + .sort((a, b) => a.totalDurationMin - b.totalDurationMin || a.departMin - b.departMin); +} + +/** Discovery cards as the trips they open: a stay's travel time counts the return that trip + * picks by default (`stayReturns`), not the sweep's latest one home, so card and trip agree. + * A same-day card already counts the trip's default, the latest return home by midnight. */ +function asOpened(trips: Getaway[]): Getaway[] { + const opened: SearchQuery = { ...query, mode: "od", via: undefined }; + return trips + .map((trip) => { + if (trip.nights === 0) return trip; + const back = stayReturns(opened, trip.outbound.origin, trip.outbound.destination, trip.back.date)[0]; + return back ? { ...trip, back, travelMin: trip.outbound.totalDurationMin + back.totalDurationMin } : trip; + }) + .sort(sortGetaways); +} + /** * Reverse round-trip discovery: a round trip with only a DESTINATION filled. Lists the * origins you can round-trip FROM to reach `destination` (and come back), each a real @@ -2090,9 +2150,8 @@ function runReverseGetaways(c: RenderCtx, destination: string): void { refs.title.textContent = t("rt_reverse_title", { station: registry.label(destination) }); // DAY-SCOPED (mirrors runGetaways): list the origins you can round-trip from on the chosen // day, so the count matches the "When to leave?" calendar's number for that day. - const { trips } = reverseGetawayIdeas(trains, destination, [query.date], getawayOpts()); // `trip.destination` here names the discovered ORIGIN (reverseGetawayIdeas relabels it). - const shown = trips; + const shown = asOpened(reverseGetawayIdeas(trains, destination, [query.date], getawayOpts()).trips); if (shown.length === 0) { refs.results.append(render.emptyEl(t("getaway_none"))); showMap(destination, []); @@ -2102,7 +2161,7 @@ function runReverseGetaways(c: RenderCtx, destination: string): void { appendInChunks(refs.results, shown, (trip) => { const metric = trip.nights === 0 - ? t("daytrip_cal_hours", { h: Math.round((trip.onSiteMin ?? 0) / 60) }) + ? t("daytrip_cal_hours", { dur: formatDuration(trip.onSiteMin ?? 0) }) : t("getaway_nights", { n: trip.nights }); // openTo = the fixed destination: the card names the origin and opens origin → dest. return render.getawayCityRowEl(trip, c, { metric, openTo: destination }); @@ -2120,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 { @@ -2368,7 +2425,7 @@ function runBestSearch(c: RenderCtx): void { // Month-long train count per destination (same figure as the "Where to?" list), // so an idea shows how well-served it is before you drill in. - const stats = windowStats(trains, query.origin, "from", filterOpts()); + const stats = windowStats(trains, query.origin, "from", window, filterOpts()); // Sort by trains / days reachable / distance / name; "rec" keeps fastest-first. const origin = query.origin; const sorted = applySort(trips, { @@ -2386,7 +2443,9 @@ function runBestSearch(c: RenderCtx): void { onSort, ), ); - appendInChunks(refs.results, sorted, (tr) => render.bestTripRowEl(tr, c, stats.get(tr.destination)?.trains)); + appendInChunks(refs.results, sorted, (tr) => + render.bestTripRowEl(tr, c, stats.get(tr.destination)?.trains, query.sort), + ); showMap( query.origin, sorted.map((tr) => tr.destination), @@ -2456,11 +2515,13 @@ function appendNearbyAlternatives( } else { if (alt.fromOrigin.length) { sec.append(el("h4", { class: "nearby-sub", text: t("nearby_from_origin", { station: registry.label(query.origin) }) })); - for (const a of alt.fromOrigin) sec.append(render.nearbyTripRowEl(a.id, Math.round(a.km), a.journey, c)); + for (const a of alt.fromOrigin) + sec.append(render.nearbyTripRowEl(a.id, t("nearby_km", { km: Math.round(a.km) }), a.journey, c)); } if (alt.toDest.length) { sec.append(el("h4", { class: "nearby-sub", text: t("nearby_to_dest", { station: registry.label(query.destination) }) })); - for (const a of alt.toDest) sec.append(render.nearbyTripRowEl(a.id, Math.round(a.km), a.journey, c)); + for (const a of alt.toDest) + sec.append(render.nearbyTripRowEl(a.id, t("nearby_km", { km: Math.round(a.km) }), a.journey, c)); } if (alt.bothEnds.length) { sec.append(el("h4", { class: "nearby-sub", text: t("nearby_both") })); @@ -2512,7 +2573,11 @@ function runOdSearch(c: RenderCtx): void { // availability, mirroring the round-trip outbound calendar's collapse pattern. // Opened from an Ideas one-way tap → show the days you can go up front (calendar open); // otherwise it's collapsed behind a one-tap "Départ : … · Changer" summary as usual. - const odCal = render.collapsibleCalendar(render.calendarEl(cal, c, query.date), "cal-collapsible", openOdCalendar); + const odCal = render.collapsibleCalendar( + render.calendarEl(cal, c, query.date), + "cal-collapsible", + openOdCalendar, + ); openOdCalendar = false; odCal.setLabel(t("outbound_change", { date: formatDate(query.date) })); refs.results.append(odCal.host); @@ -2741,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 @@ -2752,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; @@ -2814,7 +2886,9 @@ function runTripSearch(c: RenderCtx): void { // cell would green a return leaving BEFORE the outbound arrives. Re-derive that first // cell from the day-trip feasibility (nights 0: home by midnight, after arrival) so no // impossible same-day pairing leaks, and carry its hours-on-site as the count. - const sameDay = stayCalendar(trains, origin, destination, [query.date], connOpts, "hours")[0]; + // The stay's own options (its minimum time on site included) with the route's hub budget. + const stayOpts = { ...getawayOptsFor(query), ...connOpts }; + const sameDay = stayCalendar(trains, origin, destination, [query.date], stayOpts, "hours")[0]; if (retCal[0]) { retCal[0].available = Boolean(sameDay?.available); retCal[0].count = sameDay?.count ?? 0; // hours on site (0 = badge hidden) @@ -2841,8 +2915,8 @@ function runTripSearch(c: RenderCtx): void { const retCtx: RenderCtx = { ...c, onSelectDay: (d) => selectReturn(d) }; // The return options for a chosen day: same-day (nights ≤ 0) keeps only trains leaving // AFTER the outbound arrives and home by midnight, latest first (most time on site); - // a later day keeps every return, fastest first. Shared by the list render and the - // header-advance (which picks the pre-highlighted first option). + // a later day keeps every return, fastest first (`stayReturns`). Shared by the list render + // and the header-advance (which picks the pre-highlighted first option). const returnJourneys = (retDate: string): { list: Journey[]; sameDay: boolean; arrAbs: number } => { const nights = dayIndex(retDate) - dayIndex(query.date); if (nights <= 0) { @@ -2856,10 +2930,7 @@ function runTripSearch(c: RenderCtx): void { .sort((a, b) => b.departMin - a.departMin); return { list, sameDay: true, arrAbs }; } - const list = findJourneys(trains, destination, origin, retDate, journeyOpts) - .filter(accept) - .sort((a, b) => a.totalDurationMin - b.totalDurationMin || a.departMin - b.departMin); - return { list, sameDay: false, arrAbs: 0 }; + return { list: stayReturns(query, origin, destination, retDate), sameDay: false, arrAbs: 0 }; }; const renderReturns = (retDate: string): void => { clear(retList); @@ -2879,7 +2950,7 @@ function runTripSearch(c: RenderCtx): void { onPick: pickReturn, onArrow: pickReturn, selected: idx === 0, - dateLabel: retSameDay ? t("daytrip_cal_hours", { h: Math.round(Math.max(0, j.departMin - arrAbs) / 60) }) : undefined, + dateLabel: retSameDay ? t("daytrip_cal_hours", { dur: formatDuration(Math.max(0, j.departMin - arrAbs)) }) : undefined, }), ), ); @@ -2897,8 +2968,8 @@ function runTripSearch(c: RenderCtx): void { render.calendarEl(retCal, retCtx, retDate, { title: t("rt_inbound"), // First cell is same-day (hours on site); every later cell is nights away. - count: (n, day) => (day.date === query.date ? t("daytrip_cal_hours", { h: n }) : t("getaway_nights", { n })), - countLegend: t("cal_legend_nights"), + count: (n, day) => (day.date === query.date ? t("daytrip_cal_hours", { dur: formatDuration(n * 60) }) : t("getaway_nights", { n })), + countLegend: t("cal_legend_return_stay"), }), ); renderReturns(retDate); @@ -2937,8 +3008,9 @@ 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 // calendar tap must never jerk the drawer up). The paint already re-focuses the cell. }; @@ -2959,8 +3031,7 @@ function runTripSearch(c: RenderCtx): void { // drops stranding outbounds below. const sameDayMinOnSite = query.stayMinHours && query.stayMinHours > 0 ? query.stayMinHours * 60 : SAME_DAY_MIN_ON_SITE_MIN; - const outCalOpts = { ...getawayOptsFor(query), ...connOpts }; - const outCal = stayCalendar(trains, origin, destination, windowDates, outCalOpts, isSameDayTrip ? "hours" : "nights"); + const outCal = stayCalendar(trains, origin, destination, windowDates, stayOpts, isSameDayTrip ? "hours" : "nights"); gradeNearby(outCal, origin, destination, windowDates); // Linked calendars: picking a different outbound day re-anchors the trip and UPDATES the // return calendar to start from that day. A FIXED stay keeps its length — the return @@ -2993,7 +3064,7 @@ function runTripSearch(c: RenderCtx): void { const outCalCtx: RenderCtx = { ...c, onSelectDay: onOutboundDay }; const outCalEl = render.calendarEl(outCal, outCalCtx, query.date, { title: t("getaway_cal_title"), - count: (n) => (isSameDayTrip ? t("daytrip_cal_hours", { h: n }) : t("getaway_nights", { n })), + count: (n) => (isSameDayTrip ? t("daytrip_cal_hours", { dur: formatDuration(n * 60) }) : t("getaway_nights", { n })), countLegend: isSameDayTrip ? t("cal_legend_hours") : t("cal_legend_nights"), }); // The departure is already chosen (the form set it), so the possible-days calendar is @@ -3090,10 +3161,15 @@ function runTripSearch(c: RenderCtx): void { const arr = journeyArriveAbs(j); let latest = -1; for (const r of sameDayReturns) if (r.departMin >= arr && r.departMin > latest) latest = r.departMin; - return latest < 0 ? undefined : t("daytrip_cal_hours", { h: Math.round((latest - arr) / 60) }); + return latest < 0 ? undefined : t("daytrip_cal_hours", { dur: formatDuration(latest - arr) }); }; if (outJourneys.length === 0) { body0.append(render.emptyEl(t("res_none")), render.hintEl(t("res_none_hint"))); + // No same-day round trip that day: offer the shortest stay that has one. + const n = isSameDayTrip + ? [1, 2, 3].find((k) => bestGetawayTo(trains, origin, destination, query.date, { ...stayOpts, nights: k })) + : undefined; + if (n) body0.append(actionEl(t("try_nights", { n }), () => applyTripShape(stayFromNights(n)))); } else { for (const j of outJourneys) body0.append( @@ -3159,6 +3235,28 @@ function runTripSearch(c: RenderCtx): void { ); } } + +/** A one-tap way forward under an empty list: a ghost button on its own centred row. */ +function actionEl(text: string, onClick: () => void): HTMLElement { + const button = el("button", { class: "btn btn-ghost", type: "button", text, on: { click: onClick } }); + return el("div", { class: "od-comeback" }, [button]); +} + +/** The first day after the chosen one, else the first in the window, that `cal` marks + * bookable on the query's card. */ +function nextAvailableDay(cal: CalendarDay[]): string | undefined { + const open = cal.filter((d) => d.available && d.date !== query.date); + return (open.find((d) => d.date > query.date) ?? open[0])?.date; +} + +/** Move an empty list to `date`, a day its calendar marks green. */ +function nextDayEl(date: string): HTMLElement { + return actionEl(t("day_with_seats", { date: formatDate(date) }), () => { + query = { ...query, date }; + refreshInPlace(); + }); +} + /** Coarse pointer ≈ touch/phone. */ function showHint(input: HTMLInputElement): void { // Empty state: no nagging prompt — just a blank heading and a ready cursor. @@ -3171,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 { @@ -3187,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(); @@ -3242,15 +3343,39 @@ function cycleTripShape(): void { repaintFormCalendar(); // the shape now means one-way vs round → recolour the days } +/** The departure or arrival field the active surface reads whose text names no station. */ +function unknownStationField(): HTMLInputElement | undefined { + const fields = + tripType === "simple" || (tripType === "multi" && formApi.getMultiMode() === "plan") + ? [refs.origin, refs.destination] + : tripType === "ideas" + ? [refs.origin] + : []; + return fields.find((f) => f.value.trim() !== "" && !resolveStation(f.value)); +} + /** Run a fresh search from the current form (submit or "g" shortcut). */ function runFromForm(): void { - query = readQueryFromForm(); + // A name that matches no station, or several, stops here in red: searching without it + // would run a different search than the one typed. + const unknown = unknownStationField(); + if (unknown) { + unknown.classList.add("is-invalid"); + setSurpriseMsg(t("err_station", { station: unknown.value.trim() })); + return; + } + 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 = next; applyAndRun(); - // Only swap the phone to the results view when there's something real to show. An - // incomplete query (no origin, etc.) stays on the form with its field flagged, rather - // than teleporting to a blank results screen. (applyAndRun's showHint already keeps - // the form open; this stops the old unconditional flip from overriding it.) - if (queryIsRenderable(query)) setMobileForm(false); + setMobileForm(false); } /** Shift the chosen date by `delta` days, clamped to the bookable window. */ @@ -3740,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), @@ -3852,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 && @@ -3880,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 @@ -3947,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), + }), }; } @@ -4005,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/core/best.ts b/src/core/best.ts index 3c6c8c4..66989eb 100644 --- a/src/core/best.ts +++ b/src/core/best.ts @@ -6,6 +6,8 @@ export interface BestTrip { journey: Journey; /** Days in the window this destination is reachable (set by the all-days view). */ days?: number; + /** The first day in the window it is reachable (set by the all-days view). */ + firstDate?: string; } /** Distinct stations that appear (as origin or destination) on a given date. */ @@ -46,7 +48,7 @@ export function bestTripsAcrossWindow( const cur = found.get(destination); // Keep the fastest journey across the whole window, not the earliest day's. if (!cur || journey.totalDurationMin < cur.journey.totalDurationMin) { - found.set(destination, { destination, journey }); + found.set(destination, { destination, journey, firstDate: cur?.firstDate ?? date }); } } } diff --git a/src/core/connections.ts b/src/core/connections.ts index 903b5a0..16213a0 100644 --- a/src/core/connections.ts +++ b/src/core/connections.ts @@ -21,8 +21,8 @@ export interface ConnectionOptions { /** Only keep journeys that include at least one night train (sleep aboard). */ onlyNight?: boolean; /** - * reachableJourneys only: keep the EARLIEST-ARRIVING journey per destination - * (maximises time on site) instead of the shortest-duration one. Round-trip + * reachableJourneys / reachableInto only: keep the EARLIEST-ARRIVING journey per + * station (maximises time on site) instead of the shortest-duration one. Round-trip * "ideas" need this so their outbound matches bestGetawayTo's earliest-arrival * choice; one-way "best" leaves it off and keeps the fastest journey. */ @@ -132,17 +132,16 @@ export function journeyArriveAbs(j: Journey): number { return j.departMin + j.totalDurationMin; } +// One journey per chain of trains. A station group (LYON (intramuros)) can list one train at +// two of its stations, so the shortest row wins: the earliest arrival, or the latest boarding. function dedupe(journeys: Journey[]): Journey[] { - const seen = new Set(); - const out: Journey[] = []; + const byKey = new Map(); for (const j of journeys) { const key = `${j.legs.map((l) => `${l.date}/${l.trainNo}@${l.origin}`).join(">")}`; - if (!seen.has(key)) { - seen.add(key); - out.push(j); - } + const cur = byKey.get(key); + if (!cur || j.totalDurationMin < cur.totalDurationMin) byKey.set(key, j); } - return out; + return [...byKey.values()]; } /** @@ -288,12 +287,15 @@ export function bestJourney( * per candidate when you want them all (e.g. the "ideas, all days" union). Same * connection rules as findJourneys: intermediate stops must be hubs, layovers * within the window, no station visited twice, first leg departs on `date`. + * With `arriveCeil` (minutes from `date` midnight) it keeps, per destination, the + * LATEST-departing journey arriving by then: the forward mirror of {@link latestReturns}. */ export function reachableJourneys( trains: MaxTrain[], origin: string, date: string, opts: ConnectionOptions = {}, + arriveCeil?: number, ): Map { const maxConn = opts.maxConnections ?? 1; const hubSet = new Set(opts.hubs ?? HUB_STATIONS); @@ -303,7 +305,7 @@ export function reachableJourneys( const maxC = span > 2 ? Math.max(baseMaxC, (span - 1) * 1440) : baseMaxC; const memo = reachMemo(trains); - const key = `${origin}@${date}|${maxConn}|${minC}-${maxC}|${span}|${opts.departAfter ?? ""}|${opts.departBefore ?? ""}|${opts.arriveBefore ?? ""}|${opts.maxDurationMin ?? ""}|${opts.minDurationMin ?? ""}|${opts.trainType ?? ""}|${opts.excludeNight ? "nonight" : ""}|${opts.onlyNight ? "onlynight" : ""}|${opts.earliestArrival ? "earlyarr" : ""}|${[...hubSet].join(",")}`; + const key = `${origin}@${date}|${maxConn}|${minC}-${maxC}|${span}|${opts.departAfter ?? ""}|${opts.departBefore ?? ""}|${opts.arriveBefore ?? ""}|${opts.maxDurationMin ?? ""}|${opts.minDurationMin ?? ""}|${opts.trainType ?? ""}|${opts.excludeNight ? "nonight" : ""}|${opts.onlyNight ? "onlynight" : ""}|${opts.earliestArrival ? "earlyarr" : ""}|${arriveCeil ?? ""}|${[...hubSet].join(",")}`; const cached = memo.get(key); if (cached) return cached; @@ -348,19 +350,23 @@ export function reachableJourneys( // tour's min-per-train cap) don't get candidates the per-journey search rejects. const okDur = (maxDur == null || j.totalDurationMin <= maxDur) && (minDur == null || j.totalDurationMin >= minDur); // Latest acceptable arrival, on the absolute cross-date timeline. - const okArrive = arriveBy === undefined || journeyArriveAbs(j) <= arriveBy; + const okArrive = + (arriveBy === undefined || journeyArriveAbs(j) <= arriveBy) && (arriveCeil === undefined || journeyArriveAbs(j) <= arriveCeil); if (okNight && okDur && okArrive) { const cur = best.get(j.destination); // Default: keep the fastest. earliestArrival: keep the one arriving soonest in // ABSOLUTE time (ties → shorter), matching bestGetawayTo so round-trip ideas // stay at parity. Compare journeyArriveAbs, not the leg-local arriveMin — else a // via-hub journey whose last leg lands the next day would falsely look earliest. + // arriveCeil: keep the latest departure (ties → shorter), as latestReturns does. const better = !cur || - (opts.earliestArrival - ? journeyArriveAbs(j) < journeyArriveAbs(cur) || - (journeyArriveAbs(j) === journeyArriveAbs(cur) && j.totalDurationMin < cur.totalDurationMin) - : j.totalDurationMin < cur.totalDurationMin); + (arriveCeil !== undefined + ? j.departMin > cur.departMin || (j.departMin === cur.departMin && j.totalDurationMin < cur.totalDurationMin) + : opts.earliestArrival + ? journeyArriveAbs(j) < journeyArriveAbs(cur) || + (journeyArriveAbs(j) === journeyArriveAbs(cur) && j.totalDurationMin < cur.totalDurationMin) + : j.totalDurationMin < cur.totalDurationMin); if (better) best.set(j.destination, j); } if (path.length - 1 >= maxConn) return; // used all allowed changes @@ -523,7 +529,8 @@ function intoMemo(trains: MaxTrain[]): Map> { * multi-source sweep), so the "where can I come FROM" browse costs a single pass * instead of a per-origin search. Same connection rules (hub changes, layover window, * no station twice) as the forward search. Derived from {@link latestReturns} but - * without the home-by ceiling and keeping the shortest journey, not the latest. + * without the home-by ceiling and keeping the shortest journey (or, with + * `earliestArrival`, the earliest-arriving one), not the latest. */ export function reachableInto( trains: MaxTrain[], @@ -539,7 +546,7 @@ export function reachableInto( const maxC = span > 2 ? Math.max(baseMaxC, (span - 1) * 1440) : baseMaxC; const memo = intoMemo(trains); - const key = `${target}@${date}|${maxConn}|${minC}-${maxC}|${span}|${opts.departAfter ?? ""}|${opts.departBefore ?? ""}|${opts.arriveBefore ?? ""}|${opts.maxDurationMin ?? ""}|${opts.trainType ?? ""}|${opts.excludeNight ? "nonight" : ""}|${opts.onlyNight ? "onlynight" : ""}|${[...hubSet].join(",")}`; + const key = `${target}@${date}|${maxConn}|${minC}-${maxC}|${span}|${opts.departAfter ?? ""}|${opts.departBefore ?? ""}|${opts.arriveBefore ?? ""}|${opts.maxDurationMin ?? ""}|${opts.trainType ?? ""}|${opts.excludeNight ? "nonight" : ""}|${opts.onlyNight ? "onlynight" : ""}|${opts.earliestArrival ? "earlyarr" : ""}|${[...hubSet].join(",")}`; const cached = memo.get(key); if (cached) return cached; @@ -577,8 +584,15 @@ export function reachableInto( if (maxDur != null && j.totalDurationMin > maxDur) return; if (arriveBy !== undefined && journeyArriveAbs(j) > arriveBy) return; const cur = best.get(head.origin); - // Keep the shortest journey into the target (ties → earlier arrival). - if (!cur || j.totalDurationMin < cur.totalDurationMin) best.set(head.origin, j); + // Keep the shortest journey into the target, or with earliestArrival the one arriving + // soonest (ties → shorter), as reachableJourneys does. + const better = + !cur || + (opts.earliestArrival + ? journeyArriveAbs(j) < journeyArriveAbs(cur) || + (journeyArriveAbs(j) === journeyArriveAbs(cur) && j.totalDurationMin < cur.totalDurationMin) + : j.totalDurationMin < cur.totalDurationMin); + if (better) best.set(head.origin, j); }; const dfs = (): void => { diff --git a/src/core/destinations.ts b/src/core/destinations.ts index de87fb9..657a682 100644 --- a/src/core/destinations.ts +++ b/src/core/destinations.ts @@ -52,23 +52,28 @@ export function reachableOrigins( } /** - * Every station reachable from `anchor` over the WHOLE loaded window (any date), - * grouped with its total count and fastest direct time. `dir` "from" groups by - * destination (places you can go), "to" groups by origin (places you can come - * from). This is the browse list so an idea appears whenever a MAX train runs to - * it on *any* bookable day, not only the one currently selected. + * Every station reachable from `anchor` directly on any of `dates`, grouped with its + * train count and fastest direct time over those dates. `dir` "from" groups by + * destination (places you can go), "to" groups by origin (places you can come from). */ export function reachableGroups( trains: MaxTrain[], anchor: string, dir: "from" | "to", + dates: string[], opts: FilterOptions = {}, ): StationGroup[] { + return group(onDates(trains, anchor, dir, dates, opts), (t) => (dir === "from" ? t.destination : t.origin)); +} + +/** The direct free-MAX trains from (or into) `anchor` on any of `dates`. */ +function onDates(trains: MaxTrain[], anchor: string, dir: "from" | "to", dates: string[], opts: FilterOptions): MaxTrain[] { + const days = new Set(dates); const matches = dir === "from" ? filterTrains(trains, { ...opts, origin: anchor }) : filterTrains(trains, { ...opts, destination: anchor }); - return group(matches, (t) => (dir === "from" ? t.destination : t.origin)); + return matches.filter((t) => days.has(t.date)); } /** Total direct free-MAX trains and the distinct days they run on, per station. */ @@ -78,23 +83,20 @@ export interface WindowStat { } /** - * For an `anchor` station, total direct free-MAX availability over the whole - * loaded window (all dates), keyed by the other station — destinations when - * `dir` is "from", origins when "to". Lets the browse list show how many MAX - * trains run to each place over the bookable horizon, not just on one date. + * For an `anchor` station, total direct free-MAX availability over `dates` (the + * bookable window), keyed by the other station — destinations when `dir` is "from", + * origins when "to". Lets the browse list show how many MAX trains run to each place + * over the bookable horizon, not just on one date. */ export function windowStats( trains: MaxTrain[], anchor: string, dir: "from" | "to", + dates: string[], opts: FilterOptions = {}, ): Map { - const matches = - dir === "from" - ? filterTrains(trains, { ...opts, origin: anchor }) - : filterTrains(trains, { ...opts, destination: anchor }); const acc = new Map }>(); - for (const t of matches) { + for (const t of onDates(trains, anchor, dir, dates, opts)) { const key = dir === "from" ? t.destination : t.origin; let e = acc.get(key); if (!e) { diff --git a/src/core/getaways.ts b/src/core/getaways.ts index 70bae40..edf9253 100644 --- a/src/core/getaways.ts +++ b/src/core/getaways.ts @@ -106,12 +106,13 @@ export function bestGetawayTo( // trip can be same-day [0]; a flexible search walks down to the shortest stay. const nightChoices = stayChoices(maxNights, Boolean(opts.flexibleNights), sleeper); - // Earliest-arriving outbound on the start day (more time at the destination), - // unless one was supplied by the caller. + // Earliest-arriving outbound on the start day (more time at the destination; ties → + // the shorter ride, as the sweeps pick), unless one was supplied by the caller. let out = outbound ?? null; if (!out) { for (const j of findJourneys(trains, origin, dest, date, opts)) { - if (!out || journeyArriveAbs(j) < journeyArriveAbs(out)) out = j; + const arr = journeyArriveAbs(j); + if (!out || arr < journeyArriveAbs(out) || (arr === journeyArriveAbs(out) && j.totalDurationMin < out.totalDurationMin)) out = j; } } if (!out) return null; @@ -159,7 +160,7 @@ function stayChoices(maxNights: number, flexible: boolean, sleeper: boolean): nu } /** Rank: most nights, then most time on site (same-day) / least travel (stays). */ -function sortGetaways(a: Getaway, b: Getaway): number { +export function sortGetaways(a: Getaway, b: Getaway): number { return ( b.nights - a.nights || (a.nights === 0 ? (b.onSiteMin ?? 0) - (a.onSiteMin ?? 0) : a.travelMin - b.travelMin) @@ -217,8 +218,8 @@ export function getawaysAcrossWindow( * origin fanning out to destinations, this fixes the DESTINATION and finds every ORIGIN * that has a feasible free-MAX round trip O → destination → O (same-day or an N-night * stay) starting each day in `dates`. Candidate origins are the stations that can reach - * `destination` that day ({@link reachableInto}); each is scored with the tested - * {@link bestGetawayTo} in the O → destination direction. The result is a + * `destination` that day ({@link reachableInto}); each gets the round trip + * {@link bestGetawayTo} would give it in the O → destination direction. The result is a * {@link GetawaySweep} whose trips are RELABELLED so `.destination` names the discovered * ORIGIN (the station listed on each card, its outbound still O → destination), and whose * per-day counts / `datesByDest` are keyed by that origin — so the existing getaway list @@ -235,25 +236,27 @@ export function reverseGetawayIdeas( // per-origin bestGetawayTo re-runs findJourneys for every candidate origin and is far // too slow for a hub (tens of seconds → a frozen screen). reachableInto gives the // outbound INTO the destination per origin in one sweep; reachableJourneys from the - // destination on the return day gives the way back per origin in another. + // destination on the return day gives the way back per origin in another. Each keeps + // what getawayIdeas keeps: the earliest arrival out, the latest return home in time. const maxNights = Math.max(0, Math.floor(opts.nights ?? 0)); const minOnSite = opts.minOnSiteMin ?? SAME_DAY_MIN_ON_SITE_MIN; const sleeper = Boolean(opts.onlyNight); + const arriveCeil = sleeper ? NIGHT_RETURN_CEIL : opts.lateReturn ? LATE_RETURN_CEIL : MIDNIGHT; const nightChoices = stayChoices(maxNights, Boolean(opts.flexibleNights), sleeper); const byOrigin = new Map(); const datesByDest = new Map(); // keyed by the discovered origin const perDay: CalendarDay[] = []; for (const date of dates) { - // Outbound INTO the destination: fastest free-MAX journey O → destination whose first - // leg departs today, one per origin (a single backward multi-source sweep). - const intoMap = reachableInto(trains, destination, date, opts); + // Outbound INTO the destination: the earliest-arriving free-MAX journey O → destination + // whose first leg departs today, one per origin (a single backward multi-source sweep). + const intoMap = reachableInto(trains, destination, date, { ...opts, earliestArrival: true }); const startable = new Set(); // origins that can round-trip to the destination today for (const nights of nightChoices) { // The way back leaves the destination on the return day (a sleeper leaves the // evening after the last night — one day later), reaching each origin. const returnDay = addDays(date, sleeper ? nights + 1 : nights); - const returnsMap = reachableJourneys(trains, destination, returnDay, opts); + const returnsMap = reachableJourneys(trains, destination, returnDay, opts, arriveCeil); if (returnsMap.size === 0) continue; for (const [origin, outbound] of intoMap) { if (origin === destination || !accept(origin)) continue; @@ -338,9 +341,10 @@ export function getawaysForDay( * if so how much time it buys. A thin wrapper over the tested {@link bestGetawayTo}. * * `metric: "hours"` counts a SAME-DAY day trip (nights 0) and reports WHOLE HOURS on - * site; `metric: "nights"` counts a MULTI-DAY round trip (flexible up to the opts - * ceiling, default 3) and reports NIGHTS away. A day is green ONLY when a bookable - * round trip of that shape exists. + * site, rounded down; `metric: "nights"` counts a MULTI-DAY round trip and reports + * NIGHTS away: exactly `opts.nights`, or up to it with `opts.flexibleNights`, or up to + * 3 when no nights are given. A day is green ONLY when a bookable round trip of that + * shape exists. */ export function stayCalendar( trains: MaxTrain[], @@ -350,15 +354,17 @@ export function stayCalendar( opts: GetawayOptions = {}, metric: "hours" | "nights" = "nights", ): CalendarDay[] { - // For nights, flexibleNights keeps the longest feasible stay but needs a ceiling to - // search up to (maxNights 0 collapses to same-day only) — default up to 3 nights. + // Nights 0 would collapse to same-day only, so a nights calendar with no stay length + // searches the longest feasible stay up to 3 nights. const probe: GetawayOptions = metric === "hours" ? { ...opts, nights: 0 } - : { ...opts, nights: opts.nights && opts.nights > 0 ? opts.nights : 3, flexibleNights: true }; + : opts.nights && opts.nights > 0 + ? opts + : { ...opts, nights: 3, flexibleNights: true }; return dates.map((date) => { const g = bestGetawayTo(trains, origin, dest, date, probe); - const count = !g ? 0 : metric === "hours" ? (g.onSiteMin ? Math.round(g.onSiteMin / 60) : 0) : (g.nights ?? 0); + const count = !g ? 0 : metric === "hours" ? Math.floor((g.onSiteMin ?? 0) / 60) : g.nights; return { date, available: g != null, count }; }); } diff --git a/src/data/stations.ts b/src/data/stations.ts index e0a0378..5ffa668 100644 --- a/src/data/stations.ts +++ b/src/data/stations.ts @@ -2,12 +2,20 @@ import type { Station } from "../types"; import { CITY_REFERENCE } from "./cities"; import { NON_BOOKABLE_PATTERNS } from "../config"; -/** Lowercase, strip accents/diacritics for tolerant matching. */ +/** + * Match key for station and city names, typed, linked or stored: accents and case folded, + * hyphens, brackets and commas read as spaces, and "St"/"Ste" as "Saint"/"Sainte", so + * "St-Pierre" and "saint pierre" agree, as do "ESSLINGEN(NECKAR)" and "Esslingen Neckar". + */ export function normalizeText(s: string): string { return s .normalize("NFD") .replace(/[̀-ͯ]/g, "") .toLowerCase() + .replace(/[-(),]/g, " ") + .replace(/\bst\b/g, "saint") + .replace(/\bste\b/g, "sainte") + .replace(/\s+/g, " ") .trim(); } @@ -33,21 +41,6 @@ export function isAirportStation(id: string): boolean { return AIRPORT_MARKERS.some((m) => u.includes(m)); } -/** - * Tolerant match key for city resolution: accent/case-folded, hyphens → spaces, - * "St"/"Ste" → "Saint"/"Sainte". Lets "ST MALO", "Saint-Malo" and "SAINT MALO" - * all resolve to the same city. - */ -export function matchNorm(s: string): string { - return normalizeText(s) - .replace(/[(),]/g, " ") // e.g. "ESSLINGEN(NECKAR)" -> "esslingen neckar" - .replace(/-/g, " ") - .replace(/\bst\b/g, "saint") - .replace(/\bste\b/g, "sainte") - .replace(/\s+/g, " ") - .trim(); -} - /** Fallback display name for a station id not present in the registry. */ export function prettyLabel(id: string): string { return id @@ -78,6 +71,8 @@ export class StationRegistry { private cityKeys: { key: string; info: CityInfo }[] = []; // Ids actually present in the loaded dataset (i.e. bookable). private present = new Set(); + // Normalised name → station, for resolve(); rebuilt after addMissing(). + private exact: Map | null = null; constructor(stations: Station[]) { for (const s of stations) this.add(s); @@ -85,7 +80,7 @@ export class StationRegistry { const refMap = new Map(); const addRef = (name: string | undefined, info: CityInfo): void => { if (!name) return; - const k = matchNorm(name); + const k = normalizeText(name); if (k && !refMap.has(k)) refMap.set(k, info); }; // Seed from curated stations (authoritative coords), then the supplementary @@ -117,7 +112,7 @@ export class StationRegistry { /** Best city reference matching a station id (whole-word, longest match). */ private matchCity(id: string): CityInfo | undefined { - const n = matchNorm(id); + const n = normalizeText(id); if (!n) return undefined; for (const { key, info } of this.cityKeys) { if (n === key || n.startsWith(`${key} `) || n.endsWith(` ${key}`) || n.includes(` ${key} `)) { @@ -134,6 +129,7 @@ export class StationRegistry { * Every id passed here is also recorded as "present" (bookable). */ addMissing(ids: Iterable): void { + this.exact = null; for (const id of ids) { if (!id) continue; this.present.add(id); @@ -154,19 +150,6 @@ export class StationRegistry { return this.byId.get(id); } - /** The id a linked name means: a bookable id as is, else the id, label or alias equal to - * it ignoring case, accents and "St"/hyphen spelling, preferring one with trains. */ - resolve(name: string): string | undefined { - if (this.present.has(name)) return name; - const n = matchNorm(name); - let best: Station | undefined; - for (const { station } of this.index) { - const names = [station.id, station.label, ...(station.aliases ?? [])]; - if (names.some((s) => matchNorm(s) === n) && (!best || this.better(station, best))) best = station; - } - return best?.id; - } - /** Every registered station (may contain label duplicates). */ all(): Station[] { return [...this.byId.values()]; @@ -200,6 +183,41 @@ export class StationRegistry { return s && Number.isFinite(s.lat) && Number.isFinite(s.lng) ? [s.lat, s.lng] : undefined; } + /** + * The station a name means, typed or linked: a bookable id as is; else the station whose + * id, label or alias it spells under {@link normalizeText}; else the only station it + * matches. Several matches, or none, leave it unresolved, never a guess. + */ + resolve(name: string): string | undefined { + if (this.present.has(name)) return name; + const q = normalizeText(name); + if (!q) return undefined; + this.exact ??= this.exactNames(); + const exact = this.exact.get(q); + if (exact) return exact.id; + const hits = this.search(q, 2); + return hits.length === 1 ? hits[0]!.id : undefined; + } + + /** Every bookable station by its normalised names: an id or label outranks another + * station's alias, and among equal names the station with trains wins. */ + private exactNames(): Map { + const out = new Map(); + const stations = this.all().filter((s) => !isNonBookable(s.id)); + const tiers = [(s: Station) => s.aliases ?? [], (s: Station) => [s.id, s.label]]; + for (const names of tiers) { + const tier = new Map(); + for (const s of stations) + for (const n of names(s)) { + const k = normalizeText(n); + const cur = tier.get(k); + if (!cur || this.better(s, cur)) tier.set(k, s); + } + for (const [k, s] of tier) out.set(k, s); // later tiers overwrite earlier ones + } + return out; + } + /** Accent-insensitive, word-prefix-aware autocomplete (deduped by label). */ search(query: string, limit = 8): Station[] { const q = normalizeText(query); diff --git a/src/i18n/ar.ts b/src/i18n/ar.ts index f4d984a..2e8a137 100644 --- a/src/i18n/ar.ts +++ b/src/i18n/ar.ts @@ -75,6 +75,8 @@ export const ar: Dict = { nearby_to_dest: "الوصول قرب {station}", nearby_both: "الانطلاق والوصول من محطات قريبة", nearby_km: "على بعد {km} كم", + nearby_from_km: "من {station} · {km} كم", + nearby_to_km: "إلى {station} · {km} كم", nearby_none: "لا توجد محطة قريبة بها مقعد MAX لهذا المسار.", nearby_browse_from: "وجهات إضافية يمكنك الوصول إليها بدفع رحلة قصيرة إلى محطة قريبة.", nearby_browse_to: "نقاط انطلاق إضافية تصل إلى محطة قرب وجهتك — ادفع رحلة قصيرة من هناك.", @@ -115,6 +117,7 @@ export const ar: Dict = { res_destinations: "{n} وجهة", res_origins: "{n} نقطة انطلاق", badge_trains: "{n} قطار", + badge_days: "{n} يوم/أيام", lbl_direct: "مباشر", lbl_airport: "مطار", lbl_via: "عبر {hub}", @@ -212,11 +215,15 @@ export const ar: Dict = { getaway_count: "{n} رحلة ذهاب وإياب ممكنة", getaway_nights: "{n} ليلة/ليالٍ هناك", getaway_none: "لا توجد رحلات MAX ذهابًا وإيابًا بهذه المعايير.", + getaway_oneway_btn: "عرض وجهات الذهاب فقط", + day_with_seats: "عرض {date} (مقاعد متاحة)", + try_nights: "ابقَ {n} ليلة/ليالٍ بدلًا من ذلك", act_save: "حفظ", act_saved: "محفوظ", act_unsave: "إزالة من الرحلات المحفوظة", saved_title: "الرحلات المحفوظة", + menu_saved: "الرحلات المحفوظة والمفضلة", saved_see_all: "عرض الكل ({n})", saved_count: "{n} رحلة محفوظة", saved_none: "لا توجد رحلات محفوظة بعد.", @@ -257,14 +264,15 @@ export const ar: Dict = { trip_book_note: "يُحجز كل مقطع على حدة عبر SNCF Connect.", outbound_change: "المغادرة: {date} · تغيير", return_change: "العودة: {date} · تغيير", - daytrip_cal_hours: "{h} ساعة هناك", + daytrip_cal_hours: "{dur} هناك", cal_legend_hours: "الرقم = ساعات هناك", cal_legend_nights: "الرقم = عدد الليالي", + cal_legend_return_stay: "الرقم = ساعات هناك في نفس اليوم، وإلا عدد الليالي", cal_legend_depart: "العدد = قطارات الذهاب", cal_legend_return: "العدد = قطارات العودة", res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}", rt_return_leg: "العودة", - rt_finder_title: "إلى أين تهرب؟", + rt_finder_title: "رحلات ذهاب وعودة من {station} — {date} · {stay}", rt_need_origin: "أضف نقطة انطلاق لمعرفة أين يمكنك الذهاب والعودة.", rt_reverse_title: "من أين يمكنك الذهاب والعودة للوصول إلى {station}؟", rt_reverse_count: "{n} نقطة انطلاق ممكنة", @@ -274,6 +282,7 @@ export const ar: Dict = { senior_weekend_return_warn: "MAX SENIOR صالحة أيام الأسبوع فقط — عودتك في نهاية الأسبوع ولا يمكن حجزها مجانًا.", form_cal_title: "متى تسافر؟", form_cal_hint: "اختر محطة انطلاق لعرض الأيام المتاحة.", + need_origin: "اختر محطة المغادرة.", form_cal_departed: "المغادرة: {date}", form_cal_flex_hint: "اختر الذهاب ثم العودة", form_cal_range: "الذهاب: {from} ← العودة: {to}", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index ed5ef37..073eaba 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -75,6 +75,8 @@ export const de: Dict = { nearby_to_dest: "Ankunft nahe {station}", nearby_both: "Abfahrt und Ankunft in der Nähe", nearby_km: "{km} km entfernt", + nearby_from_km: "ab {station} · {km} km", + nearby_to_km: "bis {station} · {km} km", nearby_none: "Kein naher Bahnhof hat einen MAX-Platz für diese Strecke.", nearby_browse_from: "Zusätzliche Ziele, die du mit einem kurzen bezahlten Sprung zu einem nahen Bahnhof erreichst.", nearby_browse_to: "Zusätzliche Startbahnhöfe, die einen Bahnhof nahe deinem Ziel erreichen — von dort ein kurzer bezahlter Sprung.", @@ -115,6 +117,7 @@ export const de: Dict = { res_destinations: "{n} Ziel(e)", res_origins: "{n} Ausgangsort(e)", badge_trains: "{n} Zug/Züge", + badge_days: "{n} Tag(e)", lbl_direct: "Direkt", lbl_airport: "Flughafen", lbl_via: "über {hub}", @@ -213,11 +216,15 @@ export const de: Dict = { getaway_count: "{n} Hin- und Rückfahrt(en) möglich", getaway_nights: "{n} Nacht/Nächte vor Ort", getaway_none: "Keine MAX-Hin-und-Rückfahrten für diese Kriterien.", + getaway_oneway_btn: "Ziele für die einfache Fahrt ansehen", + day_with_seats: "{date} ansehen (freie Plätze)", + try_nights: "Stattdessen {n} Nacht/Nächte bleiben", act_save: "Speichern", act_saved: "Gespeichert", 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.", @@ -258,14 +265,15 @@ export const de: Dict = { trip_book_note: "Jede Fahrt wird separat auf SNCF Connect gebucht.", outbound_change: "Abfahrt: {date} · Ändern", return_change: "Rückfahrt: {date} · Ändern", - daytrip_cal_hours: "{h} Std. vor Ort", + daytrip_cal_hours: "{dur} vor Ort", cal_legend_hours: "Zahl = Stunden vor Ort", cal_legend_nights: "Zahl = Nächte", + cal_legend_return_stay: "Zahl = Stunden vor Ort am selben Tag, sonst Nächte", cal_legend_depart: "Zahl = Hinfahrten", cal_legend_return: "Zahl = Rückfahrten", res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}", rt_return_leg: "Rückfahrt", - rt_finder_title: "Wohin verreisen?", + rt_finder_title: "Hin und zurück ab {station} — {date} · {stay}", rt_need_origin: "Gib einen Abfahrtsort ein, um Hin- und Rückfahrten zu sehen.", rt_reverse_title: "Von wo aus erreichst du {station} per Hin- und Rückfahrt?", rt_reverse_count: "{n} mögliche(r) Abfahrtsort(e)", @@ -275,6 +283,7 @@ export const de: Dict = { senior_weekend_return_warn: "MAX SENIOR gilt nur werktags — Ihre Rückfahrt fällt auf ein Wochenende und ist nicht kostenlos buchbar.", form_cal_title: "Wann losfahren?", form_cal_hint: "Wählen Sie einen Abfahrtsbahnhof, um verfügbare Tage zu sehen.", + need_origin: "Wähle einen Abfahrtsbahnhof.", form_cal_departed: "Abfahrt: {date}", form_cal_flex_hint: "Erst die Hinfahrt, dann die Rückfahrt wählen", form_cal_range: "Hin: {from} → Rück: {to}", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 0f52150..7240a33 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -79,6 +79,8 @@ export const en: Dict = { nearby_to_dest: "Arrive near {station}", nearby_both: "Leave from and arrive near", nearby_km: "{km} km away", + nearby_from_km: "from {station} · {km} km", + nearby_to_km: "to {station} · {km} km", nearby_none: "No nearby station has a free MAX seat for this route.", nearby_browse_from: "Extra destinations you can reach by paying a short hop to a nearby station.", nearby_browse_to: "Extra origins that reach a station near your destination — pay a short hop from there.", @@ -119,6 +121,7 @@ export const en: Dict = { res_destinations: "{n} destination(s)", res_origins: "{n} origin(s)", badge_trains: "{n} train(s)", + badge_days: "{n} day(s)", lbl_direct: "Direct", lbl_airport: "Airport", @@ -225,11 +228,15 @@ export const en: Dict = { getaway_count: "{n} round trip(s) possible", getaway_nights: "{n} night(s) there", getaway_none: "No MAX round trips for these criteria.", + getaway_oneway_btn: "See one-way destinations", + day_with_seats: "See {date} (free seats)", + try_nights: "Stay {n} night(s) instead", act_save: "Save", act_saved: "Saved", 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.", @@ -270,14 +277,15 @@ export const en: Dict = { trip_book_note: "Each leg is booked separately on SNCF Connect.", outbound_change: "Departure: {date} · Change", return_change: "Return: {date} · Change", - daytrip_cal_hours: "{h}h on site", + daytrip_cal_hours: "{dur} on site", cal_legend_hours: "number = hours on site", cal_legend_nights: "number = nights there", + cal_legend_return_stay: "number = hours on site the same day, else nights there", cal_legend_depart: "number = outbound trains", cal_legend_return: "number = return trains", res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}", rt_return_leg: "Return", - rt_finder_title: "Where can you get away to?", + rt_finder_title: "Round trips from {station} — {date} · {stay}", rt_need_origin: "Add a departure to see where you can go and come back.", rt_reverse_title: "Where can you round-trip from to reach {station}?", rt_reverse_count: "{n} possible departure point(s)", @@ -287,6 +295,7 @@ export const en: Dict = { senior_weekend_return_warn: "MAX SENIOR is weekday-only — your return lands on a weekend and can't be booked free.", form_cal_title: "When to leave?", form_cal_hint: "Choose a departure station to see which days are available.", + need_origin: "Choose a departure station.", form_cal_departed: "Departure: {date}", form_cal_flex_hint: "Pick the outbound, then the return", form_cal_range: "Departure: {from} → Return: {to}", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index cf36eb3..6ccfc0a 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -75,6 +75,8 @@ export const es: Dict = { nearby_to_dest: "Llegar cerca de {station}", nearby_both: "Salir y llegar cerca", nearby_km: "a {km} km", + nearby_from_km: "desde {station} · {km} km", + nearby_to_km: "hacia {station} · {km} km", nearby_none: "Ninguna estación cercana tiene plaza MAX para este trayecto.", nearby_browse_from: "Destinos adicionales a los que puedes llegar pagando un trayecto corto hasta una estación cercana.", nearby_browse_to: "Orígenes adicionales que llegan a una estación cerca de tu destino — paga un trayecto corto desde allí.", @@ -115,6 +117,7 @@ export const es: Dict = { res_destinations: "{n} destino(s)", res_origins: "{n} origen(es)", badge_trains: "{n} tren(es)", + badge_days: "{n} día(s)", lbl_direct: "Directo", lbl_airport: "Aeropuerto", lbl_via: "vía {hub}", @@ -213,11 +216,15 @@ export const es: Dict = { getaway_count: "{n} ida(s) y vuelta(s) posible(s)", getaway_nights: "{n} noche(s) allí", getaway_none: "Ningún viaje MAX de ida y vuelta para estos criterios.", + getaway_oneway_btn: "Ver destinos solo ida", + day_with_seats: "Ver el {date} (plazas libres)", + try_nights: "Quedarse {n} noche(s)", act_save: "Guardar", act_saved: "Guardado", 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.", @@ -258,14 +265,15 @@ export const es: Dict = { trip_book_note: "Cada trayecto se reserva por separado en SNCF Connect.", outbound_change: "Salida: {date} · Cambiar", return_change: "Vuelta: {date} · Cambiar", - daytrip_cal_hours: "{h} h en destino", + daytrip_cal_hours: "{dur} en destino", cal_legend_hours: "número = horas en destino", cal_legend_nights: "número = noches allí", + cal_legend_return_stay: "número = horas en destino el mismo día, si no noches", cal_legend_depart: "número = trenes de ida", cal_legend_return: "número = trenes de vuelta", res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}", rt_return_leg: "Vuelta", - rt_finder_title: "¿A dónde escaparse?", + rt_finder_title: "Idas y vueltas desde {station} — {date} · {stay}", rt_need_origin: "Añade una salida para ver a dónde ir y volver.", rt_reverse_title: "¿Desde dónde puedes ir y volver a {station}?", rt_reverse_count: "{n} punto(s) de salida posible(s)", @@ -275,6 +283,7 @@ export const es: Dict = { senior_weekend_return_warn: "MAX SENIOR es solo entre semana — tu vuelta cae en fin de semana y no puede reservarse gratis.", form_cal_title: "¿Cuándo salir?", form_cal_hint: "Elige una estación de salida para ver qué días hay disponibles.", + need_origin: "Elige una estación de salida.", form_cal_departed: "Salida: {date}", form_cal_flex_hint: "Elige la ida y luego la vuelta", form_cal_range: "Ida: {from} → Vuelta: {to}", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index 5fb6061..32e92dd 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -77,6 +77,8 @@ export const fr = { nearby_to_dest: "Arriver près de {station}", nearby_both: "Partir et arriver près", nearby_km: "à {km} km", + nearby_from_km: "depuis {station} · {km} km", + nearby_to_km: "vers {station} · {km} km", nearby_none: "Aucune gare voisine n'a de place MAX pour ce trajet.", nearby_browse_from: "Des destinations en plus, en payant un court trajet jusqu'à une gare voisine.", nearby_browse_to: "Des points de départ en plus qui atteignent une gare proche de votre arrivée — payez un court trajet depuis là.", @@ -117,6 +119,7 @@ export const fr = { res_destinations: "{n} destination(s)", res_origins: "{n} origine(s)", badge_trains: "{n} train(s)", + badge_days: "{n} jour(s)", lbl_direct: "Direct", lbl_airport: "Aéroport", @@ -223,11 +226,15 @@ export const fr = { getaway_count: "{n} aller(s)-retour(s) possible(s)", getaway_nights: "{n} nuit(s) sur place", getaway_none: "Aucun aller-retour MAX pour ces critères.", + getaway_oneway_btn: "Voir les destinations en aller simple", + day_with_seats: "Voir le {date} (places libres)", + try_nights: "Rester {n} nuit(s) à la place", act_save: "Enregistrer", act_saved: "Enregistré", 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.", @@ -272,14 +279,15 @@ export const fr = { trip_book_note: "Chaque trajet se réserve séparément sur SNCF Connect.", outbound_change: "Départ : {date} · Changer", return_change: "Retour : {date} · Changer", - daytrip_cal_hours: "{h} h sur place", + daytrip_cal_hours: "{dur} sur place", cal_legend_hours: "nombre = heures sur place", cal_legend_nights: "nombre = nuits sur place", + cal_legend_return_stay: "nombre = heures sur place le jour même, sinon nuits", cal_legend_depart: "nombre = trains à l’aller", cal_legend_return: "nombre = trains au retour", res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}", rt_return_leg: "Retour", - rt_finder_title: "Où s'évader ?", + rt_finder_title: "Allers-retours depuis {station} — {date} · {stay}", rt_need_origin: "Ajoutez un départ pour voir où aller et revenir.", rt_reverse_title: "D’où partir pour rejoindre {station} ?", rt_reverse_count: "{n} point(s) de départ possible(s)", @@ -290,6 +298,7 @@ export const fr = { // The reactive availability calendar on the home form (the Trip tab's date picker). form_cal_title: "Quand partir ?", form_cal_hint: "Choisissez une gare de départ pour voir les jours disponibles.", + need_origin: "Choisissez une gare de départ.", form_cal_departed: "Départ : {date}", // Flexible: the inline calendar becomes a departure→return range picker. form_cal_flex_hint: "Choisissez l'aller, puis le retour", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index 2c45630..5e150cd 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -75,6 +75,8 @@ export const it: Dict = { nearby_to_dest: "Arrivo vicino a {station}", nearby_both: "Partenza e arrivo nelle vicinanze", nearby_km: "a {km} km", + nearby_from_km: "da {station} · {km} km", + nearby_to_km: "verso {station} · {km} km", nearby_none: "Nessuna stazione vicina ha un posto MAX per questo tragitto.", nearby_browse_from: "Destinazioni in più raggiungibili pagando un breve tratto fino a una stazione vicina.", nearby_browse_to: "Partenze in più che raggiungono una stazione vicino al tuo arrivo — paga un breve tratto da lì.", @@ -115,6 +117,7 @@ export const it: Dict = { res_destinations: "{n} destinazione/i", res_origins: "{n} origine/i", badge_trains: "{n} treno/i", + badge_days: "{n} giorno/i", lbl_direct: "Diretto", lbl_airport: "Aeroporto", lbl_via: "via {hub}", @@ -213,11 +216,15 @@ export const it: Dict = { getaway_count: "{n} andata/e e ritorno possibile/i", getaway_nights: "{n} notte/notti sul posto", getaway_none: "Nessun andata e ritorno MAX per questi criteri.", + getaway_oneway_btn: "Vedi le destinazioni solo andata", + day_with_seats: "Vedi {date} (posti liberi)", + try_nights: "Resta {n} notte/notti", act_save: "Salva", act_saved: "Salvato", 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.", @@ -258,14 +265,15 @@ export const it: Dict = { trip_book_note: "Ogni tratta si prenota separatamente su SNCF Connect.", outbound_change: "Partenza: {date} · Cambia", return_change: "Ritorno: {date} · Cambia", - daytrip_cal_hours: "{h} h sul posto", + daytrip_cal_hours: "{dur} sul posto", cal_legend_hours: "numero = ore sul posto", cal_legend_nights: "numero = notti sul posto", + cal_legend_return_stay: "numero = ore sul posto in giornata, altrimenti notti", cal_legend_depart: "numero = treni di andata", cal_legend_return: "numero = treni di ritorno", res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}", rt_return_leg: "Ritorno", - rt_finder_title: "Dove evadere?", + rt_finder_title: "Andata e ritorno da {station} — {date} · {stay}", rt_need_origin: "Aggiungi una partenza per vedere dove andare e tornare.", rt_reverse_title: "Da dove puoi fare andata e ritorno per {station}?", rt_reverse_count: "{n} punto/i di partenza possibile/i", @@ -275,6 +283,7 @@ export const it: Dict = { senior_weekend_return_warn: "MAX SENIOR vale nei giorni feriali — il tuo ritorno cade nel weekend e non è prenotabile gratis.", form_cal_title: "Quando partire?", form_cal_hint: "Scegli una stazione di partenza per vedere i giorni disponibili.", + need_origin: "Scegli una stazione di partenza.", form_cal_departed: "Partenza: {date}", form_cal_flex_hint: "Scegli l'andata, poi il ritorno", form_cal_range: "Andata: {from} → Ritorno: {to}", diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 04a7754..cc8943a 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -75,6 +75,8 @@ export const ja: Dict = { nearby_to_dest: "{station} 付近に到着", nearby_both: "近くで出発して近くに到着", nearby_km: "{km} km", + nearby_from_km: "{station} 発 · {km} km", + nearby_to_km: "{station} 着 · {km} km", nearby_none: "この経路に MAX 席のある近隣駅はありません。", nearby_browse_from: "近くの駅まで短い区間を支払えば行ける、追加の目的地。", nearby_browse_to: "目的地の近くの駅に着く追加の出発地 — そこから短い区間だけ支払います。", @@ -115,6 +117,7 @@ export const ja: Dict = { res_destinations: "目的地 {n} 件", res_origins: "出発地 {n} 件", badge_trains: "列車 {n} 本", + badge_days: "{n} 日", lbl_direct: "直通", lbl_airport: "空港", lbl_via: "{hub} 経由", @@ -212,11 +215,15 @@ export const ja: Dict = { getaway_count: "往復 {n} 件が可能", getaway_nights: "{n}泊滞在", getaway_none: "この条件では MAX 往復がありません。", + getaway_oneway_btn: "片道の行き先を見る", + day_with_seats: "{date} を見る(空席あり)", + try_nights: "代わりに {n} 泊する", act_save: "保存", act_saved: "保存済み", act_unsave: "保存した旅程から削除", saved_title: "保存した旅程", + menu_saved: "保存した旅程とお気に入り", saved_see_all: "すべて表示 ({n})", saved_count: "保存した旅程 {n} 件", saved_none: "保存した旅程はまだありません。", @@ -257,14 +264,15 @@ export const ja: Dict = { trip_book_note: "各区間は SNCF Connect で個別に予約します。", outbound_change: "出発:{date} · 変更", return_change: "復路:{date} · 変更", - daytrip_cal_hours: "現地 {h} 時間", + daytrip_cal_hours: "現地 {dur}", cal_legend_hours: "数字 = 現地滞在時間", cal_legend_nights: "数字 = 泊数", + cal_legend_return_stay: "数字 = 日帰りは現地滞在時間、それ以外は泊数", cal_legend_depart: "数字 = 往路の列車", cal_legend_return: "数字 = 復路の列車", res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}", rt_return_leg: "復路", - rt_finder_title: "どこへ出かけますか?", + rt_finder_title: "{station} 発の往復 — {date} · {stay}", rt_need_origin: "出発地を入力すると往復できる場所が表示されます。", rt_reverse_title: "{station} へ往復できる出発地は?", rt_reverse_count: "出発地 {n} か所", @@ -274,6 +282,7 @@ export const ja: Dict = { senior_weekend_return_warn: "MAX SENIOR は平日限定です — 復路が週末のため無料予約できません。", form_cal_title: "いつ出発?", form_cal_hint: "出発駅を選ぶと空いている日が表示されます。", + need_origin: "出発駅を選んでください。", form_cal_departed: "出発:{date}", form_cal_flex_hint: "往路を選び、次に復路を選択", form_cal_range: "往路:{from} → 復路:{to}", diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index 1519624..c3dcd93 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -75,6 +75,8 @@ export const ko: Dict = { nearby_to_dest: "{station} 근처에 도착", nearby_both: "근처에서 출발하고 도착", nearby_km: "{km} km", + nearby_from_km: "{station} 출발 · {km} km", + nearby_to_km: "{station} 도착 · {km} km", nearby_none: "이 경로에 MAX 좌석이 있는 인근 역이 없습니다.", nearby_browse_from: "가까운 역까지 짧은 구간을 결제하면 갈 수 있는 추가 목적지.", nearby_browse_to: "도착지 근처 역에 닿는 추가 출발지 — 거기서 짧은 구간만 결제하세요.", @@ -115,6 +117,7 @@ export const ko: Dict = { res_destinations: "목적지 {n}곳", res_origins: "출발지 {n}곳", badge_trains: "열차 {n}대", + badge_days: "{n}일", lbl_direct: "직통", lbl_airport: "공항", lbl_via: "{hub} 경유", @@ -213,11 +216,15 @@ export const ko: Dict = { getaway_count: "왕복 {n}건 가능", getaway_nights: "{n}박 체류", getaway_none: "이 조건으로 가능한 MAX 왕복이 없습니다.", + getaway_oneway_btn: "편도 목적지 보기", + day_with_seats: "{date} 보기 (빈자리 있음)", + try_nights: "대신 {n}박 머물기", act_save: "저장", act_saved: "저장됨", act_unsave: "저장한 여행에서 제거", saved_title: "저장한 여행", + menu_saved: "저장한 여행 및 즐겨찾기", saved_see_all: "전체 보기 ({n})", saved_count: "저장한 여행 {n}개", saved_none: "아직 저장한 여행이 없습니다.", @@ -258,14 +265,15 @@ export const ko: Dict = { trip_book_note: "각 구간은 SNCF Connect에서 따로 예약합니다.", outbound_change: "출발: {date} · 변경", return_change: "돌아오기: {date} · 변경", - daytrip_cal_hours: "현지 {h}시간", + daytrip_cal_hours: "현지 {dur}", cal_legend_hours: "숫자 = 현지 체류 시간", cal_legend_nights: "숫자 = 숙박 일수", + cal_legend_return_stay: "숫자 = 당일은 현지 체류 시간, 그 외는 숙박 일수", cal_legend_depart: "숫자 = 출발 열차", cal_legend_return: "숫자 = 귀환 열차", res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}", rt_return_leg: "돌아오기", - rt_finder_title: "어디로 떠나볼까요?", + rt_finder_title: "{station} 출발 왕복 — {date} · {stay}", rt_need_origin: "출발지를 입력하면 왕복 가능한 곳이 표시됩니다.", rt_reverse_title: "{station}까지 왕복할 수 있는 출발지는?", rt_reverse_count: "가능한 출발지 {n}곳", @@ -275,6 +283,7 @@ export const ko: Dict = { senior_weekend_return_warn: "MAX SENIOR는 평일 전용입니다 — 돌아오는 날이 주말이라 무료 예약이 안 됩니다.", form_cal_title: "언제 떠날까요?", form_cal_hint: "출발역을 선택하면 이용 가능한 날짜가 표시됩니다.", + need_origin: "출발역을 선택하세요.", form_cal_departed: "출발: {date}", form_cal_flex_hint: "가는 날을 고른 뒤 오는 날을 선택", form_cal_range: "가는 날: {from} → 오는 날: {to}", diff --git a/src/i18n/nl.ts b/src/i18n/nl.ts index 12a5193..c113d4a 100644 --- a/src/i18n/nl.ts +++ b/src/i18n/nl.ts @@ -75,6 +75,8 @@ export const nl: Dict = { nearby_to_dest: "Aankomst bij {station} in de buurt", nearby_both: "Vertrek en aankomst in de buurt", nearby_km: "{km} km", + nearby_from_km: "vanaf {station} · {km} km", + nearby_to_km: "naar {station} · {km} km", nearby_none: "Geen nabijgelegen station heeft een MAX-plaats voor deze route.", nearby_browse_from: "Extra bestemmingen die je bereikt door een korte hop naar een nabijgelegen station te betalen.", nearby_browse_to: "Extra vertrekpunten die een station bij je bestemming bereiken — betaal daar een korte hop.", @@ -115,6 +117,7 @@ export const nl: Dict = { res_destinations: "{n} bestemming(en)", res_origins: "{n} vertrekpunt(en)", badge_trains: "{n} trein(en)", + badge_days: "{n} dag(en)", lbl_direct: "Rechtstreeks", lbl_airport: "Luchthaven", lbl_via: "via {hub}", @@ -212,11 +215,15 @@ export const nl: Dict = { getaway_count: "{n} retour(s) mogelijk", getaway_nights: "{n} nacht(en) ter plaatse", getaway_none: "Geen MAX-retours voor deze criteria.", + getaway_oneway_btn: "Bekijk bestemmingen voor enkele reis", + day_with_seats: "Bekijk {date} (vrije plaatsen)", + try_nights: "Blijf {n} nacht(en)", act_save: "Opslaan", act_saved: "Opgeslagen", 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.", @@ -257,14 +264,15 @@ export const nl: Dict = { trip_book_note: "Elke rit wordt apart geboekt op SNCF Connect.", outbound_change: "Vertrek: {date} · Wijzigen", return_change: "Terugreis: {date} · Wijzigen", - daytrip_cal_hours: "{h} u ter plaatse", + daytrip_cal_hours: "{dur} ter plaatse", cal_legend_hours: "getal = uren ter plaatse", cal_legend_nights: "getal = nachten ter plaatse", + cal_legend_return_stay: "getal = uren ter plaatse op dezelfde dag, anders nachten", cal_legend_depart: "aantal = heentreinen", cal_legend_return: "aantal = terugtreinen", res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}", rt_return_leg: "Terugreis", - rt_finder_title: "Waar kun je heen ontsnappen?", + rt_finder_title: "Retours vanaf {station} — {date} · {stay}", rt_need_origin: "Voeg een vertrekpunt toe om te zien waar je heen en terug kunt.", rt_reverse_title: "Vanwaar kun je heen en terug naar {station}?", rt_reverse_count: "{n} mogelijk(e) vertrekpunt(en)", @@ -274,6 +282,7 @@ export const nl: Dict = { senior_weekend_return_warn: "MAX SENIOR geldt alleen doordeweeks — je terugreis valt in het weekend en is niet gratis te boeken.", form_cal_title: "Wanneer vertrekken?", form_cal_hint: "Kies een vertrekstation om te zien welke dagen beschikbaar zijn.", + need_origin: "Kies een vertrekstation.", form_cal_departed: "Vertrek: {date}", form_cal_flex_hint: "Kies de heenreis, dan de terugreis", form_cal_range: "Heen: {from} → Terug: {to}", diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts index 7475064..28d6fa4 100644 --- a/src/i18n/pt.ts +++ b/src/i18n/pt.ts @@ -75,6 +75,8 @@ export const pt: Dict = { nearby_to_dest: "Chegar perto de {station}", nearby_both: "Partir e chegar perto", nearby_km: "a {km} km", + nearby_from_km: "a partir de {station} · {km} km", + nearby_to_km: "até {station} · {km} km", nearby_none: "Nenhuma estação próxima tem lugar MAX para este trajeto.", nearby_browse_from: "Destinos extra que pode alcançar pagando um pequeno trajeto até uma estação próxima.", nearby_browse_to: "Origens extra que chegam a uma estação perto do seu destino — pague um pequeno trajeto a partir daí.", @@ -115,6 +117,7 @@ export const pt: Dict = { res_destinations: "{n} destino(s)", res_origins: "{n} origem(ns)", badge_trains: "{n} trem(ns)", + badge_days: "{n} dia(s)", lbl_direct: "Direto", lbl_airport: "Aeroporto", lbl_via: "via {hub}", @@ -212,11 +215,15 @@ export const pt: Dict = { getaway_count: "{n} ida(s) e volta(s) possível(is)", getaway_nights: "{n} noite(s) no local", getaway_none: "Nenhuma ida e volta MAX para estes critérios.", + getaway_oneway_btn: "Ver destinos só de ida", + day_with_seats: "Ver {date} (lugares livres)", + try_nights: "Ficar {n} noite(s)", act_save: "Guardar", act_saved: "Guardado", 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.", @@ -257,14 +264,15 @@ export const pt: Dict = { trip_book_note: "Cada trajeto é reservado separadamente no SNCF Connect.", outbound_change: "Partida: {date} · Alterar", return_change: "Volta: {date} · Alterar", - daytrip_cal_hours: "{h} h no destino", + daytrip_cal_hours: "{dur} no destino", cal_legend_hours: "número = horas no destino", cal_legend_nights: "número = noites no local", + cal_legend_return_stay: "número = horas no destino no mesmo dia, senão noites", cal_legend_depart: "número = comboios de ida", cal_legend_return: "número = comboios de volta", res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}", rt_return_leg: "Volta", - rt_finder_title: "Para onde escapar?", + rt_finder_title: "Idas e voltas a partir de {station} — {date} · {stay}", rt_need_origin: "Adicione uma origem para ver para onde ir e voltar.", rt_reverse_title: "De onde pode ir e voltar até {station}?", rt_reverse_count: "{n} ponto(s) de partida possível(is)", @@ -274,6 +282,7 @@ export const pt: Dict = { senior_weekend_return_warn: "MAX SENIOR é só em dias úteis — a sua volta cai no fim de semana e não pode ser reservada de graça.", form_cal_title: "Quando partir?", form_cal_hint: "Escolha uma estação de partida para ver os dias disponíveis.", + need_origin: "Escolha uma estação de partida.", form_cal_departed: "Partida: {date}", form_cal_flex_hint: "Escolha a ida e depois a volta", form_cal_range: "Ida: {from} → Volta: {to}", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 5c633ac..30d4398 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -75,6 +75,8 @@ export const zh: Dict = { nearby_to_dest: "到达{station}附近", nearby_both: "就近出发并就近到达", nearby_km: "{km} 公里", + nearby_from_km: "从 {station} 出发 · {km} 公里", + nearby_to_km: "到 {station} · {km} 公里", nearby_none: "附近没有车站为此线路提供 MAX 座位。", nearby_browse_from: "支付一小段车程到邻近车站,即可抵达的额外目的地。", nearby_browse_to: "可到达你目的地附近车站的额外出发地——从那里再付一小段车程。", @@ -115,6 +117,7 @@ export const zh: Dict = { res_destinations: "{n} 个目的地", res_origins: "{n} 个出发地", badge_trains: "{n} 趟列车", + badge_days: "{n} 天", lbl_direct: "直达", lbl_airport: "机场", lbl_via: "经 {hub}", @@ -212,11 +215,15 @@ export const zh: Dict = { getaway_count: "可行往返 {n} 个", getaway_nights: "停留 {n} 晚", getaway_none: "在这些条件下没有 MAX 往返。", + getaway_oneway_btn: "查看单程目的地", + day_with_seats: "查看 {date}(有空位)", + try_nights: "改为停留 {n} 晚", act_save: "保存", act_saved: "已保存", act_unsave: "从已保存行程中移除", saved_title: "已保存行程", + menu_saved: "已保存行程和收藏", saved_see_all: "查看全部 ({n})", saved_count: "{n} 个已保存行程", saved_none: "暂无已保存行程。", @@ -257,14 +264,15 @@ export const zh: Dict = { trip_book_note: "每程在 SNCF Connect 上分别预订。", outbound_change: "出发:{date} · 更改", return_change: "返程:{date} · 更改", - daytrip_cal_hours: "当地 {h} 小时", + daytrip_cal_hours: "当地 {dur}", cal_legend_hours: "数字 = 当地小时数", cal_legend_nights: "数字 = 停留晚数", + cal_legend_return_stay: "数字 = 当日为当地小时数,否则为停留晚数", cal_legend_depart: "数字 = 出发列车", cal_legend_return: "数字 = 返程列车", res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}", rt_return_leg: "返程", - rt_finder_title: "去哪里度假?", + rt_finder_title: "从 {station} 出发的往返 — {date} · {stay}", rt_need_origin: "填写出发地,即可查看可往返的目的地。", rt_reverse_title: "从哪里可以往返到达 {station}?", rt_reverse_count: "{n} 个可能的出发地", @@ -274,6 +282,7 @@ export const zh: Dict = { senior_weekend_return_warn: "MAX SENIOR 仅限工作日——您的返程在周末,无法免费预订。", form_cal_title: "何时出发?", form_cal_hint: "选择出发车站即可查看可用的日期。", + need_origin: "请选择出发车站。", form_cal_departed: "出发:{date}", form_cal_flex_hint: "先选去程,再选返程", form_cal_range: "去程:{from} → 返程:{to}", diff --git a/src/styles.css b/src/styles.css index 0e3c4ec..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); @@ -471,6 +475,10 @@ a { .input::placeholder { color: var(--faint); } +/* A field kept in place but inert (the same-day minimum while Flexible is on). */ +.input:disabled { + opacity: 0.45; +} /* A station field whose text matches no real station. */ .input.is-invalid { border-color: #dc2626; @@ -482,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; @@ -556,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); @@ -866,12 +874,15 @@ a { .form-cal-toggle:hover { border-color: var(--accent); } +/* The picked day sits on its own line under the title in every trip shape, so the longer + Flexible range never grows the header when Flexible toggles. */ .form-cal-heading { display: flex; - align-items: baseline; - flex-wrap: wrap; - gap: 0.5rem; + flex-direction: column; + align-items: flex-start; + gap: 0.1rem; min-width: 0; + text-align: start; } .form-cal-title { font-weight: 600; @@ -1222,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; @@ -1433,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; @@ -1644,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; } @@ -1672,12 +1681,9 @@ a { font-weight: 560; font-variant-numeric: tabular-nums; } -/* Two chips side by side on an Ideas row (train count + days reachable). */ +/* An Ideas row's chips wrap one by one inside the row's meta line. */ .row-chips { - display: inline-flex; - align-items: center; - gap: 0.35rem; - flex: none; + display: contents; } .chev { margin-left: auto; @@ -1769,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 { @@ -1968,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. */ @@ -1990,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; @@ -1998,9 +1995,6 @@ a { max-width: 100%; } .stn-airport .stn-text { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; min-width: 0; } .layover { @@ -2064,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; @@ -2081,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; } } @@ -2827,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); } @@ -3315,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; @@ -3584,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; @@ -3841,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; @@ -4271,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; @@ -4444,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/form.ts b/src/ui/form.ts index 7d8cef4..87ce90d 100644 --- a/src/ui/form.ts +++ b/src/ui/form.ts @@ -1150,8 +1150,10 @@ export function createForm(props: FormProps): FormHandle { }; // "Minimum time there" only means something for a SAME-DAY round trip (a fixed 0-night // return), on the single-trip tab — a stay with nights or a one-way has no on-site gate. + // Flexible leaves it in place but inert, as it does the stepper, so the form never shifts. const syncStayMinField = (): void => { - stayMinField.style.display = currentTrip === "simple" && roundTrip && !flexible && nights === 0 ? "" : "none"; + stayMinField.style.display = currentTrip === "simple" && roundTrip && nights === 0 ? "" : "none"; + stayMin.disabled = flexible; }; /** The current shape as a TripShape: one-way, Flexible (return picked on the calendar), * or the fixed stay the nights imply. */ @@ -1627,7 +1629,8 @@ export function createForm(props: FormProps): FormHandle { setFlexible: (n) => { roundTrip = true; flexible = true; - if (n != null) nights = Math.max(0, Math.min(NIGHTS_MAX, n)); + // The inert stepper reads the picked range, which may run past the stepper's own max. + if (n != null) nights = Math.max(0, n); syncTripShape(); }, toggleRound: () => { 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 6d89e1c..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. */ @@ -502,7 +500,7 @@ export function reachTripRowEl( j: Journey, ctx: RenderCtx, extra?: HTMLElement, - opts?: { hideMeta?: boolean; hideVia?: boolean }, + opts?: { hideVia?: boolean; openDate?: string }, ): HTMLElement { const route: RoutePair = { origin: j.origin, destination: j.destination }; const via = j.legs.length > 1; @@ -525,7 +523,7 @@ export function reachTripRowEl( const metaChips: HTMLElement[] = [ ...viaChip, ...(extra ? [extra] : []), - ...(opts?.hideMeta ? [] : [el("bdi", { text: formatDuration(j.totalDurationMin) })]), + el("bdi", { text: formatDuration(j.totalDurationMin) }), ]; const main = el( "button", @@ -533,12 +531,12 @@ export function reachTripRowEl( class: "dest-main dest-main-stacked", type: "button", attrs: { "aria-label": aria }, - on: { click: () => ctx.onOpenRoute(j.origin, j.destination) }, + on: { click: () => ctx.onOpenRoute(j.origin, j.destination, opts?.openDate ? { date: opts.openDate } : undefined) }, }, [ el("div", { class: "dest-body" }, [ stationNameEl("dest-name", station, ctx.label(station)), - ...(metaChips.length ? [el("span", { class: "dest-meta", attrs: { "aria-hidden": "true" } }, metaChips)] : []), + el("span", { class: "dest-meta", attrs: { "aria-hidden": "true" } }, metaChips), ]), el("span", { class: "chev", attrs: { "aria-hidden": "true" } }, [icon(I.arrow)]), ], @@ -726,15 +724,11 @@ export function listToolbarEl( } /** - * A ranked best-trip row ("best" mode). Shows the month-long train count for the - * destination (like the "Where to?" list) plus, in the all-days view, how many - * days it's reachable. + * A ranked best-trip row ("best" mode): the changes it takes, the month-long train count, + * the fastest travel time, and the figure the active `sort` ranks by when it is another + * one (days reachable, distance). Opens on the first day the destination is reachable. */ -export function bestTripRowEl(trip: BestTrip, ctx: RenderCtx, trains?: number): HTMLElement { - // Ideas is a discovery list: the city name is the point, so it always leads. A single - // "N trains" chip (the ranking signal — how well-served the route is across the month) - // rides alongside it; the duration meta is dropped so a long city name never gets - // squeezed out of the row. Both figures still live in the chip's tooltip. +export function bestTripRowEl(trip: BestTrip, ctx: RenderCtx, trains?: number, sort?: SortKey): HTMLElement { const chips: HTMLElement[] = []; // How many changes it takes to get there — the point of the Ideas list: Direct, or // "N correspondance(s)". Colour-matched to the map pins (green direct / amber 1 / red 2+). @@ -753,16 +747,19 @@ export function bestTripRowEl(trip: BestTrip, ctx: RenderCtx, trains?: number): }), ); } + if (sort === "days" && trip.days) chips.push(el("span", { class: "stat-chip", text: t("badge_days", { n: trip.days }) })); + const km = sort === "closest" ? ctx.distanceKm(trip.journey.origin, trip.destination) : Infinity; + if (Number.isFinite(km)) chips.push(el("span", { class: "stat-chip", text: t("nearby_km", { km: Math.round(km) }) })); const extra = el("span", { class: "row-chips" }, chips); - return reachTripRowEl(trip.destination, trip.journey, ctx, extra, { hideMeta: true, hideVia: true }); + return reachTripRowEl(trip.destination, trip.journey, ctx, extra, { hideVia: true, openDate: trip.firstDate }); } /** - * A nearby paid-connection alternative (radius search): the nearby station, how - * far it is, and the free-MAX journey it offers. Clicking opens that free route; - * the user covers the short hop to/from the exact endpoint themselves. + * A nearby paid-connection alternative (radius search): the station, a `note` naming the + * nearby station and its distance, and the free-MAX journey it offers. Clicking opens that + * free route; the user covers the short hop to/from the exact endpoint themselves. */ -export function nearbyTripRowEl(station: string, km: number, j: Journey, ctx: RenderCtx): HTMLElement { +export function nearbyTripRowEl(station: string, note: string, j: Journey, ctx: RenderCtx): HTMLElement { const via = j.legs.length > 1; const viaChip = via ? [el("span", { class: "chip chip-via", text: t("lbl_via", { hub: j.hubs.map((h) => ctx.label(h)).join(", ") }) })] @@ -770,17 +767,19 @@ export function nearbyTripRowEl(station: string, km: number, j: Journey, ctx: Re const main = el( "button", { - class: "dest-main", + class: "dest-main dest-main-stacked", type: "button", - attrs: { "aria-label": `${ctx.label(station)} — ${t("nearby_km", { km })} — ${formatDuration(j.totalDurationMin)}` }, + attrs: { "aria-label": `${ctx.label(station)} — ${note} — ${formatDuration(j.totalDurationMin)}` }, on: { click: () => ctx.onOpenRoute(j.origin, j.destination) }, }, [ - stationNameEl("dest-name", station, ctx.label(station)), - el("span", { class: "chip chip-soft km-chip", text: t("nearby_km", { km }) }), - ...viaChip, - el("span", { class: "dest-meta", attrs: { "aria-hidden": "true" } }, [ - el("bdi", { text: formatDuration(j.totalDurationMin) }), + el("div", { class: "dest-body" }, [ + stationNameEl("dest-name", station, ctx.label(station)), + el("span", { class: "dest-meta", attrs: { "aria-hidden": "true" } }, [ + el("span", { class: "chip chip-soft km-chip", text: note }), + ...viaChip, + el("bdi", { text: formatDuration(j.totalDurationMin) }), + ]), ]), el("span", { class: "chev", attrs: { "aria-hidden": "true" } }, [icon(I.arrow)]), ], @@ -806,20 +805,22 @@ export function nearbyBothRowEl( const main = el( "button", { - class: "dest-main", + class: "dest-main dest-main-stacked", type: "button", attrs: { "aria-label": `${ctx.label(fromId)} → ${ctx.label(toId)} — ${formatDuration(j.totalDurationMin)}` }, on: { click: () => ctx.onOpenRoute(j.origin, j.destination) }, }, [ - el("span", { class: "dest-name" }, [ - el("bdi", { text: ctx.label(fromId) }), - el("span", { class: "muted", text: " → " }), - el("bdi", { text: ctx.label(toId) }), - ]), - el("span", { class: "chip chip-soft km-chip", text: t("nearby_km", { km: Math.max(fromKm, toKm) }) }), - el("span", { class: "dest-meta", attrs: { "aria-hidden": "true" } }, [ - el("bdi", { text: formatDuration(j.totalDurationMin) }), + el("div", { class: "dest-body" }, [ + el("span", { class: "dest-name" }, [ + el("bdi", { text: ctx.label(fromId) }), + el("span", { class: "muted", text: " → " }), + el("bdi", { text: ctx.label(toId) }), + ]), + el("span", { class: "dest-meta", attrs: { "aria-hidden": "true" } }, [ + el("span", { class: "chip chip-soft km-chip", text: t("nearby_km", { km: Math.max(fromKm, toKm) }) }), + el("bdi", { text: formatDuration(j.totalDurationMin) }), + ]), ]), el("span", { class: "chev", attrs: { "aria-hidden": "true" } }, [icon(I.arrow)]), ], diff --git a/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/calendar-truth.test.ts b/tests/calendar-truth.test.ts new file mode 100644 index 0000000..78348ba --- /dev/null +++ b/tests/calendar-truth.test.ts @@ -0,0 +1,284 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Leaflet needs a real browser canvas; stub the map module so the UI runs under jsdom. +vi.mock("../src/ui/map", () => ({ + RouteMap: class { + onSelect: ((id: string) => void) | null = null; + show(): void {} + route(): void {} + radius(): void {} + base(): void {} + highlight(): void {} + invalidate(): void {} + focus(): void {} + setInfo(): void {} + }, +})); + +import type { RawRecord, Station, DataMeta } from "../src/types"; +import { normalizeRecords } from "../src/data/dataset"; +import { StationRegistry } from "../src/data/stations"; +import { initApp } from "../src/app"; +import { findJourneys } from "../src/core/connections"; +import { bestGetawayTo, getawayIdeas, reverseGetawayIdeas, stayCalendar } from "../src/core/getaways"; +import { reachableGroups, windowStats } from "../src/core/destinations"; +import { formatDuration } from "../src/util/time"; +import stations from "../data/stations.json"; + +const P = "PARIS (intramuros)"; +const L = "LYON (intramuros)"; +const row = (date: string, from: string, to: string, dep: string, arr: string, no: string): RawRecord => ({ + date, + origine: from, + destination: to, + heure_depart: dep, + heure_arrivee: arr, + train_no: no, + od_happy_card: "OUI", +}); + +describe("a green day and a number tell the truth (core)", () => { + it("a fixed N-night stay is green only when a return N nights later exists", () => { + // The only way back is one night later: a 1-night stay works, a 2-night stay does not. + const trains = normalizeRecords([ + row("2026-07-01", P, "ROUEN", "09:00", "10:00", "X1"), + row("2026-07-02", "ROUEN", P, "18:00", "19:00", "X2"), + ]); + const two = stayCalendar(trains, P, "ROUEN", ["2026-07-01"], { maxConnections: 0, nights: 2 }, "nights"); + expect(two[0]!.available).toBe(false); + const one = stayCalendar(trains, P, "ROUEN", ["2026-07-01"], { maxConnections: 0, nights: 1 }, "nights"); + expect(one[0]).toMatchObject({ available: true, count: 1 }); + // Flexible still keeps the longest feasible stay up to the ceiling. + const flex = stayCalendar(trains, P, "ROUEN", ["2026-07-01"], { maxConnections: 0, nights: 2, flexibleNights: true }, "nights"); + expect(flex[0]).toMatchObject({ available: true, count: 1 }); + }); + + it("counts whole hours on site rounded down, never more than the trip gives", () => { + const trains = normalizeRecords([ + row("2026-07-01", P, "ARRAS", "09:00", "10:00", "A1"), + row("2026-07-01", "ARRAS", P, "20:54", "21:54", "A2"), + ]); + const cal = stayCalendar(trains, P, "ARRAS", ["2026-07-01"], { maxConnections: 0 }, "hours"); + expect(cal[0]!.count).toBe(10); // 10:00 → 20:54 is 10 h 54 + }); + + it("reverse same-day discovery pairs the earliest arrival with the latest return, as forward does", () => { + // Two ways into ARRAS: a slow one landing at 10:00 and a fast one landing at 12:00. Two + // ways home: 14:00 and 21:00, plus one landing after midnight. + const trains = normalizeRecords([ + row("2026-07-01", P, "ARRAS", "07:00", "10:00", "S1"), + row("2026-07-01", P, "ARRAS", "11:00", "12:00", "F1"), + row("2026-07-01", "ARRAS", P, "14:00", "15:00", "R1"), + row("2026-07-01", "ARRAS", P, "21:00", "22:00", "R2"), + row("2026-07-01", "ARRAS", P, "23:30", "00:30", "R3"), + ]); + const opts = { maxConnections: 0 }; + const forward = bestGetawayTo(trains, P, "ARRAS", "2026-07-01", opts)!; + expect(forward.onSiteMin).toBe(11 * 60); + const reverse = reverseGetawayIdeas(trains, "ARRAS", ["2026-07-01"], opts); + const paris = reverse.trips.find((g) => g.destination === P); + expect(paris?.onSiteMin).toBe(forward.onSiteMin); + expect(paris?.outbound.legs[0]!.trainNo).toBe("S1"); + expect(paris?.back.legs[0]!.trainNo).toBe("R2"); // not R3: that one gets home after midnight + expect(reverse.perDay[0]!.count).toBe(1); + }); + + it("keeps the shorter ride when two outbounds land at the same time", () => { + const trains = normalizeRecords([ + row("2026-07-01", P, "ARRAS", "06:00", "10:00", "SLOW"), + row("2026-07-01", P, "ARRAS", "09:00", "10:00", "FAST"), + row("2026-07-01", "ARRAS", P, "21:00", "22:00", "R"), + ]); + const g = bestGetawayTo(trains, P, "ARRAS", "2026-07-01", { maxConnections: 0 })!; + expect(g.outbound.legs[0]!.trainNo).toBe("FAST"); + expect(g.travelMin).toBe(120); + expect(getawayIdeas(trains, P, ["2026-07-01"], { maxConnections: 0 }).trips[0]!.travelMin).toBe(g.travelMin); + }); + + it("lists a train seen at two stations of one group once, on its shortest row", () => { + // Train 6627 reaches LYON (intramuros) twice (Part-Dieu 19:54, Perrache 20:10); train + // 5380 leaves it twice (Perrache 06:12, Part-Dieu 06:30). The later row comes first. + const trains = normalizeRecords([ + row("2026-09-30", P, L, "17:52", "20:10", "6627"), + row("2026-09-30", P, L, "17:52", "19:54", "6627"), + row("2026-09-30", L, "MASSY TGV", "06:12", "08:34", "5380"), + row("2026-09-30", L, "MASSY TGV", "06:30", "08:34", "5380"), + ]); + const toLyon = findJourneys(trains, P, L, "2026-09-30", { maxConnections: 0 }); + expect(toLyon.map((j) => j.legs[0]!.arrive)).toEqual(["19:54"]); + const toMassy = findJourneys(trains, L, "MASSY TGV", "2026-09-30", { maxConnections: 0 }); + expect(toMassy.map((j) => j.legs[0]!.depart)).toEqual(["06:30"]); + }); + + it("counts and times browse cards over the given days only", () => { + const trains = normalizeRecords([ + row("2026-06-24", P, L, "08:00", "09:50", "OLD"), // yesterday: not bookable + row("2026-06-25", P, L, "08:00", "10:14", "A"), + row("2026-06-26", P, L, "08:00", "10:05", "B"), + ]); + const window = ["2026-06-25", "2026-06-26"]; + expect(windowStats(trains, P, "from", window).get(L)).toEqual({ trains: 2, days: 2 }); + const day = reachableGroups(trains, P, "from", ["2026-06-25"]); + expect(day.map((g) => [g.station, g.count, g.minDurationMin])).toEqual([[L, 1, 134]]); + }); +}); + +// --- the app ------------------------------------------------------------------ + +const meta: DataMeta = { updatedAt: "", source: "sample", recordCount: 0, isSample: true }; + +// Paris ⇄ Lyon every day from 06-25 to 06-30; the way back leaves at 19:26 and 21:00, the +// 21:00 one only from 06-27. A same-day trip on 06-25 leaves 10 h 26 on site. +const days = ["2026-06-25", "2026-06-26", "2026-06-27", "2026-06-28", "2026-06-29", "2026-06-30"]; +const fixture: RawRecord[] = days.flatMap((d, i) => [ + row(d, P, L, "07:00", "09:00", `O${i}`), + row(d, L, P, "19:26", "21:26", `R${i}`), + ...(d >= "2026-06-27" ? [row(d, L, P, "21:00", "23:00", `N${i}`)] : []), +]); + +function setup(search: string, records: RawRecord[] = fixture): HTMLElement { + localStorage.clear(); + document.body.innerHTML = '

'; + const root = document.getElementById("app") as HTMLElement; + history.replaceState(null, "", `/${search}`); + initApp(root, { trains: normalizeRecords(records), meta }, new StationRegistry(stations as Station[])); + return root; +} + +const route = `mode=od&from=${encodeURIComponent(P)}&to=${encodeURIComponent(L)}`; +const formCell = (root: HTMLElement, date: string): HTMLElement => + root.querySelector(`.form-cal-mount .cal-cell[data-date="${date}"]`)!; +const formSelected = (root: HTMLElement): string[] => + Array.from(root.querySelectorAll(".form-cal-mount .cal-cell.sel")).map((c) => c.dataset.date!); +const picked = (root: HTMLElement): string => root.querySelector(".form-cal-picked")!.textContent ?? ""; +const title = (root: HTMLElement): string => root.querySelector("#results-title")!.textContent ?? ""; +const param = (k: string): string | null => new URLSearchParams(location.search).get(k); + +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("a green day and a number tell the truth (app)", () => { + it("moves the form calendar when a day is picked under the results", () => { + const root = setup(`?${route}&date=2026-06-25`); + expect(formSelected(root)).toEqual(["2026-06-25"]); + root.querySelector('.results .cal-cell[data-date="2026-06-27"]')!.click(); + expect(param("date")).toBe("2026-06-27"); + expect(formSelected(root)).toEqual(["2026-06-27"]); + expect(picked(root)).toContain(title(root).split("—").pop()!.trim()); + }); + + it("moves the Flexible range and stepper when the return is picked under the results", () => { + const root = setup(`?${route}&date=2026-06-25&stay=flex&rdate=2026-06-27`); + expect(formSelected(root)).toEqual(["2026-06-25", "2026-06-27"]); + root.querySelector('.od-return-cal .cal-cell[data-date="2026-06-29"]')!.click(); + expect(formSelected(root)).toEqual(["2026-06-25", "2026-06-29"]); + expect(picked(root).endsWith(title(root).split("→").pop()!.trim())).toBe(true); + expect(root.querySelector(".nights-val")!.textContent).toBe("4 nights"); + }); + + it("Flexible: header, date pill, URL and results agree before and after each range tap", () => { + // No return in the link: the results propose departure + 2, so the header does too. + const root = setup(`?${route}&date=2026-06-25&stay=flex`); + const ret = (): string => title(root).split("→").pop()!.trim(); + expect(picked(root).endsWith(ret())).toBe(true); + expect(formSelected(root)).toEqual(["2026-06-25", "2026-06-27"]); + expect(root.querySelector(".nights-val")!.textContent).toBe("2 nights"); + + // First tap: the departure, pending on the form; the URL and the results keep their range. + const shown = title(root); + formCell(root, "2026-06-26").click(); + expect(param("date")).toBe("2026-06-25"); + expect(title(root)).toBe(shown); + expect(picked(root)).toBe("Departure: Fri, Jun 26 — pick the return"); + expect(formSelected(root)).toEqual(["2026-06-26"]); + // While the return is awaited, a day before the departure is not a return day. + expect(formCell(root, "2026-06-25").classList.contains("ok")).toBe(false); + expect(formCell(root, "2026-06-27").classList.contains("ok")).toBe(true); + + // Second tap: the return, run with the departure. + formCell(root, "2026-06-29").click(); + expect(param("date")).toBe("2026-06-26"); + expect(param("rdate")).toBe("2026-06-29"); + expect(title(root)).not.toBe(shown); + expect(picked(root).endsWith(ret())).toBe(true); + expect(formSelected(root)).toEqual(["2026-06-26", "2026-06-29"]); + expect(root.querySelector(".nights-val")!.textContent).toBe("3 nights"); + }); + + it("toggling Flexible keeps the same-day minimum in place, inert", () => { + const root = setup(`?${route}&date=2026-06-25&stay=day`); + const field = Array.from(root.querySelectorAll(".search-form .field")).find((f) => + (f.textContent ?? "").includes("Minimum time there"), + )!; + const select = field.querySelector("select") as HTMLSelectElement; + expect(field.style.display).not.toBe("none"); + expect(select.disabled).toBe(false); + root.querySelector(".nights-flex")!.click(); + expect(field.style.display).not.toBe("none"); + expect(select.disabled).toBe(true); + }); + + it("a stay from the last bookable day reads its real return, not the same day", () => { + const root = setup(`?${route}&date=2026-07-24&stay=1`); + const chips = Array.from(root.querySelectorAll(".results .mc-date")).map((e) => e.textContent); + expect(chips[0]).not.toBe(chips[1]); + expect(root.querySelector(".nights-val")!.textContent).toBe("1 night"); + expect(root.querySelector(".return-list .empty")).not.toBeNull(); + }); + + it("a stay's discovery card counts the return its trip picks by default, the fastest", () => { + // The sweep's latest return home by midnight (21:00, 2 h 30) is not the fastest (19:26, 2 h). + const records = [ + row("2026-06-27", P, L, "07:00", "09:00", "OUT"), + row("2026-06-28", L, P, "19:26", "21:26", "FAST"), + row("2026-06-28", L, P, "21:00", "23:30", "LATE"), + ]; + const root = setup(`?mode=from&from=${encodeURIComponent(P)}&date=2026-06-27&stay=1`, records); + const card = root.querySelector(`.results .group-card[data-station="${L}"]`)!; + const total = new RegExp(`${formatDuration(240)}(?!\\d)`); // 4 h, not 4 h 30 + expect(card.querySelector(".dest-meta bdi")!.textContent).toBe(formatDuration(240)); + card.querySelector(".dest-main")!.click(); + expect(root.querySelector(".return-list .journey")?.textContent).toContain("19:26"); + expect(root.querySelector(".rt-total")!.textContent).toMatch(total); + }); + + it("a discovery card shows the exact time on site of the trip it opens", () => { + const root = setup(`?mode=from&from=${encodeURIComponent(P)}&date=2026-06-25&stay=day`); + const card = root.querySelector(`.results .group-card[data-station="${L}"]`)!; + expect(card.textContent).toContain(formatDuration(626)); // 09:00 → 19:26 + card.querySelector(".dest-main")!.click(); + expect(root.querySelector(".rt-total")!.textContent).toContain(formatDuration(626)); + }); + + it("a browse card's time is the chosen day's fastest train, not the month's", () => { + const records = [ + row("2026-06-25", P, L, "07:00", "09:14", "SLOW"), + row("2026-06-26", P, L, "07:00", "09:05", "FAST"), + ]; + const root = setup(`?mode=from&from=${encodeURIComponent(P)}&date=2026-06-25`, records); + const card = root.querySelector(`.results .group-card[data-station="${L}"]`)!; + expect(card.querySelector(".dest-meta")!.textContent).toContain(formatDuration(134)); + }); + + it("counts a destination's trains from today on", () => { + const records = [ + row("2026-06-24", P, L, "07:00", "09:00", "PAST"), + row("2026-06-25", P, L, "07:00", "09:00", "A"), + row("2026-06-26", P, L, "07:00", "09:00", "B"), + ]; + const root = setup(`?mode=best&from=${encodeURIComponent(P)}`, records); + const card = root.querySelector(`.results .group-card[data-station="${L}"]`)!; + expect(card.querySelector(".stat-chip")!.textContent).toMatch(/^2 train/); + }); +}); 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/tests/lists-links.test.ts b/tests/lists-links.test.ts new file mode 100644 index 0000000..e610d40 --- /dev/null +++ b/tests/lists-links.test.ts @@ -0,0 +1,279 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { readFileSync } from "node:fs"; + +// Leaflet needs a real browser canvas; stub the map module so the UI runs under jsdom. +vi.mock("../src/ui/map", () => ({ + RouteMap: class { + onSelect: ((id: string) => void) | null = null; + show(): void {} + route(): void {} + radius(): void {} + base(): void {} + highlight(): void {} + invalidate(): void {} + focus(): void {} + setInfo(): void {} + }, +})); + +import type { RawRecord, Station, DataMeta } from "../src/types"; +import { normalizeRecords } from "../src/data/dataset"; +import { StationRegistry } from "../src/data/stations"; +import { initApp } from "../src/app"; +import { t, getLang } from "../src/i18n"; +import { formatDuration } from "../src/util/time"; +import stations from "../data/stations.json"; + +const meta: DataMeta = { updatedAt: "", source: "sample", recordCount: 0, isSample: true }; +const P = "PARIS (intramuros)"; +const enc = encodeURIComponent; + +/** One free-MAX train. */ +function tr(date: string, from: string, to: string, dep: string, arr: string, no: string): RawRecord { + return { + date, + origine: from, + destination: to, + heure_depart: dep, + heure_arrivee: arr, + train_no: no, + od_happy_card: "OUI", + axe: "", + } as RawRecord; +} + +// rAF callbacks wait here until flush(), as in a browser, so chunked lists render in batches. +let frames: FrameRequestCallback[] = []; +function flush(): void { + while (frames.length) frames.shift()!(0); +} + +function setup(search: string, records: RawRecord[], saved?: object): HTMLElement { + localStorage.clear(); + if (saved) localStorage.setItem("mj.settings", JSON.stringify(saved)); + document.body.innerHTML = '
'; + const root = document.getElementById("app") as HTMLElement; + history.replaceState(null, "", `/${search}`); + initApp(root, { trains: normalizeRecords(records), meta }, new StationRegistry(stations as Station[])); + flush(); + return root; +} + +const cardSelect = (root: HTMLElement): HTMLSelectElement => + root.querySelector('select option[value="senior"]')!.parentElement as HTMLSelectElement; +const originInput = (root: HTMLElement): HTMLInputElement => + root.querySelectorAll('.od-fields input[list="station-list"]')[0]!; +const destinationInput = (root: HTMLElement): HTMLInputElement => + root.querySelectorAll('.od-fields input[list="station-list"]')[1]!; +function search(root: HTMLElement): void { + root.querySelector(".search-form .form-actions button.btn-primary")!.click(); + flush(); +} +/** A day as the app writes it. */ +const formatDate = (iso: string): string => + new Intl.DateTimeFormat(getLang(), { weekday: "short", day: "numeric", month: "short" }).format( + new Date(`${iso}T00:00:00`), + ); +const button = (root: HTMLElement, text: string): HTMLButtonElement | undefined => + [...root.querySelectorAll(".results button")].find((b) => b.textContent === text); + +beforeEach(() => { + // 2026-06-25 is a Thursday: the 27th is a Saturday. + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-25T12:00:00Z")); + frames = []; + globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => { + frames.push(cb); + return frames.length; + }) as typeof requestAnimationFrame; + globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame; + Element.prototype.scrollIntoView = function scrollIntoView(): void {}; + window.scrollTo = (() => {}) as typeof window.scrollTo; +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("generated pages", () => { + it("take the site and repository URLs from src/config.ts", () => { + const src = readFileSync("scripts/generate-pages.mjs", "utf8"); + expect(src).toContain('from "../src/config.ts"'); + expect(src).not.toMatch(/https:\/\/[\w-]+\.github\.(io|com)/); + }); +}); + +describe("the card", () => { + const lyon = [ + tr("2026-06-26", P, "LYON (intramuros)", "08:00", "10:00", "1"), + tr("2026-06-27", P, "LYON (intramuros)", "08:00", "10:00", "2"), + ]; + const od = `?mode=od&from=${enc(P)}&to=${enc("LYON (intramuros)")}&date=2026-06-26`; + + it("a link without card= keeps the saved MAX SENIOR, and the address bar adds none", () => { + const root = setup(od.replace(enc(P), "paris"), lyon, { card: "senior" }); + expect(cardSelect(root).value).toBe("senior"); + const params = new URLSearchParams(location.search); + expect(params.get("from")).toBe(P); // the write-back ran + expect(params.has("card")).toBe(false); + }); +}); + +describe("a typed station", () => { + const trains = [ + tr("2026-06-25", P, "LYON (intramuros)", "08:00", "10:00", "1"), + tr("2026-06-25", "TOURS", P, "08:00", "10:00", "2"), + tr("2026-06-25", "ST PIERRE DES CORPS", P, "08:00", "10:00", "3"), + ]; + + it("that matches several stations stops Search in red with a message", () => { + const root = setup("", trains); + originInput(root).value = "pierre des"; + search(root); + expect(location.search).not.toContain("from="); + expect(originInput(root).classList.contains("is-invalid")).toBe(true); + expect(root.querySelector(".surprise-msg")!.textContent).toBe(t("err_station", { station: "pierre des" })); + }); + + it("an unknown destination does not turn into a browse from the origin", () => { + const root = setup("", trains); + originInput(root).value = "Paris"; + destinationInput(root).value = "Lyonzz"; + search(root); + expect(location.search).not.toContain("mode=from"); + expect(destinationInput(root).classList.contains("is-invalid")).toBe(true); + }); + + it("Search with no departure names what is missing", () => { + const root = setup("?mode=best", trains); + search(root); + expect(root.querySelector(".surprise-msg")!.textContent).toBe(t("need_origin")); + }); +}); + +describe("browse", () => { + // 21 direct destinations (past the first chunk of 18), one reachable only via Lyon, and + // Le Mans reachable only from Massy TGV, 16 km from Paris. + const direct = Array.from({ length: 20 }, (_, i) => tr("2026-06-25", P, `ZZ D${i + 10}`, "08:00", "10:00", `d${i}`)); + const trains = [ + ...direct, + tr("2026-06-25", P, "LYON (intramuros)", "08:00", "10:00", "l1"), + tr("2026-06-25", "LYON (intramuros)", "ZZ VIA", "11:00", "12:00", "v1"), + tr("2026-06-25", "MASSY TGV", "LE MANS", "09:00", "10:00", "m1"), + ]; + + it("lists direct cards, then via rows, then the nearby section", () => { + const root = setup(`?mode=from&from=${enc(P)}&date=2026-06-25&rad=40`, trains); + const kids = [...root.querySelector(".results")!.children]; + const station = (k: Element): string => (k as HTMLElement).dataset.station ?? ""; + const lastDirect = Math.max(...kids.flatMap((k, i) => (/^ZZ D|^LYON/.test(station(k)) ? [i] : []))); + const via = kids.findIndex((k) => station(k) === "ZZ VIA"); + const nearby = kids.findIndex((k) => k.classList.contains("nearby")); + expect(lastDirect).toBeGreaterThan(18); + expect(via).toBeGreaterThan(lastDirect); + expect(nearby).toBeGreaterThan(via); + }); + + it("names the nearby station a nearby row starts from", () => { + const root = setup(`?mode=from&from=${enc(P)}&date=2026-06-25&rad=40`, trains); + const row = root.querySelector('.nearby .group-card[data-station="LE MANS"]')!; + expect(row.textContent).toContain(t("nearby_from_km", { station: "Massy TGV", km: 16 })); + expect(row.querySelector(".dest-main-stacked .dest-body")).not.toBeNull(); + }); + + it("an empty day points at the next day with seats", () => { + const root = setup(`?mode=from&from=${enc(P)}&date=2026-06-25`, [ + tr("2026-06-27", P, "LYON (intramuros)", "08:00", "10:00", "1"), + ]); + expect(root.querySelector(".results .empty")).not.toBeNull(); + const date = formatDate("2026-06-27"); + const next = button(root, t("day_with_seats", { date })); + expect(next).toBeDefined(); + next!.click(); + flush(); + expect(location.search).toContain("date=2026-06-27"); + expect(root.querySelector('.results .group-card[data-station="LYON (intramuros)"]')).not.toBeNull(); + }); +}); + +describe("round-trip discovery", () => { + const trains = [ + tr("2026-06-25", "GAP", "LYON (intramuros)", "07:00", "09:00", "1"), + tr("2026-06-27", "GAP", "LYON (intramuros)", "07:00", "09:00", "2"), + tr("2026-06-27", "LYON (intramuros)", "GAP", "18:00", "20:00", "3"), + ]; + + it("names the origin, the day and the stay", () => { + const root = setup(`?mode=from&from=GAP&date=2026-06-27&stay=day`, trains); + const title = root.querySelector("#results-title")!.textContent!; + expect(title).toContain("Gap"); + expect(title).toContain(t("nights_sameday")); + }); + + it("an empty day offers the next day with a round trip and the one-way list", () => { + const root = setup(`?mode=from&from=GAP&date=2026-06-25&stay=day`, trains); + expect(button(root, t("getaway_oneway_btn"))).toBeDefined(); + const date = formatDate("2026-06-27"); + button(root, t("day_with_seats", { date }))!.click(); + flush(); + expect(root.querySelector('.results .group-card[data-station="LYON (intramuros)"]')).not.toBeNull(); + }); + + it("a day tapped on the form calendar brings the refreshed list into view", () => { + const root = setup(`?mode=from&from=GAP&date=2026-06-25&stay=day`, trains); + vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({ top: 5000, bottom: 5040 } as DOMRect); + const scrolled = vi.spyOn(Element.prototype, "scrollIntoView"); + root.querySelector('.form-cal-mount .cal-cell[data-date="2026-06-27"]')!.click(); + flush(); + expect(scrolled).toHaveBeenCalled(); + }); +}); + +describe("a same-day round trip with none that day", () => { + it("offers the shortest stay that has one", () => { + const root = setup( + `?mode=od&from=${enc("MARSEILLE ST CHARLES")}&to=${enc("BORDEAUX ST JEAN")}&date=2026-06-25&stay=day`, + [ + tr("2026-06-25", "MARSEILLE ST CHARLES", "BORDEAUX ST JEAN", "08:00", "14:00", "1"), + tr("2026-06-26", "BORDEAUX ST JEAN", "MARSEILLE ST CHARLES", "09:00", "15:00", "2"), + ], + ); + button(root, t("try_nights", { n: 1 }))!.click(); + flush(); + expect(location.search).toContain("stay=1"); + expect(root.querySelector(".results .od-return .journey")).not.toBeNull(); + }); +}); + +describe("Ideas", () => { + const trains = [ + tr("2026-06-25", P, "LYON (intramuros)", "08:00", "10:00", "1"), + tr("2026-06-26", P, "LILLE", "08:00", "09:00", "2"), + tr("2026-06-27", P, "PERPIGNAN", "21:25", "08:00", "3"), + ]; + const ideas = `?mode=best&from=${enc(P)}`; + + it("keeps Recommended as the default sort, and shows the durations", () => { + const root = setup(ideas, trains); + const sort = root.querySelector(".sort-select")!; + expect([...sort.options].map((o) => o.value)).toContain("rec"); + expect(sort.value).toBe("rec"); + expect(root.querySelector('.group-card[data-station="LILLE"]')!.textContent).toContain(formatDuration(60)); + }); + + it("shows the figure a Most days or Closest sort ranks by", () => { + const days = setup(`${ideas}&sort=days`, trains); + expect(days.querySelector('.group-card[data-station="LILLE"]')!.textContent).toContain(t("badge_days", { n: 1 })); + const closest = setup(`${ideas}&sort=closest`, trains); + expect(closest.querySelector('.group-card[data-station="LILLE"]')!.textContent).toMatch(/\d+ km/); + }); + + it("opens a destination on the first day it is reachable", () => { + const root = setup(ideas, trains); + root.querySelector('.group-card[data-station="PERPIGNAN"] .dest-main')!.click(); + flush(); + expect(location.search).toContain("date=2026-06-27"); + expect(root.querySelector(".results .journey")).not.toBeNull(); + }); +}); diff --git a/tests/stations.test.ts b/tests/stations.test.ts index 2c9affe..0582cc7 100644 --- a/tests/stations.test.ts +++ b/tests/stations.test.ts @@ -114,3 +114,47 @@ describe("StationRegistry lookups", () => { expect(registry.label("SOME UNKNOWN GARE")).toBe("Some Unknown Gare"); }); }); + +describe("StationRegistry.resolve", () => { + const r = new StationRegistry(stationData as Station[]); + r.addMissing([ + "PARIS (intramuros)", + "PAU", + "TOURS", + "ST PIERRE DES CORPS", + "AIX EN PROVENCE TGV", + "AIX LES BAINS LE REVARD", + "MORLAIX", + "MARNE LA VALLEE CHESSY", + ]); + + it("reads hyphens, accents, case and St/Saint alike", () => { + expect(normalizeText("St-Pierre")).toBe(normalizeText("saint pierre")); + expect(normalizeText("Ste-Foy")).toBe("sainte foy"); + }); + + it("resolves a name spelling an id, label or alias, however it is written", () => { + expect(r.resolve("Paris")).toBe("PARIS (intramuros)"); + expect(r.resolve("St-Pierre-des-Corps")).toBe("ST PIERRE DES CORPS"); + expect(r.resolve("aix")).toBe("AIX EN PROVENCE TGV"); // a curated alias + }); + + it("prefers a station's own name to another station's alias", () => { + // TOURS carries the alias "saint pierre des corps"; the station itself wins. + expect(r.resolve("saint pierre des corps")).toBe("ST PIERRE DES CORPS"); + }); + + it("resolves the only station a partial name matches", () => { + expect(r.resolve("disneyland")).toBe("MARNE LA VALLEE CHESSY"); + expect(r.resolve("aix en provence")).toBe("AIX EN PROVENCE TGV"); + expect(r.resolve("aix les")).toBe("AIX LES BAINS LE REVARD"); + }); + + it("leaves a name matching several stations, or none, unresolved", () => { + expect(r.resolve("pierre des")).toBeUndefined(); + expect(r.resolve("saint pierre")).toBeUndefined(); + expect(r.resolve("pa")).toBeUndefined(); + expect(r.resolve("ai")).toBeUndefined(); + expect(r.resolve("zzz")).toBeUndefined(); + }); +}); 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",