diff --git a/docs/user-flows.md b/docs/user-flows.md
index 36561d8..a737185 100644
--- a/docs/user-flows.md
+++ b/docs/user-flows.md
@@ -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)
diff --git a/src/app.ts b/src/app.ts
index 7d96bf7..a414d9b 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -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 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 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;
@@ -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;
}
@@ -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") {
diff --git a/src/data/stations.ts b/src/data/stations.ts
index b2ac459..e0a0378 100644
--- a/src/data/stations.ts
+++ b/src/data/stations.ts
@@ -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()];
diff --git a/src/i18n/ar.ts b/src/i18n/ar.ts
index f13aa60..f4d984a 100644
--- a/src/i18n/ar.ts
+++ b/src/i18n/ar.ts
@@ -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}",
diff --git a/src/i18n/de.ts b/src/i18n/de.ts
index 6077c82..ed5ef37 100644
--- a/src/i18n/de.ts
+++ b/src/i18n/de.ts
@@ -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}",
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index 3b92000..0f52150 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -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}",
diff --git a/src/i18n/es.ts b/src/i18n/es.ts
index 938e7ed..cf36eb3 100644
--- a/src/i18n/es.ts
+++ b/src/i18n/es.ts
@@ -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}",
diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts
index 4c788a3..5fb6061 100644
--- a/src/i18n/fr.ts
+++ b/src/i18n/fr.ts
@@ -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}",
diff --git a/src/i18n/it.ts b/src/i18n/it.ts
index c67cf2c..2c45630 100644
--- a/src/i18n/it.ts
+++ b/src/i18n/it.ts
@@ -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}",
diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts
index 377cafd..04a7754 100644
--- a/src/i18n/ja.ts
+++ b/src/i18n/ja.ts
@@ -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} から行ける全ての行き先",
diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts
index 39fc01b..1519624 100644
--- a/src/i18n/ko.ts
+++ b/src/i18n/ko.ts
@@ -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}에서 갈 수 있는 모든 목적지",
diff --git a/src/i18n/nl.ts b/src/i18n/nl.ts
index e11ec2a..12a5193 100644
--- a/src/i18n/nl.ts
+++ b/src/i18n/nl.ts
@@ -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}",
diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts
index fd411b4..7475064 100644
--- a/src/i18n/pt.ts
+++ b/src/i18n/pt.ts
@@ -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}",
diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts
index 8b34164..5c633ac 100644
--- a/src/i18n/zh.ts
+++ b/src/i18n/zh.ts
@@ -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}出发的所有目的地",
diff --git a/src/main.ts b/src/main.ts
index a1d44ab..9d4aec9 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -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: ``,
}),
- 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 },
}),
]);
}
@@ -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"
diff --git a/src/state/store.ts b/src/state/store.ts
index 16aa07d..159d906 100644
--- a/src/state/store.ts
+++ b/src/state/store.ts
@@ -1,7 +1,7 @@
import type { SearchQuery, SearchMode, CardType, Journey, SortKey, TripLeg, StayChoice } from "../types";
import type { Tour } from "../core/tour";
import { stayFromNights } from "../core/roundtrip";
-import { dayIndex } from "../util/time";
+import { dayIndex, parseTimeToMinutes } from "../util/time";
import { isLang, detectLang, type Lang } from "../i18n";
/** URL token for a stay choice (compact + stable): stay=day||flex, where is the
@@ -124,8 +124,20 @@ function sameRoute(a: RoutePair, b: RoutePair): boolean {
return a.origin === b.origin && a.destination === b.destination;
}
+// Stored lists are checked per element: one malformed entry is dropped instead of
+// crashing every render that reads the list.
+type Stored = Record;
+
+function isStored(v: unknown): v is Stored {
+ return typeof v === "object" && v !== null;
+}
+
+function isRoutePair(v: unknown): v is RoutePair & Stored {
+ return isStored(v) && typeof v.origin === "string" && typeof v.destination === "string";
+}
+
export function loadFavorites(): RoutePair[] {
- return readLS(KEY.favorites, [], Array.isArray);
+ return readLS(KEY.favorites, [], Array.isArray).filter(isRoutePair);
}
export function isFavorite(r: RoutePair): boolean {
@@ -142,7 +154,7 @@ export function toggleFavorite(r: RoutePair): RoutePair[] {
}
export function loadWatched(): RoutePair[] {
- return readLS(KEY.watched, [], Array.isArray);
+ return readLS(KEY.watched, [], Array.isArray).filter(isRoutePair);
}
export function isWatched(r: RoutePair): boolean {
@@ -190,8 +202,30 @@ export function tourId(tour: Tour): string {
return `tour:${tour.legs.map(journeyKey).join("|")}`;
}
+/** What a saved-trip row and its dialog read: dated endpoints, dated legs, the hub lists. */
+function isJourney(v: unknown): v is Journey {
+ const dated = (x: unknown): x is Stored => isRoutePair(x) && isValidIsoDate(x.date);
+ return (
+ dated(v) &&
+ Array.isArray(v.legs) &&
+ v.legs.length > 0 &&
+ v.legs.every(dated) &&
+ Array.isArray(v.hubs) &&
+ Array.isArray(v.layovers)
+ );
+}
+
+function isSavedTrip(v: unknown): v is SavedTrip {
+ return (
+ isStored(v) &&
+ isJourney(v.outbound) &&
+ (v.inbound == null || isJourney(v.inbound)) &&
+ (v.tour == null || (isStored(v.tour) && Array.isArray(v.tour.legs) && v.tour.legs.every(isJourney)))
+ );
+}
+
export function loadTrips(): SavedTrip[] {
- return readLS(KEY.trips, [], Array.isArray);
+ return readLS(KEY.trips, [], Array.isArray).filter(isSavedTrip);
}
export function isTripSaved(id: string): boolean {
@@ -293,6 +327,16 @@ export function queryFromParams(p: URLSearchParams, fallbackDate: string): Searc
const stayh = Number(p.get("stayh"));
const clampDay = (n: number, fallback: number): number =>
Number.isFinite(n) && n >= 1 ? Math.min(14, Math.floor(n)) : fallback;
+ // A malformed date or time is dropped: kept, a date crashes the render and a time
+ // filters out every train while the form shows no filter.
+ const isoDate = (key: string): string | undefined => {
+ const v = p.get(key);
+ return v && isValidIsoDate(v) ? v : undefined;
+ };
+ const time = (key: string): string | undefined => {
+ const v = p.get(key);
+ return v && /^\d{2}:\d{2}$/.test(v) && Number.isFinite(parseTimeToMinutes(v)) ? v : undefined;
+ };
return {
mode,
origin: p.get("from") ?? undefined,
@@ -306,13 +350,13 @@ export function queryFromParams(p: URLSearchParams, fallbackDate: string): Searc
// Clamp to the stepper's 0..7 range (like setStepper) — an out-of-range link
// should mean "the widest window", not silently fall back to no flexibility.
flexDays: Number.isFinite(Number(p.get("flex"))) && Math.floor(Number(p.get("flex"))) >= 1 ? Math.min(7, Math.floor(Number(p.get("flex")))) : undefined,
- returnDate: p.get("rdate") ?? undefined,
+ returnDate: isoDate("rdate"),
legs: parseLegs(p.get("legs")),
- date: p.get("date") ?? fallbackDate,
+ date: isoDate("date") ?? fallbackDate,
card: p.get("card") === "senior" ? "senior" : "jeune",
- departAfter: p.get("after") ?? undefined,
- departBefore: p.get("before") ?? undefined,
- arriveBefore: p.get("arrbefore") ?? undefined,
+ departAfter: time("after"),
+ departBefore: time("before"),
+ arriveBefore: time("arrbefore"),
maxDurationMin: Number.isFinite(maxdur) && maxdur > 0 ? maxdur : undefined,
trainType: p.get("type") ?? undefined,
maxConnections: Number.isFinite(conn) && conn >= 0 && conn <= 6 ? conn : 1,
@@ -340,7 +384,7 @@ export function queryFromParams(p: URLSearchParams, fallbackDate: string): Searc
flexNights: p.get("fn") === "1" || undefined,
stayMinHours: Number.isFinite(stayh) && stayh >= 1 ? Math.min(12, Math.floor(stayh)) : undefined,
lateReturn: p.get("late") === "1" || undefined,
- tourEndDate: p.get("by") ?? undefined,
+ tourEndDate: isoDate("by"),
sort: parseSort(p.get("sort")),
};
}
@@ -381,11 +425,11 @@ function parseStay(p: URLSearchParams): StayChoice | undefined {
// require a real ISO date so both are impossible from a URL.
const MAX_LEGS = 12;
-/** Whether a string is a well-formed, real ISO date (YYYY-MM-DD). */
-function isValidIsoDate(d: string): boolean {
- if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) return false;
- const t = new Date(`${d}T00:00:00`).getTime();
- return !Number.isNaN(t);
+/** Whether a value is a real calendar date written YYYY-MM-DD (not 2026-10-1 or 2026-02-30). */
+function isValidIsoDate(d: unknown): d is string {
+ if (typeof d !== "string") return false;
+ const t = Date.parse(`${d}T00:00:00Z`);
+ return Number.isFinite(t) && new Date(t).toISOString().slice(0, 10) === d;
}
/** Parse the "legs" param (from>to@date, ~-joined) into multi-city legs. */
diff --git a/src/styles.css b/src/styles.css
index 926bb3f..0e3c4ec 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1507,6 +1507,7 @@ a {
padding: 1.6rem;
text-align: center;
color: var(--muted);
+ overflow-wrap: anywhere; /* a long unbroken name from a link wraps instead of clipping */
}
.empty-hint {
margin: 0.55rem 0 0;
diff --git a/tests/crash-guards.test.ts b/tests/crash-guards.test.ts
new file mode 100644
index 0000000..0c89181
--- /dev/null
+++ b/tests/crash-guards.test.ts
@@ -0,0 +1,190 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+// Leaflet needs a real browser canvas; stub the map module so the rest of the
+// UI can be exercised under jsdom.
+vi.mock("../src/ui/map", () => ({
+ RouteMap: class {
+ onSelect: ((id: string) => void) | null = null;
+ show(): void {}
+ route(): void {}
+ radius(): void {}
+ base(): void {}
+ highlight(): void {}
+ invalidate(): void {}
+ focus(): void {}
+ setInfo(): void {}
+ },
+}));
+
+import type { RawRecord, Station, DataMeta } from "../src/types";
+import { normalizeRecords } from "../src/data/dataset";
+import { StationRegistry } from "../src/data/stations";
+import { initApp } from "../src/app";
+import { queryFromParams, loadFavorites, loadWatched, loadTrips } from "../src/state/store";
+import { en } from "../src/i18n/en";
+import sample from "../data/tgvmax.sample.json";
+import stations from "../data/stations.json";
+
+const meta: DataMeta = { updatedAt: "", source: "sample", recordCount: 0, isSample: true };
+const P = encodeURIComponent("PARIS (intramuros)");
+const L = encodeURIComponent("LYON (intramuros)");
+
+function setup(search: string, rows = sample as RawRecord[]): HTMLElement {
+ document.body.innerHTML = '';
+ const root = document.getElementById("app") as HTMLElement;
+ history.replaceState(null, "", `/${search}`);
+ initApp(root, { trains: normalizeRecords(rows), meta }, new StationRegistry(stations as Station[]));
+ return root;
+}
+
+const journey = {
+ date: "2026-06-25",
+ origin: "PARIS (intramuros)",
+ destination: "LYON (intramuros)",
+ legs: [{ date: "2026-06-25", origin: "PARIS (intramuros)", destination: "LYON (intramuros)", trainNo: "6601" }],
+ departMin: 480,
+ arriveMin: 600,
+ totalDurationMin: 120,
+ layovers: [],
+ hubs: [],
+};
+
+beforeEach(() => {
+ localStorage.clear();
+ // Pin "today" to the sample-data epoch so its dates sit inside the bookable window.
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(new Date("2026-06-25T12:00:00Z"));
+ globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
+ cb(0);
+ return 0;
+ }) as typeof requestAnimationFrame;
+ Element.prototype.scrollIntoView = function scrollIntoView(): void {};
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("a malformed date or time in a link (H1)", () => {
+ it("drops dates that are not real YYYY-MM-DD calendar days, and unparseable times", () => {
+ for (const bad of ["2026-10-1", "2026-10-00", "2026-02-30", "garbage"]) {
+ const q = queryFromParams(new URLSearchParams({ date: bad, rdate: bad, by: bad }), "2026-06-25");
+ expect(q.date).toBe("2026-06-25");
+ expect(q.returnDate).toBeUndefined();
+ expect(q.tourEndDate).toBeUndefined();
+ }
+ const q = queryFromParams(new URLSearchParams({ after: "garbage", before: "25:99", arrbefore: "8:00" }), "2026-06-25");
+ expect([q.departAfter, q.departBefore, q.arriveBefore]).toEqual([undefined, undefined, undefined]);
+ expect(queryFromParams(new URLSearchParams({ after: "08:30" }), "2026-06-25").departAfter).toBe("08:30");
+ });
+
+ it("opens the trip on today instead of throwing, and puts today in the address bar", () => {
+ // Both sort inside the bookable window as strings, so only the date check catches them.
+ for (const bad of ["2026-06-3", "2026-07-00"]) {
+ const root = setup(`?mode=od&from=${P}&to=${L}&date=${bad}&rdate=${bad}&lang=en`);
+ expect(root.querySelector(".mode-tab.active")).not.toBeNull();
+ const url = new URLSearchParams(location.search);
+ expect(url.get("date")).toBe("2026-06-25");
+ expect(url.has("rdate")).toBe(false);
+ expect(url.get("lang")).toBe("en");
+ }
+ });
+
+ it("rewrites an out-of-window date in place, without a new history entry", () => {
+ const before = history.length;
+ setup(`?mode=od&from=${P}&to=${L}&date=2020-01-01`);
+ expect(new URLSearchParams(location.search).get("date")).toBe("2026-06-25");
+ expect(history.length).toBe(before);
+ });
+
+ it("ignores an unparseable time filter rather than hiding every train", () => {
+ const root = setup(`?mode=od&from=${P}&to=${L}&date=2026-06-25&after=garbage&lang=en`);
+ expect(root.textContent).not.toContain(en.res_none);
+ expect(new URLSearchParams(location.search).has("after")).toBe(false);
+ });
+});
+
+describe("a malformed entry in a stored list (H2)", () => {
+ it("drops the bad entries and keeps the good ones", () => {
+ const fav = { origin: "PARIS (intramuros)", destination: "LYON (intramuros)" };
+ localStorage.setItem("mj.favorites", JSON.stringify([null, {}, 1, fav]));
+ localStorage.setItem("mj.watched", JSON.stringify([null, fav]));
+ const trip = { id: "x", kind: "one-way", outbound: journey, savedAt: 1 };
+ localStorage.setItem(
+ "mj.trips",
+ JSON.stringify([
+ null,
+ {},
+ { ...trip, outbound: { ...journey, legs: undefined } },
+ { ...trip, outbound: { ...journey, date: "2026-10-1" } },
+ { ...trip, kind: "tour", tour: {} },
+ { ...trip, inbound: { ...journey, hubs: undefined } },
+ trip,
+ ]),
+ );
+ expect(loadFavorites()).toEqual([fav]);
+ expect(loadWatched()).toEqual([fav]);
+ expect(loadTrips()).toEqual([trip]);
+ });
+
+ it("still opens the app", () => {
+ localStorage.setItem("mj.favorites", "[null]");
+ localStorage.setItem("mj.watched", "[{}]");
+ localStorage.setItem("mj.trips", '[{"id":"t","kind":"tour","outbound":{},"tour":{}}]');
+ const root = setup(`?mode=from&from=${P}&date=2026-06-25`);
+ expect(root.querySelectorAll(".group-card").length).toBeGreaterThan(0);
+ });
+});
+
+describe("station names in a link (L3)", () => {
+ it("resolves case- and accent-insensitive names to the id the data uses", () => {
+ const registry = new StationRegistry(stations as Station[]);
+ registry.addMissing(["PARIS (intramuros)", "LILLE (intramuros)", "MARSEILLE ST CHARLES"]);
+ expect(registry.resolve("paris")).toBe("PARIS (intramuros)");
+ expect(registry.resolve("LILLE")).toBe("LILLE (intramuros)");
+ expect(registry.resolve("marseille saint-charles")).toBe("MARSEILLE ST CHARLES");
+ expect(registry.resolve("FOOBAR")).toBeUndefined();
+ });
+
+ it("shows the trains of a lower-case link and writes the ids back", () => {
+ const root = setup("?mode=od&from=paris&to=lyon&date=2026-06-25&lang=en");
+ expect(root.textContent).not.toContain(en.res_none);
+ const url = new URLSearchParams(location.search);
+ expect([url.get("from"), url.get("to")]).toEqual(["PARIS (intramuros)", "LYON (intramuros)"]);
+ });
+
+ it("picks the station with trains when the registry also has a bare id", () => {
+ const rows = (sample as RawRecord[]).map((r) =>
+ r.destination === "LILLE" ? { ...r, destination: "LILLE (intramuros)" } : r,
+ );
+ const root = setup(`?mode=od&from=${P}&to=LILLE&date=2026-06-25&lang=en`, rows);
+ expect(root.textContent).not.toContain(en.res_none);
+ expect(new URLSearchParams(location.search).get("to")).toBe("LILLE (intramuros)");
+ });
+
+ it("says a name matching no station is unknown", () => {
+ const root = setup(`?mode=od&from=${P}&to=FOOBAR&date=2026-06-25&lang=en`);
+ expect(root.textContent).toContain(en.err_station.replace("{station}", "FOOBAR"));
+ expect(root.textContent).not.toContain(en.res_none);
+ });
+});
+
+describe("the boot error card", () => {
+ it("does not blame the data when the page itself fails to open", async () => {
+ vi.resetModules();
+ vi.doMock("../src/data/dataset", () => ({ loadDataset: () => Promise.resolve({ trains: [], meta }) }));
+ vi.doMock("../src/app", () => ({
+ initApp: () => {
+ throw new Error("boom");
+ },
+ }));
+ vi.doMock("../src/pwa/register", () => ({ registerServiceWorker: () => {} }));
+ vi.doMock("../src/native/capacitor", () => ({ initNative: () => Promise.resolve() }));
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ document.body.innerHTML = '';
+ Object.defineProperty(navigator, "language", { value: "en-US", configurable: true });
+ await import("../src/main");
+ await vi.waitFor(() => expect(document.querySelector(".error-title")).not.toBeNull());
+ expect(document.querySelector(".error-title")?.textContent).toBe(en.err_app);
+ });
+});