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) =>