Skip to content
Open
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
48 changes: 35 additions & 13 deletions docs/user-flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,9 @@ span applied to the lists only, and a day could read green while its list was em
- **Trip modal** = booking recap: each leg's own **travel date** rides on the ticket header
(beside "Outbound" / "Return"), and an unmistakable per-leg action — "Book the outbound" /
"Book the return" (each deep-links SNCF Connect; a connecting leg opens the step modal)
— plus Save the whole trip. Back inside the accordion re-opens the outbound before it
exits the flow (step-wise back).
— plus Save the whole trip, and "See all dates", which closes the modal and opens both
legs and both calendars in place. Back inside the accordion re-opens the outbound before
it exits the flow (step-wise back).
3. **Only From, One-way** → browse (`runBrowse` "from"). Every destination reachable from the
station, ranked by how well-served it is, with availability. Tap a card → the exact trip.
The list reads direct cards, then connection-only ("via") rows, then the radius "Stations
Expand Down Expand Up @@ -196,18 +197,40 @@ the destination is reachable, with its calendar open.
- **Map** — full-bleed behind a results drawer on mobile, side panel on desktop. Markers per
destination, hover/selection synced with the list; route line for exact trips; auto-fits
above the drawer on mobile.
- **Saved & Favorites** — star a route / save a trip, from the header menu. (The two overlap
— a known cleanup item.)
- **Saved & Favorites** — star a route / save a trip. Both cards sit in the results column;
on a phone, where the search form hides that column, the header menu's "Saved trips &
favorites" entry opens the saved page (every saved trip, the favorites card below it)
from any screen. (The two overlap — a known cleanup item.) A saved trip opens its ticket
modal, whose "See all dates" opens the route page; a favorite prefills the Trip tab with
that route alone.
- **Settings** — theme, MAX Jeune/Senior, comfortable/compact, and Low-end mode (map off +
reduced motion + compact) with a one-time nudge on weak devices; language.
- **Mobile** — the form is a sheet that collapses to a search bar; results are a bottom-sheet
drawer with detents. Back navigation preserves form state and never lands on a dead screen.
- **History model** — a genuine navigation (Search, drilling into a route, opening the saved
page) pushes **one** history entry carrying a form snapshot, so browser Back returns to the
prior page with the form intact. Refining the current view — the Aller simple/retour toggle,
the nights stepper, the Flexible pill, picking a calendar day — updates **in place**
(`replaceState`), never pushing a new entry. So repeated toggling can't pile up duplicate
entries (the old bug where Back needed ~10 presses and the form appeared wiped).
- **Mobile** — the form is a sheet that collapses to a search bar (a long route wraps it to
two lines); results are a bottom-sheet drawer with peek / half / full detents measured
below that bar, so the full sheet never covers it. Back navigation preserves form state
and never lands on a dead screen.
- **History model** — a genuine navigation (Search, a tab switch, drilling into a route,
opening the saved page) pushes **one** history entry carrying a snapshot of the form, tab
included, so browser Back and Forward return to each page with **its own** form intact,
never the latest one. Refining the current view — the Aller simple/retour toggle, the nights
stepper, the Flexible pill, picking a calendar day — updates **in place** (`replaceState`),
never pushing a new entry. So repeated toggling can't pile up duplicate entries (the old bug
where Back needed ~10 presses and the form appeared wiped). Details of the model:
- The **bare landing URL never shows results**: Back to it restores the form as it was left
and shows "press Search". A refinement made on the landing form still pushes one entry
(so there is a form to Back to), and since the user never left the form, Back to the
landing keeps what was built on that entry (a Flexible range included).
- The **saved-trips page** is its own entry: Forward onto it shows it again.
- Leaving a list for a route stamps the list's **scroll position**; Back restores it (the
drawer on a phone, the main column on a desktop).
- An open **dialog** owns one entry: Back closes it and leaves the page under it as it
was. Closing it with its own button, Escape or the backdrop pops that entry, so no stray
Back step is left behind.
- A **Search with nothing to search** (no station) adds no entry: a hint under the button
says what is missing.
- The `1/2/3` shortcuts never move focus into a field, so they keep working after landing
on an empty tab: an empty field gets a ready cursor only while nothing else holds focus,
as on the first load.
- **Deep links** — every search is a shareable URL; legacy `?rdate=` / `?rt=` links still work.
A station may be named in any case or accent (`from=paris`, `to=LILLE`) and resolves to
the station that has trains; a name matching no station shows "Unknown station" instead
Expand All @@ -223,4 +246,3 @@ the destination is reachable, with its calendar open.
- Collapse the two save systems (favorite star + Save bookmark) into one.
- One `openRoute()` primitive (list cards / favorites / map pins behave consistently).
- One home for the availability calendar (form popover vs results).
- Mobile browser-Back should close detail pages via history.
179 changes: 179 additions & 0 deletions scripts/e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,185 @@ await scenario(
{ viewport: { width: 390, height: 844, isMobile: true, hasTouch: true } },
);

