From 167060799d41cbbfd9f90ee85f97dde8b6f9a0d8 Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:55:15 +0900 Subject: [PATCH 1/3] Lists: strict station names, ordered rows, a way out of empty lists Generated pages read the site and repository URLs from src/config.ts, so the sitemap, canonicals and route pages point at offware-apps. A link without card= keeps the saved card, the address bar correction adds none, and every MAX SENIOR calendar greys its weekends. One resolver serves the form and links: a bookable id as is, else the station whose id, label or alias the name spells (its own name over another's alias, the one with trains on a tie), else the only station it matches. Otherwise Search stops with the field in red and a line naming it. Hyphens, brackets and St/Saint read alike. Browse lists direct cards, then via rows, then the nearby section, and a nearby row names the station its free leg starts from. Ideas drops the Recommended sort that repeated Fastest, shows the figure it ranks by, and opens a row on its first reachable day. Discovery titles name the origin, day and stay, a form day tap reveals the list, and empty browse, discovery and same-day lists offer the next day with seats, one-way, or the shortest stay. --- docs/user-flows.md | 35 ++++- package.json | 2 +- scripts/generate-pages.mjs | 4 +- src/app.ts | 196 +++++++++++++++++-------- src/core/best.ts | 4 +- src/data/stations.ts | 80 +++++++---- src/i18n/ar.ts | 11 +- src/i18n/de.ts | 11 +- src/i18n/en.ts | 11 +- src/i18n/es.ts | 11 +- src/i18n/fr.ts | 11 +- src/i18n/it.ts | 11 +- src/i18n/ja.ts | 11 +- src/i18n/ko.ts | 11 +- src/i18n/nl.ts | 11 +- src/i18n/pt.ts | 11 +- src/i18n/zh.ts | 11 +- src/styles.css | 7 +- src/ui/render.ts | 69 ++++----- tests/lists-links.test.ts | 288 +++++++++++++++++++++++++++++++++++++ tests/stations.test.ts | 44 ++++++ 21 files changed, 689 insertions(+), 161 deletions(-) create mode 100644 tests/lists-links.test.ts diff --git a/docs/user-flows.md b/docs/user-flows.md index a737185..db09608 100644 --- a/docs/user-flows.md +++ b/docs/user-flows.md @@ -17,6 +17,13 @@ search (`deriveMode` in `src/app.ts`; dispatch in `renderSearch`). | **Multi-city** (`multi`) | legs or cities | `tour` | a multi-stop tour | | **Ideas** (`ideas`) | From | `best` | best destinations, ranked | +A typed or linked station resolves when it spells a station's name, id or alias (accents, +case, hyphens, brackets and "St"/"Saint" read alike; a station's own name outranks another's +alias, and the one with trains wins a tie), or when it matches exactly one station. A name +matching several stations, or none, stops Search: the field turns red and a line under +Search names it, so `pierre des` never runs from Tours and an unknown arrival never turns +into a browse from the departure. Search with no departure names that step on the same line. + The **trip-type control** sits beside the date: an **`Aller simple` / `Aller-retour`** (one-way / round trip) segmented toggle — the same sliding-pill style as the main tabs. When *Aller-retour* is on, a **nights stepper** appears — `[ − ] N [ + ]` with a "Durée sur @@ -126,6 +133,8 @@ span applied to the lists only, and a day could read green while its list was em when the proposed return day has **no free-MAX return**, the return leg and its calendar **open by default** (never a collapsed summary hiding an empty list), so the days that *do* have a return are visible and one tap away. Pick a return → + - **Same day with no round trip that day** → under the empty outbound, a one-tap "Stay N + night(s) instead" offers the shortest stay (1–3 nights) that has one that day. - **Trip modal** = booking recap: each leg's own **travel date** rides on the ticket header (beside "Outbound" / "Return"), and an unmistakable per-leg action — "Book the outbound" / "Book the return" (each deep-links SNCF Connect; a connecting leg opens the step modal) @@ -133,10 +142,17 @@ span applied to the lists only, and a day could read green while its list was em exits the flow (step-wise back). 3. **Only From, One-way** → browse (`runBrowse` "from"). Every destination reachable from the station, ranked by how well-served it is, with availability. Tap a card → the exact trip. -4. **Only From, a stay chosen** → discovery (`runGetaways`), "Where can you get away to?". - Destinations ranked by **time at the destination** (hours on site if same-day is best, - else nights) + a possible-start-days calendar. Tap a day → narrows to that day - (auto-scrolls to results). Tap a destination → opens the round trip. + The list reads direct cards, then connection-only ("via") rows, then the radius "Stations + within N km" section, whatever the chunked rendering. A nearby row names the station its + free leg starts from ("from Massy TGV · 16 km"). An empty day offers a one-tap move to + the next day with seats. +4. **Only From, a stay chosen** → discovery (`runGetaways`), titled with the origin, the day + and the stay ("Round trips from Paris — Tue, Sep 29 · Same day"; the mobile search bar + repeats it). Destinations ranked by **time at the destination** (hours on site if same-day + is best, else nights); the form's calendar shows the possible start days. Tap a day → + narrows to that day and scrolls the list into view when it sits below the fold. Tap a + destination → opens the round trip. An empty day offers the next day with a round trip + and a one-tap switch to one-way. - Ranking: `sortGetaways` puts most hours-on-site first for same-day trips. - A minimum-on-site gate exists in core (`minOnSiteMin`, default 4h); NOT yet exposed as an Advanced control. (Open item.) @@ -154,8 +170,11 @@ span applied to the lists only, and a day could read green while its list was em ## Ideas tab (best) -Best free-MAX destinations from the origin, ranked (fastest / most-served / closest…), -either an all-days overview or one specific day. Tap → open the route. +Every free-MAX destination from the origin across the booking window (there is no one-day +view), fastest first; the sort picker also ranks by most trains, most days, closest or A–Z. +Each row shows the changes, the month's train count and the fastest time, plus the days +reachable or the distance when the sort ranks by it. Tap → the exact trip on the first day +the destination is reachable, with its calendar open. ## Cross-cutting (everywhere) @@ -165,7 +184,8 @@ either an all-days overview or one specific day. Tap → open the route. - **Saved & Favorites** — star a route / save a trip, from the header menu. (The two overlap — a known cleanup item.) - **Settings** — theme, MAX Jeune/Senior, comfortable/compact, and Low-end mode (map off + - reduced motion + compact) with a one-time nudge on weak devices; language. + reduced motion + compact) with a one-time nudge on weak devices; language. MAX SENIOR is + weekday-only, so every calendar greys its weekends (a weekend list keeps its notice). - **Mobile** — the form is a sheet that collapses to a search bar; results are a bottom-sheet drawer with detents. Back navigation preserves form state and never lands on a dead screen. - **History model** — a genuine navigation (Search, drilling into a route, opening the saved @@ -180,6 +200,7 @@ either an all-days overview or one specific day. Tap → open the route. of an empty result. A malformed or out-of-window departure date falls back to today; a malformed or out-of-window return or finish-by date, and a malformed time filter, are dropped. The address bar is then corrected in place (`replaceState`) to match the screen. + A link without `card=` (a route page's "open in app") keeps the card saved in Settings. - **PWA** — installable; a "new version — reload" postcard on updates. ## Known open items (see docs/trip-redesign.md for the audit plan) diff --git a/package.json b/package.json index 3be08d5..f4c0ac8 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "test:e2e": "node scripts/e2e.mjs", "screenshot": "node scripts/screenshot.mjs", "prerender": "node scripts/prerender.mjs", - "generate-pages": "node scripts/generate-pages.mjs", + "generate-pages": "tsx scripts/generate-pages.mjs", "build:mobile": "tsc --noEmit && vite build --mode capacitor", "cap:sync": "npm run build:mobile && cap sync", "cap:copy": "npm run build:mobile && cap copy", diff --git a/scripts/generate-pages.mjs b/scripts/generate-pages.mjs index f184fac..3eae3ee 100644 --- a/scripts/generate-pages.mjs +++ b/scripts/generate-pages.mjs @@ -1,9 +1,9 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; import { join, dirname } from "node:path"; import { marked } from "marked"; +import { SITE_URL as SITE, GITHUB_URL } from "../src/config.ts"; const DIST = join(process.cwd(), "dist"); -const SITE = "https://davd-gzl.github.io/MAX-Finder/"; const TOP_ROUTES = 150; if (!existsSync(join(DIST, "index.html"))) { @@ -108,7 +108,7 @@ ${body} (Licence Ouverte), instantané du ${esc(lastmod)}. Disponibilités indicatives, rafraîchies ~1×/jour ; la réservation se fait sur SNCF Connect. Projet libre et indépendant, non affilié à la SNCF — - code source. + code source.

Rechercher un trajet · FAQ · diff --git a/src/app.ts b/src/app.ts index a414d9b..7f4aa90 100644 --- a/src/app.ts +++ b/src/app.ts @@ -10,7 +10,7 @@ import { } from "./core/destinations"; import { filterTrains, isNightTrain, type FilterOptions } from "./core/search"; import { bestTripsAcrossWindow, stationsOnDate, reachableBest, type BestTrip, type ReachTrip } from "./core/best"; -import { getawayIdeas, reverseGetawayIdeas, stayCalendar } from "./core/getaways"; +import { bestGetawayTo, getawayIdeas, reverseGetawayIdeas, stayCalendar } from "./core/getaways"; import { planTours, planTourInOrder, planTourGreedy, arrivalDate, type Tour } from "./core/tour"; import { findJourneys, bestJourney, reachableJourneys, journeyArriveAbs, toJourney, MAX_RESULTS } from "./core/connections"; import type { ConnectionOptions } from "./core/connections"; @@ -73,7 +73,6 @@ let refs: Refs; let formApi: FormHandle; let mapPromise: Promise | null = null; let mapInstance: RouteMap | null = null; -let labelToId: Map; // Today (YYYY-MM-DD). MAX seats are only bookable ~30 days out, so the calendar // and the date picker stay anchored to a today..today+30 window. let today = ""; @@ -155,7 +154,7 @@ let odReturnDate: string | null = null; // rt_return_moved notice on the next render, then clears itself. let returnMoved = false; -/** Set (or clear with "") the inline status next to the "surprise" button. */ +/** Set (or clear with "") the inline status under the Search and Surprise buttons. */ function setSurpriseMsg(text: string): void { formApi?.setSurpriseMsg(text); } @@ -615,7 +614,6 @@ export function initApp(root: HTMLElement, dataset: Dataset, registry: StationRe // Every station present in the dataset becomes searchable (the curated registry // only covers map coordinates for the major ones). registry.addMissing(dataset.trains.flatMap((t) => [t.origin, t.destination])); - labelToId = new Map(registry.list().map((s) => [s.label.toLowerCase(), s.id])); today = new Date().toISOString().slice(0, 10); query = store.urlHasQuery() @@ -700,6 +698,8 @@ function queryFromUrl(): SearchQuery { const params = new URLSearchParams(location.search); const linked = params.toString(); const q = store.queryFromParams(params, today); + // A link without card= (a shared link, a route page) keeps the card the user saved. + if (!params.has("card")) q.card = settings.card; const lastBookable = addDays(today, BOOKING_WINDOW_DAYS - 1); const inWindow = (d: string): boolean => d >= today && d <= lastBookable; if (!inWindow(q.date)) q.date = today; @@ -788,15 +788,7 @@ let searchHeldBack = false; // --- station resolution ----------------------------------------------------- function resolveStation(text: string): string | undefined { - const norm = text.trim(); - if (!norm) return undefined; - const byLabel = labelToId.get(norm.toLowerCase()); - if (byLabel) return byLabel; - // Strict: no fuzzy/raw fallback. Text matching no real station resolves to - // undefined, so a typo or made-up name reads as invalid instead of being kept as - // a phantom origin / city that silently matches nothing. - const hit = deps.registry.search(norm, 1)[0]; - return hit ? hit.id : undefined; + return deps.registry.resolve(text); } // --- formatting / context --------------------------------------------------- @@ -821,6 +813,12 @@ function isWeekend(iso: string): boolean { return day === 0 || day === 6; } +/** A calendar as the card sees it: MAX SENIOR is weekday-only, so a weekend is never bookable. */ +function gradeForCard(days: CalendarDay[], card: SearchQuery["card"]): CalendarDay[] { + if (card !== "senior") return days; + return days.map((d) => (isWeekend(d.date) ? { date: d.date, available: false, count: 0 } : d)); +} + // Wikivoyage language editions that exist; others (e.g. ko) fall back to English. const WIKIVOYAGE_LANGS = new Set(["fr", "en", "es", "de", "it", "zh"]); @@ -846,14 +844,13 @@ function ctx(): RenderCtx { // Drop any "via" carried over from a previous exact-trip search: drilling into // a specific route (often a connecting one) shouldn't be filtered through an // unrelated hub, which would force it through a station it doesn't pass and - // show nothing. A getaway idea passes its own start day (open.date) so the round - // trip opens on a day it's actually feasible — not today, which may have only an - // outbound and no return (the "click a same-day idea, get no round trip" dead end). + // show nothing. A getaway or Ideas row passes a day it's feasible (open.date) — not + // today, which may have no train, or an outbound and no return. const date = open?.date ?? query.date; const returnDate = open?.date && query.returnDate ? undefined : query.returnDate; - // Opening a ONE-WAY idea (Ideas list, no stay, no dated getaway) → reveal the dates: - // the exact-trip page shows its availability calendar open instead of today's trains. - openOdCalendar = query.mode === "best" && !query.stay && !open?.date; + // Opening a ONE-WAY idea (Ideas list, no stay) → reveal the dates: the exact-trip + // page shows its availability calendar open, on the idea's first day. + openOdCalendar = query.mode === "best" && !query.stay; query = { ...query, mode: "od", origin, destination, via: undefined, date, returnDate }; syncFormFromQuery(); // Push a browser entry marked as a DETAIL page: the list stays one Back away, and the @@ -1510,7 +1507,7 @@ function repaintFormCalendar(): void { if (rangeOpt && calOpts) { calOpts = { ...calOpts, range: rangeOpt, hint: t("form_cal_flex_hint") }; } - mount.append(render.calendarEl(cal, calCtx, selected, calOpts)); + mount.append(render.calendarEl(gradeForCard(cal, fq.card), calCtx, selected, calOpts)); } /** @@ -1531,7 +1528,7 @@ function pickFormDay(date: string): void { // day, the calendar selection doesn't update the map"). Only a truly empty query stays // staged. if (queryIsRenderable(query)) { - if (sameRoute) refreshInPlace(); + if (sameRoute) refreshInPlace(true); else applyAndRun(); } repaintFormCalendar(); @@ -1982,16 +1979,24 @@ function runBrowse(c: RenderCtx, dir: "from" | "to"): void { ? nearbyBrowse(anchor, dir, query.date, query.radiusKm, filterOpts(), already) : []; - if (total === 0) { - // Suppress the empty message when nearby alternatives will fill the gap below. - if (nearby.length === 0) { - refs.results.append(render.emptyEl(t("res_none")), render.hintEl(t("res_none_hint"))); - showMap(anchor, []); - return; - } - } else { + if (total === 0 && nearby.length === 0) { + refs.results.append(render.emptyEl(t("res_none"))); + // Point at the next day the form calendar marks green, when there is one. + const window = dateRange(today, BOOKING_WINDOW_DAYS); + const opts = { ...filterOpts(), maxConnections: query.maxConnections }; + const next = nextAvailableDay(reachableCountCalendar(trains, anchor, window, opts, dir)); + if (next) refs.results.append(nextDayEl(next)); + refs.results.append(render.hintEl(t("res_none_hint"))); + showMap(anchor, []); + return; + } + + // One chunked list in reading order: direct cards, then via rows, then the nearby + // section, so a later batch of direct cards never lands below the other two. + const rows: (() => Node)[] = []; + if (total > 0) { // Sort applies to the direct destinations (the rich cards); "rec" keeps the - // most-served default. Via rows stay appended after, in their duration order. + // most-served default. Via rows follow, in their duration order. const sortedGroups = applySort(groups, { name: (g) => registry.label(g.station), distanceKm: (g) => stationDistanceKm(anchor, g.station), @@ -2005,23 +2010,32 @@ function runBrowse(c: RenderCtx, dir: "from" | "to"): void { onSort, ), ); - appendInChunks(refs.results, sortedGroups, (g) => - render.groupCardEl(g, dir, anchor, c, dayCount.get(g.station) ?? 0, stats.get(g.station), flex), - ); - for (const tr of connecting) refs.results.append(render.reachTripRowEl(tr.station, tr.journey, c)); + for (const g of sortedGroups) + rows.push(() => render.groupCardEl(g, dir, anchor, c, dayCount.get(g.station) ?? 0, stats.get(g.station), flex)); + for (const tr of connecting) rows.push(() => render.reachTripRowEl(tr.station, tr.journey, c)); } // Nearby paid-hop alternatives: a station near your anchor reaches (or is reached - // by) somewhere your exact anchor doesn't. Each row opens that nearby leg's route. - if (query.radiusKm) { - const sec = el("section", { class: "nearby" }, [ - el("h3", { text: t("nearby_title", { km: query.radiusKm }) }), - el("p", { class: "muted small", text: t(dir === "from" ? "nearby_browse_from" : "nearby_browse_to") }), - ]); - if (nearby.length === 0) sec.append(render.emptyEl(t("nearby_none"))); - else for (const n of nearby) sec.append(render.nearbyTripRowEl(n.station, Math.round(n.km), n.journey, c)); - refs.results.append(sec); + // by) somewhere your exact anchor doesn't. Each row names that station and opens its route. + const radiusKm = query.radiusKm; + if (radiusKm) { + rows.push(() => { + const sec = el("section", { class: "nearby" }, [ + el("h3", { text: t("nearby_title", { km: radiusKm }) }), + el("p", { class: "muted small", text: t(dir === "from" ? "nearby_browse_from" : "nearby_browse_to") }), + ]); + if (nearby.length === 0) sec.append(render.emptyEl(t("nearby_none"))); + for (const n of nearby) { + const note = t(dir === "from" ? "nearby_from_km" : "nearby_to_km", { + station: registry.label(n.via), + km: Math.round(n.km), + }); + sec.append(render.nearbyTripRowEl(n.station, note, n.journey, c)); + } + return sec; + }); } + appendInChunks(refs.results, rows, (row) => row()); // Tint map pins by how many changes each place takes: direct = green, each // extra connection pushes toward red (see RouteMap.reachColor). @@ -2049,8 +2063,13 @@ function runBrowse(c: RenderCtx, dir: "from" | "to"): void { * clicking a destination row drops into the with-destination flow for that place. */ function runGetaways(c: RenderCtx, origin: string): void { - const { trains } = deps; - refs.title.textContent = t("rt_finder_title"); + const { trains, registry } = deps; + const nights = query.stay ? stayNights(query.stay) : null; + refs.title.textContent = t("rt_finder_title", { + station: registry.label(origin), + date: formatDate(query.date), + stay: nights == null ? t("stay_flexible") : nights === 0 ? t("nights_sameday") : t("getaway_nights", { n: nights }), + }); // DAY-SCOPED: list the round trips you can start on the chosen day, so the count matches // the "When to leave?" calendar's number for that day (pick another day → that day's list). // A window-wide union would say "65 possible" while the calendar cell says "8 that day". @@ -2058,6 +2077,10 @@ function runGetaways(c: RenderCtx, origin: string): void { const shown = trips; if (shown.length === 0) { refs.results.append(render.emptyEl(t("getaway_none"))); + // Never a dead end: the next day with a round trip, and the one-way list. + const next = nextAvailableDay(getawayIdeas(trains, origin, dateRange(today, BOOKING_WINDOW_DAYS), getawayOpts()).perDay); + if (next) refs.results.append(nextDayEl(next)); + refs.results.append(actionEl(t("getaway_oneway_btn"), () => applyTripShape("oneway"))); showMap(origin, []); return; } @@ -2241,7 +2264,7 @@ function runMultiCity(c: RenderCtx): void { // handy when you left the date blank. Clicking a day sets it and re-runs. const legCal = availabilityCalendar(trains, leg.from, leg.to, windowDates, opts); const legCtx: RenderCtx = { ...c, onSelectDay: (d) => setLegDate(i, d) }; - const calEl = journeys.length ? render.calendarEl(legCal, legCtx, leg.date) : null; + const calEl = journeys.length ? render.calendarEl(gradeForCard(legCal, query.card), legCtx, leg.date) : null; if (calEl) sec.append(calEl); const cards: HTMLElement[] = []; if (journeys.length === 0) sec.append(render.emptyEl(t("res_none"))); @@ -2369,7 +2392,7 @@ function runBestSearch(c: RenderCtx): void { // Month-long train count per destination (same figure as the "Where to?" list), // so an idea shows how well-served it is before you drill in. const stats = windowStats(trains, query.origin, "from", filterOpts()); - // Sort by trains / days reachable / distance / name; "rec" keeps fastest-first. + // Fastest first (bestTripsAcrossWindow's own order) unless another key is picked. const origin = query.origin; const sorted = applySort(trips, { name: (tr) => registry.label(tr.destination), @@ -2381,12 +2404,14 @@ function runBestSearch(c: RenderCtx): void { refs.results.append( render.listToolbarEl( t("res_destinations", { n: trips.length }), - query.sort ?? "rec", - sortOptions(["rec", "trains", "days", "closest", "fastest", "name"]), + query.sort ?? "fastest", + sortOptions(["fastest", "trains", "days", "closest", "name"]), onSort, ), ); - appendInChunks(refs.results, sorted, (tr) => render.bestTripRowEl(tr, c, stats.get(tr.destination)?.trains)); + appendInChunks(refs.results, sorted, (tr) => + render.bestTripRowEl(tr, c, stats.get(tr.destination)?.trains, query.sort), + ); showMap( query.origin, sorted.map((tr) => tr.destination), @@ -2456,11 +2481,13 @@ function appendNearbyAlternatives( } else { if (alt.fromOrigin.length) { sec.append(el("h4", { class: "nearby-sub", text: t("nearby_from_origin", { station: registry.label(query.origin) }) })); - for (const a of alt.fromOrigin) sec.append(render.nearbyTripRowEl(a.id, Math.round(a.km), a.journey, c)); + for (const a of alt.fromOrigin) + sec.append(render.nearbyTripRowEl(a.id, t("nearby_km", { km: Math.round(a.km) }), a.journey, c)); } if (alt.toDest.length) { sec.append(el("h4", { class: "nearby-sub", text: t("nearby_to_dest", { station: registry.label(query.destination) }) })); - for (const a of alt.toDest) sec.append(render.nearbyTripRowEl(a.id, Math.round(a.km), a.journey, c)); + for (const a of alt.toDest) + sec.append(render.nearbyTripRowEl(a.id, t("nearby_km", { km: Math.round(a.km) }), a.journey, c)); } if (alt.bothEnds.length) { sec.append(el("h4", { class: "nearby-sub", text: t("nearby_both") })); @@ -2512,7 +2539,11 @@ function runOdSearch(c: RenderCtx): void { // availability, mirroring the round-trip outbound calendar's collapse pattern. // Opened from an Ideas one-way tap → show the days you can go up front (calendar open); // otherwise it's collapsed behind a one-tap "Départ : … · Changer" summary as usual. - const odCal = render.collapsibleCalendar(render.calendarEl(cal, c, query.date), "cal-collapsible", openOdCalendar); + const odCal = render.collapsibleCalendar( + render.calendarEl(gradeForCard(cal, query.card), c, query.date), + "cal-collapsible", + openOdCalendar, + ); openOdCalendar = false; odCal.setLabel(t("outbound_change", { date: formatDate(query.date) })); refs.results.append(odCal.host); @@ -2894,7 +2925,7 @@ function runTripSearch(c: RenderCtx): void { const refocus = retCalHost.contains(document.activeElement); clear(retCalHost); retCalHost.append( - render.calendarEl(retCal, retCtx, retDate, { + render.calendarEl(gradeForCard(retCal, query.card), retCtx, retDate, { title: t("rt_inbound"), // First cell is same-day (hours on site); every later cell is nights away. count: (n, day) => (day.date === query.date ? t("daytrip_cal_hours", { h: n }) : t("getaway_nights", { n })), @@ -2991,7 +3022,7 @@ function runTripSearch(c: RenderCtx): void { refreshInPlace(); }; const outCalCtx: RenderCtx = { ...c, onSelectDay: onOutboundDay }; - const outCalEl = render.calendarEl(outCal, outCalCtx, query.date, { + const outCalEl = render.calendarEl(gradeForCard(outCal, query.card), outCalCtx, query.date, { title: t("getaway_cal_title"), count: (n) => (isSameDayTrip ? t("daytrip_cal_hours", { h: n }) : t("getaway_nights", { n })), countLegend: isSameDayTrip ? t("cal_legend_hours") : t("cal_legend_nights"), @@ -3094,6 +3125,11 @@ function runTripSearch(c: RenderCtx): void { }; if (outJourneys.length === 0) { body0.append(render.emptyEl(t("res_none")), render.hintEl(t("res_none_hint"))); + // No same-day round trip that day: offer the shortest stay that has one. + const n = isSameDayTrip + ? [1, 2, 3].find((k) => bestGetawayTo(trains, origin, destination, query.date, { ...outCalOpts, nights: k })) + : undefined; + if (n) body0.append(actionEl(t("try_nights", { n }), () => applyTripShape(stayFromNights(n)))); } else { for (const j of outJourneys) body0.append( @@ -3159,6 +3195,28 @@ function runTripSearch(c: RenderCtx): void { ); } } + +/** A one-tap way forward under an empty list: a ghost button on its own centred row. */ +function actionEl(text: string, onClick: () => void): HTMLElement { + const button = el("button", { class: "btn btn-ghost", type: "button", text, on: { click: onClick } }); + return el("div", { class: "od-comeback" }, [button]); +} + +/** The first day after the chosen one, else the first in the window, that `cal` marks + * bookable on the query's card. */ +function nextAvailableDay(cal: CalendarDay[]): string | undefined { + const open = gradeForCard(cal, query.card).filter((d) => d.available && d.date !== query.date); + return (open.find((d) => d.date > query.date) ?? open[0])?.date; +} + +/** Move an empty list to `date`, a day its calendar marks green. */ +function nextDayEl(date: string): HTMLElement { + return actionEl(t("day_with_seats", { date: formatDate(date) }), () => { + query = { ...query, date }; + refreshInPlace(); + }); +} + /** Coarse pointer ≈ touch/phone. */ function showHint(input: HTMLInputElement): void { // Empty state: no nagging prompt — just a blank heading and a ready cursor. @@ -3242,15 +3300,34 @@ function cycleTripShape(): void { repaintFormCalendar(); // the shape now means one-way vs round → recolour the days } +/** The departure or arrival field the active surface reads whose text names no station. */ +function unknownStationField(): HTMLInputElement | undefined { + const fields = + tripType === "simple" || (tripType === "multi" && formApi.getMultiMode() === "plan") + ? [refs.origin, refs.destination] + : tripType === "ideas" + ? [refs.origin] + : []; + return fields.find((f) => f.value.trim() !== "" && !resolveStation(f.value)); +} + /** Run a fresh search from the current form (submit or "g" shortcut). */ function runFromForm(): void { + // A name that matches no station, or several, stops here in red: searching without it + // would run a different search than the one typed. + const unknown = unknownStationField(); + if (unknown) { + unknown.classList.add("is-invalid"); + setSurpriseMsg(t("err_station", { station: unknown.value.trim() })); + return; + } + setSurpriseMsg(""); query = readQueryFromForm(); applyAndRun(); // Only swap the phone to the results view when there's something real to show. An - // incomplete query (no origin, etc.) stays on the form with its field flagged, rather - // than teleporting to a blank results screen. (applyAndRun's showHint already keeps - // the form open; this stops the old unconditional flip from overriding it.) + // incomplete query stays on the form, with the missing step named under Search. if (queryIsRenderable(query)) setMobileForm(false); + else setSurpriseMsg(t(query.mode === "tour" && formApi.getMultiMode() === "legs" ? "multi_hint" : "need_origin")); } /** Shift the chosen date by `delta` days, clamped to the bookable window. */ @@ -3700,7 +3777,7 @@ function buildLayout(root: HTMLElement): void { // connection-aware) instead of the old direct-only undercount. cal = reachableCountCalendar(deps.trains, o, dates, opts, "from"); } - if (cal) for (const c of cal) map.set(c.date, c.available ? c.count : 0); + if (cal) for (const c of gradeForCard(cal, query.card)) map.set(c.date, c.available ? c.count : 0); return map; }, onSwitchTab: switchTab, @@ -3735,6 +3812,7 @@ function buildLayout(root: HTMLElement): void { query = { ...query, card }; store.updateUrl(query); runSearch(); + repaintFormCalendar(); // a Senior calendar greys its weekends }, onShare: (onCopied) => void shareCurrentUrl(onCopied), onInstall: () => void promptInstall(), diff --git a/src/core/best.ts b/src/core/best.ts index 3c6c8c4..66989eb 100644 --- a/src/core/best.ts +++ b/src/core/best.ts @@ -6,6 +6,8 @@ export interface BestTrip { journey: Journey; /** Days in the window this destination is reachable (set by the all-days view). */ days?: number; + /** The first day in the window it is reachable (set by the all-days view). */ + firstDate?: string; } /** Distinct stations that appear (as origin or destination) on a given date. */ @@ -46,7 +48,7 @@ export function bestTripsAcrossWindow( const cur = found.get(destination); // Keep the fastest journey across the whole window, not the earliest day's. if (!cur || journey.totalDurationMin < cur.journey.totalDurationMin) { - found.set(destination, { destination, journey }); + found.set(destination, { destination, journey, firstDate: cur?.firstDate ?? date }); } } } diff --git a/src/data/stations.ts b/src/data/stations.ts index e0a0378..5ffa668 100644 --- a/src/data/stations.ts +++ b/src/data/stations.ts @@ -2,12 +2,20 @@ import type { Station } from "../types"; import { CITY_REFERENCE } from "./cities"; import { NON_BOOKABLE_PATTERNS } from "../config"; -/** Lowercase, strip accents/diacritics for tolerant matching. */ +/** + * Match key for station and city names, typed, linked or stored: accents and case folded, + * hyphens, brackets and commas read as spaces, and "St"/"Ste" as "Saint"/"Sainte", so + * "St-Pierre" and "saint pierre" agree, as do "ESSLINGEN(NECKAR)" and "Esslingen Neckar". + */ export function normalizeText(s: string): string { return s .normalize("NFD") .replace(/[̀-ͯ]/g, "") .toLowerCase() + .replace(/[-(),]/g, " ") + .replace(/\bst\b/g, "saint") + .replace(/\bste\b/g, "sainte") + .replace(/\s+/g, " ") .trim(); } @@ -33,21 +41,6 @@ export function isAirportStation(id: string): boolean { return AIRPORT_MARKERS.some((m) => u.includes(m)); } -/** - * Tolerant match key for city resolution: accent/case-folded, hyphens → spaces, - * "St"/"Ste" → "Saint"/"Sainte". Lets "ST MALO", "Saint-Malo" and "SAINT MALO" - * all resolve to the same city. - */ -export function matchNorm(s: string): string { - return normalizeText(s) - .replace(/[(),]/g, " ") // e.g. "ESSLINGEN(NECKAR)" -> "esslingen neckar" - .replace(/-/g, " ") - .replace(/\bst\b/g, "saint") - .replace(/\bste\b/g, "sainte") - .replace(/\s+/g, " ") - .trim(); -} - /** Fallback display name for a station id not present in the registry. */ export function prettyLabel(id: string): string { return id @@ -78,6 +71,8 @@ export class StationRegistry { private cityKeys: { key: string; info: CityInfo }[] = []; // Ids actually present in the loaded dataset (i.e. bookable). private present = new Set(); + // Normalised name → station, for resolve(); rebuilt after addMissing(). + private exact: Map | null = null; constructor(stations: Station[]) { for (const s of stations) this.add(s); @@ -85,7 +80,7 @@ export class StationRegistry { const refMap = new Map(); const addRef = (name: string | undefined, info: CityInfo): void => { if (!name) return; - const k = matchNorm(name); + const k = normalizeText(name); if (k && !refMap.has(k)) refMap.set(k, info); }; // Seed from curated stations (authoritative coords), then the supplementary @@ -117,7 +112,7 @@ export class StationRegistry { /** Best city reference matching a station id (whole-word, longest match). */ private matchCity(id: string): CityInfo | undefined { - const n = matchNorm(id); + const n = normalizeText(id); if (!n) return undefined; for (const { key, info } of this.cityKeys) { if (n === key || n.startsWith(`${key} `) || n.endsWith(` ${key}`) || n.includes(` ${key} `)) { @@ -134,6 +129,7 @@ export class StationRegistry { * Every id passed here is also recorded as "present" (bookable). */ addMissing(ids: Iterable): void { + this.exact = null; for (const id of ids) { if (!id) continue; this.present.add(id); @@ -154,19 +150,6 @@ export class StationRegistry { return this.byId.get(id); } - /** The id a linked name means: a bookable id as is, else the id, label or alias equal to - * it ignoring case, accents and "St"/hyphen spelling, preferring one with trains. */ - resolve(name: string): string | undefined { - if (this.present.has(name)) return name; - const n = matchNorm(name); - let best: Station | undefined; - for (const { station } of this.index) { - const names = [station.id, station.label, ...(station.aliases ?? [])]; - if (names.some((s) => matchNorm(s) === n) && (!best || this.better(station, best))) best = station; - } - return best?.id; - } - /** Every registered station (may contain label duplicates). */ all(): Station[] { return [...this.byId.values()]; @@ -200,6 +183,41 @@ export class StationRegistry { return s && Number.isFinite(s.lat) && Number.isFinite(s.lng) ? [s.lat, s.lng] : undefined; } + /** + * The station a name means, typed or linked: a bookable id as is; else the station whose + * id, label or alias it spells under {@link normalizeText}; else the only station it + * matches. Several matches, or none, leave it unresolved, never a guess. + */ + resolve(name: string): string | undefined { + if (this.present.has(name)) return name; + const q = normalizeText(name); + if (!q) return undefined; + this.exact ??= this.exactNames(); + const exact = this.exact.get(q); + if (exact) return exact.id; + const hits = this.search(q, 2); + return hits.length === 1 ? hits[0]!.id : undefined; + } + + /** Every bookable station by its normalised names: an id or label outranks another + * station's alias, and among equal names the station with trains wins. */ + private exactNames(): Map { + const out = new Map(); + const stations = this.all().filter((s) => !isNonBookable(s.id)); + const tiers = [(s: Station) => s.aliases ?? [], (s: Station) => [s.id, s.label]]; + for (const names of tiers) { + const tier = new Map(); + for (const s of stations) + for (const n of names(s)) { + const k = normalizeText(n); + const cur = tier.get(k); + if (!cur || this.better(s, cur)) tier.set(k, s); + } + for (const [k, s] of tier) out.set(k, s); // later tiers overwrite earlier ones + } + return out; + } + /** Accent-insensitive, word-prefix-aware autocomplete (deduped by label). */ search(query: string, limit = 8): Station[] { const q = normalizeText(query); diff --git a/src/i18n/ar.ts b/src/i18n/ar.ts index f4d984a..6aa02ea 100644 --- a/src/i18n/ar.ts +++ b/src/i18n/ar.ts @@ -75,6 +75,8 @@ export const ar: Dict = { nearby_to_dest: "الوصول قرب {station}", nearby_both: "الانطلاق والوصول من محطات قريبة", nearby_km: "على بعد {km} كم", + nearby_from_km: "من {station} · {km} كم", + nearby_to_km: "إلى {station} · {km} كم", nearby_none: "لا توجد محطة قريبة بها مقعد MAX لهذا المسار.", nearby_browse_from: "وجهات إضافية يمكنك الوصول إليها بدفع رحلة قصيرة إلى محطة قريبة.", nearby_browse_to: "نقاط انطلاق إضافية تصل إلى محطة قرب وجهتك — ادفع رحلة قصيرة من هناك.", @@ -87,7 +89,7 @@ export const ar: Dict = { desc_simple: "اعثر على رحلة محددة، أو تصفّح كل الوجهات انطلاقًا من محطة (أو كل نقاط الانطلاق نحو محطة).", desc_multi_plan: "اذكر المدن التي تريد زيارتها: نرتّبها ونحدّد تواريخها في جولة قابلة للتنفيذ.", desc_multi_legs: "كوّن كل رحلة يدويًا، محطة بمحطة وتاريخًا بتاريخ.", - desc_ideas: "اكتشف إلى أين يمكنك الذهاب من محطة، مرتّبًا حسب عدد القطارات المجانية.", + desc_ideas: "اكتشف إلى أين يمكنك الذهاب من محطة، الأسرع أولًا.", hint_od_optional: "اختياري: املأ المغادرة أو الوصول أو كليهما.", radius_hint: "يوسّع البحث ليشمل المحطات القريبة لإيجاد مزيد من المقاعد المجانية.", act_swap: "تبديل المغادرة والوصول", @@ -115,6 +117,7 @@ export const ar: Dict = { res_destinations: "{n} وجهة", res_origins: "{n} نقطة انطلاق", badge_trains: "{n} قطار", + badge_days: "{n} يوم/أيام", lbl_direct: "مباشر", lbl_airport: "مطار", lbl_via: "عبر {hub}", @@ -212,6 +215,9 @@ export const ar: Dict = { getaway_count: "{n} رحلة ذهاب وإياب ممكنة", getaway_nights: "{n} ليلة/ليالٍ هناك", getaway_none: "لا توجد رحلات MAX ذهابًا وإيابًا بهذه المعايير.", + getaway_oneway_btn: "عرض وجهات الذهاب فقط", + day_with_seats: "عرض {date} (مقاعد متاحة)", + try_nights: "ابقَ {n} ليلة/ليالٍ بدلًا من ذلك", act_save: "حفظ", act_saved: "محفوظ", @@ -264,7 +270,7 @@ export const ar: Dict = { cal_legend_return: "العدد = قطارات العودة", res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}", rt_return_leg: "العودة", - rt_finder_title: "إلى أين تهرب؟", + rt_finder_title: "رحلات ذهاب وعودة من {station} — {date} · {stay}", rt_need_origin: "أضف نقطة انطلاق لمعرفة أين يمكنك الذهاب والعودة.", rt_reverse_title: "من أين يمكنك الذهاب والعودة للوصول إلى {station}؟", rt_reverse_count: "{n} نقطة انطلاق ممكنة", @@ -274,6 +280,7 @@ export const ar: Dict = { senior_weekend_return_warn: "MAX SENIOR صالحة أيام الأسبوع فقط — عودتك في نهاية الأسبوع ولا يمكن حجزها مجانًا.", form_cal_title: "متى تسافر؟", form_cal_hint: "اختر محطة انطلاق لعرض الأيام المتاحة.", + need_origin: "اختر محطة المغادرة.", form_cal_departed: "المغادرة: {date}", form_cal_flex_hint: "اختر الذهاب ثم العودة", form_cal_range: "الذهاب: {from} ← العودة: {to}", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index ed5ef37..caeb4d2 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -75,6 +75,8 @@ export const de: Dict = { nearby_to_dest: "Ankunft nahe {station}", nearby_both: "Abfahrt und Ankunft in der Nähe", nearby_km: "{km} km entfernt", + nearby_from_km: "ab {station} · {km} km", + nearby_to_km: "bis {station} · {km} km", nearby_none: "Kein naher Bahnhof hat einen MAX-Platz für diese Strecke.", nearby_browse_from: "Zusätzliche Ziele, die du mit einem kurzen bezahlten Sprung zu einem nahen Bahnhof erreichst.", nearby_browse_to: "Zusätzliche Startbahnhöfe, die einen Bahnhof nahe deinem Ziel erreichen — von dort ein kurzer bezahlter Sprung.", @@ -87,7 +89,7 @@ export const de: Dict = { desc_simple: "Finde eine genaue Verbindung oder durchstöbere alle Ziele ab einem Bahnhof (oder alle Startorte zu einem).", desc_multi_plan: "Liste die Städte auf: Wir ordnen und datieren sie zu einer machbaren Rundreise.", desc_multi_legs: "Stelle jede Etappe selbst zusammen, Bahnhof für Bahnhof und Tag für Tag.", - desc_ideas: "Entdecke, wohin du ab einem Bahnhof fahren kannst — sortiert nach Anzahl freier Züge.", + desc_ideas: "Entdecke, wohin du ab einem Bahnhof fahren kannst — die schnellsten zuerst.", hint_od_optional: "Optional: Fülle Abfahrt, Ankunft oder beides aus.", radius_hint: "Erweitert die Suche auf nahe Bahnhöfe, um mehr freie Plätze zu finden.", act_swap: "Abfahrt und Ankunft tauschen", @@ -115,6 +117,7 @@ export const de: Dict = { res_destinations: "{n} Ziel(e)", res_origins: "{n} Ausgangsort(e)", badge_trains: "{n} Zug/Züge", + badge_days: "{n} Tag(e)", lbl_direct: "Direkt", lbl_airport: "Flughafen", lbl_via: "über {hub}", @@ -213,6 +216,9 @@ export const de: Dict = { getaway_count: "{n} Hin- und Rückfahrt(en) möglich", getaway_nights: "{n} Nacht/Nächte vor Ort", getaway_none: "Keine MAX-Hin-und-Rückfahrten für diese Kriterien.", + getaway_oneway_btn: "Ziele für die einfache Fahrt ansehen", + day_with_seats: "{date} ansehen (freie Plätze)", + try_nights: "Stattdessen {n} Nacht/Nächte bleiben", act_save: "Speichern", act_saved: "Gespeichert", @@ -265,7 +271,7 @@ export const de: Dict = { cal_legend_return: "Zahl = Rückfahrten", res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}", rt_return_leg: "Rückfahrt", - rt_finder_title: "Wohin verreisen?", + rt_finder_title: "Hin und zurück ab {station} — {date} · {stay}", rt_need_origin: "Gib einen Abfahrtsort ein, um Hin- und Rückfahrten zu sehen.", rt_reverse_title: "Von wo aus erreichst du {station} per Hin- und Rückfahrt?", rt_reverse_count: "{n} mögliche(r) Abfahrtsort(e)", @@ -275,6 +281,7 @@ export const de: Dict = { senior_weekend_return_warn: "MAX SENIOR gilt nur werktags — Ihre Rückfahrt fällt auf ein Wochenende und ist nicht kostenlos buchbar.", form_cal_title: "Wann losfahren?", form_cal_hint: "Wählen Sie einen Abfahrtsbahnhof, um verfügbare Tage zu sehen.", + need_origin: "Wähle einen Abfahrtsbahnhof.", form_cal_departed: "Abfahrt: {date}", form_cal_flex_hint: "Erst die Hinfahrt, dann die Rückfahrt wählen", form_cal_range: "Hin: {from} → Rück: {to}", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 0f52150..e2232ae 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -79,6 +79,8 @@ export const en: Dict = { nearby_to_dest: "Arrive near {station}", nearby_both: "Leave from and arrive near", nearby_km: "{km} km away", + nearby_from_km: "from {station} · {km} km", + nearby_to_km: "to {station} · {km} km", nearby_none: "No nearby station has a free MAX seat for this route.", nearby_browse_from: "Extra destinations you can reach by paying a short hop to a nearby station.", nearby_browse_to: "Extra origins that reach a station near your destination — pay a short hop from there.", @@ -91,7 +93,7 @@ export const en: Dict = { desc_simple: "Find an exact trip, or browse every destination from a station (or every origin to one).", desc_multi_plan: "List the cities to visit: we order and date them into a feasible tour.", desc_multi_legs: "Build each leg by hand, station by station and date by date.", - desc_ideas: "Discover where to go from a station, ranked by how many free trains run.", + desc_ideas: "Discover where to go from a station, fastest first.", hint_od_optional: "Optional: fill the départ, the arrivée, or both.", radius_hint: "Widen the search to nearby stations to surface more free seats.", act_swap: "Swap départ and arrivée", @@ -119,6 +121,7 @@ export const en: Dict = { res_destinations: "{n} destination(s)", res_origins: "{n} origin(s)", badge_trains: "{n} train(s)", + badge_days: "{n} day(s)", lbl_direct: "Direct", lbl_airport: "Airport", @@ -225,6 +228,9 @@ export const en: Dict = { getaway_count: "{n} round trip(s) possible", getaway_nights: "{n} night(s) there", getaway_none: "No MAX round trips for these criteria.", + getaway_oneway_btn: "See one-way destinations", + day_with_seats: "See {date} (free seats)", + try_nights: "Stay {n} night(s) instead", act_save: "Save", act_saved: "Saved", @@ -277,7 +283,7 @@ export const en: Dict = { cal_legend_return: "number = return trains", res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}", rt_return_leg: "Return", - rt_finder_title: "Where can you get away to?", + rt_finder_title: "Round trips from {station} — {date} · {stay}", rt_need_origin: "Add a departure to see where you can go and come back.", rt_reverse_title: "Where can you round-trip from to reach {station}?", rt_reverse_count: "{n} possible departure point(s)", @@ -287,6 +293,7 @@ export const en: Dict = { senior_weekend_return_warn: "MAX SENIOR is weekday-only — your return lands on a weekend and can't be booked free.", form_cal_title: "When to leave?", form_cal_hint: "Choose a departure station to see which days are available.", + need_origin: "Choose a departure station.", form_cal_departed: "Departure: {date}", form_cal_flex_hint: "Pick the outbound, then the return", form_cal_range: "Departure: {from} → Return: {to}", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index cf36eb3..cb1c769 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -75,6 +75,8 @@ export const es: Dict = { nearby_to_dest: "Llegar cerca de {station}", nearby_both: "Salir y llegar cerca", nearby_km: "a {km} km", + nearby_from_km: "desde {station} · {km} km", + nearby_to_km: "hacia {station} · {km} km", nearby_none: "Ninguna estación cercana tiene plaza MAX para este trayecto.", nearby_browse_from: "Destinos adicionales a los que puedes llegar pagando un trayecto corto hasta una estación cercana.", nearby_browse_to: "Orígenes adicionales que llegan a una estación cerca de tu destino — paga un trayecto corto desde allí.", @@ -87,7 +89,7 @@ export const es: Dict = { desc_simple: "Encuentra un trayecto exacto, o explora todos los destinos desde una estación (o todos los orígenes hacia una).", desc_multi_plan: "Enumera las ciudades a visitar: las ordenamos y fechamos en un circuito viable.", desc_multi_legs: "Compón cada trayecto a mano, estación por estación y fecha por fecha.", - desc_ideas: "Descubre adónde ir desde una estación, ordenado por cuántos trenes gratuitos hay.", + desc_ideas: "Descubre adónde ir desde una estación, los más rápidos primero.", hint_od_optional: "Opcional: rellena la salida, la llegada, o ambas.", radius_hint: "Amplía la búsqueda a estaciones cercanas para encontrar más plazas gratuitas.", act_swap: "Intercambiar salida y llegada", @@ -115,6 +117,7 @@ export const es: Dict = { res_destinations: "{n} destino(s)", res_origins: "{n} origen(es)", badge_trains: "{n} tren(es)", + badge_days: "{n} día(s)", lbl_direct: "Directo", lbl_airport: "Aeropuerto", lbl_via: "vía {hub}", @@ -213,6 +216,9 @@ export const es: Dict = { getaway_count: "{n} ida(s) y vuelta(s) posible(s)", getaway_nights: "{n} noche(s) allí", getaway_none: "Ningún viaje MAX de ida y vuelta para estos criterios.", + getaway_oneway_btn: "Ver destinos solo ida", + day_with_seats: "Ver el {date} (plazas libres)", + try_nights: "Quedarse {n} noche(s)", act_save: "Guardar", act_saved: "Guardado", @@ -265,7 +271,7 @@ export const es: Dict = { cal_legend_return: "número = trenes de vuelta", res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}", rt_return_leg: "Vuelta", - rt_finder_title: "¿A dónde escaparse?", + rt_finder_title: "Idas y vueltas desde {station} — {date} · {stay}", rt_need_origin: "Añade una salida para ver a dónde ir y volver.", rt_reverse_title: "¿Desde dónde puedes ir y volver a {station}?", rt_reverse_count: "{n} punto(s) de salida posible(s)", @@ -275,6 +281,7 @@ export const es: Dict = { senior_weekend_return_warn: "MAX SENIOR es solo entre semana — tu vuelta cae en fin de semana y no puede reservarse gratis.", form_cal_title: "¿Cuándo salir?", form_cal_hint: "Elige una estación de salida para ver qué días hay disponibles.", + need_origin: "Elige una estación de salida.", form_cal_departed: "Salida: {date}", form_cal_flex_hint: "Elige la ida y luego la vuelta", form_cal_range: "Ida: {from} → Vuelta: {to}", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index 5fb6061..101e39f 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -77,6 +77,8 @@ export const fr = { nearby_to_dest: "Arriver près de {station}", nearby_both: "Partir et arriver près", nearby_km: "à {km} km", + nearby_from_km: "depuis {station} · {km} km", + nearby_to_km: "vers {station} · {km} km", nearby_none: "Aucune gare voisine n'a de place MAX pour ce trajet.", nearby_browse_from: "Des destinations en plus, en payant un court trajet jusqu'à une gare voisine.", nearby_browse_to: "Des points de départ en plus qui atteignent une gare proche de votre arrivée — payez un court trajet depuis là.", @@ -89,7 +91,7 @@ export const fr = { desc_simple: "Trouvez un trajet précis, ou parcourez toutes les destinations au départ d'une gare (ou toutes les origines vers une gare).", desc_multi_plan: "Listez les villes à visiter : on les ordonne et les date en un périple faisable.", desc_multi_legs: "Composez chaque trajet à la main, gare par gare et date par date.", - desc_ideas: "Découvrez où partir depuis une gare, classé par nombre de trains gratuits.", + desc_ideas: "Découvrez où partir depuis une gare, les plus rapides d'abord.", hint_od_optional: "Optionnel : remplissez le départ, l'arrivée, ou les deux.", radius_hint: "Élargit la recherche aux gares voisines pour trouver plus de places gratuites.", act_swap: "Intervertir départ et arrivée", @@ -117,6 +119,7 @@ export const fr = { res_destinations: "{n} destination(s)", res_origins: "{n} origine(s)", badge_trains: "{n} train(s)", + badge_days: "{n} jour(s)", lbl_direct: "Direct", lbl_airport: "Aéroport", @@ -223,6 +226,9 @@ export const fr = { getaway_count: "{n} aller(s)-retour(s) possible(s)", getaway_nights: "{n} nuit(s) sur place", getaway_none: "Aucun aller-retour MAX pour ces critères.", + getaway_oneway_btn: "Voir les destinations en aller simple", + day_with_seats: "Voir le {date} (places libres)", + try_nights: "Rester {n} nuit(s) à la place", act_save: "Enregistrer", act_saved: "Enregistré", @@ -279,7 +285,7 @@ export const fr = { cal_legend_return: "nombre = trains au retour", res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}", rt_return_leg: "Retour", - rt_finder_title: "Où s'évader ?", + rt_finder_title: "Allers-retours depuis {station} — {date} · {stay}", rt_need_origin: "Ajoutez un départ pour voir où aller et revenir.", rt_reverse_title: "D’où partir pour rejoindre {station} ?", rt_reverse_count: "{n} point(s) de départ possible(s)", @@ -290,6 +296,7 @@ export const fr = { // The reactive availability calendar on the home form (the Trip tab's date picker). form_cal_title: "Quand partir ?", form_cal_hint: "Choisissez une gare de départ pour voir les jours disponibles.", + need_origin: "Choisissez une gare de départ.", form_cal_departed: "Départ : {date}", // Flexible: the inline calendar becomes a departure→return range picker. form_cal_flex_hint: "Choisissez l'aller, puis le retour", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index 2c45630..7185431 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -75,6 +75,8 @@ export const it: Dict = { nearby_to_dest: "Arrivo vicino a {station}", nearby_both: "Partenza e arrivo nelle vicinanze", nearby_km: "a {km} km", + nearby_from_km: "da {station} · {km} km", + nearby_to_km: "verso {station} · {km} km", nearby_none: "Nessuna stazione vicina ha un posto MAX per questo tragitto.", nearby_browse_from: "Destinazioni in più raggiungibili pagando un breve tratto fino a una stazione vicina.", nearby_browse_to: "Partenze in più che raggiungono una stazione vicino al tuo arrivo — paga un breve tratto da lì.", @@ -87,7 +89,7 @@ export const it: Dict = { desc_simple: "Trova un viaggio preciso, o esplora tutte le destinazioni da una stazione (o tutte le origini verso una).", desc_multi_plan: "Elenca le città da visitare: le ordiniamo e le datiamo in un tour fattibile.", desc_multi_legs: "Componi ogni tratta a mano, stazione per stazione e data per data.", - desc_ideas: "Scopri dove andare da una stazione, ordinato per numero di treni gratuiti.", + desc_ideas: "Scopri dove andare da una stazione, prima i più veloci.", hint_od_optional: "Facoltativo: compila la partenza, l'arrivo, o entrambi.", radius_hint: "Allarga la ricerca alle stazioni vicine per trovare più posti gratuiti.", act_swap: "Inverti partenza e arrivo", @@ -115,6 +117,7 @@ export const it: Dict = { res_destinations: "{n} destinazione/i", res_origins: "{n} origine/i", badge_trains: "{n} treno/i", + badge_days: "{n} giorno/i", lbl_direct: "Diretto", lbl_airport: "Aeroporto", lbl_via: "via {hub}", @@ -213,6 +216,9 @@ export const it: Dict = { getaway_count: "{n} andata/e e ritorno possibile/i", getaway_nights: "{n} notte/notti sul posto", getaway_none: "Nessun andata e ritorno MAX per questi criteri.", + getaway_oneway_btn: "Vedi le destinazioni solo andata", + day_with_seats: "Vedi {date} (posti liberi)", + try_nights: "Resta {n} notte/notti", act_save: "Salva", act_saved: "Salvato", @@ -265,7 +271,7 @@ export const it: Dict = { cal_legend_return: "numero = treni di ritorno", res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}", rt_return_leg: "Ritorno", - rt_finder_title: "Dove evadere?", + rt_finder_title: "Andata e ritorno da {station} — {date} · {stay}", rt_need_origin: "Aggiungi una partenza per vedere dove andare e tornare.", rt_reverse_title: "Da dove puoi fare andata e ritorno per {station}?", rt_reverse_count: "{n} punto/i di partenza possibile/i", @@ -275,6 +281,7 @@ export const it: Dict = { senior_weekend_return_warn: "MAX SENIOR vale nei giorni feriali — il tuo ritorno cade nel weekend e non è prenotabile gratis.", form_cal_title: "Quando partire?", form_cal_hint: "Scegli una stazione di partenza per vedere i giorni disponibili.", + need_origin: "Scegli una stazione di partenza.", form_cal_departed: "Partenza: {date}", form_cal_flex_hint: "Scegli l'andata, poi il ritorno", form_cal_range: "Andata: {from} → Ritorno: {to}", diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 04a7754..d0dbcd9 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -75,6 +75,8 @@ export const ja: Dict = { nearby_to_dest: "{station} 付近に到着", nearby_both: "近くで出発して近くに到着", nearby_km: "{km} km", + nearby_from_km: "{station} 発 · {km} km", + nearby_to_km: "{station} 着 · {km} km", nearby_none: "この経路に MAX 席のある近隣駅はありません。", nearby_browse_from: "近くの駅まで短い区間を支払えば行ける、追加の目的地。", nearby_browse_to: "目的地の近くの駅に着く追加の出発地 — そこから短い区間だけ支払います。", @@ -87,7 +89,7 @@ export const ja: Dict = { desc_simple: "正確な経路を探すか、ある駅からの全目的地(またはある駅への全出発地)を見て回れます。", desc_multi_plan: "訪れる都市を並べると、実行可能な周遊として順番と日付を決めます。", desc_multi_legs: "駅ごと・日付ごとに各区間を手動で組み立てます。", - desc_ideas: "ある駅から行ける場所を、無料列車の本数順で見つけます。", + desc_ideas: "ある駅から行ける場所を、速い順に見つけます。", hint_od_optional: "任意:出発地、到着地、またはその両方を入力してください。", radius_hint: "検索を近くの駅まで広げ、より多くの無料席を見つけます。", act_swap: "出発地と到着地を入れ替える", @@ -115,6 +117,7 @@ export const ja: Dict = { res_destinations: "目的地 {n} 件", res_origins: "出発地 {n} 件", badge_trains: "列車 {n} 本", + badge_days: "{n} 日", lbl_direct: "直通", lbl_airport: "空港", lbl_via: "{hub} 経由", @@ -212,6 +215,9 @@ export const ja: Dict = { getaway_count: "往復 {n} 件が可能", getaway_nights: "{n}泊滞在", getaway_none: "この条件では MAX 往復がありません。", + getaway_oneway_btn: "片道の行き先を見る", + day_with_seats: "{date} を見る(空席あり)", + try_nights: "代わりに {n} 泊する", act_save: "保存", act_saved: "保存済み", @@ -264,7 +270,7 @@ export const ja: Dict = { cal_legend_return: "数字 = 復路の列車", res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}", rt_return_leg: "復路", - rt_finder_title: "どこへ出かけますか?", + rt_finder_title: "{station} 発の往復 — {date} · {stay}", rt_need_origin: "出発地を入力すると往復できる場所が表示されます。", rt_reverse_title: "{station} へ往復できる出発地は?", rt_reverse_count: "出発地 {n} か所", @@ -274,6 +280,7 @@ export const ja: Dict = { senior_weekend_return_warn: "MAX SENIOR は平日限定です — 復路が週末のため無料予約できません。", form_cal_title: "いつ出発?", form_cal_hint: "出発駅を選ぶと空いている日が表示されます。", + need_origin: "出発駅を選んでください。", form_cal_departed: "出発:{date}", form_cal_flex_hint: "往路を選び、次に復路を選択", form_cal_range: "往路:{from} → 復路:{to}", diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index 1519624..5292123 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -75,6 +75,8 @@ export const ko: Dict = { nearby_to_dest: "{station} 근처에 도착", nearby_both: "근처에서 출발하고 도착", nearby_km: "{km} km", + nearby_from_km: "{station} 출발 · {km} km", + nearby_to_km: "{station} 도착 · {km} km", nearby_none: "이 경로에 MAX 좌석이 있는 인근 역이 없습니다.", nearby_browse_from: "가까운 역까지 짧은 구간을 결제하면 갈 수 있는 추가 목적지.", nearby_browse_to: "도착지 근처 역에 닿는 추가 출발지 — 거기서 짧은 구간만 결제하세요.", @@ -87,7 +89,7 @@ export const ko: Dict = { desc_simple: "정확한 여정을 찾거나, 한 역에서 갈 수 있는 모든 목적지(또는 한 역으로 오는 모든 출발지)를 둘러보세요.", desc_multi_plan: "방문할 도시를 나열하면 실행 가능한 여정으로 순서와 날짜를 정해 드립니다.", desc_multi_legs: "역과 날짜를 하나씩 직접 지정해 각 구간을 구성하세요.", - desc_ideas: "한 역에서 갈 수 있는 곳을, 무료 열차 수 기준으로 정렬해 살펴보세요.", + desc_ideas: "한 역에서 갈 수 있는 곳을 빠른 순으로 살펴보세요.", hint_od_optional: "선택 사항: 출발지, 도착지 또는 둘 다 입력하세요.", radius_hint: "검색을 인근 역까지 넓혀 더 많은 무료 좌석을 찾습니다.", act_swap: "출발지와 도착지 바꾸기", @@ -115,6 +117,7 @@ export const ko: Dict = { res_destinations: "목적지 {n}곳", res_origins: "출발지 {n}곳", badge_trains: "열차 {n}대", + badge_days: "{n}일", lbl_direct: "직통", lbl_airport: "공항", lbl_via: "{hub} 경유", @@ -213,6 +216,9 @@ export const ko: Dict = { getaway_count: "왕복 {n}건 가능", getaway_nights: "{n}박 체류", getaway_none: "이 조건으로 가능한 MAX 왕복이 없습니다.", + getaway_oneway_btn: "편도 목적지 보기", + day_with_seats: "{date} 보기 (빈자리 있음)", + try_nights: "대신 {n}박 머물기", act_save: "저장", act_saved: "저장됨", @@ -265,7 +271,7 @@ export const ko: Dict = { cal_legend_return: "숫자 = 귀환 열차", res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}", rt_return_leg: "돌아오기", - rt_finder_title: "어디로 떠나볼까요?", + rt_finder_title: "{station} 출발 왕복 — {date} · {stay}", rt_need_origin: "출발지를 입력하면 왕복 가능한 곳이 표시됩니다.", rt_reverse_title: "{station}까지 왕복할 수 있는 출발지는?", rt_reverse_count: "가능한 출발지 {n}곳", @@ -275,6 +281,7 @@ export const ko: Dict = { senior_weekend_return_warn: "MAX SENIOR는 평일 전용입니다 — 돌아오는 날이 주말이라 무료 예약이 안 됩니다.", form_cal_title: "언제 떠날까요?", form_cal_hint: "출발역을 선택하면 이용 가능한 날짜가 표시됩니다.", + need_origin: "출발역을 선택하세요.", form_cal_departed: "출발: {date}", form_cal_flex_hint: "가는 날을 고른 뒤 오는 날을 선택", form_cal_range: "가는 날: {from} → 오는 날: {to}", diff --git a/src/i18n/nl.ts b/src/i18n/nl.ts index 12a5193..9641b8b 100644 --- a/src/i18n/nl.ts +++ b/src/i18n/nl.ts @@ -75,6 +75,8 @@ export const nl: Dict = { nearby_to_dest: "Aankomst bij {station} in de buurt", nearby_both: "Vertrek en aankomst in de buurt", nearby_km: "{km} km", + nearby_from_km: "vanaf {station} · {km} km", + nearby_to_km: "naar {station} · {km} km", nearby_none: "Geen nabijgelegen station heeft een MAX-plaats voor deze route.", nearby_browse_from: "Extra bestemmingen die je bereikt door een korte hop naar een nabijgelegen station te betalen.", nearby_browse_to: "Extra vertrekpunten die een station bij je bestemming bereiken — betaal daar een korte hop.", @@ -87,7 +89,7 @@ export const nl: Dict = { desc_simple: "Vind een exacte reis, of blader door alle bestemmingen vanaf een station (of alle vertrekpunten naar één).", desc_multi_plan: "Noem de te bezoeken steden: we ordenen en dateren ze tot een haalbare rondreis.", desc_multi_legs: "Stel elk traject zelf samen, station voor station en dag voor dag.", - desc_ideas: "Ontdek waar je vanaf een station heen kunt, gerangschikt op aantal gratis treinen.", + desc_ideas: "Ontdek waar je vanaf een station heen kunt, snelste eerst.", hint_od_optional: "Optioneel: vul vertrek, aankomst, of beide in.", radius_hint: "Verbreedt de zoekopdracht naar nabijgelegen stations om meer gratis plaatsen te vinden.", act_swap: "Vertrek en aankomst omwisselen", @@ -115,6 +117,7 @@ export const nl: Dict = { res_destinations: "{n} bestemming(en)", res_origins: "{n} vertrekpunt(en)", badge_trains: "{n} trein(en)", + badge_days: "{n} dag(en)", lbl_direct: "Rechtstreeks", lbl_airport: "Luchthaven", lbl_via: "via {hub}", @@ -212,6 +215,9 @@ export const nl: Dict = { getaway_count: "{n} retour(s) mogelijk", getaway_nights: "{n} nacht(en) ter plaatse", getaway_none: "Geen MAX-retours voor deze criteria.", + getaway_oneway_btn: "Bekijk bestemmingen voor enkele reis", + day_with_seats: "Bekijk {date} (vrije plaatsen)", + try_nights: "Blijf {n} nacht(en)", act_save: "Opslaan", act_saved: "Opgeslagen", @@ -264,7 +270,7 @@ export const nl: Dict = { cal_legend_return: "aantal = terugtreinen", res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}", rt_return_leg: "Terugreis", - rt_finder_title: "Waar kun je heen ontsnappen?", + rt_finder_title: "Retours vanaf {station} — {date} · {stay}", rt_need_origin: "Voeg een vertrekpunt toe om te zien waar je heen en terug kunt.", rt_reverse_title: "Vanwaar kun je heen en terug naar {station}?", rt_reverse_count: "{n} mogelijk(e) vertrekpunt(en)", @@ -274,6 +280,7 @@ export const nl: Dict = { senior_weekend_return_warn: "MAX SENIOR geldt alleen doordeweeks — je terugreis valt in het weekend en is niet gratis te boeken.", form_cal_title: "Wanneer vertrekken?", form_cal_hint: "Kies een vertrekstation om te zien welke dagen beschikbaar zijn.", + need_origin: "Kies een vertrekstation.", form_cal_departed: "Vertrek: {date}", form_cal_flex_hint: "Kies de heenreis, dan de terugreis", form_cal_range: "Heen: {from} → Terug: {to}", diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts index 7475064..85eb61e 100644 --- a/src/i18n/pt.ts +++ b/src/i18n/pt.ts @@ -75,6 +75,8 @@ export const pt: Dict = { nearby_to_dest: "Chegar perto de {station}", nearby_both: "Partir e chegar perto", nearby_km: "a {km} km", + nearby_from_km: "a partir de {station} · {km} km", + nearby_to_km: "até {station} · {km} km", nearby_none: "Nenhuma estação próxima tem lugar MAX para este trajeto.", nearby_browse_from: "Destinos extra que pode alcançar pagando um pequeno trajeto até uma estação próxima.", nearby_browse_to: "Origens extra que chegam a uma estação perto do seu destino — pague um pequeno trajeto a partir daí.", @@ -87,7 +89,7 @@ export const pt: Dict = { desc_simple: "Encontre um trajeto exato, ou explore todos os destinos a partir de uma estação (ou todas as origens para uma).", desc_multi_plan: "Liste as cidades a visitar: ordenamo-las e datamo-las num circuito viável.", desc_multi_legs: "Componha cada trajeto à mão, estação a estação e data a data.", - desc_ideas: "Descubra para onde ir a partir de uma estação, ordenado pelo número de comboios gratuitos.", + desc_ideas: "Descubra para onde ir a partir de uma estação, os mais rápidos primeiro.", hint_od_optional: "Opcional: preencha a partida, a chegada, ou ambas.", radius_hint: "Alarga a pesquisa a estações próximas para encontrar mais lugares gratuitos.", act_swap: "Trocar partida e chegada", @@ -115,6 +117,7 @@ export const pt: Dict = { res_destinations: "{n} destino(s)", res_origins: "{n} origem(ns)", badge_trains: "{n} trem(ns)", + badge_days: "{n} dia(s)", lbl_direct: "Direto", lbl_airport: "Aeroporto", lbl_via: "via {hub}", @@ -212,6 +215,9 @@ export const pt: Dict = { getaway_count: "{n} ida(s) e volta(s) possível(is)", getaway_nights: "{n} noite(s) no local", getaway_none: "Nenhuma ida e volta MAX para estes critérios.", + getaway_oneway_btn: "Ver destinos só de ida", + day_with_seats: "Ver {date} (lugares livres)", + try_nights: "Ficar {n} noite(s)", act_save: "Guardar", act_saved: "Guardado", @@ -264,7 +270,7 @@ export const pt: Dict = { cal_legend_return: "número = comboios de volta", res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}", rt_return_leg: "Volta", - rt_finder_title: "Para onde escapar?", + rt_finder_title: "Idas e voltas a partir de {station} — {date} · {stay}", rt_need_origin: "Adicione uma origem para ver para onde ir e voltar.", rt_reverse_title: "De onde pode ir e voltar até {station}?", rt_reverse_count: "{n} ponto(s) de partida possível(is)", @@ -274,6 +280,7 @@ export const pt: Dict = { senior_weekend_return_warn: "MAX SENIOR é só em dias úteis — a sua volta cai no fim de semana e não pode ser reservada de graça.", form_cal_title: "Quando partir?", form_cal_hint: "Escolha uma estação de partida para ver os dias disponíveis.", + need_origin: "Escolha uma estação de partida.", form_cal_departed: "Partida: {date}", form_cal_flex_hint: "Escolha a ida e depois a volta", form_cal_range: "Ida: {from} → Volta: {to}", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 5c633ac..af25314 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -75,6 +75,8 @@ export const zh: Dict = { nearby_to_dest: "到达{station}附近", nearby_both: "就近出发并就近到达", nearby_km: "{km} 公里", + nearby_from_km: "从 {station} 出发 · {km} 公里", + nearby_to_km: "到 {station} · {km} 公里", nearby_none: "附近没有车站为此线路提供 MAX 座位。", nearby_browse_from: "支付一小段车程到邻近车站,即可抵达的额外目的地。", nearby_browse_to: "可到达你目的地附近车站的额外出发地——从那里再付一小段车程。", @@ -87,7 +89,7 @@ export const zh: Dict = { desc_simple: "查找精确行程,或浏览从某车站出发的所有目的地(或前往某车站的所有出发地)。", desc_multi_plan: "列出要游览的城市:我们把它们排序并排期成可行的环游。", desc_multi_legs: "逐站、逐日手动编排每一段行程。", - desc_ideas: "发现从某车站可以去哪里,按免费列车数量排序。", + desc_ideas: "发现从某车站可以去哪里,最快的排在前面。", hint_od_optional: "可选:填写出发地、到达地,或两者都填。", radius_hint: "将搜索扩展到邻近车站,以发现更多免费座位。", act_swap: "交换出发地和到达地", @@ -115,6 +117,7 @@ export const zh: Dict = { res_destinations: "{n} 个目的地", res_origins: "{n} 个出发地", badge_trains: "{n} 趟列车", + badge_days: "{n} 天", lbl_direct: "直达", lbl_airport: "机场", lbl_via: "经 {hub}", @@ -212,6 +215,9 @@ export const zh: Dict = { getaway_count: "可行往返 {n} 个", getaway_nights: "停留 {n} 晚", getaway_none: "在这些条件下没有 MAX 往返。", + getaway_oneway_btn: "查看单程目的地", + day_with_seats: "查看 {date}(有空位)", + try_nights: "改为停留 {n} 晚", act_save: "保存", act_saved: "已保存", @@ -264,7 +270,7 @@ export const zh: Dict = { cal_legend_return: "数字 = 返程列车", res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}", rt_return_leg: "返程", - rt_finder_title: "去哪里度假?", + rt_finder_title: "从 {station} 出发的往返 — {date} · {stay}", rt_need_origin: "填写出发地,即可查看可往返的目的地。", rt_reverse_title: "从哪里可以往返到达 {station}?", rt_reverse_count: "{n} 个可能的出发地", @@ -274,6 +280,7 @@ export const zh: Dict = { senior_weekend_return_warn: "MAX SENIOR 仅限工作日——您的返程在周末,无法免费预订。", form_cal_title: "何时出发?", form_cal_hint: "选择出发车站即可查看可用的日期。", + need_origin: "请选择出发车站。", form_cal_departed: "出发:{date}", form_cal_flex_hint: "先选去程,再选返程", form_cal_range: "去程:{from} → 返程:{to}", diff --git a/src/styles.css b/src/styles.css index 0e3c4ec..0dd27b2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1672,12 +1672,9 @@ a { font-weight: 560; font-variant-numeric: tabular-nums; } -/* Two chips side by side on an Ideas row (train count + days reachable). */ +/* An Ideas row's chips wrap one by one inside the row's meta line. */ .row-chips { - display: inline-flex; - align-items: center; - gap: 0.35rem; - flex: none; + display: contents; } .chev { margin-left: auto; diff --git a/src/ui/render.ts b/src/ui/render.ts index 6d89e1c..ba20014 100644 --- a/src/ui/render.ts +++ b/src/ui/render.ts @@ -502,7 +502,7 @@ export function reachTripRowEl( j: Journey, ctx: RenderCtx, extra?: HTMLElement, - opts?: { hideMeta?: boolean; hideVia?: boolean }, + opts?: { hideVia?: boolean; openDate?: string }, ): HTMLElement { const route: RoutePair = { origin: j.origin, destination: j.destination }; const via = j.legs.length > 1; @@ -525,7 +525,7 @@ export function reachTripRowEl( const metaChips: HTMLElement[] = [ ...viaChip, ...(extra ? [extra] : []), - ...(opts?.hideMeta ? [] : [el("bdi", { text: formatDuration(j.totalDurationMin) })]), + el("bdi", { text: formatDuration(j.totalDurationMin) }), ]; const main = el( "button", @@ -533,12 +533,12 @@ export function reachTripRowEl( class: "dest-main dest-main-stacked", type: "button", attrs: { "aria-label": aria }, - on: { click: () => ctx.onOpenRoute(j.origin, j.destination) }, + on: { click: () => ctx.onOpenRoute(j.origin, j.destination, opts?.openDate ? { date: opts.openDate } : undefined) }, }, [ el("div", { class: "dest-body" }, [ stationNameEl("dest-name", station, ctx.label(station)), - ...(metaChips.length ? [el("span", { class: "dest-meta", attrs: { "aria-hidden": "true" } }, metaChips)] : []), + el("span", { class: "dest-meta", attrs: { "aria-hidden": "true" } }, metaChips), ]), el("span", { class: "chev", attrs: { "aria-hidden": "true" } }, [icon(I.arrow)]), ], @@ -726,15 +726,11 @@ export function listToolbarEl( } /** - * A ranked best-trip row ("best" mode). Shows the month-long train count for the - * destination (like the "Where to?" list) plus, in the all-days view, how many - * days it's reachable. + * A ranked best-trip row ("best" mode): the changes it takes, the month-long train count, + * the fastest travel time, and the figure the active `sort` ranks by when it is another + * one (days reachable, distance). Opens on the first day the destination is reachable. */ -export function bestTripRowEl(trip: BestTrip, ctx: RenderCtx, trains?: number): HTMLElement { - // Ideas is a discovery list: the city name is the point, so it always leads. A single - // "N trains" chip (the ranking signal — how well-served the route is across the month) - // rides alongside it; the duration meta is dropped so a long city name never gets - // squeezed out of the row. Both figures still live in the chip's tooltip. +export function bestTripRowEl(trip: BestTrip, ctx: RenderCtx, trains?: number, sort?: SortKey): HTMLElement { const chips: HTMLElement[] = []; // How many changes it takes to get there — the point of the Ideas list: Direct, or // "N correspondance(s)". Colour-matched to the map pins (green direct / amber 1 / red 2+). @@ -753,16 +749,19 @@ export function bestTripRowEl(trip: BestTrip, ctx: RenderCtx, trains?: number): }), ); } + if (sort === "days" && trip.days) chips.push(el("span", { class: "stat-chip", text: t("badge_days", { n: trip.days }) })); + const km = sort === "closest" ? ctx.distanceKm(trip.journey.origin, trip.destination) : Infinity; + if (Number.isFinite(km)) chips.push(el("span", { class: "stat-chip", text: t("nearby_km", { km: Math.round(km) }) })); const extra = el("span", { class: "row-chips" }, chips); - return reachTripRowEl(trip.destination, trip.journey, ctx, extra, { hideMeta: true, hideVia: true }); + return reachTripRowEl(trip.destination, trip.journey, ctx, extra, { hideVia: true, openDate: trip.firstDate }); } /** - * A nearby paid-connection alternative (radius search): the nearby station, how - * far it is, and the free-MAX journey it offers. Clicking opens that free route; - * the user covers the short hop to/from the exact endpoint themselves. + * A nearby paid-connection alternative (radius search): the station, a `note` naming the + * nearby station and its distance, and the free-MAX journey it offers. Clicking opens that + * free route; the user covers the short hop to/from the exact endpoint themselves. */ -export function nearbyTripRowEl(station: string, km: number, j: Journey, ctx: RenderCtx): HTMLElement { +export function nearbyTripRowEl(station: string, note: string, j: Journey, ctx: RenderCtx): HTMLElement { const via = j.legs.length > 1; const viaChip = via ? [el("span", { class: "chip chip-via", text: t("lbl_via", { hub: j.hubs.map((h) => ctx.label(h)).join(", ") }) })] @@ -770,17 +769,19 @@ export function nearbyTripRowEl(station: string, km: number, j: Journey, ctx: Re const main = el( "button", { - class: "dest-main", + class: "dest-main dest-main-stacked", type: "button", - attrs: { "aria-label": `${ctx.label(station)} — ${t("nearby_km", { km })} — ${formatDuration(j.totalDurationMin)}` }, + attrs: { "aria-label": `${ctx.label(station)} — ${note} — ${formatDuration(j.totalDurationMin)}` }, on: { click: () => ctx.onOpenRoute(j.origin, j.destination) }, }, [ - stationNameEl("dest-name", station, ctx.label(station)), - el("span", { class: "chip chip-soft km-chip", text: t("nearby_km", { km }) }), - ...viaChip, - el("span", { class: "dest-meta", attrs: { "aria-hidden": "true" } }, [ - el("bdi", { text: formatDuration(j.totalDurationMin) }), + el("div", { class: "dest-body" }, [ + stationNameEl("dest-name", station, ctx.label(station)), + el("span", { class: "dest-meta", attrs: { "aria-hidden": "true" } }, [ + el("span", { class: "chip chip-soft km-chip", text: note }), + ...viaChip, + el("bdi", { text: formatDuration(j.totalDurationMin) }), + ]), ]), el("span", { class: "chev", attrs: { "aria-hidden": "true" } }, [icon(I.arrow)]), ], @@ -806,20 +807,22 @@ export function nearbyBothRowEl( const main = el( "button", { - class: "dest-main", + class: "dest-main dest-main-stacked", type: "button", attrs: { "aria-label": `${ctx.label(fromId)} → ${ctx.label(toId)} — ${formatDuration(j.totalDurationMin)}` }, on: { click: () => ctx.onOpenRoute(j.origin, j.destination) }, }, [ - el("span", { class: "dest-name" }, [ - el("bdi", { text: ctx.label(fromId) }), - el("span", { class: "muted", text: " → " }), - el("bdi", { text: ctx.label(toId) }), - ]), - el("span", { class: "chip chip-soft km-chip", text: t("nearby_km", { km: Math.max(fromKm, toKm) }) }), - el("span", { class: "dest-meta", attrs: { "aria-hidden": "true" } }, [ - el("bdi", { text: formatDuration(j.totalDurationMin) }), + el("div", { class: "dest-body" }, [ + el("span", { class: "dest-name" }, [ + el("bdi", { text: ctx.label(fromId) }), + el("span", { class: "muted", text: " → " }), + el("bdi", { text: ctx.label(toId) }), + ]), + el("span", { class: "dest-meta", attrs: { "aria-hidden": "true" } }, [ + el("span", { class: "chip chip-soft km-chip", text: t("nearby_km", { km: Math.max(fromKm, toKm) }) }), + el("bdi", { text: formatDuration(j.totalDurationMin) }), + ]), ]), el("span", { class: "chev", attrs: { "aria-hidden": "true" } }, [icon(I.arrow)]), ], diff --git a/tests/lists-links.test.ts b/tests/lists-links.test.ts new file mode 100644 index 0000000..dfc6188 --- /dev/null +++ b/tests/lists-links.test.ts @@ -0,0 +1,288 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { readFileSync } from "node:fs"; + +// Leaflet needs a real browser canvas; stub the map module so the UI runs under jsdom. +vi.mock("../src/ui/map", () => ({ + RouteMap: class { + onSelect: ((id: string) => void) | null = null; + show(): void {} + route(): void {} + radius(): void {} + base(): void {} + highlight(): void {} + invalidate(): void {} + focus(): void {} + setInfo(): void {} + }, +})); + +import type { RawRecord, Station, DataMeta } from "../src/types"; +import { normalizeRecords } from "../src/data/dataset"; +import { StationRegistry } from "../src/data/stations"; +import { initApp } from "../src/app"; +import { t, getLang } from "../src/i18n"; +import { formatDuration } from "../src/util/time"; +import stations from "../data/stations.json"; + +const meta: DataMeta = { updatedAt: "", source: "sample", recordCount: 0, isSample: true }; +const P = "PARIS (intramuros)"; +const enc = encodeURIComponent; + +/** One free-MAX train. */ +function tr(date: string, from: string, to: string, dep: string, arr: string, no: string): RawRecord { + return { + date, + origine: from, + destination: to, + heure_depart: dep, + heure_arrivee: arr, + train_no: no, + od_happy_card: "OUI", + axe: "", + } as RawRecord; +} + +// rAF callbacks wait here until flush(), as in a browser, so chunked lists render in batches. +let frames: FrameRequestCallback[] = []; +function flush(): void { + while (frames.length) frames.shift()!(0); +} + +function setup(search: string, records: RawRecord[], saved?: object): HTMLElement { + localStorage.clear(); + if (saved) localStorage.setItem("mj.settings", JSON.stringify(saved)); + document.body.innerHTML = '

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

` 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 @@ -73,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) | @@ -89,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 @@ -125,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 @@ -138,8 +149,9 @@ span applied to the lists only, and a day could read green while its list was em - **Trip modal** = booking recap: each leg's own **travel date** rides on the ticket header (beside "Outbound" / "Return"), and an unmistakable per-leg action — "Book the outbound" / "Book the return" (each deep-links SNCF Connect; a connecting leg opens the step modal) - — plus Save the whole trip. Back inside the accordion re-opens the outbound before it - exits the flow (step-wise back). + — plus Save the whole trip, and "See all dates", which closes the modal and opens both + legs and both calendars in place. Back inside the accordion re-opens the outbound before + it exits the flow (step-wise back). 3. **Only From, One-way** → browse (`runBrowse` "from"). Every destination reachable from the station, ranked by how well-served it is, with availability. Tap a card → the exact trip. The list reads direct cards, then connection-only ("via") rows, then the radius "Stations @@ -154,6 +166,9 @@ span applied to the lists only, and a day could read green while its list was em 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 @@ -182,18 +197,40 @@ the destination is reachable, with its calendar open. - **Map** — full-bleed behind a results drawer on mobile, side panel on desktop. Markers per destination, hover/selection synced with the list; route line for exact trips; auto-fits above the drawer on mobile. -- **Saved & Favorites** — star a route / save a trip, from the header menu. (The two overlap - — a known cleanup item.) +- **Saved & Favorites** — star a route / save a trip. Both cards sit in the results column; + on a phone, where the search form hides that column, the header menu's "Saved trips & + favorites" entry opens the saved page (every saved trip, the favorites card below it) + from any screen. (The two overlap — a known cleanup item.) A saved trip opens its ticket + modal, whose "See all dates" opens the route page; a favorite prefills the Trip tab with + that route alone. - **Settings** — theme, MAX Jeune/Senior, comfortable/compact, and Low-end mode (map off + reduced motion + compact) with a one-time nudge on weak devices; language. -- **Mobile** — the form is a sheet that collapses to a search bar; results are a bottom-sheet - drawer with detents. Back navigation preserves form state and never lands on a dead screen. -- **History model** — a genuine navigation (Search, drilling into a route, opening the saved - page) pushes **one** history entry carrying a form snapshot, so browser Back returns to the - prior page with the form intact. Refining the current view — the Aller simple/retour toggle, - the nights stepper, the Flexible pill, picking a calendar day — updates **in place** - (`replaceState`), never pushing a new entry. So repeated toggling can't pile up duplicate - entries (the old bug where Back needed ~10 presses and the form appeared wiped). +- **Mobile** — the form is a sheet that collapses to a search bar (a long route wraps it to + two lines); results are a bottom-sheet drawer with peek / half / full detents measured + below that bar, so the full sheet never covers it. Back navigation preserves form state + and never lands on a dead screen. +- **History model** — a genuine navigation (Search, a tab switch, drilling into a route, + opening the saved page) pushes **one** history entry carrying a snapshot of the form, tab + included, so browser Back and Forward return to each page with **its own** form intact, + never the latest one. Refining the current view — the Aller simple/retour toggle, the nights + stepper, the Flexible pill, picking a calendar day — updates **in place** (`replaceState`), + never pushing a new entry. So repeated toggling can't pile up duplicate entries (the old bug + where Back needed ~10 presses and the form appeared wiped). Details of the model: + - The **bare landing URL never shows results**: Back to it restores the form as it was left + and shows "press Search". A refinement made on the landing form still pushes one entry + (so there is a form to Back to), and since the user never left the form, Back to the + landing keeps what was built on that entry (a Flexible range included). + - The **saved-trips page** is its own entry: Forward onto it shows it again. + - Leaving a list for a route stamps the list's **scroll position**; Back restores it (the + drawer on a phone, the main column on a desktop). + - An open **dialog** owns one entry: Back closes it and leaves the page under it as it + was. Closing it with its own button, Escape or the backdrop pops that entry, so no stray + Back step is left behind. + - A **Search with nothing to search** (no station) adds no entry: a hint under the button + says what is missing. + - The `1/2/3` shortcuts never move focus into a field, so they keep working after landing + on an empty tab: an empty field gets a ready cursor only while nothing else holds focus, + as on the first load. - **Deep links** — every search is a shareable URL; legacy `?rdate=` / `?rt=` links still work. A station may be named in any case or accent (`from=paris`, `to=LILLE`) and resolves to the station that has trains; a name matching no station shows "Unknown station" instead @@ -209,4 +246,3 @@ the destination is reachable, with its calendar open. - Collapse the two save systems (favorite star + Save bookmark) into one. - One `openRoute()` primitive (list cards / favorites / map pins behave consistently). - One home for the availability calendar (form popover vs results). -- Mobile browser-Back should close detail pages via history. diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs index 4cb0e3f..a4662f8 100644 --- a/scripts/e2e.mjs +++ b/scripts/e2e.mjs @@ -557,6 +557,185 @@ await scenario( { viewport: { width: 390, height: 844, isMobile: true, hasTouch: true } }, ); +const MOBILE = { width: 390, height: 844, isMobile: true, hasTouch: true }; +const urlParam = (page, key) => new URL(page.url()).searchParams.get(key); +const fields = (page) => page.$$eval(".search-form .od-fields input", (els) => els.map((e) => e.value).join("|")); +async function fillOd(page, from, to) { + await page.evaluate( + (vals) => { + const inputs = document.querySelectorAll(".search-form .od-fields input"); + vals.forEach((v, i) => { + inputs[i].value = v; + inputs[i].dispatchEvent(new Event("input", { bubbles: true })); + inputs[i].dispatchEvent(new Event("change", { bubbles: true })); + }); + }, + [from, to], + ); +} +const clickSearch = (page) => page.click(".search-form .form-actions button.btn-primary"); + +// 16. Regression: each history entry restores its OWN form and tab. Search Paris → Lyon from +// the landing, open Ideas, Back twice: the landing comes back on the Trip tab with Paris → +// Lyon filled (not the Ideas form built after it), and its bare URL shows no results. +await scenario( + "history: Back to the landing restores its own form and tab, with no results under it", + BASE, + async (page) => { + await fillOd(page, P, L); + await clickSearch(page); + assert(await until(async () => (await $count(page, "article.journey")) > 0), "precondition: no Paris → Lyon results"); + await page.click('.mode-tab[data-trip="ideas"]'); + assert(await until(async () => urlParam(page, "mode") === "best"), "precondition: Ideas did not open"); + await page.goBack(); + await page.goBack(); + assert(await until(async () => !new URL(page.url()).search), `two Backs did not reach the landing (${page.url()})`); + const landed = await until(async () => + (await activeTrip(page)) === "simple" && (await $count(page, ".results .empty")) > 0 ? true : null, + ); + assert(landed, `landing shows tab "${await activeTrip(page)}", results "${(await $text(page, ".results"))?.slice(0, 60)}"`); + const filled = await fields(page); + assert(/paris/i.test(filled) && /lyon/i.test(filled), `landing form is not its own (got "${filled}")`); + const rows = await $count(page, ".results .group-card, .results article.journey"); + assert(rows === 0, `the bare landing URL shows ${rows} result rows`); + }, +); + +// 17. Regression: the tab follows the entry, not the last form. Legs link → Ideas (no origin) +// → Multi-city → Back must land on the Ideas tab its URL names, without the legs results. +await scenario( + "history: Back to an Ideas entry shows the Ideas tab, not the Multi-city built after it", + `${BASE}?mode=tour&legs=${enc(`${P}>${L}@${DATE}`)}&date=${DATE}`, + async (page) => { + assert(await until(async () => (await $count(page, ".mc-result")) > 0), "precondition: legs did not render"); + await page.click('.mode-tab[data-trip="ideas"]'); + assert(await until(async () => urlParam(page, "mode") === "best"), "precondition: Ideas did not open"); + await page.click('.mode-tab[data-trip="multi"]'); + assert(await until(async () => urlParam(page, "mode") === "tour"), "precondition: Multi-city did not open"); + await page.goBack(); + const back = await until(async () => + urlParam(page, "mode") === "best" && (await activeTrip(page)) === "ideas" && (await $count(page, ".mc-result")) === 0, + ); + assert(back, `Back to ?mode=best shows tab "${await activeTrip(page)}" with ${await $count(page, ".mc-result")} legs`); + }, +); + +// 18. Regression: the saved-trips page is its own history entry. See all → Back → Forward +// must land on the saved page again, not on the list under it. +await scenario( + "history: Forward returns to the saved-trips page", + `${BASE}?mode=od&from=${enc(P)}&to=${enc(L)}&date=${RT_DATE}`, + async (page) => { + assert(await until(async () => (await $count(page, "article.journey button[aria-pressed]")) > 0), "precondition: no train to save"); + await page.click("article.journey button[aria-pressed]"); + await page.click(".saved-see-all"); + assert(await until(async () => (await $count(page, ".saved-page-card")) > 0), "the saved-trips page did not open"); + await page.goBack(); + assert(await until(async () => (await $count(page, "article.journey")) > 0), "Back did not return to the list"); + await page.goForward(); + assert( + await until(async () => (await $count(page, ".saved-page-card")) > 0 && (await $count(page, "article.journey")) === 0), + `Forward shows "${await $text(page, "#results-title")}" instead of the saved-trips page`, + ); + }, +); + +// 19. Regression: a dialog owns one history entry. At 390px, Back with a dialog open closes +// it and stays on the results; a dialog closed by its own button leaves no stray entry, +// so the next Back reaches the form. +await scenario( + "history: Back closes an open dialog first; a dialog closed by its button leaves no entry", + BASE, + async (page) => { + await fillOd(page, P, L); + await clickSearch(page); + assert(await until(async () => (await $count(page, "article.journey")) > 0), "precondition: no Paris → Lyon results"); + const openDialog = async () => { + await page.keyboard.press("?"); + assert(await until(async () => (await $count(page, "dialog[open]")) === 1), "the help dialog did not open"); + }; + await openDialog(); + await page.goBack(); + assert(await until(async () => (await $count(page, "dialog[open]")) === 0), "Back left the dialog open"); + assert(urlParam(page, "from"), "Back with a dialog open left the results page"); + assert((await $count(page, "article.journey")) > 0, "Back with a dialog open re-rendered the page empty"); + await openDialog(); + await page.click("dialog[open] .modal-close"); + assert(await until(async () => (await $count(page, "dialog[open]")) === 0), "the Close button did not close the dialog"); + await page.goBack(); + assert(await until(async () => !urlParam(page, "from")), `one Back after closing did not reach the form (${page.url()})`); + }, + { viewport: MOBILE }, +); + +// 20. Regression: Back from a drilled-in route puts the list back where it was scrolled, in +// the drawer at 390px and in the main column at 1366px. +for (const [viewport, label] of [[MOBILE, "390px drawer"], [undefined, "1366px column"]]) { + await scenario( + `history: Back from a route restores the list's scroll (${label})`, + `${BASE}?mode=from&from=${enc(P)}&date=${DATE}`, + async (page) => { + assert(await until(async () => (await $count(page, ".results .group-card")) > 30), "precondition: short list"); + const scrollTop = () => + page.evaluate(() => { + const el = [".drawer-scroll", ".main-col"] + .map((s) => document.querySelector(s)) + .find((e) => e && /auto|scroll/.test(getComputedStyle(e).overflowY)); + return (el ?? document.scrollingElement).scrollTop; + }); + await page.evaluate(() => document.querySelectorAll(".results .group-card")[25].scrollIntoView()); + const before = await scrollTop(); + assert(before > 500, `precondition: the list did not scroll (${before}px)`); + await page.evaluate(() => { + const card = document.querySelectorAll(".results .group-card")[25]; + (card.querySelector(".dest-main") || card).click(); + }); + assert(await until(async () => (await $count(page, ".back-btn")) > 0), "the route did not open"); + await page.goBack(); + const restored = await until(async () => Math.abs((await scrollTop()) - before) <= 2); + assert(restored, `Back put the list at ${await scrollTop()}px, it was at ${before}px`); + }, + viewport && { viewport }, + ); +} + +// 21. Regression: "See all dates" in the trip dialog shows the dates in place — both +// calendars open, same page — instead of re-running the route as a new detail page. +await scenario( + "trip dialog: 'See all dates' opens the calendars in place, with no new page", + `${BASE}?mode=od&from=${enc(P)}&to=${enc(L)}&date=${RT_DATE}&rdate=${RT_DATE2}`, + async (page) => { + assert(await until(async () => page.$eval(".rt-view-ticket", (b) => !b.hidden).catch(() => false)), "precondition: no ticket"); + const url = page.url(); + await page.click(".rt-view-ticket"); + assert(await until(async () => (await $count(page, "dialog[open] .trip-more")) === 1), "the trip dialog did not open"); + await page.click("dialog[open] .trip-more"); + const opened = await until(async () => + (await $count(page, "dialog[open]")) === 0 && (await $count(page, '.results .cal-toggle[aria-expanded="true"]')) >= 2, + ); + assert(opened, `calendars open: ${await $count(page, '.results .cal-toggle[aria-expanded="true"]')} of 2`); + assert(page.url() === url, `the URL changed to ${page.url()}`); + assert((await $count(page, ".back-btn")) === 0, "'See all dates' opened a new detail page"); + }, +); + +// 22. Regression: the tab shortcuts keep working after one lands on an empty tab. Landing +// on the empty Trip tab must not pull focus into its field, where "3" would be typed. +await scenario( + "keyboard: 1/2/3 keep switching tabs after landing on an empty one", + `${BASE}?mode=tour&date=${DATE}`, + async (page) => { + await page.keyboard.press("1"); + const settled = await until(async () => (await activeTrip(page)) === "simple" && (await $count(page, ".results .loading")) === 0); + assert(settled, "'1' did not open the Trip tab"); + await page.keyboard.press("3"); + const ideas = await until(async () => (await activeTrip(page)) === "ideas"); + assert(ideas, `'3' did not open Ideas; the fields read "${await fields(page)}"`); + await page.keyboard.press("?"); + assert(await until(async () => (await $count(page, "dialog[open]")) === 1), "'?' did not open the shortcuts help"); + }, +); + // 12. PWA manifest is served and parseable, icon reference resolves. await scenario("pwa: manifest is served and valid JSON", BASE, async (page) => { const manifestHref = await page.$eval('link[rel="manifest"]', (el) => el.getAttribute("href")); diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs index 6f49bb7..244cf6f 100644 --- a/scripts/prerender.mjs +++ b/scripts/prerender.mjs @@ -41,6 +41,9 @@ const page = await browser.newPage(); await page.evaluateOnNewDocument(() => { Object.defineProperty(navigator, "language", { get: () => "fr-FR" }); Object.defineProperty(navigator, "languages", { get: () => ["fr-FR", "fr"] }); + // Map off: the snapshot must not carry Leaflet's DOM, a tile or the map chunk's + // preload links, or every visitor (low-end mode included) downloads them. + localStorage.setItem("mj.settings", JSON.stringify({ map: false })); }); await page.goto(BASE, { waitUntil: "networkidle2", timeout: 60000 }); await page.waitForFunction( @@ -65,6 +68,7 @@ server.close(); // fallback background); hydration re-adds the pill, correctly placed for the real // viewport, with no initial animation. html = html + .replace(/(]*?)\s+data-map="off"/, "$1") .replace(/(]*?)\s+style="[^"]*"/g, "$1") .replace(/(class="[^"]*?)\s+has-thumb\b/g, "$1") .replace(/(class="[^"]*?)\s+animate-thumb\b/g, "$1"); @@ -75,6 +79,7 @@ if (!html.includes(' errors.push(e.message)); + page.on("console", (m) => { + if (m.type() === "error" && m.text().includes("Content Security Policy")) errors.push(m.text().slice(0, 160)); + }); // Ignore cross-origin (map tile) failures — they're expected and harmless. page.on("requestfailed", (r) => { const u = r.url(); @@ -76,6 +81,119 @@ for (const { name, url } of pages) { await page.close(); } +// Low-end mode must not fetch the map chunk or a tile; with the map on, Leaflet still mounts. +for (const map of [false, true]) { + const page = await browser.newPage(); + await page.evaluateOnNewDocument((s) => localStorage.setItem("mj.settings", s), JSON.stringify({ map })); + const mapRequests = []; + page.on("request", (r) => { + if (/\/assets\/map-|tile\.openstreetmap/.test(r.url())) mapRequests.push(r.url()); + }); + await page.goto(pages[1].url, { waitUntil: "load", timeout: 45000 }); + await page.waitForSelector(".results article.journey, .results .empty", { timeout: 45000 }); + if (map) { + const mounted = await page.waitForSelector(".leaflet-container", { timeout: 20000 }).catch(() => null); + if (!mounted) failures.push("[map on] Leaflet never mounted"); + } else { + await page.waitForNetworkIdle({ idleTime: 500, timeout: 20000 }).catch(() => {}); + if (mapRequests.length) failures.push(`[map off] fetched map assets: ${mapRequests.join(", ")}`); + } + console.log(` map ${map ? "on" : "off"}: map requests=${mapRequests.length}`); + await page.close(); +} + +// Zero truncated text (product rule 2): every visible text node on these screens must fit, +// never ellipsized, line-clamped, clipped by an overflow:hidden box or pushed off screen. +const L = (id) => encodeURIComponent(id); +const CDG = "AEROPORT ROISSY CDG 2 TGV", VAL = "VALENCE TGV AUVERGNE RHONE ALPES", SPC = "ST PIERRE DES CORPS"; +const DATE2 = new Date(Date.now() + 6 * 86_400_000).toISOString().slice(0, 10); +// The first day the snapshot runs both legs of a connection through `hub`, so connecting +// cards (via chips, long leg names) render; DATE when the data has none. +const snapshot = JSON.parse(readFileSync(join(DIST, "data", "tgvmax.json"), "utf-8")); +const runs = (o, d) => + new Set(snapshot.filter((t) => t.origine === o && t.destination === d && t.date >= DATE).map((t) => t.date)); +const viaDate = (o, hub, d) => [...runs(o, hub)].filter((day) => runs(hub, d).has(day)).sort()[0] ?? DATE; +const PARIS = "PARIS (intramuros)", LYON = "LYON (intramuros)", BDX = "BORDEAUX ST JEAN", TLS = "TOULOUSE MATABIAU"; +const textPages = [ + { + name: "exact-trip", + width: 390, + url: `${BASE}?mode=od&from=${P}&to=${T}&date=${viaDate(PARIS, BDX, TLS)}`, + must: ".msearch-text", + }, + { + name: "long-names", + width: 360, + url: `${BASE}?mode=od&from=${L(CDG)}&to=${L(VAL)}&date=${viaDate(CDG, LYON, VAL)}`, + must: ".msearch-text", + }, + { + name: "tour-legs", + width: 390, + url: `${BASE}?mode=tour&legs=${L(`${SPC}>${VAL}@${DATE}~${VAL}>${CDG}@${DATE2}`)}&date=${DATE}`, + must: ".mc-route", + }, + // Desktop too: an airport name beside its ✈ badge was cut here. + { + name: "browse", + width: 1100, + url: `${BASE}?mode=from&from=${L(VAL)}&date=${[...runs(VAL, CDG)].sort()[0] ?? DATE}`, + must: ".stn-airport", + }, +]; +for (const { name, width, url, must } of textPages) { + const page = await browser.newPage(); + await page.setViewport({ width, height: 844, isMobile: width < 861, hasTouch: width < 861 }); + // Reduced motion: no view transition, so the screen is final once the results render. + await page.evaluateOnNewDocument(() => localStorage.setItem("mj.settings", '{"lang":"en","reduceMotion":true}')); + await page.goto(url, { waitUntil: "load", timeout: 45000 }); + await page.waitForFunction( + (sel) => + document.querySelector(sel)?.textContent.trim() && + document.querySelector(".results")?.children.length && + !document.querySelector(".results .loading"), + { timeout: 45000 }, + must, + ).catch(() => failures.push(`[${name}@${width}] ${must} never rendered`)); + const cut = await page.evaluate(() => { + const out = []; + for (const el of document.querySelectorAll("#app *")) { + const texts = [...el.childNodes].filter((c) => c.nodeType === 3 && c.textContent.trim()); + if (!texts.length || el.closest(".sr-only, .leaflet-container, [aria-hidden='true'], select")) continue; + const cs = getComputedStyle(el); + const box = el.getBoundingClientRect(); + if (cs.visibility !== "visible" || box.width < 2 || box.height < 2) continue; // hidden or visually hidden + const label = `${el.className || el.tagName} "${el.textContent.trim().slice(0, 40)}"`; + const overflows = el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1; + if ((cs.textOverflow === "ellipsis" || cs.webkitLineClamp !== "none") && overflows) { + out.push(`${label} ellipsized`); + continue; + } + const range = document.createRange(); + range.selectNodeContents(texts[0]); + const r = range.getBoundingClientRect(); + if (r.right > document.documentElement.clientWidth + 1 || r.left < -1) { + out.push(`${label} off screen`); + continue; + } + for (let a = el; a && a.id !== "app"; a = a.parentElement) { + const o = getComputedStyle(a).overflowX; + if (o === "auto" || o === "scroll") break; + const ar = a.getBoundingClientRect(); + if ((o === "hidden" || o === "clip") && (r.right > ar.right + 1 || r.left < ar.left - 1)) { + out.push(`${label} clipped`); + break; + } + } + } + return out; + }); + if (cut.length) failures.push(`[${name}@${width}] cut text: ${cut.slice(0, 5).join(" | ")}`); + const vias = await page.$$eval(".results .chip-via", (n) => n.length); + console.log(` ${name}@${width}: cut text=${cut.length}, via chips=${vias}`); + await page.close(); +} + await browser.close(); server.close(); @@ -84,4 +202,4 @@ if (failures.length) { for (const f of failures) console.error(" ✗ " + f); process.exit(1); } -console.log("\nRender verification passed — the app mounts on every checked page."); +console.log("\nRender verification passed — the app mounts, low-end mode skips the map, no text is cut."); diff --git a/src/app.ts b/src/app.ts index a24a15c..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 { bestGetawayTo, 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"; @@ -121,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)); } @@ -655,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); }); } @@ -892,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)); @@ -1044,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), @@ -1143,34 +1136,41 @@ interface HistoryState { * the in-app "Retour" shows and Back returns to the underlying list. The browser history * is the single back-stack — this flag just marks which entries are drill-ins. */ detail?: boolean; + /** The saved-trips page: renderSearch shows it in place of the search under it. */ + saved?: boolean; + /** Pushed off the bare landing form by an in-place refinement (the trip-type toggle, the + * nights, Flexible): the user is still on the form, so it is the landing's form too. */ + landing?: boolean; + /** The list's scroll offset, stamped when a navigation leaves the entry. */ + scroll?: number; +} +/** The current history entry's state, empty on an entry the app never stamped. */ +function entryState(): Partial { + const s: unknown = history.state; + return s && typeof s === "object" ? (s as Partial) : {}; } /** Whether the current history entry is a drilled-in detail page. */ function currentDetail(): boolean { - const s = history.state; - return Boolean(s && typeof s === "object" && (s as { detail?: unknown }).detail); -} -// The last form the user actually built (origin/destination/legs filled). Kept so a Back -// that lands on the bare home entry — whose snapshot predates the finished build (e.g. it -// was stamped the moment Round trip was toggled, before Flexible + the return were picked) — -// restores the WHOLE form the user assembled instead of wiping it. "Keep all data of the -// initial form across every screen." -let lastBuiltForm: SearchQuery | null = null; -/** A form with no route yet — nothing worth preserving across a Back. */ -function isBlankForm(q: SearchQuery): boolean { - return !q.origin && !q.destination && !(q.legs && q.legs.length > 0); -} -function formSnapshot(detail = false): HistoryState { - const form = readQueryFromForm(); - if (!isBlankForm(form)) lastBuiltForm = form; // remember the richest form we've seen - return detail ? { form, detail: true } : { form }; -} -/** Read a form snapshot back off a popstate `event.state`, if one is present. */ -function formStateFrom(state: unknown): SearchQuery | null { - if (state && typeof state === "object" && "form" in state) { - const form = (state as { form?: unknown }).form; - if (form && typeof form === "object") return form as SearchQuery; + return Boolean(entryState().detail); +} +// The latest form of the entry pushed off the bare landing by an in-place refinement, which +// a Back to the landing restores; null when a Search or a drill-in left the landing instead. +let landingForm: SearchQuery | null = null; +/** Stamp the current entry with `query` and the live form, keeping its other flags. */ +function restamp(): void { + const state = { ...entryState(), form: readQueryFromForm() }; + if (state.landing) landingForm = state.form; + store.updateUrl(query, state); +} +/** Stamp the entry a navigation leaves: the list's scroll offset, which Back restores, and + * on the bare landing the form as the user left it. */ +function leaveEntry(): void { + const state = { ...entryState(), scroll: listScroller().scrollTop }; + if (!store.urlHasQuery()) { + state.form = readQueryFromForm(); + landingForm = null; } - return null; + history.replaceState(state, "", location.href); } /** Parse a day-count input into 1..14, falling back to `fallback`. */ @@ -1211,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); @@ -1253,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 @@ -1281,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 @@ -1298,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(); @@ -1404,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 }); @@ -1452,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 @@ -1460,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); @@ -1512,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 @@ -1530,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(); } @@ -1699,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; @@ -1725,6 +1748,7 @@ function runSearch(): void { searchLoading = false; clear(refs.results); renderSearch(); + if (scrollTo !== undefined) scrollListTo(scrollTo); }); }); }; @@ -1816,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" @@ -1917,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) || @@ -2005,7 +2026,7 @@ function runBrowse(c: RenderCtx, dir: "from" | "to"): void { ), ); for (const g of sortedGroups) - rows.push(() => render.groupCardEl(g, dir, anchor, c, dayCount.get(g.station) ?? 0, stats.get(g.station), flex)); + 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)); } @@ -2067,8 +2088,7 @@ function runGetaways(c: RenderCtx, origin: string): void { // 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. @@ -2085,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 }); }); @@ -2096,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 @@ -2107,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, []); @@ -2119,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 }); @@ -2137,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 { @@ -2385,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, { @@ -2766,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 @@ -2777,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; @@ -2839,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) @@ -2866,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) { @@ -2881,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); @@ -2904,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, }), ), ); @@ -2922,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); @@ -2962,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. }; @@ -2984,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 @@ -3018,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 @@ -3115,13 +3161,13 @@ 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, { ...outCalOpts, nights: k })) + ? [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 { @@ -3223,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 { @@ -3239,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(); @@ -3315,13 +3364,18 @@ function runFromForm(): void { setSurpriseMsg(t("err_station", { station: unknown.value.trim() })); return; } + const next = readQueryFromForm(); + // Nothing to search yet: name the missing step under Search, and leave the page and the + // history as they are rather than committing an empty search. + if (!queryIsRenderable(next)) { + const legs = tripType === "multi" && formApi.getMultiMode() === "legs"; + setSurpriseMsg(t(legs ? "multi_hint" : "need_origin")); + return; + } setSurpriseMsg(""); - query = readQueryFromForm(); + query = next; applyAndRun(); - // Only swap the phone to the results view when there's something real to show. An - // incomplete query stays on the form, with the missing step named under Search. - if (queryIsRenderable(query)) setMobileForm(false); - else setSurpriseMsg(t(query.mode === "tour" && formApi.getMultiMode() === "legs" ? "multi_hint" : "need_origin")); + setMobileForm(false); } /** Shift the chosen date by `delta` days, clamped to the bookable window. */ @@ -3811,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), @@ -3923,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 && @@ -3951,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 @@ -4018,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), + }), }; } @@ -4076,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/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/i18n/ar.ts b/src/i18n/ar.ts index 3bb32fb..2e8a137 100644 --- a/src/i18n/ar.ts +++ b/src/i18n/ar.ts @@ -223,6 +223,7 @@ export const ar: Dict = { act_saved: "محفوظ", act_unsave: "إزالة من الرحلات المحفوظة", saved_title: "الرحلات المحفوظة", + menu_saved: "الرحلات المحفوظة والمفضلة", saved_see_all: "عرض الكل ({n})", saved_count: "{n} رحلة محفوظة", saved_none: "لا توجد رحلات محفوظة بعد.", @@ -263,9 +264,10 @@ 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}", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 04313b1..073eaba 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -224,6 +224,7 @@ export const de: Dict = { act_saved: "Gespeichert", act_unsave: "Aus gespeicherten Reisen entfernen", saved_title: "Gespeicherte Reisen", + menu_saved: "Gespeicherte Reisen & Favoriten", saved_see_all: "Alle anzeigen ({n})", saved_count: "{n} gespeicherte Reise(n)", saved_none: "Noch keine gespeicherten Reisen.", @@ -264,9 +265,10 @@ 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}", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 0d3c49e..7240a33 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -236,6 +236,7 @@ export const en: Dict = { act_saved: "Saved", act_unsave: "Remove from saved trips", saved_title: "Saved trips", + menu_saved: "Saved trips & favorites", saved_see_all: "See all ({n})", saved_count: "{n} saved trip(s)", saved_none: "No saved trips yet.", @@ -276,9 +277,10 @@ 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}", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 3d77426..6ccfc0a 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -224,6 +224,7 @@ export const es: Dict = { act_saved: "Guardado", act_unsave: "Quitar de viajes guardados", saved_title: "Viajes guardados", + menu_saved: "Viajes guardados y favoritos", saved_see_all: "Ver todos ({n})", saved_count: "{n} viaje(s) guardado(s)", saved_none: "Aún no hay viajes guardados.", @@ -264,9 +265,10 @@ 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}", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index 7768b0c..32e92dd 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -234,6 +234,7 @@ export const fr = { act_saved: "Enregistré", act_unsave: "Retirer des trajets enregistrés", saved_title: "Trajets enregistrés", + menu_saved: "Trajets enregistrés et favoris", saved_see_all: "Voir tout ({n})", saved_count: "{n} trajet(s) enregistré(s)", saved_none: "Aucun trajet enregistré pour l'instant.", @@ -278,9 +279,10 @@ 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}", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index 0f6fe99..5e150cd 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -224,6 +224,7 @@ export const it: Dict = { act_saved: "Salvato", act_unsave: "Rimuovi dai viaggi salvati", saved_title: "Viaggi salvati", + menu_saved: "Viaggi salvati e preferiti", saved_see_all: "Mostra tutti ({n})", saved_count: "{n} viaggio/i salvato/i", saved_none: "Nessun viaggio salvato per ora.", @@ -264,9 +265,10 @@ 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}", diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index d28318b..cc8943a 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -223,6 +223,7 @@ export const ja: Dict = { act_saved: "保存済み", act_unsave: "保存した旅程から削除", saved_title: "保存した旅程", + menu_saved: "保存した旅程とお気に入り", saved_see_all: "すべて表示 ({n})", saved_count: "保存した旅程 {n} 件", saved_none: "保存した旅程はまだありません。", @@ -263,9 +264,10 @@ 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}", diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index b71b0d5..c3dcd93 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -224,6 +224,7 @@ export const ko: Dict = { act_saved: "저장됨", act_unsave: "저장한 여행에서 제거", saved_title: "저장한 여행", + menu_saved: "저장한 여행 및 즐겨찾기", saved_see_all: "전체 보기 ({n})", saved_count: "저장한 여행 {n}개", saved_none: "아직 저장한 여행이 없습니다.", @@ -264,9 +265,10 @@ 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}", diff --git a/src/i18n/nl.ts b/src/i18n/nl.ts index 008b444..c113d4a 100644 --- a/src/i18n/nl.ts +++ b/src/i18n/nl.ts @@ -223,6 +223,7 @@ export const nl: Dict = { act_saved: "Opgeslagen", act_unsave: "Verwijderen uit opgeslagen reizen", saved_title: "Opgeslagen reizen", + menu_saved: "Opgeslagen reizen & favorieten", saved_see_all: "Alles bekijken ({n})", saved_count: "{n} opgeslagen reis(zen)", saved_none: "Nog geen opgeslagen reizen.", @@ -263,9 +264,10 @@ 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}", diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts index ce4c382..28d6fa4 100644 --- a/src/i18n/pt.ts +++ b/src/i18n/pt.ts @@ -223,6 +223,7 @@ export const pt: Dict = { act_saved: "Guardado", act_unsave: "Remover das viagens guardadas", saved_title: "Viagens guardadas", + menu_saved: "Viagens guardadas e favoritos", saved_see_all: "Ver tudo ({n})", saved_count: "{n} viagem(ns) guardada(s)", saved_none: "Ainda não há viagens guardadas.", @@ -263,9 +264,10 @@ 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}", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index ee2ea1b..30d4398 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -223,6 +223,7 @@ export const zh: Dict = { act_saved: "已保存", act_unsave: "从已保存行程中移除", saved_title: "已保存行程", + menu_saved: "已保存行程和收藏", saved_see_all: "查看全部 ({n})", saved_count: "{n} 个已保存行程", saved_none: "暂无已保存行程。", @@ -263,9 +264,10 @@ 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}", diff --git a/src/styles.css b/src/styles.css index 0dd27b2..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; } @@ -1766,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 { @@ -1965,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. */ @@ -1987,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; @@ -1995,9 +1995,6 @@ a { max-width: 100%; } .stn-airport .stn-text { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; min-width: 0; } .layover { @@ -2061,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; @@ -2078,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; } } @@ -2824,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); } @@ -3312,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; @@ -3581,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; @@ -3838,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; @@ -4268,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; @@ -4441,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 ba20014..7b16e1d 100644 --- a/src/ui/render.ts +++ b/src/ui/render.ts @@ -42,8 +42,6 @@ export interface RenderCtx { isTripSaved: (outbound: Journey, inbound?: Journey) => boolean; /** Save the trip if absent, else remove it. */ onToggleTrip: (outbound: Journey, inbound?: Journey) => void; - /** Open the consolidated one-page view of a trip (round trip when `inbound` is set). */ - onShowTrip: (outbound: Journey, inbound?: Journey) => void; /** Whether this multi-city tour is saved. */ isTourSaved: (tour: Tour) => boolean; /** Save the tour if absent, else remove it. */ diff --git a/src/ui/shell.ts b/src/ui/shell.ts index e4e7b00..f8286b3 100644 --- a/src/ui/shell.ts +++ b/src/ui/shell.ts @@ -11,6 +11,7 @@ import { LOGO_SVG, GITHUB_SVG, SEARCH_SVG, + BOOKMARK_SVG, themeSvg, } from "./icons"; @@ -32,6 +33,7 @@ export interface ShellProps { onInstall: () => void; onShortcuts: () => void; onSettings: () => void; + onSaved: () => void; onOpenMobileForm: () => void; onSelect: (id: string) => void; onPeek: (id: string | null) => void; @@ -93,10 +95,10 @@ let teardownDrawer: (() => void) | null = null; * between peek / half / full detents. A no-op where matchMedia is unavailable. * @param drawer the drawer element to size. * @param handle the grab handle that drives the drag. - * @param mapSection the map behind the drawer, used to measure available height. - * @returns a cleanup that removes the media-query/resize listeners it installed. + * @param bar the floating search bar; the drawer's full height stops just below it. + * @returns a cleanup that removes the listeners and observer it installed. */ -function setupDrawer(drawer: HTMLElement, handle: HTMLElement, mapSection: HTMLElement): () => void { +function setupDrawer(drawer: HTMLElement, handle: HTMLElement, bar: HTMLElement): () => void { const mq = typeof window.matchMedia === "function" ? window.matchMedia("(max-width: 860px)") : null; if (!mq) return () => {}; const order = ["peek", "half", "full"] as const; @@ -107,8 +109,7 @@ function setupDrawer(drawer: HTMLElement, handle: HTMLElement, mapSection: HTMLE let state: Detent = "half"; const sizes = (): Record => { - const mapTop = mapSection.getBoundingClientRect().top; - const full = Math.max(240, Math.round(window.innerHeight - mapTop - 6)); + const full = Math.max(240, Math.round(window.innerHeight - bar.getBoundingClientRect().bottom - 8)); const handleH = handle.offsetHeight || 46; return { peek: Math.max(handleH + 92, Math.round(full * 0.24)), @@ -120,7 +121,8 @@ function setupDrawer(drawer: HTMLElement, handle: HTMLElement, mapSection: HTMLE const snap = (s: Detent): void => { state = s; drawer.dataset.state = s; - if (mq.matches) drawer.style.height = `${sizes()[s]}px`; + // The bar only shows in the results view; measured while hidden, every detent is wrong. + if (mq.matches && bar.getClientRects().length) drawer.style.height = `${sizes()[s]}px`; }; let dragging = false; @@ -184,7 +186,11 @@ function setupDrawer(drawer: HTMLElement, handle: HTMLElement, mapSection: HTMLE const sync = (): void => { if (mq.matches) { + // A layout change resizes the sheet at once; only a tap or a drag animates it. + drawer.style.transition = "none"; snap(state); + void drawer.offsetHeight; + drawer.style.transition = ""; } else { drawer.style.height = ""; drawer.style.transition = ""; @@ -192,13 +198,11 @@ function setupDrawer(drawer: HTMLElement, handle: HTMLElement, mapSection: HTMLE }; mq.addEventListener("change", sync); window.addEventListener("resize", sync); - // The initial sync must wait until the layout is attached: buildShell runs before - // buildLayout appends the shell, so a synchronous measure here reads a detached - // mapSection (top = 0) and snaps to a too-tall drawer. Defer one frame so `full` - // is measured against the real viewport position. - if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => sync()); - else sync(); + // Re-measure each time the bar appears (entering the results view) or changes height. + const ro = typeof ResizeObserver === "function" ? new ResizeObserver(() => sync()) : null; + ro?.observe(bar); return () => { + ro?.disconnect(); mq.removeEventListener("change", sync); window.removeEventListener("resize", sync); window.removeEventListener("pointermove", onMove); @@ -291,6 +295,15 @@ function buildHeader(props: ShellProps): { header: HTMLElement; cardSelect: HTML }); }); + // Saved trips + favorites live in the results column, which a phone hides on the + // form screen; this entry opens the saved page from anywhere. + const savedBtn = el("button", { + class: "ctl saved-btn", + type: "button", + html: `${BOOKMARK_SVG}${t("menu_saved")}`, + on: { click: () => props.onSaved() }, + }); + const ghLink = el("a", { class: "ctl icon-ctl gh-link", html: GITHUB_SVG, @@ -303,7 +316,7 @@ function buildHeader(props: ShellProps): { header: HTMLElement; cardSelect: HTML // theme, share, shortcuts — lives behind the hamburger to keep the mobile header compact. const quickCtls = el("div", { class: "header-quick" }, [cardSel, installBtn, settingsBtn]); const headerCtls = el("div", { class: "header-ctls" }, [ - el("div", { class: "menu-selects" }, [langSel]), + el("div", { class: "menu-selects" }, [langSel, savedBtn]), el("div", { class: "menu-actions" }, [ghLink, keysBtn, themeBtn, shareBtn]), ]); const menuBtn = el("button", { @@ -421,7 +434,7 @@ export function buildShell(props: ShellProps): ShellHandles { el("div", { class: "side-col" }, [mapSection]), ]); - teardownDrawer = setupDrawer(resultsDrawer, drawerHandle, mapSection); + teardownDrawer = setupDrawer(resultsDrawer, drawerHandle, msearchBar); results.addEventListener("click", (ev) => { const card = (ev.target as HTMLElement).closest("[data-station]"); diff --git a/tests/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/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",