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
5 changes: 5 additions & 0 deletions docs/user-flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,11 @@ either an all-days overview or one specific day. Tap → open the route.
(`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).
- **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
of an empty result. A malformed or out-of-window departure date falls back to today; a
malformed or out-of-window return or finish-by date, and a malformed time filter, are
dropped. The address bar is then corrected in place (`replaceState`) to match the screen.
- **PWA** — installable; a "new version — reload" postcard on updates.

## Known open items (see docs/trip-redesign.md for the audit plan)
Expand Down
57 changes: 50 additions & 7 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -689,14 +689,17 @@ export function initApp(root: HTMLElement, dataset: Dataset, registry: StationRe
}

/**
* Parse the query from the URL and snap its date back into the bookable window. The
* date <input> is clamped to [today, today+29], so the search form can never produce
* an out-of-range date — but a stale or shared link can. An out-of-window date would
* otherwise collapse the ±flex browse window (only the chosen, unbookable day is in
* range) and skew the exact-trip return calendar, so fall back to today.
* Parse the query from the URL, resolve its station names, snap its dates back into the
* bookable window, and correct the address bar to match. The date <input> is clamped
* to [today, today+29], so the search form can never produce an out-of-range date — but
* a stale or shared link can. An out-of-window date would otherwise collapse the ±flex
* browse window (only the chosen, unbookable day is in range) and skew the exact-trip
* return calendar, so fall back to today.
*/
function queryFromUrl(): SearchQuery {
const q = store.queryFromParams(new URLSearchParams(location.search), today);
const params = new URLSearchParams(location.search);
const linked = params.toString();
const q = store.queryFromParams(params, today);
const lastBookable = addDays(today, BOOKING_WINDOW_DAYS - 1);
const inWindow = (d: string): boolean => d >= today && d <= lastBookable;
if (!inWindow(q.date)) q.date = today;
Expand All @@ -713,11 +716,34 @@ function queryFromUrl(): SearchQuery {
if (q.returnDate && q.mode === "od" && q.returnDate >= q.date && q.stay !== "flexible") {
q.stay = stayFromNights(dayIndex(q.returnDate) - dayIndex(q.date));
}
// A linked station name ("paris", "LILLE") becomes the id the data uses; a name
// matching no station is kept, and renderSearch reports it.
const station = (name: string): string => deps.registry.resolve(name) ?? name;
if (q.origin) q.origin = station(q.origin);
if (q.destination) q.destination = station(q.destination);
if (q.via) q.via = station(q.via);
q.cities = q.cities?.map(station);
// Multi-city legs are clamped too: a leg outside the bookable window can never
// have a free MAX seat, so pull it back to today rather than showing an empty leg.
if (q.legs) q.legs = q.legs.map((l) => (inWindow(l.date) ? l : { ...l, date: today }));
q.legs = q.legs?.map((l) => ({
from: station(l.from),
to: station(l.to),
date: inWindow(l.date) ? l.date : today,
}));
// The tour "finish by" date only constrains the plan when it's inside the window.
if (q.tourEndDate && !inWindow(q.tourEndDate)) q.tourEndDate = undefined;
// Write every value dropped, clamped or resolved above back into the address bar, in
// place, so a reload or a shared link carries what is on screen.
const parsed = store.queryToParams(q);
for (const key of ["from", "to", "via", "cities", "legs", "date", "rdate", "by", "after", "before", "arrbefore"]) {
if (!params.has(key)) continue;
const value = parsed.get(key);
if (value) params.set(key, value);
else params.delete(key);
}
if (params.toString() !== linked) {
history.replaceState(history.state, "", `${location.pathname}?${params.toString()}`);
}
return q;
}

Expand Down Expand Up @@ -1815,6 +1841,23 @@ function renderSearch(): void {
);
}

// Only a link can name a station the registry lacks (the form resolves to known ids):
// say so, rather than "No MAX seat" for a place that does not exist.
const unknown = [
query.origin,
query.destination,
query.via,
...(query.cities ?? []),
...(query.legs ?? []).flatMap((l) => [l.from, l.to]),
].find((s) => s && !deps.registry.get(s));
if (unknown) {
refs.title.textContent = "";
refs.results.append(render.emptyEl(t("err_station", { station: unknown })));
showBaseMap();
updateSearchBar();
return;
}

// MAX SENIOR free tickets are weekday-only — flag a weekend outbound, and (round trip)
// a weekend RETURN on a later day, since either leg must be booked free.
if (query.card === "senior") {
Expand Down
13 changes: 13 additions & 0 deletions src/data/stations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,19 @@ export class StationRegistry {
return this.byId.get(id);
}

/** The id a linked name means: a bookable id as is, else the id, label or alias equal to
* it ignoring case, accents and "St"/hyphen spelling, preferring one with trains. */
resolve(name: string): string | undefined {
if (this.present.has(name)) return name;
const n = matchNorm(name);
let best: Station | undefined;
for (const { station } of this.index) {
const names = [station.id, station.label, ...(station.aliases ?? [])];
if (names.some((s) => matchNorm(s) === n) && (!best || this.better(station, best))) best = station;
}
return best?.id;
}

/** Every registered station (may contain label duplicates). */
all(): Station[] {
return [...this.byId.values()];
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const ar: Dict = {
datepick_window: "المنقّط = الأيام ضمن هامش ±",
err_load: "تعذّر تحميل البيانات.",
act_retry: "إعادة المحاولة",
err_app: "تعذّر فتح هذه الصفحة.",
err_station: "محطة غير معروفة: «{station}». اختر محطة من القائمة.",
tagline: "مقاعد MAX JEUNE / SENIOR المتاحة للحجز.",
mode_best: "أفكار",
best_title_all: "كل الوجهات من {station}",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const de: Dict = {
datepick_window: "gepunktet = Tage innerhalb der ±-Spanne",
err_load: "Daten konnten nicht geladen werden.",
act_retry: "Erneut versuchen",
err_app: "Diese Seite konnte nicht geöffnet werden.",
err_station: "Unbekannter Bahnhof: „{station}“. Wähle einen aus der Liste.",
tagline: "Reservierbare MAX JEUNE / SENIOR Plätze.",
mode_best: "Ideen",
best_title_all: "Alle Ziele ab {station}",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const en: Dict = {
datepick_window: "dotted = days within the ± margin",
err_load: "Couldn't load the data.",
act_retry: "Retry",
err_app: "This page couldn't be opened.",
err_station: "Unknown station: “{station}”. Pick one from the list.",
tagline: "MAX JEUNE / SENIOR seats open for reservation.",
mode_best: "Ideas",
best_title_all: "All destinations from {station}",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const es: Dict = {
datepick_window: "punteado = días dentro del margen ±",
err_load: "No se pudieron cargar los datos.",
act_retry: "Reintentar",
err_app: "No se pudo abrir esta página.",
err_station: "Estación desconocida: «{station}». Elige una de la lista.",
tagline: "Plazas MAX JEUNE / SENIOR abiertas a reserva.",
mode_best: "Ideas",
best_title_all: "Todos los destinos desde {station}",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export const fr = {
datepick_window: "pointillés = jours compris dans la marge ±",
err_load: "Impossible de charger les données.",
act_retry: "Réessayer",
err_app: "Cette page n'a pas pu s'ouvrir.",
err_station: "Gare inconnue : «\u00a0{station}\u00a0». Choisissez-en une dans la liste.",
tagline: "Places MAX JEUNE / SENIOR ouvertes à la réservation.",
mode_best: "Idées",
best_title_all: "Toutes les destinations depuis {station}",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const it: Dict = {
datepick_window: "punteggiato = giorni entro il margine ±",
err_load: "Impossibile caricare i dati.",
act_retry: "Riprova",
err_app: "Impossibile aprire questa pagina.",
err_station: "Stazione sconosciuta: «{station}». Scegline una dall'elenco.",
tagline: "Posti MAX JEUNE / SENIOR aperti alla prenotazione.",
mode_best: "Idee",
best_title_all: "Tutte le destinazioni da {station}",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const ja: Dict = {
datepick_window: "点線 = ±範囲内の日",
err_load: "データを読み込めませんでした。",
act_retry: "再試行",
err_app: "このページを開けませんでした。",
err_station: "不明な駅:「{station}」。リストから選んでください。",
tagline: "予約できる MAX JEUNE / SENIOR の座席。",
mode_best: "おすすめ",
best_title_all: "{station} から行ける全ての行き先",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const ko: Dict = {
datepick_window: "점선 = ± 범위 내의 날짜",
err_load: "데이터를 불러올 수 없습니다.",
act_retry: "다시 시도",
err_app: "이 페이지를 열 수 없습니다.",
err_station: "알 수 없는 역: “{station}”. 목록에서 선택하세요.",
tagline: "예약 가능한 MAX JEUNE / SENIOR 좌석.",
mode_best: "추천",
best_title_all: "{station}에서 갈 수 있는 모든 목적지",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const nl: Dict = {
datepick_window: "stippellijn = dagen binnen de ±-marge",
err_load: "Kan de gegevens niet laden.",
act_retry: "Opnieuw proberen",
err_app: "Deze pagina kan niet worden geopend.",
err_station: "Onbekend station: “{station}”. Kies er een uit de lijst.",
tagline: "MAX JEUNE / SENIOR-plaatsen die te reserveren zijn.",
mode_best: "Ideeën",
best_title_all: "Alle bestemmingen vanaf {station}",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const pt: Dict = {
datepick_window: "pontilhado = dias dentro da margem ±",
err_load: "Não foi possível carregar os dados.",
act_retry: "Tentar novamente",
err_app: "Não foi possível abrir esta página.",
err_station: "Estação desconhecida: «{station}». Escolha uma da lista.",
tagline: "Lugares MAX JEUNE / SENIOR abertos para reserva.",
mode_best: "Ideias",
best_title_all: "Todos os destinos a partir de {station}",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const zh: Dict = {
datepick_window: "虚线 = ± 范围内的日期",
err_load: "无法加载数据。",
act_retry: "重试",
err_app: "无法打开此页面。",
err_station: "未知车站:“{station}”。请从列表中选择。",
tagline: "可预订的 MAX JEUNE / SENIOR 座位。",
mode_best: "推荐",
best_title_all: "从{station}出发的所有目的地",
Expand Down
27 changes: 18 additions & 9 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,20 +27,20 @@ function loadingStateEl(): HTMLElement {
]);
}

/** Clean error card with a retry action if the dataset fails to load. */
function errorStateEl(): HTMLElement {
/** Clean error card with a retry action: the data failed to load, or the page failed to open. */
function errorStateEl(message: string, retry: () => void): HTMLElement {
return el("div", { class: "error-state", attrs: { role: "alert" } }, [
el("span", {
class: "error-icon",
attrs: { "aria-hidden": "true" },
html: `<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 9v4M12 17h.01"/><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/></svg>`,
}),
el("p", { class: "error-title", text: t("err_load") }),
el("p", { class: "error-title", text: message }),
el("button", {
class: "btn btn-primary",
type: "button",
text: t("act_retry"),
on: { click: () => location.reload() },
on: { click: retry },
}),
]);
}
Expand All @@ -61,12 +61,21 @@ if (root) {
root.replaceChildren(loadingStateEl());
}
const registry = new StationRegistry(stationData as Station[]);
loadDataset()
.then((dataset) => initApp(root, dataset, registry))
.catch((err: unknown) => {
loadDataset().then(
(dataset) => {
try {
initApp(root, dataset, registry);
} catch (err) {
// The data loaded but this link or saved state broke the page: retry without the link.
console.error(err);
root.replaceChildren(errorStateEl(t("err_app"), () => location.assign(location.pathname)));
}
},
(err: unknown) => {
console.error(err);
root.replaceChildren(errorStateEl());
});
root.replaceChildren(errorStateEl(t("err_load"), () => location.reload()));
},
);
}

// When a new build is deployed, greet the user with a dismissible "reload to update"
Expand Down
Loading
Loading