const MOBILE = { width: 390, height: 844, isMobile: true, hasTouch: true };
const urlParam = (page, key) => new URL(page.url()).searchParams.get(key);
const fields = (page) => page.$$eval(".search-form .od-fields input", (els) => els.map((e) => e.value).join("|"));
async function fillOd(page, from, to) {
await page.evaluate(
(vals) => {
const inputs = document.querySelectorAll(".search-form .od-fields input");
vals.forEach((v, i) => {
inputs[i].value = v;
inputs[i].dispatchEvent(new Event("input", { bubbles: true }));
inputs[i].dispatchEvent(new Event("change", { bubbles: true }));
});
},
[from, to],
);
}
const clickSearch = (page) => page.click(".search-form .form-actions button.btn-primary");

// 16. Regression: each history entry restores its OWN form and tab. Search Paris → Lyon from
// the landing, open Ideas, Back twice: the landing comes back on the Trip tab with Paris →
// Lyon filled (not the Ideas form built after it), and its bare URL shows no results.
await scenario(
"history: Back to the landing restores its own form and tab, with no results under it",
BASE,
async (page) => {
await fillOd(page, P, L);
await clickSearch(page);
assert(await until(async () => (await $count(page, "article.journey")) > 0), "precondition: no Paris → Lyon results");
await page.click('.mode-tab[data-trip="ideas"]');
assert(await until(async () => urlParam(page, "mode") === "best"), "precondition: Ideas did not open");
await page.goBack();
await page.goBack();
assert(await until(async () => !new URL(page.url()).search), `two Backs did not reach the landing (${page.url()})`);
const landed = await until(async () =>
(await activeTrip(page)) === "simple" && (await $count(page, ".results .empty")) > 0 ? true : null,
);
assert(landed, `landing shows tab "${await activeTrip(page)}", results "${(await $text(page, ".results"))?.slice(0, 60)}"`);
const filled = await fields(page);
assert(/paris/i.test(filled) && /lyon/i.test(filled), `landing form is not its own (got "${filled}")`);
const rows = await $count(page, ".results .group-card, .results article.journey");
assert(rows === 0, `the bare landing URL shows ${rows} result rows`);
},
);

// 17. Regression: the tab follows the entry, not the last form. Legs link → Ideas (no origin)
// → Multi-city → Back must land on the Ideas tab its URL names, without the legs results.
await scenario(
"history: Back to an Ideas entry shows the Ideas tab, not the Multi-city built after it",
`${BASE}?mode=tour&legs=${enc(`${P}>${L}@${DATE}`)}&date=${DATE}`,
async (page) => {
assert(await until(async () => (await $count(page, ".mc-result")) > 0), "precondition: legs did not render");
await page.click('.mode-tab[data-trip="ideas"]');
assert(await until(async () => urlParam(page, "mode") === "best"), "precondition: Ideas did not open");
await page.click('.mode-tab[data-trip="multi"]');
assert(await until(async () => urlParam(page, "mode") === "tour"), "precondition: Multi-city did not open");
await page.goBack();
const back = await until(async () =>
urlParam(page, "mode") === "best" && (await activeTrip(page)) === "ideas" && (await $count(page, ".mc-result")) === 0,
);
assert(back, `Back to ?mode=best shows tab "${await activeTrip(page)}" with ${await $count(page, ".mc-result")} legs`);
},
);

