Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions docs/user-flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<h3>` 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 |
|-------|------------------|-------------|
Expand Down
59 changes: 46 additions & 13 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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;
Expand Down
29 changes: 29 additions & 0 deletions tests/app.smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLSelectElement>(".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<HTMLElement>(".search-form .field")).find((f) =>
Expand Down
Loading