diff --git a/docs/user-flows.md b/docs/user-flows.md index a737185..62578e9 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,12 @@ span applied to the lists only, and a day could read green while its list was em ## Ideas tab (best) -Best free-MAX destinations from the origin, ranked (fastest / most-served / closest…), -either an all-days overview or one specific day. Tap → open the route. +Every free-MAX destination from the origin across the booking window (there is no one-day +view), Recommended (fastest first) by default; the sort picker also ranks by most trains, +most days, closest, fastest or A–Z. +Each row shows the changes, the month's train count and the fastest time, plus the days +reachable or the distance when the sort ranks by it. Tap → the exact trip on the first day +the destination is reachable, with its calendar open. ## Cross-cutting (everywhere) @@ -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..a24a15c 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 --------------------------------------------------- @@ -846,14 +838,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 @@ -1531,7 +1522,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 +1973,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 +2004,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 +2057,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 +2071,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; } @@ -2386,7 +2403,9 @@ function runBestSearch(c: RenderCtx): void { onSort, ), ); - appendInChunks(refs.results, sorted, (tr) => render.bestTripRowEl(tr, c, stats.get(tr.destination)?.trains)); + appendInChunks(refs.results, sorted, (tr) => + render.bestTripRowEl(tr, c, stats.get(tr.destination)?.trains, query.sort), + ); showMap( query.origin, sorted.map((tr) => tr.destination), @@ -2456,11 +2475,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 +2533,11 @@ function runOdSearch(c: RenderCtx): void { // availability, mirroring the round-trip outbound calendar's collapse pattern. // Opened from an Ideas one-way tap → show the days you can go up front (calendar open); // otherwise it's collapsed behind a one-tap "Départ : … · Changer" summary as usual. - const odCal = render.collapsibleCalendar(render.calendarEl(cal, c, query.date), "cal-collapsible", openOdCalendar); + const odCal = render.collapsibleCalendar( + render.calendarEl(cal, c, query.date), + "cal-collapsible", + openOdCalendar, + ); openOdCalendar = false; odCal.setLabel(t("outbound_change", { date: formatDate(query.date) })); refs.results.append(odCal.host); @@ -3094,6 +3119,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 +3189,28 @@ function runTripSearch(c: RenderCtx): void { ); } } + +/** A one-tap way forward under an empty list: a ghost button on its own centred row. */ +function actionEl(text: string, onClick: () => void): HTMLElement { + const button = el("button", { class: "btn btn-ghost", type: "button", text, on: { click: onClick } }); + return el("div", { class: "od-comeback" }, [button]); +} + +/** The first day after the chosen one, else the first in the window, that `cal` marks + * bookable on the query's card. */ +function nextAvailableDay(cal: CalendarDay[]): string | undefined { + const open = cal.filter((d) => d.available && d.date !== query.date); + return (open.find((d) => d.date > query.date) ?? open[0])?.date; +} + +/** Move an empty list to `date`, a day its calendar marks green. */ +function nextDayEl(date: string): HTMLElement { + return actionEl(t("day_with_seats", { date: formatDate(date) }), () => { + query = { ...query, date }; + refreshInPlace(); + }); +} + /** Coarse pointer ≈ touch/phone. */ function showHint(input: HTMLInputElement): void { // Empty state: no nagging prompt — just a blank heading and a ready cursor. @@ -3242,15 +3294,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. */ 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..3bb32fb 100644 --- a/src/i18n/ar.ts +++ b/src/i18n/ar.ts @@ -75,6 +75,8 @@ export const ar: Dict = { nearby_to_dest: "الوصول قرب {station}", nearby_both: "الانطلاق والوصول من محطات قريبة", nearby_km: "على بعد {km} كم", + nearby_from_km: "من {station} · {km} كم", + nearby_to_km: "إلى {station} · {km} كم", nearby_none: "لا توجد محطة قريبة بها مقعد MAX لهذا المسار.", nearby_browse_from: "وجهات إضافية يمكنك الوصول إليها بدفع رحلة قصيرة إلى محطة قريبة.", nearby_browse_to: "نقاط انطلاق إضافية تصل إلى محطة قرب وجهتك — ادفع رحلة قصيرة من هناك.", @@ -115,6 +117,7 @@ export const ar: Dict = { res_destinations: "{n} وجهة", res_origins: "{n} نقطة انطلاق", badge_trains: "{n} قطار", + badge_days: "{n} يوم/أيام", lbl_direct: "مباشر", lbl_airport: "مطار", lbl_via: "عبر {hub}", @@ -212,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..04313b1 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -75,6 +75,8 @@ export const de: Dict = { nearby_to_dest: "Ankunft nahe {station}", nearby_both: "Abfahrt und Ankunft in der Nähe", nearby_km: "{km} km entfernt", + nearby_from_km: "ab {station} · {km} km", + nearby_to_km: "bis {station} · {km} km", nearby_none: "Kein naher Bahnhof hat einen MAX-Platz für diese Strecke.", nearby_browse_from: "Zusätzliche Ziele, die du mit einem kurzen bezahlten Sprung zu einem nahen Bahnhof erreichst.", nearby_browse_to: "Zusätzliche Startbahnhöfe, die einen Bahnhof nahe deinem Ziel erreichen — von dort ein kurzer bezahlter Sprung.", @@ -115,6 +117,7 @@ export const de: Dict = { res_destinations: "{n} Ziel(e)", res_origins: "{n} Ausgangsort(e)", badge_trains: "{n} Zug/Züge", + badge_days: "{n} Tag(e)", lbl_direct: "Direkt", lbl_airport: "Flughafen", lbl_via: "über {hub}", @@ -213,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..0d3c49e 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -79,6 +79,8 @@ export const en: Dict = { nearby_to_dest: "Arrive near {station}", nearby_both: "Leave from and arrive near", nearby_km: "{km} km away", + nearby_from_km: "from {station} · {km} km", + nearby_to_km: "to {station} · {km} km", nearby_none: "No nearby station has a free MAX seat for this route.", nearby_browse_from: "Extra destinations you can reach by paying a short hop to a nearby station.", nearby_browse_to: "Extra origins that reach a station near your destination — pay a short hop from there.", @@ -119,6 +121,7 @@ export const en: Dict = { res_destinations: "{n} destination(s)", res_origins: "{n} origin(s)", badge_trains: "{n} train(s)", + badge_days: "{n} day(s)", lbl_direct: "Direct", lbl_airport: "Airport", @@ -225,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..3d77426 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -75,6 +75,8 @@ export const es: Dict = { nearby_to_dest: "Llegar cerca de {station}", nearby_both: "Salir y llegar cerca", nearby_km: "a {km} km", + nearby_from_km: "desde {station} · {km} km", + nearby_to_km: "hacia {station} · {km} km", nearby_none: "Ninguna estación cercana tiene plaza MAX para este trayecto.", nearby_browse_from: "Destinos adicionales a los que puedes llegar pagando un trayecto corto hasta una estación cercana.", nearby_browse_to: "Orígenes adicionales que llegan a una estación cerca de tu destino — paga un trayecto corto desde allí.", @@ -115,6 +117,7 @@ export const es: Dict = { res_destinations: "{n} destino(s)", res_origins: "{n} origen(es)", badge_trains: "{n} tren(es)", + badge_days: "{n} día(s)", lbl_direct: "Directo", lbl_airport: "Aeropuerto", lbl_via: "vía {hub}", @@ -213,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..7768b0c 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -77,6 +77,8 @@ export const fr = { nearby_to_dest: "Arriver près de {station}", nearby_both: "Partir et arriver près", nearby_km: "à {km} km", + nearby_from_km: "depuis {station} · {km} km", + nearby_to_km: "vers {station} · {km} km", nearby_none: "Aucune gare voisine n'a de place MAX pour ce trajet.", nearby_browse_from: "Des destinations en plus, en payant un court trajet jusqu'à une gare voisine.", nearby_browse_to: "Des points de départ en plus qui atteignent une gare proche de votre arrivée — payez un court trajet depuis là.", @@ -117,6 +119,7 @@ export const fr = { res_destinations: "{n} destination(s)", res_origins: "{n} origine(s)", badge_trains: "{n} train(s)", + badge_days: "{n} jour(s)", lbl_direct: "Direct", lbl_airport: "Aéroport", @@ -223,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..0f6fe99 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -75,6 +75,8 @@ export const it: Dict = { nearby_to_dest: "Arrivo vicino a {station}", nearby_both: "Partenza e arrivo nelle vicinanze", nearby_km: "a {km} km", + nearby_from_km: "da {station} · {km} km", + nearby_to_km: "verso {station} · {km} km", nearby_none: "Nessuna stazione vicina ha un posto MAX per questo tragitto.", nearby_browse_from: "Destinazioni in più raggiungibili pagando un breve tratto fino a una stazione vicina.", nearby_browse_to: "Partenze in più che raggiungono una stazione vicino al tuo arrivo — paga un breve tratto da lì.", @@ -115,6 +117,7 @@ export const it: Dict = { res_destinations: "{n} destinazione/i", res_origins: "{n} origine/i", badge_trains: "{n} treno/i", + badge_days: "{n} giorno/i", lbl_direct: "Diretto", lbl_airport: "Aeroporto", lbl_via: "via {hub}", @@ -213,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..d28318b 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -75,6 +75,8 @@ export const ja: Dict = { nearby_to_dest: "{station} 付近に到着", nearby_both: "近くで出発して近くに到着", nearby_km: "{km} km", + nearby_from_km: "{station} 発 · {km} km", + nearby_to_km: "{station} 着 · {km} km", nearby_none: "この経路に MAX 席のある近隣駅はありません。", nearby_browse_from: "近くの駅まで短い区間を支払えば行ける、追加の目的地。", nearby_browse_to: "目的地の近くの駅に着く追加の出発地 — そこから短い区間だけ支払います。", @@ -115,6 +117,7 @@ export const ja: Dict = { res_destinations: "目的地 {n} 件", res_origins: "出発地 {n} 件", badge_trains: "列車 {n} 本", + badge_days: "{n} 日", lbl_direct: "直通", lbl_airport: "空港", lbl_via: "{hub} 経由", @@ -212,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..b71b0d5 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -75,6 +75,8 @@ export const ko: Dict = { nearby_to_dest: "{station} 근처에 도착", nearby_both: "근처에서 출발하고 도착", nearby_km: "{km} km", + nearby_from_km: "{station} 출발 · {km} km", + nearby_to_km: "{station} 도착 · {km} km", nearby_none: "이 경로에 MAX 좌석이 있는 인근 역이 없습니다.", nearby_browse_from: "가까운 역까지 짧은 구간을 결제하면 갈 수 있는 추가 목적지.", nearby_browse_to: "도착지 근처 역에 닿는 추가 출발지 — 거기서 짧은 구간만 결제하세요.", @@ -115,6 +117,7 @@ export const ko: Dict = { res_destinations: "목적지 {n}곳", res_origins: "출발지 {n}곳", badge_trains: "열차 {n}대", + badge_days: "{n}일", lbl_direct: "직통", lbl_airport: "공항", lbl_via: "{hub} 경유", @@ -213,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..008b444 100644 --- a/src/i18n/nl.ts +++ b/src/i18n/nl.ts @@ -75,6 +75,8 @@ export const nl: Dict = { nearby_to_dest: "Aankomst bij {station} in de buurt", nearby_both: "Vertrek en aankomst in de buurt", nearby_km: "{km} km", + nearby_from_km: "vanaf {station} · {km} km", + nearby_to_km: "naar {station} · {km} km", nearby_none: "Geen nabijgelegen station heeft een MAX-plaats voor deze route.", nearby_browse_from: "Extra bestemmingen die je bereikt door een korte hop naar een nabijgelegen station te betalen.", nearby_browse_to: "Extra vertrekpunten die een station bij je bestemming bereiken — betaal daar een korte hop.", @@ -115,6 +117,7 @@ export const nl: Dict = { res_destinations: "{n} bestemming(en)", res_origins: "{n} vertrekpunt(en)", badge_trains: "{n} trein(en)", + badge_days: "{n} dag(en)", lbl_direct: "Rechtstreeks", lbl_airport: "Luchthaven", lbl_via: "via {hub}", @@ -212,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..ce4c382 100644 --- a/src/i18n/pt.ts +++ b/src/i18n/pt.ts @@ -75,6 +75,8 @@ export const pt: Dict = { nearby_to_dest: "Chegar perto de {station}", nearby_both: "Partir e chegar perto", nearby_km: "a {km} km", + nearby_from_km: "a partir de {station} · {km} km", + nearby_to_km: "até {station} · {km} km", nearby_none: "Nenhuma estação próxima tem lugar MAX para este trajeto.", nearby_browse_from: "Destinos extra que pode alcançar pagando um pequeno trajeto até uma estação próxima.", nearby_browse_to: "Origens extra que chegam a uma estação perto do seu destino — pague um pequeno trajeto a partir daí.", @@ -115,6 +117,7 @@ export const pt: Dict = { res_destinations: "{n} destino(s)", res_origins: "{n} origem(ns)", badge_trains: "{n} trem(ns)", + badge_days: "{n} dia(s)", lbl_direct: "Direto", lbl_airport: "Aeroporto", lbl_via: "via {hub}", @@ -212,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..ee2ea1b 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -75,6 +75,8 @@ export const zh: Dict = { nearby_to_dest: "到达{station}附近", nearby_both: "就近出发并就近到达", nearby_km: "{km} 公里", + nearby_from_km: "从 {station} 出发 · {km} 公里", + nearby_to_km: "到 {station} · {km} 公里", nearby_none: "附近没有车站为此线路提供 MAX 座位。", nearby_browse_from: "支付一小段车程到邻近车站,即可抵达的额外目的地。", nearby_browse_to: "可到达你目的地附近车站的额外出发地——从那里再付一小段车程。", @@ -115,6 +117,7 @@ export const zh: Dict = { res_destinations: "{n} 个目的地", res_origins: "{n} 个出发地", badge_trains: "{n} 趟列车", + badge_days: "{n} 天", lbl_direct: "直达", lbl_airport: "机场", lbl_via: "经 {hub}", @@ -212,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..e610d40 --- /dev/null +++ b/tests/lists-links.test.ts @@ -0,0 +1,279 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { readFileSync } from "node:fs"; + +// Leaflet needs a real browser canvas; stub the map module so the UI runs under jsdom. +vi.mock("../src/ui/map", () => ({ + RouteMap: class { + onSelect: ((id: string) => void) | null = null; + show(): void {} + route(): void {} + radius(): void {} + base(): void {} + highlight(): void {} + invalidate(): void {} + focus(): void {} + setInfo(): void {} + }, +})); + +import type { RawRecord, Station, DataMeta } from "../src/types"; +import { normalizeRecords } from "../src/data/dataset"; +import { StationRegistry } from "../src/data/stations"; +import { initApp } from "../src/app"; +import { t, getLang } from "../src/i18n"; +import { formatDuration } from "../src/util/time"; +import stations from "../data/stations.json"; + +const meta: DataMeta = { updatedAt: "", source: "sample", recordCount: 0, isSample: true }; +const P = "PARIS (intramuros)"; +const enc = encodeURIComponent; + +/** One free-MAX train. */ +function tr(date: string, from: string, to: string, dep: string, arr: string, no: string): RawRecord { + return { + date, + origine: from, + destination: to, + heure_depart: dep, + heure_arrivee: arr, + train_no: no, + od_happy_card: "OUI", + axe: "", + } as RawRecord; +} + +// rAF callbacks wait here until flush(), as in a browser, so chunked lists render in batches. +let frames: FrameRequestCallback[] = []; +function flush(): void { + while (frames.length) frames.shift()!(0); +} + +function setup(search: string, records: RawRecord[], saved?: object): HTMLElement { + localStorage.clear(); + if (saved) localStorage.setItem("mj.settings", JSON.stringify(saved)); + document.body.innerHTML = '