// 18. Regression: the saved-trips page is its own history entry. See all → Back → Forward
// must land on the saved page again, not on the list under it.
await scenario(
"history: Forward returns to the saved-trips page",
`${BASE}?mode=od&from=${enc(P)}&to=${enc(L)}&date=${RT_DATE}`,
async (page) => {
assert(await until(async () => (await $count(page, "article.journey button[aria-pressed]")) > 0), "precondition: no train to save");
await page.click("article.journey button[aria-pressed]");
await page.click(".saved-see-all");
assert(await until(async () => (await $count(page, ".saved-page-card")) > 0), "the saved-trips page did not open");
await page.goBack();
assert(await until(async () => (await $count(page, "article.journey")) > 0), "Back did not return to the list");
await page.goForward();
assert(
await until(async () => (await $count(page, ".saved-page-card")) > 0 && (await $count(page, "article.journey")) === 0),
`Forward shows "${await $text(page, "#results-title")}" instead of the saved-trips page`,
);
},
);

// 19. Regression: a dialog owns one history entry. At 390px, Back with a dialog open closes
// it and stays on the results; a dialog closed by its own button leaves no stray entry,
// so the next Back reaches the form.
await scenario(
"history: Back closes an open dialog first; a dialog closed by its button leaves no entry",
BASE,
async (page) => {
await fillOd(page, P, L);
await clickSearch(page);
assert(await until(async () => (await $count(page, "article.journey")) > 0), "precondition: no Paris → Lyon results");
const openDialog = async () => {
await page.keyboard.press("?");
assert(await until(async () => (await $count(page, "dialog[open]")) === 1), "the help dialog did not open");
};
await openDialog();
await page.goBack();
assert(await until(async () => (await $count(page, "dialog[open]")) === 0), "Back left the dialog open");
assert(urlParam(page, "from"), "Back with a dialog open left the results page");
assert((await $count(page, "article.journey")) > 0, "Back with a dialog open re-rendered the page empty");
await openDialog();
await page.click("dialog[open] .modal-close");
assert(await until(async () => (await $count(page, "dialog[open]")) === 0), "the Close button did not close the dialog");
await page.goBack();
assert(await until(async () => !urlParam(page, "from")), `one Back after closing did not reach the form (${page.url()})`);
},
{ viewport: MOBILE },
);

// 20. Regression: Back from a drilled-in route puts the list back where it was scrolled, in
// the drawer at 390px and in the main column at 1366px.
for (const [viewport, label] of [[MOBILE, "390px drawer"], [undefined, "1366px column"]]) {
await scenario(
`history: Back from a route restores the list's scroll (${label})`,
`${BASE}?mode=from&from=${enc(P)}&date=${DATE}`,
async (page) => {
assert(await until(async () => (await $count(page, ".results .group-card")) > 30), "precondition: short list");
const scrollTop = () =>
page.evaluate(() => {
const el = [".drawer-scroll", ".main-col"]
.map((s) => document.querySelector(s))
.find((e) => e && /auto|scroll/.test(getComputedStyle(e).overflowY));
return (el ?? document.scrollingElement).scrollTop;
});
await page.evaluate(() => document.querySelectorAll(".results .group-card")[25].scrollIntoView());
const before = await scrollTop();
assert(before > 500, `precondition: the list did not scroll (${before}px)`);
await page.evaluate(() => {
const card = document.querySelectorAll(".results .group-card")[25];
(card.querySelector(".dest-main") || card).click();
});
assert(await until(async () => (await $count(page, ".back-btn")) > 0), "the route did not open");
await page.goBack();
const restored = await until(async () => Math.abs((await scrollTop()) - before) <= 2);
assert(restored, `Back put the list at ${await scrollTop()}px, it was at ${before}px`);
},
viewport && { viewport },
);
}

