diff --git a/docs/user-flows.md b/docs/user-flows.md index a367363..36561d8 100644 --- a/docs/user-flows.md +++ b/docs/user-flows.md @@ -49,21 +49,31 @@ toggle, the nights stepper, **and every filter that feeds the per-day sweeps**: correspondances, the via hub, depart-after / depart-before / arrive-before, the max-duration cap, the train type, night trains + only-night, overnight stopovers, and the same-day minimum time on site. So a green day always means *a trip is possible that day* for the current -choice. (Changing a filter only **repaints the calendar** — like every filter it stays staged, -and the results still wait for Search.) +choice. + +**Filters apply LIVE; the route stays staged.** A filter is one deliberate choice, so +changing any of the controls listed above (plus the radius, the trip span, hidden trains and +the Ideas/tour region) runs `applyFilterChange`: it repaints the form calendar **and** +refreshes the results already on screen for that route in place — their own calendars +included — with no second Search tap, and no extra history entry. What a **typed route** edit +does is unchanged: a departure/destination/leg the user hasn't committed stays staged until +Search, and a filter change never smuggles it in (the refresh is skipped whenever the form's +route, legs, cities or date no longer match what is on screen). Two other states also stay +staged, because there is nothing on screen to bring up to date: the bare landing form, and +the post-reload "press Search" prompt. | State | Builder (reused) | Green means | |-------|------------------|-------------| | no origin yet | — (neutral month) | any day, tappable — with a "pick a departure station" hint | | origin + dest, one-way | `availabilityCalendar` | a departure exists that day (count = trains) | -| origin + dest, same day (0 nights) | `dayTripCalendar` | a same-day there-and-back works (count = hours on site) | -| origin + dest, N nights | `roundTripCalendar` | an N-night round trip is feasible (count = nights) | +| origin + dest, same day (0 nights) | `stayCalendar` (hours) | a same-day there-and-back works (count = hours on site) | +| origin + dest, N nights | `stayCalendar` (nights) | an N-night round trip is feasible (count = nights) | | origin only, one-way | `reachableCountCalendar` | you can leave that day (count = destinations) | | origin only, round / same day | `getawayIdeas().perDay` | a getaway is possible that day (count = destinations) | Tapping a green day sets the departure (`query.date` + the form's date field); with both endpoints filled it also shows/refreshes that day's trip in place. It reuses the same option -helpers (`odConnOptsFor` / `getawayOptsFor`) as the real search, so the per-day journey +helpers (`odJourneyOptsFor` / `getawayOptsFor`) as the real search, so the per-day journey sweeps hit the warm memo caches; origin typing is debounced. The compact date pill above it stays as the exact-date / ±flex keyboard entry for power users. @@ -86,6 +96,12 @@ Advanced. **Night trains are included by default.** On the results screen, once date is chosen the possible-days calendar is **collapsed by default** (one tap to reveal other dates); it stays expanded only during discovery (no exact date/destination yet). +**Every exact-route calendar is graded by the sweep its list runs** — `odJourneyOptsFor` +returns both the connection options and the accept-filter, and the one-way calendar, the +round-trip **return** calendar and the form calendar all use it, so the Advanced **max trip +span (days)** cap narrows the green days exactly as it narrows the trains. Before this the +span applied to the lists only, and a day could read green while its list was empty. + ## Trip tab 1. **From + To, One-way** → exact one-way trip (`runOdSearch`). The 30-day availability diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs index e0ff900..bab3e00 100644 --- a/scripts/e2e.mjs +++ b/scripts/e2e.mjs @@ -192,10 +192,10 @@ await scenario( }, ); -// 4. Staged-edits model: editing a field must NOT re-run the search until Search is clicked. +// 4. Staged-edits model: editing the ROUTE must NOT re-run the search until Search is clicked. // (Regression guard for PR #12 / #18 / #19.) await scenario( - "behaviour: edits stay staged until Search is clicked", + "behaviour: a typed route edit stays staged until Search is clicked", `${BASE}?mode=od&from=${enc(P)}&to=${enc(T)}&date=${DATE}`, async (page) => { const before = (await $text(page, "#results-title")) || ""; @@ -220,6 +220,28 @@ await scenario( }, ); +// 4b. …while a FILTER is one deliberate choice, so it applies live: raising "Max +// correspondances" refreshes the list and its possible-days calendar with no second +// Search tap, and puts the applied value in the URL. +await scenario( + "behaviour: a filter change applies live (no second Search tap)", + `${BASE}?mode=od&from=${enc(P)}&to=${enc(T)}&conn=0`, + async (page) => { + const green = () => + page.$$eval(".results .cal-grid .cal-cell.ok", (cells) => cells.length).catch(() => 0); + const direct = await green(); + await page.evaluate(() => { + const sel = document.querySelector(".connections-field select.input"); + sel.value = "2"; + sel.dispatchEvent(new Event("change", { bubbles: true })); + }); + await sleep(900); + const viaTwo = await green(); + assert(viaTwo > direct, `calendar did not refresh from the filter (${direct} -> ${viaTwo} green days)`); + assert(new URL(page.url()).searchParams.get("conn") === "2", "the applied filter is not in the URL"); + }, +); + // 5. Multi-city deep link (explicit legs — the v2 'multi' tab flow) renders one // titled leg section per hop (results or a valid empty-state, never a crash). await scenario( diff --git a/src/app.ts b/src/app.ts index 5eefc02..7d96bf7 100644 --- a/src/app.ts +++ b/src/app.ts @@ -12,7 +12,7 @@ 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, journeySpanDays, journeyArriveAbs, toJourney, MAX_RESULTS } from "./core/connections"; +import { findJourneys, bestJourney, reachableJourneys, journeyArriveAbs, toJourney, MAX_RESULTS } from "./core/connections"; import type { ConnectionOptions } from "./core/connections"; import { availabilityCalendar, reachableCountCalendar, dateRange } from "./core/calendar"; import { findHiddenTrains } from "./core/hidden"; @@ -46,7 +46,7 @@ import { OVERNIGHT_MAX_CONNECTION_MIN, SAME_DAY_MIN_ON_SITE_MIN, } from "./config"; -import { filterOptsFor, odConnOptsFor, getawayOptsFor } from "./core/queryOpts"; +import { filterOptsFor, odConnOptsFor, odJourneyOptsFor, getawayOptsFor } from "./core/queryOpts"; import { warmSearch } from "./search/searchClient"; import { notify } from "./pwa/register"; @@ -748,11 +748,17 @@ function isPageReload(): boolean { * "g" shortcut) runs the restored query as-is. */ function showSearchPrompt(): void { + searchHeldBack = true; clear(refs.results); refs.results.append(render.emptyEl(t("prompt_search"))); showBaseMap(); } +/** True while the reload prompt above is up: the restored form is deliberately waiting for + * the Search button, so nothing — a live filter change included — may run a search behind + * it. Cleared the moment a search actually runs. */ +let searchHeldBack = false; + // --- station resolution ----------------------------------------------------- function resolveStation(text: string): string | undefined { @@ -1322,6 +1328,38 @@ function deferFormCalRepaint(): void { requestAnimationFrame(() => repaintFormCalendar()); } +/** + * A committed FILTER change — max correspondances, the via hub, the time window, the night + * rules… Unlike a typed route edit, which stays staged so the search doesn't chase every + * keystroke, a filter is one deliberate choice, so it applies to everything on screen at + * once: the form calendar repaints, and the results already showing that route re-run in + * place, taking their own calendars with them. + * + * Three cases deliberately stay staged, because there is nothing on screen to bring up to + * date and running a search behind the user's back would be a surprise: the bare landing + * form (nothing searched yet), the reload prompt (`searchHeldBack` — it waits for Search by + * design), and a form whose ROUTE has been edited but not yet searched. That last one keeps + * the older contract intact: a staged destination never rides along on a filter change; it + * waits for Search, and the filter goes with it. + */ +function applyFilterChange(): void { + deferFormCalRepaint(); + if (searchHeldBack || !store.urlHasQuery()) return; + const fq = readQueryFromForm(); + if (!queryIsRenderable(fq) || !sameTargetAs(fq, query)) return; + query = fq; + refreshInPlace(); +} + +/** Whether two queries search the same THING — the route/legs/cities and the day — so one can + * replace the other in place. Filters, sort and stay length are deliberately not compared: + * they're what the refresh is applying. */ +function sameTargetAs(a: SearchQuery, b: SearchQuery): boolean { + const key = (q: SearchQuery): string => + JSON.stringify([q.mode, q.origin ?? "", q.destination ?? "", q.date, q.legs ?? [], q.cities ?? []]); + return key(a) === key(b); +} + /** * Paint the Trip-tab home form's availability calendar for the CURRENT controls, so a * green day always means "a trip is possible that day" for the chosen shape: @@ -1390,9 +1428,10 @@ function repaintFormCalendar(): void { let cal: CalendarDay[]; let calOpts: Parameters[3]; if (o && d) { - const { connOpts, passesVia } = odConnOptsFor(fq, o, d); + // Span-aware, exactly like the results list this calendar previews (odJourneyOptsFor). + const { journeyOpts, accept } = odJourneyOptsFor(fq, o, d); if (!round) { - cal = availabilityCalendar(trains, o, d, windowDates, connOpts, passesVia); + cal = availabilityCalendar(trains, o, d, windowDates, journeyOpts, accept); calOpts = { title: t("form_cal_title"), hideTitle: true }; } else if (nights === 0 && !flexRange) { cal = stayCalendar(trains, o, d, windowDates, getawayOptsFor(fq), "hours"); @@ -1404,11 +1443,11 @@ function repaintFormCalendar(): void { // you're picking right now" (David: "available trains per day for departure or return // depending on what you're choosing"). if (formRangeAwait) { - const ret = odConnOptsFor(fq, d, o); - cal = availabilityCalendar(trains, d, o, windowDates, ret.connOpts, ret.passesVia); + const ret = odJourneyOptsFor(fq, d, o); + cal = availabilityCalendar(trains, d, o, windowDates, ret.journeyOpts, ret.accept); calOpts = { title: t("form_cal_title"), hideTitle: true, countLegend: t("cal_legend_return") }; } else { - cal = availabilityCalendar(trains, o, d, windowDates, connOpts, passesVia); + cal = availabilityCalendar(trains, o, d, windowDates, journeyOpts, accept); calOpts = { title: t("form_cal_title"), hideTitle: true, countLegend: t("cal_legend_depart") }; } } else { @@ -1647,6 +1686,7 @@ function runSearch(): void { searchToken++; const token = searchToken; searchLoading = true; + searchHeldBack = false; if (pendingRaf) { cancelAnimationFrame(pendingRaf); pendingRaf = 0; @@ -2320,6 +2360,15 @@ function odConnOpts( return odConnOptsFor(query, origin, destination); } +// The same, plus the Advanced "max trip span (days)": the widened day pool and the span cap +// that the journey lists AND their availability calendars both have to honour. +function odJourneyOpts( + origin: string, + destination: string, +): { journeyOpts: ConnectionOptions; accept: (j: Journey) => boolean } { + return odJourneyOptsFor(query, origin, destination); +} + /** Append the "hidden train" (hidden-city ticketing) section for the exact route, if on. */ function appendHiddenTrains(c: RenderCtx): void { if (!query.hidden || !query.origin || !query.destination) return; @@ -2397,9 +2446,11 @@ function runOdSearch(c: RenderCtx): void { }); refs.results.append(el("p", { class: "od-guide" }, [render.guideEl(c, query.destination, "link")])); - const { connOpts, passesVia } = odConnOpts(query.origin, query.destination); + // The list and its possible-days calendar run the SAME sweep — span cap included — so a + // green day is always a day whose list has trains on it. + const { journeyOpts, accept } = odJourneyOpts(query.origin, query.destination); const windowDates = dateRange(today, BOOKING_WINDOW_DAYS); - const cal = availabilityCalendar(trains, query.origin, query.destination, windowDates, connOpts, passesVia); + const cal = availabilityCalendar(trains, query.origin, query.destination, windowDates, journeyOpts, accept); if (query.radiusKm) { const levels = nearbyCalendarLevels(query.origin, query.destination, windowDates, query.radiusKm, { ...filterOpts(), @@ -2424,9 +2475,9 @@ function runOdSearch(c: RenderCtx): void { refs.results.append(odCal.host); const lastBookable = addDays(today, BOOKING_WINDOW_DAYS - 1); - const withinSpan = (j: Journey): boolean => !query.maxSpanDays || journeySpanDays(j) <= query.maxSpanDays; - const spanDays = query.maxSpanDays && query.maxSpanDays > 2 ? query.maxSpanDays : undefined; - const journeyOpts = spanDays ? { ...connOpts, spanDays } : connOpts; + // A widened pool (a span past the default 2 days) turns the list into "itineraries" with + // its own count + cap notice; odJourneyOpts owns whether that widening happened. + const spanDays = journeyOpts.spanDays; const flex = query.flexDays ?? 0; const searchDates: string[] = []; @@ -2438,8 +2489,7 @@ function runOdSearch(c: RenderCtx): void { const raw = searchDates.flatMap((d) => findJourneys(trains, query.origin!, query.destination!, d, journeyOpts)); const journeys: Journey[] = applySort( raw - .filter(passesVia) - .filter(withinSpan) + .filter(accept) .sort( (a, b) => (flex > 0 ? dayIndex(a.date) - dayIndex(b.date) : 0) || @@ -2546,14 +2596,12 @@ function runTripSearch(c: RenderCtx): void { const destination = query.destination; const lastBookable = addDays(today, BOOKING_WINDOW_DAYS - 1); const windowDates = dateRange(today, BOOKING_WINDOW_DAYS); - const { connOpts, passesVia } = odConnOpts(origin, destination); - + const { connOpts } = odConnOpts(origin, destination); // Advanced "max trip span" caps how many days a single journey may span (overnight // trains). It is honoured on one-way trips through this same tab, so honour it here - // too rather than silently ignoring the control on a round trip. - const withinSpan = (j: Journey): boolean => !query.maxSpanDays || journeySpanDays(j) <= query.maxSpanDays; - const spanDays = query.maxSpanDays && query.maxSpanDays > 2 ? query.maxSpanDays : undefined; - const journeyOpts = spanDays ? { ...connOpts, spanDays } : connOpts; + // too rather than silently ignoring the control on a round trip — on the return + // calendar as much as on the lists it grades. + const { journeyOpts, accept } = odJourneyOpts(origin, destination); // Amber "reachable only via a station within the radius" grading for the possible-days // calendars on a radius search — parity with the one-way calendar (runOdSearch). @@ -2718,7 +2766,7 @@ function runTripSearch(c: RenderCtx): void { // is then self-evident from the cell tapped — no separate mode. const retCalHost = el("div", { class: "ret-cal" }); const retDates = dateRange(query.date, dayIndex(lastBookable) - dayIndex(query.date) + 1); - const retCal = availabilityCalendar(trains, destination, origin, retDates, connOpts, passesVia); + const retCal = availabilityCalendar(trains, destination, origin, retDates, journeyOpts, accept); // availabilityCalendar only asks "does a return exist that day?", which for the same-day // cell would green a return leaving BEFORE the outbound arrives. Re-derive that first // cell from the day-trip feasibility (nights 0: home by midnight, after arrival) so no @@ -2760,15 +2808,13 @@ function runTripSearch(c: RenderCtx): void { // platform-to-platform bounce) and still get you home by midnight — the same // minimum the discovery list promises, so a listed day trip drills into one. const list = findJourneys(trains, destination, origin, query.date, journeyOpts) - .filter(passesVia) - .filter(withinSpan) + .filter(accept) .filter((j) => journeyArriveAbs(j) <= 24 * 60 && j.departMin >= arrAbs + sameDayMinOnSite) .sort((a, b) => b.departMin - a.departMin); return { list, sameDay: true, arrAbs }; } const list = findJourneys(trains, destination, origin, retDate, journeyOpts) - .filter(passesVia) - .filter(withinSpan) + .filter(accept) .sort((a, b) => a.totalDurationMin - b.totalDurationMin || a.departMin - b.departMin); return { list, sameDay: false, arrAbs: 0 }; }; @@ -2922,14 +2968,12 @@ function runTripSearch(c: RenderCtx): void { // arrives early enough to leave that gap before it. const latestSameDayReturnDepart = isSameDayTrip ? findJourneys(trains, destination, origin, query.date, journeyOpts) - .filter(passesVia) - .filter(withinSpan) + .filter(accept) .filter((j) => journeyArriveAbs(j) <= 24 * 60) .reduce((max, j) => Math.max(max, j.departMin), -Infinity) : Infinity; const outJourneys = findJourneys(trains, origin, destination, query.date, journeyOpts) - .filter(passesVia) - .filter(withinSpan) + .filter(accept) .filter((j) => !isSameDayTrip || journeyArriveAbs(j) + sameDayMinOnSite <= latestSameDayReturnDepart) .sort((a, b) => journeyArriveAbs(a) - journeyArriveAbs(b) || a.totalDurationMin - b.totalDurationMin); chosenOutbound = outJourneys[0] ?? null; @@ -2995,8 +3039,7 @@ function runTripSearch(c: RenderCtx): void { const sameDayTrip = (odReturnDate ?? proposed) === query.date; const sameDayReturns = sameDayTrip ? findJourneys(trains, destination, origin, query.date, journeyOpts) - .filter(passesVia) - .filter(withinSpan) + .filter(accept) .filter((j) => journeyArriveAbs(j) <= 24 * 60) : []; const outboundStayLabel = (j: Journey): string | undefined => { @@ -3684,15 +3727,13 @@ function buildLayout(root: HTMLElement): void { inp.addEventListener("input", scheduleFormCalRepaint); inp.addEventListener("change", deferFormCalRepaint); } - // The grid is derived from the WHOLE form, not just the route: "Max correspondances", the - // via hub, the departure/arrival window, the duration cap, the train type, the night-train - // rules, overnight stopovers and the same-day minimum time on site all reach the per-day - // sweeps through readQueryFromForm() → odConnOptsFor / getawayOptsFor / filterOptsFor. - // Changing one changes which days are possible, so each has to repaint the calendar as - // well — otherwise raising "Max correspondances" left the previous (now wrong) month sitting - // right above the control, greyed on days that had just become reachable. This only - // repaints: like every other filter these stay STAGED, and the results still wait for - // Search. + // Everything the search is derived from BEYOND the route: "Max correspondances", the via + // hub, the departure/arrival window, the duration cap, the trip span, the train type, the + // night-train rules, overnight stopovers, the search radius and the same-day minimum time + // on site. Each is one deliberate choice, so each applies live (applyFilterChange) — + // repainting the form calendar and refreshing the results showing that route. Before this + // they did neither: raising "Max correspondances" left the previous, now-wrong month + // sitting right above the control, greyed on days it had just opened up. for (const ctl of [ refs.maxConnections, refs.trainType, @@ -3703,14 +3744,16 @@ function buildLayout(root: HTMLElement): void { refs.night, refs.onlyNight, refs.overnight, + refs.hidden, + refs.region, ]) { - ctl.addEventListener("change", deferFormCalRepaint); + ctl.addEventListener("change", applyFilterChange); } - // Typed filters settle keystroke by keystroke — debounce them like the route fields, and - // repaint straight away on the committed value (a via pick from the datalist, a blur). - for (const inp of [refs.via, refs.maxDuration]) { + // Typed filters settle keystroke by keystroke — debounce the calendar like the route + // fields, and apply in full on the committed value (a via pick from the datalist, a blur). + for (const inp of [refs.via, refs.maxDuration, refs.maxSpanDays, refs.radius]) { inp.addEventListener("input", scheduleFormCalRepaint); - inp.addEventListener("change", deferFormCalRepaint); + inp.addEventListener("change", applyFilterChange); } mapPromise = null; mapInstance = null; diff --git a/src/core/queryOpts.ts b/src/core/queryOpts.ts index 3585b18..5e64f62 100644 --- a/src/core/queryOpts.ts +++ b/src/core/queryOpts.ts @@ -8,6 +8,7 @@ import type { FilterOptions } from "./search"; import type { ConnectionOptions } from "./connections"; import type { GetawayOptions } from "./getaways"; import { HUB_STATIONS, OVERNIGHT_MAX_CONNECTION_MIN } from "../config"; +import { journeySpanDays } from "./connections"; import { stayNights } from "./roundtrip"; import { dayIndex } from "../util/time"; @@ -43,6 +44,27 @@ export function odConnOptsFor( return { connOpts, passesVia: (j) => !viaId || j.hubs.includes(viaId) }; } +/** + * Everything an exact-route sweep needs, SPAN-aware: the connection options above plus the + * accept-filter every journey must pass. The Advanced "max trip span (days)" both widens the + * day pool — so an itinerary may wait overnight at a hub — and caps a journey's total span, + * and it belongs to the availability calendars exactly as much as to the lists: a green day + * has to mean a journey the list will actually show, never one the span cap then drops. + */ +export function odJourneyOptsFor( + q: SearchQuery, + origin: string, + destination: string, +): { journeyOpts: ConnectionOptions; accept: (j: Journey) => boolean } { + const { connOpts, passesVia } = odConnOptsFor(q, origin, destination); + // 2 days is already the default pool, so only a WIDER span changes what the search sees. + const spanDays = q.maxSpanDays && q.maxSpanDays > 2 ? q.maxSpanDays : undefined; + return { + journeyOpts: spanDays ? { ...connOpts, spanDays } : connOpts, + accept: (j) => passesVia(j) && (!q.maxSpanDays || journeySpanDays(j) <= q.maxSpanDays), + }; +} + /** Connection options for the getaway / round-trip-discovery searches, keyed off the * "How long?" stay choice: same-day is nights 0, a fixed N-night stay pins that many * nights, and Flexible keeps the longest feasible stay up to 3 nights. */ diff --git a/tests/app.smoke.test.ts b/tests/app.smoke.test.ts index 4cfba23..b9ac808 100644 --- a/tests/app.smoke.test.ts +++ b/tests/app.smoke.test.ts @@ -248,26 +248,72 @@ describe("app (jsdom smoke)", () => { expect(root.textContent ?? "").toContain("Lyon"); }); - it("stages a field change without auto-running the search until Search is clicked", () => { + it("stages a typed ROUTE edit until Search is clicked", () => { + // The route is what stays staged: the search must not chase a half-typed station, and a + // destination the user hasn't committed must not ride along on anything else either. + const root = setup( + `?mode=od&from=${encodeURIComponent("PARIS (intramuros)")}&to=${encodeURIComponent("LYON (intramuros)")}&date=2026-06-25`, + ); + expect(root.querySelector("#results-title")?.textContent ?? "").toContain("Lyon"); + const dest = root.querySelectorAll('.od-fields input[list="station-list"]')[1]!; + dest.value = "TOULOUSE MATABIAU"; + dest.dispatchEvent(new Event("input", { bubbles: true })); + dest.dispatchEvent(new Event("change", { bubbles: true })); + // Staged: the results still show the searched route. + expect(root.querySelector("#results-title")?.textContent ?? "").toContain("Lyon"); + + // A filter change must NOT smuggle the staged route in either — it stays on Lyon. + const conn = root.querySelector(".connections-field select.input")!; + conn.value = "2"; + conn.dispatchEvent(new Event("change", { bubbles: true })); + expect(root.querySelector("#results-title")?.textContent ?? "").toContain("Lyon"); + + // Search commits the route (and the filter that was staged with it). + (root.querySelector(".search-form button[type=submit]") as HTMLElement).click(); + expect(root.querySelector("#results-title")?.textContent ?? "").toContain("Toulouse"); + }); + + it("applies a filter change live to the results — no second Search tap", () => { const root = setup(`?mode=from&from=${encodeURIComponent("PARIS (intramuros)")}&date=2026-06-25&conn=0`); // conn=0 → only direct destinations; Toulouse (reachable via Bordeaux) is absent. expect(root.textContent ?? "").not.toContain("Toulouse"); - // Allow one change, then commit the field. The connections select lives in its own - // full-width band (.connections-field), so target that rather than "the select with a - // 6 option" — the same-day minimum-time-there select also offers a 6 (hours). + // Allow one change. The connections select lives in its own full-width band + // (.connections-field), so target that rather than "the select with a 6 option" — the + // same-day minimum-time-there select also offers a 6 (hours). const conn = root.querySelector(".connections-field select.input"); expect(conn).toBeTruthy(); - conn!.value = "1"; + conn!.value = "2"; conn!.dispatchEvent(new Event("change", { bubbles: true })); - // The results must NOT recompute on a field change — still the direct-only list. - expect(root.textContent ?? "").not.toContain("Toulouse"); - - // Running the search (the submit button) applies the staged change. - const searchBtn = root.querySelector(".search-form button[type=submit]") as HTMLElement; - expect(searchBtn).not.toBeNull(); - searchBtn.click(); + // The list follows the control it was just given — the connection-only city appears. expect(root.textContent ?? "").toContain("Toulouse"); + // …and the applied filter is in the URL, so a reload / Share carries it. + expect(new URLSearchParams(location.search).get("conn")).toBe("2"); + }); + + it("holds a live filter back while the reload prompt is up (Search still owns the first run)", () => { + // A reload restores the form but deliberately waits for Search. A filter change must not + // sneak a search in behind that prompt — it only repaints the form calendar. + const original = performance.getEntriesByType.bind(performance); + const spy = vi + .spyOn(performance, "getEntriesByType") + .mockImplementation((type: string) => + type === "navigation" ? ([{ type: "reload" }] as unknown as PerformanceEntryList) : original(type), + ); + try { + const root = setup(`?mode=from&from=${encodeURIComponent("PARIS (intramuros)")}&date=2026-06-25&conn=0`); + expect(root.querySelectorAll(".group-card").length).toBe(0); + const conn = root.querySelector(".connections-field select.input")!; + conn.value = "1"; + conn.dispatchEvent(new Event("change", { bubbles: true })); + expect(root.querySelectorAll(".group-card").length).toBe(0); + expect(root.textContent ?? "").not.toContain("Toulouse"); + // Search runs it, filter included. + (root.querySelector(".search-form button[type=submit]") as HTMLElement).click(); + expect(root.textContent ?? "").toContain("Toulouse"); + } finally { + spy.mockRestore(); + } }); it("repaints the form's possible-days calendar when Max changes is raised (bug: stale grid)", () => { @@ -293,9 +339,7 @@ describe("app (jsdom smoke)", () => { conn.dispatchEvent(new Event("change", { bubbles: true })); expect(okDays()).toBeGreaterThan(0); - // The filter is still only STAGED — the calendar refreshes, the results wait for Search. - expect(root.querySelector(".results .chip-via")).toBeNull(); - (root.querySelector(".search-form button[type=submit]") as HTMLElement).click(); + // The results follow the same control, in place: the via-Bordeaux journey is now listed. expect(root.querySelector(".results .chip-via")).not.toBeNull(); }); @@ -320,10 +364,10 @@ describe("app (jsdom smoke)", () => { expect(onsiteField(nights)!.style.display).toBe("none"); }); - it("keeps a staged filter when the results refresh in place (sort change)", () => { + it("keeps the form's own values when the results refresh in place (sort change)", () => { // Regression: an in-place refresh (sort/calendar/day-shift) used to re-sync the - // whole form from the last-searched query, silently discarding a staged, not-yet- - // searched edit — the "my filter disappeared" bug. + // whole form from the last-searched query, silently discarding an edit the form was + // holding — the "my filter disappeared" bug. const root = setup(`?mode=from&from=${encodeURIComponent("PARIS (intramuros)")}&date=2026-06-25&nonight=1`); const nightBox = () => { // Booleans render as a toggle switch; the checkbox behind it is still the diff --git a/tests/core.test.ts b/tests/core.test.ts index 5e6d3e5..be81281 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; -import type { RawRecord } from "../src/types"; +import type { RawRecord, SearchQuery, Journey } from "../src/types"; +import { odJourneyOptsFor } from "../src/core/queryOpts"; import { normalizeRecords, normalizeRecord } from "../src/data/dataset"; import type { DatasetProfile } from "../src/data/profile"; import { filterTrains } from "../src/core/search"; @@ -236,6 +237,42 @@ describe("availabilityCalendar", () => { }); }); +describe("odJourneyOptsFor (span-aware exact-route options)", () => { + const q = (extra: Partial = {}): SearchQuery => ({ + mode: "od", + date: "2026-06-25", + card: "jeune", + maxConnections: 1, + ...extra, + }); + // A journey that starts on the 25th and finishes on the 27th — a 3-day span. + const wide = { + date: "2026-06-25", + hubs: ["BORDEAUX ST JEAN"], + legs: [{ date: "2026-06-25", arriveMin: 600 }, { date: "2026-06-27", arriveMin: 600 }], + } as unknown as Journey; + + it("only widens the day pool for a span BEYOND the default 2 days", () => { + expect(odJourneyOptsFor(q(), "A", "B").journeyOpts.spanDays).toBeUndefined(); + expect(odJourneyOptsFor(q({ maxSpanDays: 2 }), "A", "B").journeyOpts.spanDays).toBeUndefined(); + expect(odJourneyOptsFor(q({ maxSpanDays: 5 }), "A", "B").journeyOpts.spanDays).toBe(5); + }); + + it("caps a journey's total span, so a calendar day can't be greener than its list", () => { + // No cap → kept; a 2-day cap → dropped, the same verdict the results list reaches. + expect(odJourneyOptsFor(q(), "A", "B").accept(wide)).toBe(true); + expect(odJourneyOptsFor(q({ maxSpanDays: 2 }), "A", "B").accept(wide)).toBe(false); + expect(odJourneyOptsFor(q({ maxSpanDays: 3 }), "A", "B").accept(wide)).toBe(true); + }); + + it("still enforces the via hub alongside the span cap", () => { + const viaMissing = odJourneyOptsFor(q({ via: "LYON (intramuros)", maxSpanDays: 5 }), "A", "B"); + expect(viaMissing.accept(wide)).toBe(false); // hubs don't include Lyon + const viaHit = odJourneyOptsFor(q({ via: "BORDEAUX ST JEAN", maxSpanDays: 5 }), "A", "B"); + expect(viaHit.accept(wide)).toBe(true); + }); +}); + describe("reachableCountCalendar dir:'to' (destination-only browse-by-arrival)", () => { it("counts the origins that can reach a destination each day — never an empty grid when arrivals exist", () => { const dates = ["2026-06-25", "2026-06-26", "2026-06-27"];