'; + const root = document.getElementById("app") as HTMLElement; + history.replaceState(null, "", `/${search}`); + initApp(root, { trains: normalizeRecords(records), meta }, new StationRegistry(stations as Station[])); + flush(); + return root; +} + +const cardSelect = (root: HTMLElement): HTMLSelectElement => + root.querySelector('select option[value="senior"]')!.parentElement as HTMLSelectElement; +const originInput = (root: HTMLElement): HTMLInputElement => + root.querySelectorAll('.od-fields input[list="station-list"]')[0]!; +const destinationInput = (root: HTMLElement): HTMLInputElement => + root.querySelectorAll('.od-fields input[list="station-list"]')[1]!; +function search(root: HTMLElement): void { + root.querySelector(".search-form .form-actions button.btn-primary")!.click(); + flush(); +} +/** A day as the app writes it. */ +const formatDate = (iso: string): string => + new Intl.DateTimeFormat(getLang(), { weekday: "short", day: "numeric", month: "short" }).format( + new Date(`${iso}T00:00:00`), + ); +const button = (root: HTMLElement, text: string): HTMLButtonElement | undefined => + [...root.querySelectorAll(".results button")].find((b) => b.textContent === text); + +beforeEach(() => { + // 2026-06-25 is a Thursday: the 27th is a Saturday. + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-25T12:00:00Z")); + frames = []; + globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => { + frames.push(cb); + return frames.length; + }) as typeof requestAnimationFrame; + globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame; + Element.prototype.scrollIntoView = function scrollIntoView(): void {}; + window.scrollTo = (() => {}) as typeof window.scrollTo; +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("generated pages", () => { + it("take the site and repository URLs from src/config.ts", () => { + const src = readFileSync("scripts/generate-pages.mjs", "utf8"); + expect(src).toContain('from "../src/config.ts"'); + expect(src).not.toMatch(/https:\/\/[\w-]+\.github\.(io|com)/); + }); +}); + +describe("the card", () => { + const lyon = [ + tr("2026-06-26", P, "LYON (intramuros)", "08:00", "10:00", "1"), + tr("2026-06-27", P, "LYON (intramuros)", "08:00", "10:00", "2"), + ]; + const od = `?mode=od&from=${enc(P)}&to=${enc("LYON (intramuros)")}&date=2026-06-26`; + + it("a link without card= keeps the saved MAX SENIOR, and the address bar adds none", () => { + const root = setup(od.replace(enc(P), "paris"), lyon, { card: "senior" }); + expect(cardSelect(root).value).toBe("senior"); + const params = new URLSearchParams(location.search); + expect(params.get("from")).toBe(P); // the write-back ran + expect(params.has("card")).toBe(false); + }); +}); + +describe("a typed station", () => { + const trains = [ + tr("2026-06-25", P, "LYON (intramuros)", "08:00", "10:00", "1"), + tr("2026-06-25", "TOURS", P, "08:00", "10:00", "2"), + tr("2026-06-25", "ST PIERRE DES CORPS", P, "08:00", "10:00", "3"), + ]; + + it("that matches several stations stops Search in red with a message", () => { + const root = setup("", trains); + originInput(root).value = "pierre des"; + search(root); + expect(location.search).not.toContain("from="); + expect(originInput(root).classList.contains("is-invalid")).toBe(true); + expect(root.querySelector(".surprise-msg")!.textContent).toBe(t("err_station", { station: "pierre des" })); + }); + + it("an unknown destination does not turn into a browse from the origin", () => { + const root = setup("", trains); + originInput(root).value = "Paris"; + destinationInput(root).value = "Lyonzz"; + search(root); + expect(location.search).not.toContain("mode=from"); + expect(destinationInput(root).classList.contains("is-invalid")).toBe(true); + }); + + it("Search with no departure names what is missing", () => { + const root = setup("?mode=best", trains); + search(root); + expect(root.querySelector(".surprise-msg")!.textContent).toBe(t("need_origin")); + }); +}); + +describe("browse", () => { + // 21 direct destinations (past the first chunk of 18), one reachable only via Lyon, and + // Le Mans reachable only from Massy TGV, 16 km from Paris. + const direct = Array.from({ length: 20 }, (_, i) => tr("2026-06-25", P, `ZZ D${i + 10}`, "08:00", "10:00", `d${i}`)); + const trains = [ + ...direct, + tr("2026-06-25", P, "LYON (intramuros)", "08:00", "10:00", "l1"), + tr("2026-06-25", "LYON (intramuros)", "ZZ VIA", "11:00", "12:00", "v1"), + tr("2026-06-25", "MASSY TGV", "LE MANS", "09:00", "10:00", "m1"), + ]; + + it("lists direct cards, then via rows, then the nearby section", () => { + const root = setup(`?mode=from&from=${enc(P)}&date=2026-06-25&rad=40`, trains); + const kids = [...root.querySelector(".results")!.children]; + const station = (k: Element): string => (k as HTMLElement).dataset.station ?? ""; + const lastDirect = Math.max(...kids.flatMap((k, i) => (/^ZZ D|^LYON/.test(station(k)) ? [i] : []))); + const via = kids.findIndex((k) => station(k) === "ZZ VIA"); + const nearby = kids.findIndex((k) => k.classList.contains("nearby")); + expect(lastDirect).toBeGreaterThan(18); + expect(via).toBeGreaterThan(lastDirect); + expect(nearby).toBeGreaterThan(via); + }); + + it("names the nearby station a nearby row starts from", () => { + const root = setup(`?mode=from&from=${enc(P)}&date=2026-06-25&rad=40`, trains); + const row = root.querySelector('.nearby .group-card[data-station="LE MANS"]')!; + expect(row.textContent).toContain(t("nearby_from_km", { station: "Massy TGV", km: 16 })); + expect(row.querySelector(".dest-main-stacked .dest-body")).not.toBeNull(); + }); + + it("an empty day points at the next day with seats", () => { + const root = setup(`?mode=from&from=${enc(P)}&date=2026-06-25`, [ + tr("2026-06-27", P, "LYON (intramuros)", "08:00", "10:00", "1"), + ]); + expect(root.querySelector(".results .empty")).not.toBeNull(); + const date = formatDate("2026-06-27"); + const next = button(root, t("day_with_seats", { date })); + expect(next).toBeDefined(); + next!.click(); + flush(); + expect(location.search).toContain("date=2026-06-27"); + expect(root.querySelector('.results .group-card[data-station="LYON (intramuros)"]')).not.toBeNull(); + }); +}); + +describe("round-trip discovery", () => { + const trains = [ + tr("2026-06-25", "GAP", "LYON (intramuros)", "07:00", "09:00", "1"), + tr("2026-06-27", "GAP", "LYON (intramuros)", "07:00", "09:00", "2"), + tr("2026-06-27", "LYON (intramuros)", "GAP", "18:00", "20:00", "3"), + ]; + + it("names the origin, the day and the stay", () => { + const root = setup(`?mode=from&from=GAP&date=2026-06-27&stay=day`, trains); + const title = root.querySelector("#results-title")!.textContent!; + expect(title).toContain("Gap"); + expect(title).toContain(t("nights_sameday")); + }); + + it("an empty day offers the next day with a round trip and the one-way list", () => { + const root = setup(`?mode=from&from=GAP&date=2026-06-25&stay=day`, trains); + expect(button(root, t("getaway_oneway_btn"))).toBeDefined(); + const date = formatDate("2026-06-27"); + button(root, t("day_with_seats", { date }))!.click(); + flush(); + expect(root.querySelector('.results .group-card[data-station="LYON (intramuros)"]')).not.toBeNull(); + }); + + it("a day tapped on the form calendar brings the refreshed list into view", () => { + const root = setup(`?mode=from&from=GAP&date=2026-06-25&stay=day`, trains); + vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({ top: 5000, bottom: 5040 } as DOMRect); + const scrolled = vi.spyOn(Element.prototype, "scrollIntoView"); + root.querySelector('.form-cal-mount .cal-cell[data-date="2026-06-27"]')!.click(); + flush(); + expect(scrolled).toHaveBeenCalled(); + }); +}); + +describe("a same-day round trip with none that day", () => { + it("offers the shortest stay that has one", () => { + const root = setup( + `?mode=od&from=${enc("MARSEILLE ST CHARLES")}&to=${enc("BORDEAUX ST JEAN")}&date=2026-06-25&stay=day`, + [ + tr("2026-06-25", "MARSEILLE ST CHARLES", "BORDEAUX ST JEAN", "08:00", "14:00", "1"), + tr("2026-06-26", "BORDEAUX ST JEAN", "MARSEILLE ST CHARLES", "09:00", "15:00", "2"), + ], + ); + button(root, t("try_nights", { n: 1 }))!.click(); + flush(); + expect(location.search).toContain("stay=1"); + expect(root.querySelector(".results .od-return .journey")).not.toBeNull(); + }); +}); + +describe("Ideas", () => { + const trains = [ + tr("2026-06-25", P, "LYON (intramuros)", "08:00", "10:00", "1"), + tr("2026-06-26", P, "LILLE", "08:00", "09:00", "2"), + tr("2026-06-27", P, "PERPIGNAN", "21:25", "08:00", "3"), + ]; + const ideas = `?mode=best&from=${enc(P)}`; + + it("keeps Recommended as the default sort, and shows the durations", () => { + const root = setup(ideas, trains); + const sort = root.querySelector(".sort-select")!; + expect([...sort.options].map((o) => o.value)).toContain("rec"); + expect(sort.value).toBe("rec"); + expect(root.querySelector('.group-card[data-station="LILLE"]')!.textContent).toContain(formatDuration(60)); + }); + + it("shows the figure a Most days or Closest sort ranks by", () => { + const days = setup(`${ideas}&sort=days`, trains); + expect(days.querySelector('.group-card[data-station="LILLE"]')!.textContent).toContain(t("badge_days", { n: 1 })); + const closest = setup(`${ideas}&sort=closest`, trains); + expect(closest.querySelector('.group-card[data-station="LILLE"]')!.textContent).toMatch(/\d+ km/); + }); + + it("opens a destination on the first day it is reachable", () => { + const root = setup(ideas, trains); + root.querySelector('.group-card[data-station="PERPIGNAN"] .dest-main')!.click(); + flush(); + expect(location.search).toContain("date=2026-06-27"); + expect(root.querySelector(".results .journey")).not.toBeNull(); + }); +}); diff --git a/tests/stations.test.ts b/tests/stations.test.ts index 2c9affe..0582cc7 100644 --- a/tests/stations.test.ts +++ b/tests/stations.test.ts @@ -114,3 +114,47 @@ describe("StationRegistry lookups", () => { expect(registry.label("SOME UNKNOWN GARE")).toBe("Some Unknown Gare"); }); }); + +describe("StationRegistry.resolve", () => { + const r = new StationRegistry(stationData as Station[]); + r.addMissing([ + "PARIS (intramuros)", + "PAU", + "TOURS", + "ST PIERRE DES CORPS", + "AIX EN PROVENCE TGV", + "AIX LES BAINS LE REVARD", + "MORLAIX", + "MARNE LA VALLEE CHESSY", + ]); + + it("reads hyphens, accents, case and St/Saint alike", () => { + expect(normalizeText("St-Pierre")).toBe(normalizeText("saint pierre")); + expect(normalizeText("Ste-Foy")).toBe("sainte foy"); + }); + + it("resolves a name spelling an id, label or alias, however it is written", () => { + expect(r.resolve("Paris")).toBe("PARIS (intramuros)"); + expect(r.resolve("St-Pierre-des-Corps")).toBe("ST PIERRE DES CORPS"); + expect(r.resolve("aix")).toBe("AIX EN PROVENCE TGV"); // a curated alias + }); + + it("prefers a station's own name to another station's alias", () => { + // TOURS carries the alias "saint pierre des corps"; the station itself wins. + expect(r.resolve("saint pierre des corps")).toBe("ST PIERRE DES CORPS"); + }); + + it("resolves the only station a partial name matches", () => { + expect(r.resolve("disneyland")).toBe("MARNE LA VALLEE CHESSY"); + expect(r.resolve("aix en provence")).toBe("AIX EN PROVENCE TGV"); + expect(r.resolve("aix les")).toBe("AIX LES BAINS LE REVARD"); + }); + + it("leaves a name matching several stations, or none, unresolved", () => { + expect(r.resolve("pierre des")).toBeUndefined(); + expect(r.resolve("saint pierre")).toBeUndefined(); + expect(r.resolve("pa")).toBeUndefined(); + expect(r.resolve("ai")).toBeUndefined(); + expect(r.resolve("zzz")).toBeUndefined(); + }); +});