// 21. Regression: "See all dates" in the trip dialog shows the dates in place — both
// calendars open, same page — instead of re-running the route as a new detail page.
await scenario(
"trip dialog: 'See all dates' opens the calendars in place, with no new page",
`${BASE}?mode=od&from=${enc(P)}&to=${enc(L)}&date=${RT_DATE}&rdate=${RT_DATE2}`,
async (page) => {
assert(await until(async () => page.$eval(".rt-view-ticket", (b) => !b.hidden).catch(() => false)), "precondition: no ticket");
const url = page.url();
await page.click(".rt-view-ticket");
assert(await until(async () => (await $count(page, "dialog[open] .trip-more")) === 1), "the trip dialog did not open");
await page.click("dialog[open] .trip-more");
const opened = await until(async () =>
(await $count(page, "dialog[open]")) === 0 && (await $count(page, '.results .cal-toggle[aria-expanded="true"]')) >= 2,
);
assert(opened, `calendars open: ${await $count(page, '.results .cal-toggle[aria-expanded="true"]')} of 2`);
assert(page.url() === url, `the URL changed to ${page.url()}`);
assert((await $count(page, ".back-btn")) === 0, "'See all dates' opened a new detail page");
},
);

// 22. Regression: the tab shortcuts keep working after one lands on an empty tab. Landing
// on the empty Trip tab must not pull focus into its field, where "3" would be typed.
await scenario(
"keyboard: 1/2/3 keep switching tabs after landing on an empty one",
`${BASE}?mode=tour&date=${DATE}`,
async (page) => {
await page.keyboard.press("1");
const settled = await until(async () => (await activeTrip(page)) === "simple" && (await $count(page, ".results .loading")) === 0);
assert(settled, "'1' did not open the Trip tab");
await page.keyboard.press("3");
const ideas = await until(async () => (await activeTrip(page)) === "ideas");
assert(ideas, `'3' did not open Ideas; the fields read "${await fields(page)}"`);
await page.keyboard.press("?");
assert(await until(async () => (await $count(page, "dialog[open]")) === 1), "'?' did not open the shortcuts help");
},
);

// 12. PWA manifest is served and parseable, icon reference resolves.
await scenario("pwa: manifest is served and valid JSON", BASE, async (page) => {
const manifestHref = await page.$eval('link[rel="manifest"]', (el) => el.getAttribute("href"));
Expand Down
5 changes: 5 additions & 0 deletions scripts/prerender.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ const page = await browser.newPage();
await page.evaluateOnNewDocument(() => {
Object.defineProperty(navigator, "language", { get: () => "fr-FR" });
Object.defineProperty(navigator, "languages", { get: () => ["fr-FR", "fr"] });
// Map off: the snapshot must not carry Leaflet's DOM, a tile <img> or the map chunk's
// preload links, or every visitor (low-end mode included) downloads them.
localStorage.setItem("mj.settings", JSON.stringify({ map: false }));
});
await page.goto(BASE, { waitUntil: "networkidle2", timeout: 60000 });
await page.waitForFunction(
Expand All @@ -65,6 +68,7 @@ server.close();
// fallback background); hydration re-adds the pill, correctly placed for the real
// viewport, with no initial animation.
html = html
.replace(/(<html\b[^>]*?)\s+data-map="off"/, "$1")
.replace(/(<span class="mode-tab-thumb"[^>]*?)\s+style="[^"]*"/g, "$1")
.replace(/(class="[^"]*?)\s+has-thumb\b/g, "$1")
.replace(/(class="[^"]*?)\s+animate-thumb\b/g, "$1");
Expand All @@ -75,6 +79,7 @@ if (!html.includes('<html lang="fr"')) failures.push("prerendered HTML is not in
if (!html.includes('id="app"')) failures.push("prerendered HTML lost #app");
if (!html.includes("application/ld+json")) failures.push("prerendered HTML lost JSON-LD");
if (!html.includes("<noscript")) failures.push("prerendered HTML lost the noscript fallback");
if (/leaflet|assets\/map-/.test(html)) failures.push("prerendered HTML carries the map (Leaflet DOM or map chunk)");
if (failures.length) {
console.error("PRERENDER FAILED:");
for (const f of failures) console.error(" ✗ " + f);
Expand Down
Loading
Loading