From 1b135615505a3751911f48736d2b7e6b2642d2c1 Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:41:22 +0900 Subject: [PATCH] Multi-city: chain legs on arrival times and flag what cannot be taken A custom leg now lists only the trains leaving at least 15 minutes after the previous leg's pick arrives, and re-picking a leg re-lists every later one. A leg with no such train, or dated before the previous arrival, stays open with a notice instead of a done mark. Every leg keeps its availability calendar, and the trip dialog dates each leg. Surprise me fills an empty trailing row from the previous leg's end instead of re-rolling leg 1, and picks a hop that leaves after that leg arrives, on the first day with one. Search stops on a leg or tour city naming no station and says so; an unknown city stays in its field. Leg fields, headers and city removers get proper names, the legs editor's links get a taller touch target, and the saved-tour dialog drops its map actions, which did nothing. --- docs/user-flows.md | 17 ++- src/app.ts | 204 +++++++++++++++++++++--------------- src/core/connections.ts | 10 ++ src/i18n/ar.ts | 7 +- src/i18n/de.ts | 7 +- src/i18n/en.ts | 7 +- src/i18n/es.ts | 7 +- src/i18n/fr.ts | 7 +- src/i18n/it.ts | 7 +- src/i18n/ja.ts | 7 +- src/i18n/ko.ts | 7 +- src/i18n/nl.ts | 7 +- src/i18n/pt.ts | 7 +- src/i18n/zh.ts | 7 +- src/styles.css | 7 +- src/ui/form.ts | 54 +++++++--- src/ui/modals.ts | 6 +- src/ui/render.ts | 43 ++++---- tests/multi-city.test.ts | 220 +++++++++++++++++++++++++++++++++++++++ 19 files changed, 500 insertions(+), 138 deletions(-) create mode 100644 tests/multi-city.test.ts diff --git a/docs/user-flows.md b/docs/user-flows.md index 36561d8..0919e04 100644 --- a/docs/user-flows.md +++ b/docs/user-flows.md @@ -147,10 +147,21 @@ span applied to the lists only, and a day could read green while its list was em ## Multi-city tab (tour) -- **Custom legs** — spell out each hop (from → to @ date). "Surprise me" fills a random - reachable next stop; can build a whole trip from an empty editor. +- **Custom legs** — spell out each hop (from → to @ date). The results list the legs as a + stepper, each defaulting to its fastest train. A leg lists only the trains leaving at + least 15 min after the previous leg's pick arrives (`catchableAfter`), with a note when + that hides some; re-picking a leg re-lists every later one. A leg with no such train, or + dated before the previous arrival, stays open with a notice; every leg keeps its + availability calendar, so a day with no train is one tap from another. The trip modal + dates each leg. "Surprise me" fills the first row missing its destination (an empty + trailing row starts where the previous leg ends) or appends a hop, choosing a direct + train that leaves after the previous leg's default arrives, on the first day with one; + from an empty editor it starts at a random station, so repeated taps build a whole + trip. Search stops on a leg field whose text names no station, flags it and says so. - **Tour planner** — add cities to visit (or "Surprise" / "Nearest stop"), set days-per-city; - it auto-orders a feasible tour. Save as a tour. + it auto-orders a feasible tour. A typed city that names no station stays in the field, + flagged, and Search stops on it. Save as a tour; a saved tour opens in a dialog without + map actions (the map sits behind it). ## Ideas tab (best) diff --git a/src/app.ts b/src/app.ts index 7d96bf7..fd59600 100644 --- a/src/app.ts +++ b/src/app.ts @@ -12,11 +12,11 @@ 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 { planTours, planTourInOrder, planTourGreedy, arrivalDate, type Tour } from "./core/tour"; -import { findJourneys, bestJourney, reachableJourneys, journeyArriveAbs, toJourney, MAX_RESULTS } from "./core/connections"; +import { findJourneys, bestJourney, reachableJourneys, journeyArriveAbs, catchableAfter, toJourney, MAX_RESULTS } from "./core/connections"; import type { ConnectionOptions } from "./core/connections"; import { availabilityCalendar, reachableCountCalendar, dateRange } from "./core/calendar"; import { findHiddenTrains } from "./core/hidden"; -import { addDays, dayIndex, formatDuration } from "./util/time"; +import { addDays, dayIndex, formatDuration, minutesToHHMM } from "./util/time"; import { haversineKm } from "./util/geo"; import { el, clear, isTouch } from "./ui/dom"; import { buildShell, applyTheme, applyDensity, applyReduceMotion, applyMap, closeHeaderMenu } from "./ui/shell"; @@ -2082,6 +2082,14 @@ function runArmedPrompt(): void { if (!isTouch()) refs.origin.focus({ preventScroll: true }); } +/** A typed multi-city leg's journeys on its date, fastest first, then earliest. */ +function legJourneys(leg: { from: string; to: string; date: string }): Journey[] { + const opts = { ...filterOpts(), maxConnections: query.maxConnections }; + return findJourneys(deps.trains, leg.from, leg.to, leg.date, opts).sort( + (a, b) => a.totalDurationMin - b.totalDurationMin || a.departMin - b.departMin, + ); +} + function runMultiCity(c: RenderCtx): void { const { trains, registry } = deps; const legs = (query.legs ?? []).filter((l) => l.from && l.to); @@ -2092,7 +2100,6 @@ function runMultiCity(c: RenderCtx): void { } refs.title.textContent = t("multi_title", { n: legs.length }); const stations: string[] = []; - const legSections: HTMLElement[] = []; const chosen: (Journey | null)[] = legs.map(() => null); const windowDates = dateRange(today, BOOKING_WINDOW_DAYS); @@ -2100,14 +2107,18 @@ function runMultiCity(c: RenderCtx): void { // to just the chosen train once you pick one, so a long list doesn't push the next // leg far down the page. Picking a train collapses its leg, opens the next, and // scrolls to it; picking the LAST leg opens the whole-trip ticket modal. The head - // toggles a leg open/closed by hand. A leg with no seat stays open (nothing to - // collapse to) so its "no MAX seat" message is never hidden. + // toggles a leg open/closed by hand. A leg with nothing to pick stays open so its + // message is never hidden. interface LegUI { + sec: HTMLElement; head: HTMLElement; num: HTMLElement; summary: HTMLElement; - calEl: HTMLElement | null; - cards: HTMLElement[]; + calEl: HTMLElement; + /** Every journey on the leg's date, fastest first. */ + journeys: Journey[]; + /** The leg's notice and train cards, rebuilt by fillLeg. */ + nodes: HTMLElement[]; chosenCard: HTMLElement | null; collapsed: boolean; empty: boolean; @@ -2125,24 +2136,65 @@ function runMultiCity(c: RenderCtx): void { }; const setCollapsed = (i: number, collapsed: boolean): void => { const ui = legUI[i]; - if (!ui || ui.empty) return; // a seatless leg stays open — nothing to collapse to - ui.collapsed = collapsed; - legSections[i]?.classList.toggle("mc-collapsed", collapsed); - ui.head.setAttribute("aria-expanded", String(!collapsed)); - if (ui.calEl) ui.calEl.style.display = collapsed ? "none" : ""; - for (const card of ui.cards) card.style.display = !collapsed || card === ui.chosenCard ? "" : "none"; + if (!ui) return; + ui.collapsed = collapsed && !ui.empty; // a seatless leg stays open — nothing to collapse to + ui.sec.classList.toggle("mc-collapsed", ui.collapsed); + ui.head.setAttribute("aria-expanded", String(!ui.collapsed)); + ui.calEl.style.display = ui.collapsed ? "none" : ""; + for (const n of ui.nodes) n.style.display = !ui.collapsed || n === ui.chosenCard ? "" : "none"; refreshSummary(i); }; + // List the journeys of leg i still catchable after leg i-1's pick arrives, keeping + // leg i's own pick when it is among them, else the fastest. + const fillLeg = (i: number): void => { + const ui = legUI[i]!; + const prev = chosen[i - 1] ?? null; + const options = catchableAfter(ui.journeys, prev); + const keep = chosen[i]; + chosen[i] = keep && options.includes(keep) ? keep : (options[0] ?? null); + for (const n of ui.nodes) n.remove(); + ui.nodes = []; + ui.chosenCard = null; + ui.empty = options.length === 0; + if (prev && (options.length < ui.journeys.length || legs[i]!.date < arrivalDate(prev))) { + // Some (or all) of this leg's trains leave before the previous leg arrives. + const arrival = { date: formatDate(arrivalDate(prev)), time: minutesToHHMM(journeyArriveAbs(prev)) }; + ui.nodes.push( + options.length + ? render.hintEl(t("mc_after", arrival)) + : el("p", { class: "notice", text: t("mc_missed", arrival) }), + ); + } else if (options.length === 0) { + ui.nodes.push(render.emptyEl(t("res_none"))); + } + for (const j of options) { + // Clicking a card (body or arrow) picks that train: collapse this leg to its + // summary and step to the next one — see pickLeg. + const card: HTMLElement = render.journeyEl(j, c, { + selected: j === chosen[i], + onPick: () => pickLeg(i, card, j), + onArrow: () => pickLeg(i, card, j), + }); + if (j === chosen[i]) ui.chosenCard = card; + ui.nodes.push(card); + } + ui.sec.append(...ui.nodes); + }; const pickLeg = (i: number, card: HTMLElement, j: Journey): void => { chosen[i] = j; const ui = legUI[i]; if (ui) ui.chosenCard = card; + // Every later leg now starts from this arrival: re-list each in trip order. + for (let k = i + 1; k < legUI.length; k++) { + fillLeg(k); + setCollapsed(k, legUI[k]!.collapsed); + } setCollapsed(i, true); // collapse this leg to its summary… - const next = legSections[i + 1]; + const next = legUI[i + 1]; if (next) { // …open the next one and gently reveal it if it's below the fold (never scroll up). setCollapsed(i + 1, false); - revealElement(next); + revealElement(next.sec); } else { // Last leg chosen → the whole itinerary is settled; open the trip ticket modal. showMultiTripModal( @@ -2164,12 +2216,10 @@ function runMultiCity(c: RenderCtx): void { }; legs.forEach((leg, i) => { const opts = { ...filterOpts(), maxConnections: query.maxConnections }; - const journeys = findJourneys(trains, leg.from, leg.to, leg.date, opts).sort( - (a, b) => a.totalDurationMin - b.totalDurationMin || a.departMin - b.departMin, - ); - chosen[i] = journeys[0] ?? null; + const journeys = legJourneys(leg); // The head is a button: collapsed it shows a ✓ + the picked train's summary; // clicking it re-opens the leg to change the choice (the "go back" affordance). + // Its name is its content (number, route, date, pick); aria-expanded gives the state. const num = el("span", { class: "mc-num", text: String(i + 1) }); const summary = el("span", { class: "mc-pick-slot" }); const head = el( @@ -2177,7 +2227,7 @@ function runMultiCity(c: RenderCtx): void { { class: "mc-result-head", type: "button", - attrs: { "aria-expanded": "true", "aria-label": t("mc_toggle") }, + attrs: { "aria-expanded": "true" }, on: { click: () => setCollapsed(i, !legUI[i]!.collapsed) }, }, [ @@ -2192,39 +2242,16 @@ function runMultiCity(c: RenderCtx): void { el("span", { class: "mc-chev", attrs: { "aria-hidden": "true" } }), ], ); - const sec = el("section", { class: "mc-result" }, [head]); // Which days this leg has a free MAX seat, shown right here in the results so you // can see (and pick) an available date without opening the leg's own calendar — - // handy when you left the date blank. Clicking a day sets it and re-runs. + // including when the chosen day has no train, or none after the previous leg. + // 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; - if (calEl) sec.append(calEl); - const cards: HTMLElement[] = []; - if (journeys.length === 0) sec.append(render.emptyEl(t("res_none"))); - else - for (const j of journeys) { - // Clicking a card (body or arrow) picks that train: collapse this leg to its - // summary and step to the next one — see pickLeg. - const card: HTMLElement = render.journeyEl(j, c, { - selected: j === chosen[i], - onPick: () => pickLeg(i, card, j), - onArrow: () => pickLeg(i, card, j), - }); - cards.push(card); - sec.append(card); - } - legUI[i] = { - head, - num, - summary, - calEl, - cards, - chosenCard: cards[0] ?? null, - collapsed: false, - empty: journeys.length === 0, - }; - legSections.push(sec); + const calEl = render.calendarEl(legCal, legCtx, leg.date); + const sec = el("section", { class: "mc-result" }, [head, calEl]); + legUI[i] = { sec, head, num, summary, calEl, journeys, nodes: [], chosenCard: null, collapsed: false, empty: false }; + fillLeg(i); refs.results.append(sec); stations.push(leg.from); const next = legs[i + 1]; @@ -3201,6 +3228,14 @@ function cycleTripShape(): void { /** Run a fresh search from the current form (submit or "g" shortcut). */ function runFromForm(): void { + // A Multi-city station nobody could resolve would silently drop its leg or city: + // stop on it and say so instead. + const unknown = tripType === "multi" ? formApi.invalidStation() : undefined; + setSurpriseMsg(unknown ? t("mc_unknown_station") : ""); + if (unknown) { + unknown.focus(); + return; + } query = readQueryFromForm(); applyAndRun(); // Only swap the phone to the results view when there's something real to show. An @@ -3420,11 +3455,12 @@ function surpriseMe(): void { } /** - * "Surprise me" inside the custom-legs editor: fill the last leg's empty destination - * — or, if every leg is already complete, append one more hop — with a random place - * that has a direct free-MAX train from the current endpoint on that leg's date, never - * a city already on the itinerary. Populates the form (staged); the user still hits - * Search, like every other legs edit. + * "Surprise me" inside the custom-legs editor: fill the first leg missing its + * destination — or, if every leg is complete, append one more hop — with a random + * place that has a direct free-MAX train from that leg's start, never a city already + * on the itinerary. The train must leave after the previous leg's default train + * arrives, on the first day from then that has one. Populates the form (staged); + * the user still hits Search, like every other legs edit. */ function surpriseLeg(): void { const raw = formApi.getLegValues(); // [{ from, to, date }] as label strings @@ -3441,45 +3477,43 @@ function surpriseLeg(): void { if (l.to) visited.add(l.to); } const legs = raw.map((l) => ({ ...l })); - const lastIdx = resolved.length - 1; - // The "frontier" is the first leg with an origin but no destination yet — that's the - // hop Surprise should complete. If every started leg is complete, append a fresh hop - // from the itinerary's endpoint. If the editor is empty, bootstrap a random origin so - // Surprise can build a whole trip from nothing. - const frontier = resolved.findIndex((l) => l.from && !l.to); - let originId: string | undefined; - let date: string; - let target: number; // index in `legs` to write the destination into - if (frontier >= 0) { - originId = resolved[frontier]!.from; - date = resolved[frontier]!.date; - target = frontier; - } else if (resolved[lastIdx]!.to || resolved[lastIdx]!.from) { - originId = resolved[lastIdx]!.to ?? resolved[lastIdx]!.from; - date = resolved[lastIdx]!.date; - legs.push({ from: deps.registry.label(originId!), to: "", date }); - target = legs.length - 1; - } else { + let target = resolved.findIndex((l) => !l.to); + if (target < 0) { + target = legs.length; + legs.push({ from: "", to: "", date: "" }); + } + // Where the traveller stands before this hop: each earlier leg's default train, + // chained the way the results list them (runMultiCity). + let prev: Journey | null = null; + for (const l of resolved.slice(0, target)) { + if (l.from && l.to) prev = catchableAfter(legJourneys({ from: l.from, to: l.to, date: l.date }), prev)[0] ?? null; + } + // The hop starts where it was typed, else where the previous leg ends; an empty + // editor starts from a random station, so Surprise can build a whole trip. + let originId = resolved[target]?.from ?? resolved[target - 1]?.to; + if (!originId) { const origins = [...new Set(deps.trains.filter((tr) => tr.available).map((tr) => tr.origin))]; - originId = origins.length ? origins[Math.floor(Math.random() * origins.length)] : undefined; - date = query.date; - target = 0; - if (originId) legs[0] = { from: deps.registry.label(originId), to: "", date: legs[0]!.date || date }; + originId = origins[Math.floor(Math.random() * origins.length)]; } if (!originId) { setSurpriseMsg(t("surprise_none")); return; } - const reachable = filterTrains(deps.trains, { ...filterOpts(), origin: originId, date }); - const pool = [...new Set(reachable.map((tr) => tr.destination))].filter((d) => !visited.has(d) && d !== originId); - const pick = pool.length ? pool[Math.floor(Math.random() * pool.length)] : undefined; - if (!pick) { - setSurpriseMsg(t("surprise_none")); + const after = prev ? arrivalDate(prev) : (resolved[target - 1]?.date ?? query.date); + const typed = raw[target]?.date ?? ""; + const fromHere = filterTrains(deps.trains, { ...filterOpts(), origin: originId }); + const lastDay = addDays(today, BOOKING_WINDOW_DAYS - 1); + for (let d = typed > after ? typed : after; d <= lastDay; d = addDays(d, 1)) { + const catchable = catchableAfter(fromHere.filter((tr) => tr.date === d), prev); + const pool = [...new Set(catchable.map((tr) => tr.destination))].filter((id) => !visited.has(id) && id !== originId); + if (pool.length === 0) continue; + const pick = pool[Math.floor(Math.random() * pool.length)]!; + setSurpriseMsg(""); + legs[target] = { from: deps.registry.label(originId), to: deps.registry.label(pick), date: d }; + formApi.setLegs(legs); return; } - setSurpriseMsg(""); - legs[target] = { ...legs[target]!, to: deps.registry.label(pick) }; - formApi.setLegs(legs); + setSurpriseMsg(t("surprise_none")); } function ensureMap(): Promise { diff --git a/src/core/connections.ts b/src/core/connections.ts index 903b5a0..cd56863 100644 --- a/src/core/connections.ts +++ b/src/core/connections.ts @@ -132,6 +132,16 @@ export function journeyArriveAbs(j: Journey): number { return j.departMin + j.totalDurationMin; } +/** + * The trains or journeys a traveller arriving on `prev` can still catch: those leaving + * at least MIN_CONNECTION_MIN after it arrives, across dates. All of them when `prev` is null. + */ +export function catchableAfter(xs: T[], prev: Journey | null): T[] { + if (!prev) return xs; + const ready = absoluteMinute(prev.date, journeyArriveAbs(prev)) + MIN_CONNECTION_MIN; + return xs.filter((x) => absoluteMinute(x.date, x.departMin) >= ready); +} + function dedupe(journeys: Journey[]): Journey[] { const seen = new Set(); const out: Journey[] = []; diff --git a/src/i18n/ar.ts b/src/i18n/ar.ts index f13aa60..18473f4 100644 --- a/src/i18n/ar.ts +++ b/src/i18n/ar.ts @@ -7,6 +7,11 @@ export const ar: Dict = { multi_title: "متعدد المدن — {n} رحلة", multi_hint: "أدخل رحلة واحدة على الأقل (المغادرة والوصول).", multi_incomplete: "بعض المقاطع لا تحتوي على مقعد MAX مجاني — هذا المسار غير مكتمل.", + leg_n: "المرحلة {n}", + mc_after: "القطارات التي تغادر قبل وصولك في {time} مخفية.", + mc_missed: "لا يغادر أي قطار بعد وصولك ({date}، {time}). اختر قطارًا أبكر في الأعلى أو يومًا آخر.", + mc_unknown_station: "محطة غير معروفة: اختر واحدة من القائمة.", + city_remove: "إزالة {station}", ph_anywhere: "أي محطة", tab_simple: "رحلة", tab_multi: "متعدد المدن", @@ -24,7 +29,7 @@ export const ar: Dict = { mode_tour: "جولة", tour_title: "مسارات من {station} — {date}", field_cities: "مدن لزيارتها", - tour_hint: "أضف مدنًا لزيارتها (مفصولة بفواصل).", + tour_hint: "أضف مدنًا لزيارتها.", tour_none: "لا توجد جولة MAX لهذه المدن والتواريخ.", tour_day: "اليوم {n} — {date}", mode_from: "إلى أين؟", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 6077c82..59fa850 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -7,6 +7,11 @@ export const de: Dict = { multi_title: "Mehrere Städte — {n} Abschnitt(e)", multi_hint: "Gib mindestens einen Abschnitt an (Start und Ziel).", multi_incomplete: "Für einige Abschnitte gibt es keinen freien MAX-Platz — diese Reise ist unvollständig.", + leg_n: "Abschnitt {n}", + mc_after: "Züge, die vor deiner Ankunft um {time} abfahren, sind ausgeblendet.", + mc_missed: "Nach deiner Ankunft ({date}, {time}) fährt kein Zug mehr. Wähle oben einen früheren Zug oder einen anderen Tag.", + mc_unknown_station: "Unbekannter Bahnhof: Wähle einen aus der Liste.", + city_remove: "{station} entfernen", ph_anywhere: "Überallhin", tab_simple: "Reise", tab_multi: "Mehrere Städte", @@ -24,7 +29,7 @@ export const de: Dict = { mode_tour: "Tour", tour_title: "Routen ab {station} — {date}", field_cities: "Zu besuchende Städte", - tour_hint: "Füge zu besuchende Städte hinzu (durch Kommas getrennt).", + tour_hint: "Füge zu besuchende Städte hinzu.", tour_none: "Keine MAX-Route für diese Städte und Daten.", tour_day: "Tag {n} — {date}", mode_from: "Wohin?", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 3b92000..53cb67b 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -7,6 +7,11 @@ export const en: Dict = { multi_title: "Multi-city — {n} leg(s)", multi_hint: "Fill in at least one leg (from and to).", multi_incomplete: "Some legs have no free MAX seat — this itinerary is incomplete.", + leg_n: "Leg {n}", + mc_after: "Trains leaving before you arrive at {time} are hidden.", + mc_missed: "No train leaves after you arrive ({date}, {time}). Pick an earlier train above or another day.", + mc_unknown_station: "Unknown station: pick one from the list.", + city_remove: "Remove {station}", ph_anywhere: "Anywhere", tab_simple: "Trip", tab_multi: "Multi-city", @@ -24,7 +29,7 @@ export const en: Dict = { mode_tour: "Tour", tour_title: "Tours from {station} — {date}", field_cities: "Cities to visit", - tour_hint: "Add cities to visit (comma-separated).", + tour_hint: "Add cities to visit.", tour_none: "No MAX tour for these cities and dates.", tour_day: "Day {n} — {date}", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 938e7ed..0628971 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -7,6 +7,11 @@ export const es: Dict = { multi_title: "Multiciudad — {n} trayecto(s)", multi_hint: "Indica al menos un trayecto (origen y destino).", multi_incomplete: "Algunos trayectos no tienen plaza MAX gratuita — este itinerario está incompleto.", + leg_n: "Trayecto {n}", + mc_after: "Se ocultan los trenes que salen antes de tu llegada a las {time}.", + mc_missed: "Ningún tren sale después de tu llegada ({date}, {time}). Elige un tren anterior arriba u otro día.", + mc_unknown_station: "Estación desconocida: elige una de la lista.", + city_remove: "Quitar {station}", ph_anywhere: "Cualquier estación", tab_simple: "Viaje", tab_multi: "Multiciudad", @@ -24,7 +29,7 @@ export const es: Dict = { mode_tour: "Tour", tour_title: "Itinerarios desde {station} — {date}", field_cities: "Ciudades a visitar", - tour_hint: "Añade ciudades a visitar (separadas por comas).", + tour_hint: "Añade ciudades a visitar.", tour_none: "Ningún itinerario MAX para estas ciudades y fechas.", tour_day: "Día {n} — {date}", mode_from: "¿A dónde ir?", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index 4c788a3..f14b276 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -5,6 +5,11 @@ export const fr = { multi_title: "Multiville — {n} trajet(s)", multi_hint: "Renseignez au moins un trajet (départ et arrivée).", multi_incomplete: "Certains trajets n'ont pas de place MAX gratuite — cet itinéraire est incomplet.", + leg_n: "Trajet {n}", + mc_after: "Les trains partant avant votre arrivée à {time} sont masqués.", + mc_missed: "Aucun train ne part après votre arrivée ({date}, {time}). Choisissez un train plus tôt au-dessus ou un autre jour.", + mc_unknown_station: "Gare inconnue : choisissez-en une dans la liste.", + city_remove: "Retirer {station}", ph_anywhere: "Toutes les gares", tab_simple: "Trajet", tab_multi: "Multiville", @@ -22,7 +27,7 @@ export const fr = { mode_tour: "Tour", tour_title: "Itinéraires depuis {station} — {date}", field_cities: "Villes à visiter", - tour_hint: "Ajoutez des villes à visiter (séparées par des virgules).", + tour_hint: "Ajoutez des villes à visiter.", tour_none: "Aucun itinéraire MAX pour ces villes et dates.", tour_day: "Jour {n} — {date}", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index c67cf2c..9cbcc2f 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -7,6 +7,11 @@ export const it: Dict = { multi_title: "Multi-città — {n} tratta/e", multi_hint: "Inserisci almeno una tratta (partenza e arrivo).", multi_incomplete: "Alcune tratte non hanno un posto MAX gratuito — questo itinerario è incompleto.", + leg_n: "Tratta {n}", + mc_after: "I treni che partono prima del tuo arrivo alle {time} sono nascosti.", + mc_missed: "Nessun treno parte dopo il tuo arrivo ({date}, {time}). Scegli un treno precedente qui sopra o un altro giorno.", + mc_unknown_station: "Stazione sconosciuta: scegline una dall'elenco.", + city_remove: "Rimuovi {station}", ph_anywhere: "Ovunque", tab_simple: "Viaggio", tab_multi: "Multi-città", @@ -24,7 +29,7 @@ export const it: Dict = { mode_tour: "Tour", tour_title: "Itinerari da {station} — {date}", field_cities: "Città da visitare", - tour_hint: "Aggiungi le città da visitare (separate da virgole).", + tour_hint: "Aggiungi le città da visitare.", tour_none: "Nessun itinerario MAX per queste città e date.", tour_day: "Giorno {n} — {date}", mode_from: "Dove andare?", diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 377cafd..f4ac5ff 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -7,6 +7,11 @@ export const ja: Dict = { multi_title: "周遊 — {n}区間", multi_hint: "少なくとも1区間を入力してください(出発・到着)。", multi_incomplete: "一部の区間に無料MAX席がありません — この行程は不完全です。", + leg_n: "区間 {n}", + mc_after: "{time} の到着より前に出発する列車は表示していません。", + mc_missed: "到着({date}、{time})以降に出発する列車はありません。上でより早い列車か別の日を選んでください。", + mc_unknown_station: "不明な駅です。リストから選んでください。", + city_remove: "{station} を削除", ph_anywhere: "どこでも", tab_simple: "旅程", tab_multi: "周遊", @@ -24,7 +29,7 @@ export const ja: Dict = { mode_tour: "ツアー", tour_title: "{station} 発の旅程 — {date}", field_cities: "訪れる都市", - tour_hint: "訪れる都市をカンマ区切りで追加してください。", + tour_hint: "訪れる都市を追加してください。", tour_none: "これらの都市・日付の MAX 旅程はありません。", tour_day: "{n}日目 — {date}", mode_from: "どこへ?", diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index 39fc01b..f19a702 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -7,6 +7,11 @@ export const ko: Dict = { multi_title: "다구간 — {n}개 구간", multi_hint: "구간을 하나 이상 입력하세요 (출발·도착).", multi_incomplete: "일부 구간에 무료 MAX 좌석이 없습니다 — 이 일정은 미완성입니다.", + leg_n: "구간 {n}", + mc_after: "{time} 도착 전에 출발하는 열차는 숨겨집니다.", + mc_missed: "도착({date}, {time}) 후에 출발하는 열차가 없습니다. 위에서 더 이른 열차나 다른 날을 선택하세요.", + mc_unknown_station: "알 수 없는 역입니다. 목록에서 선택하세요.", + city_remove: "{station} 삭제", ph_anywhere: "어디든", tab_simple: "여정", tab_multi: "다구간", @@ -24,7 +29,7 @@ export const ko: Dict = { mode_tour: "투어", tour_title: "{station} 출발 일정 — {date}", field_cities: "방문할 도시", - tour_hint: "방문할 도시를 쉼표로 구분해 입력하세요.", + tour_hint: "방문할 도시를 추가하세요.", tour_none: "해당 도시와 날짜에 대한 MAX 일정이 없습니다.", tour_day: "{n}일째 — {date}", mode_from: "어디로 갈까요?", diff --git a/src/i18n/nl.ts b/src/i18n/nl.ts index e11ec2a..6e99b49 100644 --- a/src/i18n/nl.ts +++ b/src/i18n/nl.ts @@ -7,6 +7,11 @@ export const nl: Dict = { multi_title: "Meerdere steden — {n} traject(en)", multi_hint: "Vul minstens één traject in (van en naar).", multi_incomplete: "Sommige trajecten hebben geen gratis MAX-plaats — deze reis is onvolledig.", + leg_n: "Traject {n}", + mc_after: "Treinen die vóór je aankomst om {time} vertrekken, zijn verborgen.", + mc_missed: "Na je aankomst ({date}, {time}) vertrekt geen trein meer. Kies hierboven een eerdere trein of een andere dag.", + mc_unknown_station: "Onbekend station: kies er een uit de lijst.", + city_remove: "{station} verwijderen", ph_anywhere: "Overal", tab_simple: "Reis", tab_multi: "Meerdere steden", @@ -24,7 +29,7 @@ export const nl: Dict = { mode_tour: "Tour", tour_title: "Routes vanaf {station} — {date}", field_cities: "Te bezoeken steden", - tour_hint: "Voeg steden toe om te bezoeken (door komma's gescheiden).", + tour_hint: "Voeg steden toe om te bezoeken.", tour_none: "Geen MAX-route voor deze steden en data.", tour_day: "Dag {n} — {date}", mode_from: "Waarheen?", diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts index fd411b4..0028b69 100644 --- a/src/i18n/pt.ts +++ b/src/i18n/pt.ts @@ -7,6 +7,11 @@ export const pt: Dict = { multi_title: "Multicidade — {n} trajeto(s)", multi_hint: "Indique pelo menos um trajeto (origem e destino).", multi_incomplete: "Alguns trajetos não têm lugar MAX gratuito — este itinerário está incompleto.", + leg_n: "Trajeto {n}", + mc_after: "Os comboios que partem antes da sua chegada às {time} estão ocultos.", + mc_missed: "Nenhum comboio parte depois da sua chegada ({date}, {time}). Escolha um comboio mais cedo acima ou outro dia.", + mc_unknown_station: "Estação desconhecida: escolha uma da lista.", + city_remove: "Remover {station}", ph_anywhere: "Qualquer estação", tab_simple: "Viagem", tab_multi: "Multicidade", @@ -24,7 +29,7 @@ export const pt: Dict = { mode_tour: "Tour", tour_title: "Roteiros de {station} — {date}", field_cities: "Cidades a visitar", - tour_hint: "Adicione cidades a visitar (separadas por vírgulas).", + tour_hint: "Adicione cidades a visitar.", tour_none: "Nenhum roteiro MAX para estas cidades e datas.", tour_day: "Dia {n} — {date}", mode_from: "Para onde?", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 8b34164..4c07883 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -7,6 +7,11 @@ export const zh: Dict = { multi_title: "多城市 — {n} 段", multi_hint: "请至少填写一段行程(出发和到达)。", multi_incomplete: "部分行程没有免费 MAX 座位——此行程不完整。", + leg_n: "第 {n} 段", + mc_after: "已隐藏在您 {time} 到达之前出发的列车。", + mc_missed: "您到达({date},{time})后没有列车出发。请在上方选择更早的列车或其他日期。", + mc_unknown_station: "未知车站:请从列表中选择。", + city_remove: "移除 {station}", ph_anywhere: "任意车站", tab_simple: "行程", tab_multi: "多城市", @@ -24,7 +29,7 @@ export const zh: Dict = { mode_tour: "行程", tour_title: "从 {station} 出发的行程 — {date}", field_cities: "要游览的城市", - tour_hint: "添加要游览的城市(用逗号分隔)。", + tour_hint: "添加要游览的城市。", tour_none: "这些城市和日期没有 MAX 行程。", tour_day: "第 {n} 天 — {date}", mode_from: "去哪里?", diff --git a/src/styles.css b/src/styles.css index 926bb3f..50b7427 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4522,12 +4522,17 @@ select.input::-ms-expand { } /* Comfortable touch targets: the tiny "×" removers (multi-city leg, tour city chip) are ~27px — grow their HIT area to ~44px on coarse pointers without changing the - glyph size. */ + glyph size. The legs editor's text links grow to ~33px tall the same way, their + negative margin keeping the row's height. */ @media (pointer: coarse) { .mc-remove, .chip-x { position: relative; } + .mc-actions .linklike { + padding-block: 0.55rem; + margin-block: -0.55rem; + } .mc-remove::after, .chip-x::after { content: ""; diff --git a/src/ui/form.ts b/src/ui/form.ts index 7d8cef4..ed97303 100644 --- a/src/ui/form.ts +++ b/src/ui/form.ts @@ -174,6 +174,9 @@ export interface FormHandle { updateFieldVisibility(trip: TripType): void; refreshTourEndDate(): void; setSurpriseMsg(text: string): void; + /** The first station field of the active Multi-city surface whose text names no + * station, after flagging every such field. */ + invalidStation(): HTMLInputElement | undefined; /** Tear down date-picker popovers (which live in ) before the form is discarded. */ destroy(): void; } @@ -345,6 +348,8 @@ export function createForm(props: FormProps): FormHandle { const setLabel = (): void => { valueText.textContent = input.value ? props.formatDate(input.value) : t("field_date"); + // A bare field has no label around it: name the trigger itself. + if (bare) trigger.setAttribute("aria-label", input.value ? `${label}: ${valueText.textContent}` : label); if (margin > 0) { valueBadge.textContent = `±${margin}`; valueBadge.removeAttribute("hidden"); @@ -564,6 +569,14 @@ export function createForm(props: FormProps): FormHandle { }; } + /** Flag a station field whose text names no station; true when it is empty or known. */ + function checkStation(inp: HTMLInputElement): boolean { + const v = inp.value.trim(); + const bad = v !== "" && !props.resolveStation(v); + inp.classList.toggle("is-invalid", bad); + return !bad; + } + /** * Build one multi-city leg row. * @param fromVal initial origin label. @@ -578,12 +591,11 @@ export function createForm(props: FormProps): FormHandle { to.value = toVal; from.placeholder = t("field_origin"); to.placeholder = t("field_destination"); + from.setAttribute("aria-label", t("field_origin")); + to.setAttribute("aria-label", t("field_destination")); for (const inp of [from, to]) { inp.addEventListener("input", () => inp.classList.remove("is-invalid")); - inp.addEventListener("change", () => { - const v = inp.value.trim(); - inp.classList.toggle("is-invalid", v !== "" && !props.resolveStation(v)); - }); + inp.addEventListener("change", () => checkStation(inp)); } const dateCtl = makeDateField( t("field_date"), @@ -597,7 +609,7 @@ export function createForm(props: FormProps): FormHandle { text: "×", attrs: { "aria-label": t("leg_remove"), title: t("leg_remove") }, }); - const row = el("div", { class: "mc-leg" }, [from, to, dateCtl.root, remove]); + const row = el("div", { class: "mc-leg", attrs: { role: "group" } }, [from, to, dateCtl.root, remove]); const ctl: LegCtl = { from, to, dateCtl, row, remove }; remove.addEventListener("click", () => removeLeg(ctl)); to.addEventListener("change", () => { @@ -612,7 +624,8 @@ export function createForm(props: FormProps): FormHandle { if (!legsContainer) return; clear(legsContainer); const removable = legRows.length > 2; - legRows.forEach((l) => { + legRows.forEach((l, i) => { + l.row.setAttribute("aria-label", t("leg_n", { n: i + 1 })); l.remove.style.display = removable ? "" : "none"; legsContainer!.append(l.row); }); @@ -648,7 +661,7 @@ export function createForm(props: FormProps): FormHandle { class: "chip-x", type: "button", text: "×", - attrs: { "aria-label": `${t("act_fav_remove")} — ${props.stationLabel(id)}` }, + attrs: { "aria-label": t("city_remove", { station: props.stationLabel(id) }) }, on: { click: () => { tourCities.splice(i, 1); @@ -1241,32 +1254,37 @@ export function createForm(props: FormProps): FormHandle { cities.placeholder = t("cities_add"); const cityChips = el("div", { class: "city-chips" }); const citiesBox = el("div", { class: "cities-input" }, [cityChips, cities]); - const commitCities = (raw: string): void => { + // Turn the typed names into chips; a name matching no station stays in the field, + // flagged, with a message saying so. + const commitCities = (): void => { let added = false; - for (const part of raw.split(",")) { + const unknown: string[] = []; + for (const part of cities.value.split(",")) { const id = props.resolveStation(part); - if (id && !tourCities.includes(id)) { + if (!id) { + if (part.trim()) unknown.push(part.trim()); + } else if (!tourCities.includes(id)) { tourCities.push(id); added = true; } } + cities.value = unknown.join(", "); + checkStation(cities); + surpriseMsg.textContent = unknown.length ? t("mc_unknown_station") : ""; if (added) renderCityChips(); }; + cities.addEventListener("input", () => cities.classList.remove("is-invalid")); cities.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === ",") { e.preventDefault(); - commitCities(cities.value); - cities.value = ""; + commitCities(); } else if (e.key === "Backspace" && cities.value === "" && tourCities.length) { tourCities.pop(); renderCityChips(); } }); cities.addEventListener("change", () => { - if (cities.value.trim()) { - commitCities(cities.value); - cities.value = ""; - } + if (cities.value.trim()) commitCities(); }); const originField = clearableField(t("field_origin"), origin); @@ -1644,6 +1662,10 @@ export function createForm(props: FormProps): FormHandle { setSurpriseMsg: (text) => { surpriseMsg.textContent = text; }, + invalidStation: () => { + const fields = multiMode === "legs" ? legRows.flatMap((l) => [l.from, l.to]) : [cities]; + return fields.filter((inp) => !checkStation(inp))[0]; + }, destroy: () => { departDate.destroy(); for (const l of legRows) l.dateCtl.destroy(); diff --git a/src/ui/modals.ts b/src/ui/modals.ts index 661541e..68dbd09 100644 --- a/src/ui/modals.ts +++ b/src/ui/modals.ts @@ -263,16 +263,16 @@ export function showMultiTripModal(legs: render.RecapLeg[], ctx: RenderCtx): voi /** * A saved multi-city tour on one page: the full itinerary with every bookable - * leg. Map actions are no-ops. + * leg. The map sits behind the dialog, so its actions are hidden. * @param tour the tour to lay out. * @param ctx render context for the tour card. */ export function showTourModal(tour: Tour, ctx: RenderCtx): void { - const modalCtx: RenderCtx = { ...ctx, onShowTour: () => {}, onShowJourney: () => {} }; + const modalCtx: RenderCtx = { ...ctx, onShowJourney: () => {} }; const dialog = el("dialog", { class: "modal trip-modal" }) as HTMLDialogElement; dialog.append( el("div", { class: "modal-body" }, [ - render.tourEl(tour, modalCtx), + render.tourEl(tour, modalCtx, true), el("div", { class: "modal-actions" }, [closeButton(dialog, "ghost")]), ]), ); diff --git a/src/ui/render.ts b/src/ui/render.ts index 6d89e1c..9e62a4b 100644 --- a/src/ui/render.ts +++ b/src/ui/render.ts @@ -1183,17 +1183,19 @@ export function multiTripViewEl(legs: RecapLeg[], ctx: RenderCtx): HTMLElement { // A leg with no seat can't just vanish — call the whole itinerary out as incomplete // so an N-1-leg chain isn't presented as a finished trip. if (incomplete) view.append(el("p", { class: "notice trip-incomplete", text: t("multi_incomplete") })); + // Each leg's travel date rides on its title, as on the round-trip ticket. legs.forEach((leg) => { view.append( el("section", { class: "trip-leg" }, [ - el("h3", { class: "trip-leg-title" }, [ - el("bdi", { text: ctx.label(leg.from) }), - el("span", { class: "muted", text: " → " }), - el("bdi", { text: ctx.label(leg.to) }), + el("h3", { class: "trip-leg-title trip-leg-title-dated" }, [ + el("span", { class: "trip-leg-name" }, [ + el("bdi", { text: ctx.label(leg.from) }), + el("span", { class: "muted", text: " → " }), + el("bdi", { text: ctx.label(leg.to) }), + ]), + el("span", { class: "trip-leg-date", text: ctx.formatDate(leg.date) }), ]), - leg.journey - ? journeyEl(leg.journey, ctx, { saveable: false, hideMap: true }) - : emptyEl(`${t("res_none")} · ${ctx.formatDate(leg.date)}`), + leg.journey ? journeyEl(leg.journey, ctx, { saveable: false, hideMap: true }) : emptyEl(t("res_none")), ]), ); }); @@ -1206,8 +1208,8 @@ function legKm(j: Journey, ctx: RenderCtx): number | null { return Number.isFinite(d) ? Math.round(d) : null; } -/** A multi-city tour itinerary (tour mode). */ -export function tourEl(tour: Tour, ctx: RenderCtx): HTMLElement { +/** A multi-city tour itinerary (tour mode); `hideMap` drops its map actions. */ +export function tourEl(tour: Tour, ctx: RenderCtx, hideMap = false): HTMLElement { const first = tour.legs[0]; const stops = first ? [first.origin, ...tour.legs.map((l) => l.destination)] : []; // Total straight-line distance across every hop ("as the crow flies"). @@ -1215,14 +1217,7 @@ export function tourEl(tour: Tour, ctx: RenderCtx): HTMLElement { // Build the article first so each leg can share it as a selection group: clicking // a leg highlights only that one across the whole tour (not one per day band). const article = el("article", { class: "tour" }); - // The header is a button: clicking it draws the whole tour (every stop) on the - // map, so after inspecting a single leg you can get the overview back. - const head = el("button", { - class: "tour-head is-clickable", - type: "button", - attrs: { title: t("act_map"), "aria-label": t("act_map") }, - on: { click: () => ctx.onShowTour(tour) }, - }, [ + const headParts = [ el("span", { class: "tour-route", text: stops.map((s) => ctx.label(s)).join(" → ") }), el("span", { class: "tour-totals" }, [ ...(totalKm > 0 @@ -1233,7 +1228,17 @@ export function tourEl(tour: Tour, ctx: RenderCtx): HTMLElement { el("span", { text: formatDuration(tour.totalDurationMin) }), ]), ]), - ]); + ]; + // Unless the map is hidden, the header is a button drawing the whole tour (every + // stop) on the map, so after inspecting a single leg you can get the overview back. + const head = hideMap + ? el("div", { class: "tour-head" }, headParts) + : el("button", { + class: "tour-head is-clickable", + type: "button", + attrs: { title: t("act_map"), "aria-label": t("act_map") }, + on: { click: () => ctx.onShowTour(tour) }, + }, headParts); // "Day N" is the actual trip day of each hop, so a multi-day stay shows real // gaps (Day 1, Day 4, …) rather than a misleading 1-per-row count. Each leg is a // clear day band: a bold numbered badge + the date, so days are easy to scan. @@ -1254,7 +1259,7 @@ export function tourEl(tour: Tour, ctx: RenderCtx): HTMLElement { ? [el("span", { class: "leg-km muted", attrs: { title: t("nearest_hint") }, text: `${km} km` })] : []), ]), - journeyEl(j, ctx, { group: article }), + journeyEl(j, ctx, { group: article, hideMap }), ]); }); article.append( diff --git a/tests/multi-city.test.ts b/tests/multi-city.test.ts new file mode 100644 index 0000000..cf45f6a --- /dev/null +++ b/tests/multi-city.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Leaflet needs a real browser canvas; stub the map module (as app.smoke 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 stations from "../data/stations.json"; + +const meta: DataMeta = { updatedAt: "", source: "sample", recordCount: 0, isSample: true }; +const P = "PARIS (intramuros)"; +const L = "LYON (intramuros)"; +const M = "MARSEILLE ST CHARLES"; + +const train = (date: string, o: string, d: string, dep: string, arr: string, no: string): RawRecord => ({ + date, + origine: o, + destination: d, + heure_depart: dep, + heure_arrivee: arr, + train_no: no, + od_happy_card: "OUI", + axe: "SUD EST", +}); + +// A small timetable where the order of trains decides whether a chain connects. +const records: RawRecord[] = [ + train("2026-06-25", P, L, "08:00", "10:00", "1"), + train("2026-06-25", P, L, "14:00", "16:00", "2"), + train("2026-06-26", P, L, "09:00", "11:00", "3"), + train("2026-06-25", L, M, "09:00", "10:40", "4"), + train("2026-06-25", L, M, "11:00", "12:40", "5"), + train("2026-06-25", L, M, "17:00", "18:40", "6"), + train("2026-06-25", L, P, "18:00", "20:00", "7"), + train("2026-06-25", P, "LILLE", "07:00", "08:00", "8"), + train("2026-06-25", P, "TOULON", "23:00", "00:50", "9"), +]; + +function setup(search: string): 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 legsLink = (...legs: string[]): string => `?mode=tour&legs=${encodeURIComponent(legs.join("~"))}`; +const legSections = (root: HTMLElement): HTMLElement[] => Array.from(root.querySelectorAll(".mc-result")); +const departures = (sec: HTMLElement): string[] => + Array.from(sec.querySelectorAll(".journey")).map((j) => j.textContent ?? ""); +const legRows = (root: HTMLElement): HTMLInputElement[][] => + Array.from(root.querySelectorAll(".mc-leg")).map((r) => Array.from(r.querySelectorAll("input"))); +const type = (input: HTMLInputElement, value: string): void => { + input.value = value; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); +}; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-25T06:00:00Z")); + globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => { + cb(0); + return 0; + }) as typeof requestAnimationFrame; + Element.prototype.scrollIntoView = function scrollIntoView(): void {}; + HTMLDialogElement.prototype.showModal = function showModal(this: HTMLDialogElement): void { + this.setAttribute("open", ""); + }; +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("multi-city legs chain", () => { + it("lists only the trains leaving after the previous leg arrives, and re-lists on a new pick", () => { + const root = setup(legsLink(`${P}>${L}@2026-06-25`, `${L}>${M}@2026-06-25`)); + const [leg1, leg2] = legSections(root); + // Leg 1 defaults to the 08:00 (arrives 10:00): the 09:00 out of Lyon is gone, with a note. + expect(departures(leg2!).map((j) => j.includes("09:00"))).toEqual([false, false]); + expect(leg2!.querySelector(".empty-hint")).not.toBeNull(); + // Taking the 14:00 instead leaves only the 17:00 for leg 2. + const later = Array.from(leg1!.querySelectorAll(".journey")).find((j) => j.textContent?.includes("14:00")); + later!.click(); + expect(departures(leg2!).length).toBe(1); + expect(departures(leg2!)[0]).toContain("17:00"); + }); + + it("flags a leg with no train after the previous arrival instead of marking it done", () => { + const root = setup(legsLink(`${L}>${P}@2026-06-25`, `${P}>${L}@2026-06-25`)); + const leg2 = legSections(root)[1]!; + expect(departures(leg2)).toEqual([]); + expect(leg2.querySelector(".notice")).not.toBeNull(); + expect(leg2.querySelector(".mc-num")?.textContent).toBe("2"); + // The leg's calendar stays, to move it to another day. + expect(leg2.querySelector(".cal-grid")).not.toBeNull(); + }); + + it("flags a leg dated before the previous leg arrives, even on a day with no train", () => { + for (const to of [M, "LILLE"]) { + const root = setup(legsLink(`${P}>${L}@2026-06-26`, `${L}>${to}@2026-06-25`)); + expect(legSections(root)[1]!.querySelector(".notice")).not.toBeNull(); + } + }); + + it("offers the calendar on a leg with no train that day", () => { + const root = setup(legsLink(`${L}>${M}@2026-06-26`)); + const leg = legSections(root)[0]!; + expect(departures(leg)).toEqual([]); + expect(leg.querySelector(".cal-grid")).not.toBeNull(); + }); + + it("dates each leg in the trip modal", () => { + const root = setup(legsLink(`${P}>${L}@2026-06-25`, `${L}>${M}@2026-06-25`)); + for (const sec of legSections(root)) sec.querySelector(".journey")!.click(); + const dialog = document.querySelector("dialog.trip-modal[open]"); + expect(dialog?.querySelectorAll(".trip-leg-date").length).toBe(2); + }); +}); + +describe("multi-city Surprise me", () => { + it("fills the empty second row from the first leg's end, never re-rolling the first", () => { + vi.spyOn(Math, "random").mockReturnValue(0.5); + const root = setup(""); + (root.querySelector('[data-trip="multi"]') as HTMLElement).click(); + const surprise = root.querySelector(".surprise-btn")!; + surprise.click(); + const [first] = legRows(root); + const leg1 = [first![0]!.value, first![1]!.value]; + expect(leg1.every(Boolean)).toBe(true); + surprise.click(); + const rows = legRows(root); + expect([rows[0]![0]!.value, rows[0]![1]!.value]).toEqual(leg1); + expect(rows[1]![0]!.value).toBe(leg1[1]); + expect(rows[1]![1]!.value).not.toBe(""); + }); + + it("picks a next hop leaving after the previous leg arrives", () => { + vi.spyOn(Math, "random").mockReturnValue(0); + const root = setup(legsLink(`${L}>${P}@2026-06-25`)); + root.querySelector(".surprise-btn")!.click(); + // Lyon → Paris lands at 20:00: the 07:00 to Lille has left, the 23:00 to Toulon has not. + const [, next] = legRows(root); + expect(next![1]!.value).toBe("Toulon"); + expect(next!.find((i) => i.type === "date")!.value).toBe("2026-06-25"); + }); +}); + +describe("multi-city input", () => { + it("stops Search on a leg naming no station and says so", () => { + const root = setup(""); + (root.querySelector('[data-trip="multi"]') as HTMLElement).click(); + const [leg1, leg2] = legRows(root); + type(leg1![0]!, "Paris"); + type(leg1![1]!, "Lyon"); + type(leg2![1]!, "Zzzville"); + root.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true })); + expect(new URLSearchParams(location.search).get("legs")).toBeNull(); + expect(root.querySelector(".surprise-msg")?.textContent).not.toBe(""); + expect(leg2![1]!.classList.contains("is-invalid")).toBe(true); + }); + + it("keeps an unknown tour city in the field, flagged", () => { + const root = setup(""); + (root.querySelector('[data-trip="multi"]') as HTMLElement).click(); + root.querySelectorAll(".multi-switch .multi-tab")[1]!.click(); + const cities = root.querySelector(".cities-input input")!; + cities.value = "Qwerty"; + cities.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); + expect(cities.value).toBe("Qwerty"); + expect(cities.classList.contains("is-invalid")).toBe(true); + expect(root.querySelector(".surprise-msg")?.textContent).not.toBe(""); + }); + + it("names the route on each leg header and the city on each chip remover", () => { + const root = setup(legsLink(`${P}>${L}@2026-06-25`)); + const head = root.querySelector(".mc-result-head")!; + expect(head.getAttribute("aria-label")).toBeNull(); + expect(head.textContent).toContain("Lyon"); + for (const input of legRows(root)[0]!.filter((i) => i.type === "text")) { + expect(input.getAttribute("aria-label")).toBeTruthy(); + } + root.querySelectorAll(".multi-switch .multi-tab")[1]!.click(); + const cities = root.querySelector(".cities-input input")!; + cities.value = "Lyon"; + cities.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); + const remove = root.querySelector(".city-chip .chip-x")?.getAttribute("aria-label") ?? ""; + expect(remove).toContain("Lyon"); + expect(remove).not.toMatch(/favo/i); + }); +}); + +describe("saved tour modal", () => { + it("shows no map action behind the dialog", () => { + const root = setup(`?mode=tour&from=${encodeURIComponent(P)}&cities=${encodeURIComponent(L)}&date=2026-06-25&dmin=1&dmax=3`); + root.querySelector(".results .tour-save")!.click(); + root.querySelector(".trip-open")!.click(); + const dialog = document.querySelector("dialog.trip-modal[open]")!; + expect(dialog.querySelector(".tour")).not.toBeNull(); + expect(dialog.querySelector(".btn-map")).toBeNull(); + expect(dialog.querySelector("button.tour-head")).toBeNull(); + }); +});