diff --git a/docs/user-flows.md b/docs/user-flows.md index 7bf274e..a367363 100644 --- a/docs/user-flows.md +++ b/docs/user-flows.md @@ -43,9 +43,14 @@ The **Trip tab's date picker is a live availability calendar on the form itself* **"When to leave?"** header (which also shows the picked departure) and is **collapsed by default** so the form stays short on a phone — one tap opens the month to change the day. The header names the calendar once (the in-body `

` is rendered `sr-only` via -`calendarEl`'s `hideTitle`, so "When to leave?" isn't written twice). It **recomputes whenever origin, -destination, the Aller simple / Aller-retour toggle, or the nights stepper change**, so a -green day always means *a trip is possible that day* for the current choice: +`calendarEl`'s `hideTitle`, so "When to leave?" isn't written twice). It **recomputes whenever +anything it is derived from changes** — origin, destination, the Aller simple / Aller-retour +toggle, the nights stepper, **and every filter that feeds the per-day sweeps**: Max +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.) | State | Builder (reused) | Green means | |-------|------------------|-------------| diff --git a/src/app.ts b/src/app.ts index 8684c5c..5eefc02 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1311,6 +1311,17 @@ function scheduleFormCalRepaint(): void { }, 140) as unknown as number; } +/** Repaint on the NEXT frame rather than inline, cancelling any pending debounced pass: the + * control's own visual update (a select's new value, a flipped switch) paints first, then + * the availability sweep — which can take ~1s cold for a hub like Paris — runs. */ +function deferFormCalRepaint(): void { + if (formCalTimer) { + clearTimeout(formCalTimer); + formCalTimer = 0; + } + requestAnimationFrame(() => repaintFormCalendar()); +} + /** * 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: @@ -3663,21 +3674,43 @@ function buildLayout(root: HTMLElement): void { // Keep the reactive home-form calendar in step as the route is typed (staged — this // never runs a search, only repaints the "which days are possible" grid). Debounced on // input, immediate on a committed value (a datalist pick / blur). + // A committed pick (datalist tap / blur / Enter) fires `change`. DEFER the repaint one + // frame so the field + chip update paint first: the origin-only "possible days" grid runs + // a full getaway sweep that can take ~1s cold for a hub like Paris, and running it inline + // froze the tap so the picked station only appeared a second later. Deferring lets the + // selection apply instantly; the calendar (which stays visible until then — the repaint + // clears and rebuilds in one go) fills a beat afterwards. for (const inp of [refs.origin, refs.destination]) { inp.addEventListener("input", scheduleFormCalRepaint); - // A committed pick (datalist tap / blur / Enter) fires `change`. DEFER the repaint one - // frame so the field + chip update paint first: the origin-only "possible days" grid runs - // a full getaway sweep that can take ~1s cold for a hub like Paris, and running it inline - // froze the tap so the picked station only appeared a second later. Deferring lets the - // selection apply instantly; the calendar (which stays visible until then — the repaint - // clears and rebuilds in one go) fills a beat afterwards. - inp.addEventListener("change", () => { - if (formCalTimer) { - clearTimeout(formCalTimer); - formCalTimer = 0; - } - requestAnimationFrame(() => repaintFormCalendar()); - }); + 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. + for (const ctl of [ + refs.maxConnections, + refs.trainType, + refs.stayMin, + refs.departAfter, + refs.departBefore, + refs.arriveBefore, + refs.night, + refs.onlyNight, + refs.overnight, + ]) { + ctl.addEventListener("change", deferFormCalRepaint); + } + // 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]) { + inp.addEventListener("input", scheduleFormCalRepaint); + inp.addEventListener("change", deferFormCalRepaint); } mapPromise = null; mapInstance = null; diff --git a/tests/app.smoke.test.ts b/tests/app.smoke.test.ts index 2208131..4cfba23 100644 --- a/tests/app.smoke.test.ts +++ b/tests/app.smoke.test.ts @@ -270,6 +270,35 @@ describe("app (jsdom smoke)", () => { expect(root.textContent ?? "").toContain("Toulouse"); }); + it("repaints the form's possible-days calendar when Max changes is raised (bug: stale grid)", () => { + // Regression: only the origin/destination fields repainted the reactive calendar, so + // raising "Max correspondances" — the control sitting right under it — left the previous + // month on screen, greyed on the very days the new budget had just opened up. + const root = setup( + `?mode=od&from=${encodeURIComponent("PARIS (intramuros)")}&to=${encodeURIComponent("TOULOUSE MATABIAU")}&date=2026-06-25&conn=0`, + ); + const block = root.querySelector(".form-cal-block") as HTMLElement; + if ((block.querySelector(".form-cal-body") as HTMLElement).hasAttribute("hidden")) { + (block.querySelector(".form-cal-toggle") as HTMLElement).click(); + } + const okDays = (): number => block.querySelectorAll(".form-cal-body .cal-cell.ok").length; + // Direct only: the fixture's one direct Paris → Toulouse train isn't a free-MAX seat, + // so no day of the window is possible. + expect(block.querySelectorAll(".form-cal-body .cal-cell").length).toBeGreaterThan(0); + expect(okDays()).toBe(0); + + // Allow one change: 25 June works via Bordeaux, so the grid must green up on the spot. + const conn = root.querySelector(".connections-field select.input")!; + conn.value = "1"; + 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(); + expect(root.querySelector(".results .chip-via")).not.toBeNull(); + }); + it("shows the 'minimum time there' control only for a same-day round trip", () => { const onsiteField = (root: HTMLElement): HTMLElement | null => Array.from(root.querySelectorAll(".search-form .field")).find((f) =>