diff --git a/docs/user-flows.md b/docs/user-flows.md
index 62578e9..df8e0cb 100644
--- a/docs/user-flows.md
+++ b/docs/user-flows.md
@@ -34,7 +34,9 @@ round trip out of fixed-nights mode so you pick the **exact departure and return
Trip-tab calendar** (Ulysse-style). While Flexible is active the fixed-nights stepper stays
**in place but inert (dimmed, buttons disabled)** rather than being removed — so toggling
Flexible never moves the "Durée sur place" label or reflows the row (no layout jump); only
-the pill lights up. The form's stay becomes `flexible`. Tapping the stepper or a segment leaves
+the pill lights up. The inert stepper reads the nights of the range on screen, and the
+same-day "Temps minimum sur place" field likewise stays in place, inert. Switching to
+Flexible keeps the current stay as the range. The form's stay becomes `flexible`. Tapping the stepper or a segment leaves
Flexible again. The stepper (and the pill) are hidden for one-way. `r` toggles one-way ↔
round trip (keeping the nights count, never Flexible); `1/2/3` switch tabs. Toggling,
stepping, or picking Flexible re-runs in place (no second Search tap when origin +
@@ -47,8 +49,10 @@ return date on `query.returnDate` (URL `stay=flex` + `rdate`).
The **Trip tab's date picker is a live availability calendar on the form itself**
(`repaintFormCalendar` in `src/app.ts`, painted into `refs.formCalendar` via
`render.calendarEl`). It sits under the date + trip-shape row behind a one-tap
-**"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.
+**"When to leave?"** header (which also shows the picked departure, on its own line so the
+header keeps one height in every trip shape) and is **collapsed by default** so the form
+stays short on a phone — one tap opens the month to change the day. A day picked under the
+results (either leg's calendar) moves it too.
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
anything it is derived from changes** — origin, destination, the Aller simple / Aller-retour
@@ -73,8 +77,8 @@ the post-reload "press Search" prompt.
|-------|------------------|-------------|
| no origin yet | — (neutral month) | any day, tappable — with a "pick a departure station" hint |
| origin + dest, one-way | `availabilityCalendar` | a departure exists that day (count = trains) |
-| origin + dest, same day (0 nights) | `stayCalendar` (hours) | a same-day there-and-back works (count = hours on site) |
-| origin + dest, N nights | `stayCalendar` (nights) | an N-night round trip is feasible (count = nights) |
+| origin + dest, same day (0 nights) | `stayCalendar` (hours) | a same-day there-and-back works (count = whole hours on site, rounded down) |
+| origin + dest, N nights | `stayCalendar` (nights) | a return exactly N nights later exists (count = N) |
| origin only, one-way | `reachableCountCalendar` | you can leave that day (count = destinations) |
| origin only, round / same day | `getawayIdeas().perDay` | a getaway is possible that day (count = destinations) |
@@ -89,13 +93,18 @@ stays as the exact-date / ±flex keyboard entry for power users.
departure** and arms the calendar for the return (`formRangeAwait`); the **next tap on/after
it sets the return** — `query.returnDate` with `stay: "flexible"` — and (route complete) runs
the flexible round trip in place, while an earlier tap just restarts. A **third tap begins a
-fresh range**. The days between the two picked endpoints (both `.sel`) get a `.range` band,
-and while the return is being chosen hovering previews the pending span (`.preview`).
-Availability is shown exactly as the single-date calendar (round-trip feasibility). The
+fresh range**. The first tap stays on the form: the header reads the departure as pending
+("choose the return") while the URL and the results keep the range already on screen, and
+the return tap runs the new range. A link with no `rdate` shows the departure + 2 its
+results propose, in the header and the range too. The days between the two endpoints (both
+`.sel`) get a `.range` band, and while the return is being chosen hovering previews the
+pending span (`.preview`). The days count outbound trains, and while the return is awaited
+they count return trains, the days before the departure greyed (a tap there restarts). The
collapsed header spells out the two endpoints ("Aller: … → Retour: …", or a "choose the
-return" prompt); `syncFormFromQuery` restores the highlighted range from `stay=flex` +
-`rdate`. Fixed-nights and one-way modes keep the single-date departure picker; only Flexible
-turns on range selection. The results-page return calendar still handles the return too —
+return" prompt while the return is awaited and in discovery, which proposes no return);
+`syncFormFromQuery` restores the highlighted range from `stay=flex` + `rdate`. Fixed-nights
+and one-way modes keep the single-date departure picker; only Flexible turns on range
+selection. The results-page return calendar still handles the return too —
this only adds the pick on the **first page**.
**Max correspondances** (0 / 1 / 2 / 3 / no limit) is a **main-form field**, not buried in
@@ -125,6 +134,8 @@ span applied to the lists only, and a day could read green while its list was em
- **Leg 2 Return** opens (gently revealed only if below the fold — a calendar tap never
scrolls the drawer up) — a return calendar whose **first cell is the same day** (hours
on site), later cells are nights at the destination, pre-selected to the stay's return.
+ A stay's list is fastest first, and a stay from the last bookable day keeps its return
+ past the window, where the return leg says there is none.
For a **fixed** N-night stay the return is derived with no second question, so its
calendar is **collapsed by default** behind a "Return: · Change" toggle (same
`.cal-collapsible` / `.cal-toggle` / `.cal-panel` pattern as the outbound one). In
@@ -154,6 +165,9 @@ span applied to the lists only, and a day could read green while its list was em
destination → opens the round trip. An empty day offers the next day with a round trip
and a one-tap switch to one-way.
- Ranking: `sortGetaways` puts most hours-on-site first for same-day trips.
+ - A card's travel time is the trip it opens: a stay counts that trip's default return,
+ the fastest on the return day (`asOpened`); a same-day card counts the latest return
+ home by midnight, as the trip does.
- A minimum-on-site gate exists in core (`minOnSiteMin`, default 4h); NOT yet exposed as
an Advanced control. (Open item.)
5. **Only To** → reverse browse (`runBrowse` "to"): where you can come *from* to reach the
diff --git a/src/app.ts b/src/app.ts
index a24a15c..cfab858 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -10,7 +10,14 @@ import {
} from "./core/destinations";
import { filterTrains, isNightTrain, type FilterOptions } from "./core/search";
import { bestTripsAcrossWindow, stationsOnDate, reachableBest, type BestTrip, type ReachTrip } from "./core/best";
-import { bestGetawayTo, getawayIdeas, reverseGetawayIdeas, stayCalendar } from "./core/getaways";
+import {
+ bestGetawayTo,
+ getawayIdeas,
+ reverseGetawayIdeas,
+ sortGetaways,
+ stayCalendar,
+ type Getaway,
+} from "./core/getaways";
import { planTours, planTourInOrder, planTourGreedy, arrivalDate, type Tour } from "./core/tour";
import { findJourneys, bestJourney, reachableJourneys, journeyArriveAbs, toJourney, MAX_RESULTS } from "./core/connections";
import type { ConnectionOptions } from "./core/connections";
@@ -121,13 +128,12 @@ function returnForStay(stay: StayChoice, depart: string): string {
return returnAfterNights(depart, n);
}
-/** Departure + N nights, clamped to the last bookable day. The nights stepper's return
- * date for a round trip — used for any N (a fixed N-night stay is `` `n${N}` ``, fully
- * decoupled from Flexible, so the explicit return day is departure + N for every N). */
+/** Departure + N nights: the nights stepper's return date for a round trip — used for any N
+ * (a fixed N-night stay is `` `n${N}` ``, fully decoupled from Flexible, so the explicit
+ * return day is departure + N for every N). Past the bookable window it has no return train,
+ * which the return leg says, rather than a shorter stay than the stepper reads. */
function returnAfterNights(depart: string, nights: number): string {
- const last = addDays(today, BOOKING_WINDOW_DAYS - 1);
- const d = addDays(depart, Math.max(0, nights));
- return d > last ? last : d;
+ return addDays(depart, Math.max(0, nights));
}
@@ -1044,19 +1050,18 @@ function readQueryFromForm(): SearchQuery {
? rawStayMin
: undefined;
const outDate = refs.date.value || query.date;
- // Flexible on an exact route keeps the return the user picked on the calendar (still in
- // window and on/after the outbound), else leaves it unset so the results page proposes one.
+ // Flexible keeps the return the user picked on the calendar (still in window and on/after
+ // the outbound).
const flexReturn = query.returnDate && query.returnDate >= outDate ? query.returnDate : undefined;
+ // A stay derives an explicit return for an exact route only (discovery derives its own from
+ // the sweep): departure + the stepper's nights, which in Flexible is the range's span.
+ const stayReturn = mode === "od" && formNights !== null ? returnAfterNights(outDate, formNights) : undefined;
// Flexible carries the return the user picked on the calendar in EVERY stay-taking mode —
// not just an exact route. Origin-only ("from") and destination-only ("to") discovery use
// départ→retour as the getaway window (see getawayOptsFor), so dropping it here was what
- // left the arrival un-pickable when only one endpoint was filled. A FIXED stay still only
- // derives an explicit return for an exact route (discovery derives its own from the sweep).
- const returnDate = formFlexible
- ? flexReturn
- : mode === "od" && formNights !== null
- ? returnAfterNights(outDate, formNights)
- : undefined;
+ // left the arrival un-pickable when only one endpoint was filled. With no pick yet, an exact
+ // route keeps the current span, so switching to Flexible or moving its departure keeps the stay.
+ const returnDate = formFlexible ? (flexReturn ?? stayReturn) : stayReturn;
return {
mode,
origin: legsMode ? undefined : resolveStation(refs.origin.value),
@@ -1298,9 +1303,12 @@ function refreshInPlace(reveal = false): void {
// whole form from `query` here: that clobbered a staged, not-yet-searched edit — a
// ticked "Night trains", a tour city chip — because those live in the form (and
// `tourCities`) but aren't folded into `query` until Search. Re-syncing silently
- // reset them, which is the "my filter / cities disappeared" bug.
+ // reset them, which is the "my filter / cities disappeared" bug. A day picked under the
+ // results moves the form calendar's day too.
+ const dateMoved = refs.date.value !== query.date;
refs.date.value = query.date;
refs.departDate.setDate(query.date);
+ if (dateMoved) deferFormCalRepaint();
formApi.refreshTourEndDate();
clear(refs.results);
renderSearch();
@@ -1404,20 +1412,25 @@ function repaintFormCalendar(): void {
const nights = formApi.getStayNights(); // null = one-way, else 0..N
const round = nights !== null;
// Flexible → the inline month becomes a departure→return RANGE picker (requirement 2):
- // `selected` is the departure, `query.returnDate` (in window, on/after it) the return.
- // No return yet ⇒ awaiting the second tap. Other shapes keep the single-date picker.
+ // `selected` is the departure, the return is `query.returnDate` (in window, on/after it),
+ // else — a link with none — on an exact route the departure + 2 its results propose. While
+ // the return tap is awaited there is none yet. Other shapes keep the single-date picker.
const flexRange = formApi.isFlexible();
const selected = refs.date.value || query.date;
const windowDates = dateRange(today, BOOKING_WINDOW_DAYS);
- const rangeEnd = flexRange && query.returnDate && query.returnDate >= selected ? query.returnDate : undefined;
+ const pickedReturn = query.returnDate && query.returnDate >= selected ? query.returnDate : undefined;
+ const rangeEnd =
+ flexRange && !formRangeAwait ? (pickedReturn ?? (o && d ? proposedReturn(selected) : undefined)) : undefined;
const rangeOpt = flexRange ? { end: rangeEnd, awaiting: formRangeAwait } : undefined;
// A query snapshot read purely to derive the SAME options the eventual search will use.
const fq = readQueryFromForm();
// Keep the collapsed-header summary in step with the picked day — in Flexible it spells
- // out the two endpoints ("Aller: … → Retour: …") or prompts for the return.
+ // out the two endpoints ("Aller: … → Retour: …") or prompts for the return, and the
+ // inert stepper reads the range's nights.
if (flexRange) {
const dep = selected ? formatDate(selected) : "";
+ if (rangeEnd) formApi.setFlexible(dayIndex(rangeEnd) - dayIndex(selected));
refs.formCalPicked.textContent = rangeEnd
? t("form_cal_range", { from: dep, to: formatDate(rangeEnd) })
: t("form_cal_range_await", { from: dep });
@@ -1452,7 +1465,7 @@ function repaintFormCalendar(): void {
calOpts = { title: t("form_cal_title"), hideTitle: true };
} else if (nights === 0 && !flexRange) {
cal = stayCalendar(trains, o, d, windowDates, getawayOptsFor(fq), "hours");
- calOpts = { title: t("form_cal_title"), hideTitle: true, count: (h: number) => t("daytrip_cal_hours", { h }), countLegend: t("cal_legend_hours") };
+ calOpts = { title: t("form_cal_title"), hideTitle: true, count: (h: number) => t("daytrip_cal_hours", { dur: formatDuration(h * 60) }), countLegend: t("cal_legend_hours") };
} else if (flexRange) {
// Flexible: show the availability for the LEG being picked — the OUTBOUND (o→d) while
// choosing the departure, the RETURN (d→o) once the departure is set and we're awaiting
@@ -1460,8 +1473,11 @@ function repaintFormCalendar(): void {
// you're picking right now" (David: "available trains per day for departure or return
// depending on what you're choosing").
if (formRangeAwait) {
+ // A day before the departure is no return: a tap there restarts the range.
const ret = odJourneyOptsFor(fq, d, o);
- cal = availabilityCalendar(trains, d, o, windowDates, ret.journeyOpts, ret.accept);
+ cal = availabilityCalendar(trains, d, o, windowDates, ret.journeyOpts, ret.accept).map((day) =>
+ day.date < selected ? { ...day, available: false, count: 0 } : day,
+ );
calOpts = { title: t("form_cal_title"), hideTitle: true, countLegend: t("cal_legend_return") };
} else {
cal = availabilityCalendar(trains, o, d, windowDates, journeyOpts, accept);
@@ -1512,6 +1528,11 @@ function repaintFormCalendar(): void {
function pickFormDay(date: string): void {
refs.date.value = date;
refs.departDate.setDate(date);
+ commitFormPick();
+}
+
+/** Run the form's picked day(s): refresh the results for the same route, else search. */
+function commitFormPick(): void {
const fq = readQueryFromForm();
const sameRoute =
query.origin === fq.origin && query.destination === fq.destination && (query.mode === "od" || tripIsRound());
@@ -1530,41 +1551,24 @@ function pickFormDay(date: string): void {
/**
* A day tapped on the Flexible Trip-tab calendar, which is a departure→return RANGE picker
- * (requirement 2). The phase is derived from `query.returnDate`: with no return yet the
- * calendar is AWAITING the second tap, so a tap on/after the departure sets the return
- * (query.returnDate) with stay "flexible" and — if the route is complete — runs the
- * flexible round trip; a tap before the departure just restarts. Any tap once the range is
- * complete restarts from a new departure. The two taps stay on the form, so the range is
- * built inline before any navigation, and a third tap begins a fresh range.
+ * (requirement 2). The first tap stages the departure on the form and arms the calendar for
+ * the return; the next tap on/after it sets the return (query.returnDate) with stay
+ * "flexible" and runs the range like a single-day pick, while an earlier tap restarts. A tap
+ * once the range is complete begins a fresh range.
*/
function pickFormRange(date: string): void {
const out = refs.date.value || query.date;
if (formRangeAwait && date >= out) {
- // Second tap ≥ departure → the return: complete the range and run the flexible trip.
formRangeAwait = false;
query = { ...query, returnDate: date };
- const fq = readQueryFromForm(); // reads query.returnDate → carries it as the flexible return
- const sameRoute =
- query.origin === fq.origin && query.destination === fq.destination && (query.mode === "od" || tripIsRound());
- query = fq;
- // Run as soon as the completed range yields something searchable — an exact route OR a
- // one-ended discovery (origin-only "from", destination-only "to"), so picking départ →
- // retour with only Paris filled shows the getaways for that window instead of just staging.
- if (queryIsRenderable(query)) {
- if (sameRoute) refreshInPlace();
- else applyAndRun();
- }
- // else: nothing searchable yet (no endpoint) — the range stays staged on the form.
- repaintFormCalendar();
+ commitFormPick();
return;
}
- // First tap (or a restart, or a tap before an armed departure) → the departure: stage it,
- // drop any prior return, and arm for the return tap. Stays on the form so the range is
- // completed inline.
+ // The departure stays on the form, its header reading it as pending until the return
+ // tap; `query`, the URL and the results keep the range already on screen meanwhile.
formRangeAwait = true;
refs.date.value = date;
refs.departDate.setDate(date);
- query = { ...query, date, returnDate: undefined };
repaintFormCalendar();
}
@@ -1917,26 +1921,14 @@ function runBrowse(c: RenderCtx, dir: "from" | "to"): void {
// bookable window.
if (i === 0 || (d >= today && d <= lastBookable)) windowDates.push(d);
}
- const dayCount = new Map();
- for (const d of windowDates) {
- const g =
- dir === "from"
- ? reachableDestinations(trains, anchor, d, filterOpts())
- : reachableOrigins(trains, anchor, d, filterOpts());
- for (const x of g) dayCount.set(x.station, (dayCount.get(x.station) ?? 0) + x.count);
- }
-
- // Take the whole-window record for those reachable stations, so each card can show
- // both the day/window count and the month total (richer card data: fastest time etc.).
- const groups = reachableGroups(trains, anchor, dir, filterOpts()).filter(
- (g) => (dayCount.get(g.station) ?? 0) > 0,
- );
+ // Each card's train count and fastest time cover that span, as its list does.
+ const groups = reachableGroups(trains, anchor, dir, windowDates, filterOpts());
const directStations = new Set(groups.map((g) => g.station));
- // Total MAX availability over the whole booking window, per destination, so each
+ // Total MAX availability over the booking window (today on), per destination, so each
// card shows how many tickets exist before drilling into the exact-trip calendar.
// Rank the list by that total (most-served first) — the "statistic" view.
- const stats = windowStats(trains, anchor, dir, filterOpts());
+ const stats = windowStats(trains, anchor, dir, dateRange(today, BOOKING_WINDOW_DAYS), filterOpts());
groups.sort(
(a, b) =>
(stats.get(b.station)?.trains ?? 0) - (stats.get(a.station)?.trains ?? 0) ||
@@ -2005,7 +1997,7 @@ function runBrowse(c: RenderCtx, dir: "from" | "to"): void {
),
);
for (const g of sortedGroups)
- rows.push(() => render.groupCardEl(g, dir, anchor, c, dayCount.get(g.station) ?? 0, stats.get(g.station), flex));
+ rows.push(() => render.groupCardEl(g, dir, anchor, c, g.count, stats.get(g.station), flex));
for (const tr of connecting) rows.push(() => render.reachTripRowEl(tr.station, tr.journey, c));
}
@@ -2067,8 +2059,7 @@ function runGetaways(c: RenderCtx, origin: string): void {
// DAY-SCOPED: list the round trips you can start on the chosen day, so the count matches
// the "When to leave?" calendar's number for that day (pick another day → that day's list).
// A window-wide union would say "65 possible" while the calendar cell says "8 that day".
- const { trips } = getawayIdeas(trains, origin, [query.date], getawayOpts());
- const shown = trips;
+ const shown = asOpened(getawayIdeas(trains, origin, [query.date], getawayOpts()).trips);
if (shown.length === 0) {
refs.results.append(render.emptyEl(t("getaway_none")));
// Never a dead end: the next day with a round trip, and the one-way list.
@@ -2085,7 +2076,7 @@ function runGetaways(c: RenderCtx, origin: string): void {
// separate mode.
const metric =
trip.nights === 0
- ? t("daytrip_cal_hours", { h: Math.round((trip.onSiteMin ?? 0) / 60) })
+ ? t("daytrip_cal_hours", { dur: formatDuration(trip.onSiteMin ?? 0) })
: t("getaway_nights", { n: trip.nights });
return render.getawayCityRowEl(trip, c, { metric });
});
@@ -2096,6 +2087,29 @@ function runGetaways(c: RenderCtx, origin: string): void {
);
}
+/** A stay's returns on a later day, fastest first: the trip page's return list, whose first
+ * is the return it picks by default. */
+function stayReturns(q: SearchQuery, origin: string, destination: string, retDate: string): Journey[] {
+ const { journeyOpts, accept } = odJourneyOptsFor(q, origin, destination);
+ return findJourneys(deps.trains, destination, origin, retDate, journeyOpts)
+ .filter(accept)
+ .sort((a, b) => a.totalDurationMin - b.totalDurationMin || a.departMin - b.departMin);
+}
+
+/** Discovery cards as the trips they open: a stay's travel time counts the return that trip
+ * picks by default (`stayReturns`), not the sweep's latest one home, so card and trip agree.
+ * A same-day card already counts the trip's default, the latest return home by midnight. */
+function asOpened(trips: Getaway[]): Getaway[] {
+ const opened: SearchQuery = { ...query, mode: "od", via: undefined };
+ return trips
+ .map((trip) => {
+ if (trip.nights === 0) return trip;
+ const back = stayReturns(opened, trip.outbound.origin, trip.outbound.destination, trip.back.date)[0];
+ return back ? { ...trip, back, travelMin: trip.outbound.totalDurationMin + back.totalDurationMin } : trip;
+ })
+ .sort(sortGetaways);
+}
+
/**
* Reverse round-trip discovery: a round trip with only a DESTINATION filled. Lists the
* origins you can round-trip FROM to reach `destination` (and come back), each a real
@@ -2107,9 +2121,8 @@ function runReverseGetaways(c: RenderCtx, destination: string): void {
refs.title.textContent = t("rt_reverse_title", { station: registry.label(destination) });
// DAY-SCOPED (mirrors runGetaways): list the origins you can round-trip from on the chosen
// day, so the count matches the "When to leave?" calendar's number for that day.
- const { trips } = reverseGetawayIdeas(trains, destination, [query.date], getawayOpts());
// `trip.destination` here names the discovered ORIGIN (reverseGetawayIdeas relabels it).
- const shown = trips;
+ const shown = asOpened(reverseGetawayIdeas(trains, destination, [query.date], getawayOpts()).trips);
if (shown.length === 0) {
refs.results.append(render.emptyEl(t("getaway_none")));
showMap(destination, []);
@@ -2119,7 +2132,7 @@ function runReverseGetaways(c: RenderCtx, destination: string): void {
appendInChunks(refs.results, shown, (trip) => {
const metric =
trip.nights === 0
- ? t("daytrip_cal_hours", { h: Math.round((trip.onSiteMin ?? 0) / 60) })
+ ? t("daytrip_cal_hours", { dur: formatDuration(trip.onSiteMin ?? 0) })
: t("getaway_nights", { n: trip.nights });
// openTo = the fixed destination: the card names the origin and opens origin → dest.
return render.getawayCityRowEl(trip, c, { metric, openTo: destination });
@@ -2385,7 +2398,7 @@ function runBestSearch(c: RenderCtx): void {
// Month-long train count per destination (same figure as the "Where to?" list),
// so an idea shows how well-served it is before you drill in.
- const stats = windowStats(trains, query.origin, "from", filterOpts());
+ const stats = windowStats(trains, query.origin, "from", window, filterOpts());
// Sort by trains / days reachable / distance / name; "rec" keeps fastest-first.
const origin = query.origin;
const sorted = applySort(trips, {
@@ -2839,7 +2852,9 @@ function runTripSearch(c: RenderCtx): void {
// cell would green a return leaving BEFORE the outbound arrives. Re-derive that first
// cell from the day-trip feasibility (nights 0: home by midnight, after arrival) so no
// impossible same-day pairing leaks, and carry its hours-on-site as the count.
- const sameDay = stayCalendar(trains, origin, destination, [query.date], connOpts, "hours")[0];
+ // The stay's own options (its minimum time on site included) with the route's hub budget.
+ const stayOpts = { ...getawayOptsFor(query), ...connOpts };
+ const sameDay = stayCalendar(trains, origin, destination, [query.date], stayOpts, "hours")[0];
if (retCal[0]) {
retCal[0].available = Boolean(sameDay?.available);
retCal[0].count = sameDay?.count ?? 0; // hours on site (0 = badge hidden)
@@ -2866,8 +2881,8 @@ function runTripSearch(c: RenderCtx): void {
const retCtx: RenderCtx = { ...c, onSelectDay: (d) => selectReturn(d) };
// The return options for a chosen day: same-day (nights ≤ 0) keeps only trains leaving
// AFTER the outbound arrives and home by midnight, latest first (most time on site);
- // a later day keeps every return, fastest first. Shared by the list render and the
- // header-advance (which picks the pre-highlighted first option).
+ // a later day keeps every return, fastest first (`stayReturns`). Shared by the list render
+ // and the header-advance (which picks the pre-highlighted first option).
const returnJourneys = (retDate: string): { list: Journey[]; sameDay: boolean; arrAbs: number } => {
const nights = dayIndex(retDate) - dayIndex(query.date);
if (nights <= 0) {
@@ -2881,10 +2896,7 @@ function runTripSearch(c: RenderCtx): void {
.sort((a, b) => b.departMin - a.departMin);
return { list, sameDay: true, arrAbs };
}
- const list = findJourneys(trains, destination, origin, retDate, journeyOpts)
- .filter(accept)
- .sort((a, b) => a.totalDurationMin - b.totalDurationMin || a.departMin - b.departMin);
- return { list, sameDay: false, arrAbs: 0 };
+ return { list: stayReturns(query, origin, destination, retDate), sameDay: false, arrAbs: 0 };
};
const renderReturns = (retDate: string): void => {
clear(retList);
@@ -2904,7 +2916,7 @@ function runTripSearch(c: RenderCtx): void {
onPick: pickReturn,
onArrow: pickReturn,
selected: idx === 0,
- dateLabel: retSameDay ? t("daytrip_cal_hours", { h: Math.round(Math.max(0, j.departMin - arrAbs) / 60) }) : undefined,
+ dateLabel: retSameDay ? t("daytrip_cal_hours", { dur: formatDuration(Math.max(0, j.departMin - arrAbs)) }) : undefined,
}),
),
);
@@ -2922,8 +2934,8 @@ function runTripSearch(c: RenderCtx): void {
render.calendarEl(retCal, retCtx, retDate, {
title: t("rt_inbound"),
// First cell is same-day (hours on site); every later cell is nights away.
- count: (n, day) => (day.date === query.date ? t("daytrip_cal_hours", { h: n }) : t("getaway_nights", { n })),
- countLegend: t("cal_legend_nights"),
+ count: (n, day) => (day.date === query.date ? t("daytrip_cal_hours", { dur: formatDuration(n * 60) }) : t("getaway_nights", { n })),
+ countLegend: t("cal_legend_return_stay"),
}),
);
renderReturns(retDate);
@@ -2964,6 +2976,7 @@ function runTripSearch(c: RenderCtx): void {
}
store.updateUrl(query, formSnapshot(currentDetail()));
paintReturn(retDate);
+ deferFormCalRepaint(); // the form's range, header and stay grading follow the new return
// The return list updates IN PLACE right where the calendar is — no scroll jump (a
// calendar tap must never jerk the drawer up). The paint already re-focuses the cell.
};
@@ -2984,8 +2997,7 @@ function runTripSearch(c: RenderCtx): void {
// drops stranding outbounds below.
const sameDayMinOnSite =
query.stayMinHours && query.stayMinHours > 0 ? query.stayMinHours * 60 : SAME_DAY_MIN_ON_SITE_MIN;
- const outCalOpts = { ...getawayOptsFor(query), ...connOpts };
- const outCal = stayCalendar(trains, origin, destination, windowDates, outCalOpts, isSameDayTrip ? "hours" : "nights");
+ const outCal = stayCalendar(trains, origin, destination, windowDates, stayOpts, isSameDayTrip ? "hours" : "nights");
gradeNearby(outCal, origin, destination, windowDates);
// Linked calendars: picking a different outbound day re-anchors the trip and UPDATES the
// return calendar to start from that day. A FIXED stay keeps its length — the return
@@ -3018,7 +3030,7 @@ function runTripSearch(c: RenderCtx): void {
const outCalCtx: RenderCtx = { ...c, onSelectDay: onOutboundDay };
const outCalEl = render.calendarEl(outCal, outCalCtx, query.date, {
title: t("getaway_cal_title"),
- count: (n) => (isSameDayTrip ? t("daytrip_cal_hours", { h: n }) : t("getaway_nights", { n })),
+ count: (n) => (isSameDayTrip ? t("daytrip_cal_hours", { dur: formatDuration(n * 60) }) : t("getaway_nights", { n })),
countLegend: isSameDayTrip ? t("cal_legend_hours") : t("cal_legend_nights"),
});
// The departure is already chosen (the form set it), so the possible-days calendar is
@@ -3115,13 +3127,13 @@ function runTripSearch(c: RenderCtx): void {
const arr = journeyArriveAbs(j);
let latest = -1;
for (const r of sameDayReturns) if (r.departMin >= arr && r.departMin > latest) latest = r.departMin;
- return latest < 0 ? undefined : t("daytrip_cal_hours", { h: Math.round((latest - arr) / 60) });
+ return latest < 0 ? undefined : t("daytrip_cal_hours", { dur: formatDuration(latest - arr) });
};
if (outJourneys.length === 0) {
body0.append(render.emptyEl(t("res_none")), render.hintEl(t("res_none_hint")));
// No same-day round trip that day: offer the shortest stay that has one.
const n = isSameDayTrip
- ? [1, 2, 3].find((k) => bestGetawayTo(trains, origin, destination, query.date, { ...outCalOpts, nights: k }))
+ ? [1, 2, 3].find((k) => bestGetawayTo(trains, origin, destination, query.date, { ...stayOpts, nights: k }))
: undefined;
if (n) body0.append(actionEl(t("try_nights", { n }), () => applyTripShape(stayFromNights(n))));
} else {
diff --git a/src/core/connections.ts b/src/core/connections.ts
index 903b5a0..16213a0 100644
--- a/src/core/connections.ts
+++ b/src/core/connections.ts
@@ -21,8 +21,8 @@ export interface ConnectionOptions {
/** Only keep journeys that include at least one night train (sleep aboard). */
onlyNight?: boolean;
/**
- * reachableJourneys only: keep the EARLIEST-ARRIVING journey per destination
- * (maximises time on site) instead of the shortest-duration one. Round-trip
+ * reachableJourneys / reachableInto only: keep the EARLIEST-ARRIVING journey per
+ * station (maximises time on site) instead of the shortest-duration one. Round-trip
* "ideas" need this so their outbound matches bestGetawayTo's earliest-arrival
* choice; one-way "best" leaves it off and keeps the fastest journey.
*/
@@ -132,17 +132,16 @@ export function journeyArriveAbs(j: Journey): number {
return j.departMin + j.totalDurationMin;
}
+// One journey per chain of trains. A station group (LYON (intramuros)) can list one train at
+// two of its stations, so the shortest row wins: the earliest arrival, or the latest boarding.
function dedupe(journeys: Journey[]): Journey[] {
- const seen = new Set();
- const out: Journey[] = [];
+ const byKey = new Map();
for (const j of journeys) {
const key = `${j.legs.map((l) => `${l.date}/${l.trainNo}@${l.origin}`).join(">")}`;
- if (!seen.has(key)) {
- seen.add(key);
- out.push(j);
- }
+ const cur = byKey.get(key);
+ if (!cur || j.totalDurationMin < cur.totalDurationMin) byKey.set(key, j);
}
- return out;
+ return [...byKey.values()];
}
/**
@@ -288,12 +287,15 @@ export function bestJourney(
* per candidate when you want them all (e.g. the "ideas, all days" union). Same
* connection rules as findJourneys: intermediate stops must be hubs, layovers
* within the window, no station visited twice, first leg departs on `date`.
+ * With `arriveCeil` (minutes from `date` midnight) it keeps, per destination, the
+ * LATEST-departing journey arriving by then: the forward mirror of {@link latestReturns}.
*/
export function reachableJourneys(
trains: MaxTrain[],
origin: string,
date: string,
opts: ConnectionOptions = {},
+ arriveCeil?: number,
): Map {
const maxConn = opts.maxConnections ?? 1;
const hubSet = new Set(opts.hubs ?? HUB_STATIONS);
@@ -303,7 +305,7 @@ export function reachableJourneys(
const maxC = span > 2 ? Math.max(baseMaxC, (span - 1) * 1440) : baseMaxC;
const memo = reachMemo(trains);
- const key = `${origin}@${date}|${maxConn}|${minC}-${maxC}|${span}|${opts.departAfter ?? ""}|${opts.departBefore ?? ""}|${opts.arriveBefore ?? ""}|${opts.maxDurationMin ?? ""}|${opts.minDurationMin ?? ""}|${opts.trainType ?? ""}|${opts.excludeNight ? "nonight" : ""}|${opts.onlyNight ? "onlynight" : ""}|${opts.earliestArrival ? "earlyarr" : ""}|${[...hubSet].join(",")}`;
+ const key = `${origin}@${date}|${maxConn}|${minC}-${maxC}|${span}|${opts.departAfter ?? ""}|${opts.departBefore ?? ""}|${opts.arriveBefore ?? ""}|${opts.maxDurationMin ?? ""}|${opts.minDurationMin ?? ""}|${opts.trainType ?? ""}|${opts.excludeNight ? "nonight" : ""}|${opts.onlyNight ? "onlynight" : ""}|${opts.earliestArrival ? "earlyarr" : ""}|${arriveCeil ?? ""}|${[...hubSet].join(",")}`;
const cached = memo.get(key);
if (cached) return cached;
@@ -348,19 +350,23 @@ export function reachableJourneys(
// tour's min-per-train cap) don't get candidates the per-journey search rejects.
const okDur = (maxDur == null || j.totalDurationMin <= maxDur) && (minDur == null || j.totalDurationMin >= minDur);
// Latest acceptable arrival, on the absolute cross-date timeline.
- const okArrive = arriveBy === undefined || journeyArriveAbs(j) <= arriveBy;
+ const okArrive =
+ (arriveBy === undefined || journeyArriveAbs(j) <= arriveBy) && (arriveCeil === undefined || journeyArriveAbs(j) <= arriveCeil);
if (okNight && okDur && okArrive) {
const cur = best.get(j.destination);
// Default: keep the fastest. earliestArrival: keep the one arriving soonest in
// ABSOLUTE time (ties → shorter), matching bestGetawayTo so round-trip ideas
// stay at parity. Compare journeyArriveAbs, not the leg-local arriveMin — else a
// via-hub journey whose last leg lands the next day would falsely look earliest.
+ // arriveCeil: keep the latest departure (ties → shorter), as latestReturns does.
const better =
!cur ||
- (opts.earliestArrival
- ? journeyArriveAbs(j) < journeyArriveAbs(cur) ||
- (journeyArriveAbs(j) === journeyArriveAbs(cur) && j.totalDurationMin < cur.totalDurationMin)
- : j.totalDurationMin < cur.totalDurationMin);
+ (arriveCeil !== undefined
+ ? j.departMin > cur.departMin || (j.departMin === cur.departMin && j.totalDurationMin < cur.totalDurationMin)
+ : opts.earliestArrival
+ ? journeyArriveAbs(j) < journeyArriveAbs(cur) ||
+ (journeyArriveAbs(j) === journeyArriveAbs(cur) && j.totalDurationMin < cur.totalDurationMin)
+ : j.totalDurationMin < cur.totalDurationMin);
if (better) best.set(j.destination, j);
}
if (path.length - 1 >= maxConn) return; // used all allowed changes
@@ -523,7 +529,8 @@ function intoMemo(trains: MaxTrain[]): Map> {
* multi-source sweep), so the "where can I come FROM" browse costs a single pass
* instead of a per-origin search. Same connection rules (hub changes, layover window,
* no station twice) as the forward search. Derived from {@link latestReturns} but
- * without the home-by ceiling and keeping the shortest journey, not the latest.
+ * without the home-by ceiling and keeping the shortest journey (or, with
+ * `earliestArrival`, the earliest-arriving one), not the latest.
*/
export function reachableInto(
trains: MaxTrain[],
@@ -539,7 +546,7 @@ export function reachableInto(
const maxC = span > 2 ? Math.max(baseMaxC, (span - 1) * 1440) : baseMaxC;
const memo = intoMemo(trains);
- const key = `${target}@${date}|${maxConn}|${minC}-${maxC}|${span}|${opts.departAfter ?? ""}|${opts.departBefore ?? ""}|${opts.arriveBefore ?? ""}|${opts.maxDurationMin ?? ""}|${opts.trainType ?? ""}|${opts.excludeNight ? "nonight" : ""}|${opts.onlyNight ? "onlynight" : ""}|${[...hubSet].join(",")}`;
+ const key = `${target}@${date}|${maxConn}|${minC}-${maxC}|${span}|${opts.departAfter ?? ""}|${opts.departBefore ?? ""}|${opts.arriveBefore ?? ""}|${opts.maxDurationMin ?? ""}|${opts.trainType ?? ""}|${opts.excludeNight ? "nonight" : ""}|${opts.onlyNight ? "onlynight" : ""}|${opts.earliestArrival ? "earlyarr" : ""}|${[...hubSet].join(",")}`;
const cached = memo.get(key);
if (cached) return cached;
@@ -577,8 +584,15 @@ export function reachableInto(
if (maxDur != null && j.totalDurationMin > maxDur) return;
if (arriveBy !== undefined && journeyArriveAbs(j) > arriveBy) return;
const cur = best.get(head.origin);
- // Keep the shortest journey into the target (ties → earlier arrival).
- if (!cur || j.totalDurationMin < cur.totalDurationMin) best.set(head.origin, j);
+ // Keep the shortest journey into the target, or with earliestArrival the one arriving
+ // soonest (ties → shorter), as reachableJourneys does.
+ const better =
+ !cur ||
+ (opts.earliestArrival
+ ? journeyArriveAbs(j) < journeyArriveAbs(cur) ||
+ (journeyArriveAbs(j) === journeyArriveAbs(cur) && j.totalDurationMin < cur.totalDurationMin)
+ : j.totalDurationMin < cur.totalDurationMin);
+ if (better) best.set(head.origin, j);
};
const dfs = (): void => {
diff --git a/src/core/destinations.ts b/src/core/destinations.ts
index de87fb9..657a682 100644
--- a/src/core/destinations.ts
+++ b/src/core/destinations.ts
@@ -52,23 +52,28 @@ export function reachableOrigins(
}
/**
- * Every station reachable from `anchor` over the WHOLE loaded window (any date),
- * grouped with its total count and fastest direct time. `dir` "from" groups by
- * destination (places you can go), "to" groups by origin (places you can come
- * from). This is the browse list so an idea appears whenever a MAX train runs to
- * it on *any* bookable day, not only the one currently selected.
+ * Every station reachable from `anchor` directly on any of `dates`, grouped with its
+ * train count and fastest direct time over those dates. `dir` "from" groups by
+ * destination (places you can go), "to" groups by origin (places you can come from).
*/
export function reachableGroups(
trains: MaxTrain[],
anchor: string,
dir: "from" | "to",
+ dates: string[],
opts: FilterOptions = {},
): StationGroup[] {
+ return group(onDates(trains, anchor, dir, dates, opts), (t) => (dir === "from" ? t.destination : t.origin));
+}
+
+/** The direct free-MAX trains from (or into) `anchor` on any of `dates`. */
+function onDates(trains: MaxTrain[], anchor: string, dir: "from" | "to", dates: string[], opts: FilterOptions): MaxTrain[] {
+ const days = new Set(dates);
const matches =
dir === "from"
? filterTrains(trains, { ...opts, origin: anchor })
: filterTrains(trains, { ...opts, destination: anchor });
- return group(matches, (t) => (dir === "from" ? t.destination : t.origin));
+ return matches.filter((t) => days.has(t.date));
}
/** Total direct free-MAX trains and the distinct days they run on, per station. */
@@ -78,23 +83,20 @@ export interface WindowStat {
}
/**
- * For an `anchor` station, total direct free-MAX availability over the whole
- * loaded window (all dates), keyed by the other station — destinations when
- * `dir` is "from", origins when "to". Lets the browse list show how many MAX
- * trains run to each place over the bookable horizon, not just on one date.
+ * For an `anchor` station, total direct free-MAX availability over `dates` (the
+ * bookable window), keyed by the other station — destinations when `dir` is "from",
+ * origins when "to". Lets the browse list show how many MAX trains run to each place
+ * over the bookable horizon, not just on one date.
*/
export function windowStats(
trains: MaxTrain[],
anchor: string,
dir: "from" | "to",
+ dates: string[],
opts: FilterOptions = {},
): Map {
- const matches =
- dir === "from"
- ? filterTrains(trains, { ...opts, origin: anchor })
- : filterTrains(trains, { ...opts, destination: anchor });
const acc = new Map }>();
- for (const t of matches) {
+ for (const t of onDates(trains, anchor, dir, dates, opts)) {
const key = dir === "from" ? t.destination : t.origin;
let e = acc.get(key);
if (!e) {
diff --git a/src/core/getaways.ts b/src/core/getaways.ts
index 70bae40..edf9253 100644
--- a/src/core/getaways.ts
+++ b/src/core/getaways.ts
@@ -106,12 +106,13 @@ export function bestGetawayTo(
// trip can be same-day [0]; a flexible search walks down to the shortest stay.
const nightChoices = stayChoices(maxNights, Boolean(opts.flexibleNights), sleeper);
- // Earliest-arriving outbound on the start day (more time at the destination),
- // unless one was supplied by the caller.
+ // Earliest-arriving outbound on the start day (more time at the destination; ties →
+ // the shorter ride, as the sweeps pick), unless one was supplied by the caller.
let out = outbound ?? null;
if (!out) {
for (const j of findJourneys(trains, origin, dest, date, opts)) {
- if (!out || journeyArriveAbs(j) < journeyArriveAbs(out)) out = j;
+ const arr = journeyArriveAbs(j);
+ if (!out || arr < journeyArriveAbs(out) || (arr === journeyArriveAbs(out) && j.totalDurationMin < out.totalDurationMin)) out = j;
}
}
if (!out) return null;
@@ -159,7 +160,7 @@ function stayChoices(maxNights: number, flexible: boolean, sleeper: boolean): nu
}
/** Rank: most nights, then most time on site (same-day) / least travel (stays). */
-function sortGetaways(a: Getaway, b: Getaway): number {
+export function sortGetaways(a: Getaway, b: Getaway): number {
return (
b.nights - a.nights ||
(a.nights === 0 ? (b.onSiteMin ?? 0) - (a.onSiteMin ?? 0) : a.travelMin - b.travelMin)
@@ -217,8 +218,8 @@ export function getawaysAcrossWindow(
* origin fanning out to destinations, this fixes the DESTINATION and finds every ORIGIN
* that has a feasible free-MAX round trip O → destination → O (same-day or an N-night
* stay) starting each day in `dates`. Candidate origins are the stations that can reach
- * `destination` that day ({@link reachableInto}); each is scored with the tested
- * {@link bestGetawayTo} in the O → destination direction. The result is a
+ * `destination` that day ({@link reachableInto}); each gets the round trip
+ * {@link bestGetawayTo} would give it in the O → destination direction. The result is a
* {@link GetawaySweep} whose trips are RELABELLED so `.destination` names the discovered
* ORIGIN (the station listed on each card, its outbound still O → destination), and whose
* per-day counts / `datesByDest` are keyed by that origin — so the existing getaway list
@@ -235,25 +236,27 @@ export function reverseGetawayIdeas(
// per-origin bestGetawayTo re-runs findJourneys for every candidate origin and is far
// too slow for a hub (tens of seconds → a frozen screen). reachableInto gives the
// outbound INTO the destination per origin in one sweep; reachableJourneys from the
- // destination on the return day gives the way back per origin in another.
+ // destination on the return day gives the way back per origin in another. Each keeps
+ // what getawayIdeas keeps: the earliest arrival out, the latest return home in time.
const maxNights = Math.max(0, Math.floor(opts.nights ?? 0));
const minOnSite = opts.minOnSiteMin ?? SAME_DAY_MIN_ON_SITE_MIN;
const sleeper = Boolean(opts.onlyNight);
+ const arriveCeil = sleeper ? NIGHT_RETURN_CEIL : opts.lateReturn ? LATE_RETURN_CEIL : MIDNIGHT;
const nightChoices = stayChoices(maxNights, Boolean(opts.flexibleNights), sleeper);
const byOrigin = new Map();
const datesByDest = new Map(); // keyed by the discovered origin
const perDay: CalendarDay[] = [];
for (const date of dates) {
- // Outbound INTO the destination: fastest free-MAX journey O → destination whose first
- // leg departs today, one per origin (a single backward multi-source sweep).
- const intoMap = reachableInto(trains, destination, date, opts);
+ // Outbound INTO the destination: the earliest-arriving free-MAX journey O → destination
+ // whose first leg departs today, one per origin (a single backward multi-source sweep).
+ const intoMap = reachableInto(trains, destination, date, { ...opts, earliestArrival: true });
const startable = new Set(); // origins that can round-trip to the destination today
for (const nights of nightChoices) {
// The way back leaves the destination on the return day (a sleeper leaves the
// evening after the last night — one day later), reaching each origin.
const returnDay = addDays(date, sleeper ? nights + 1 : nights);
- const returnsMap = reachableJourneys(trains, destination, returnDay, opts);
+ const returnsMap = reachableJourneys(trains, destination, returnDay, opts, arriveCeil);
if (returnsMap.size === 0) continue;
for (const [origin, outbound] of intoMap) {
if (origin === destination || !accept(origin)) continue;
@@ -338,9 +341,10 @@ export function getawaysForDay(
* if so how much time it buys. A thin wrapper over the tested {@link bestGetawayTo}.
*
* `metric: "hours"` counts a SAME-DAY day trip (nights 0) and reports WHOLE HOURS on
- * site; `metric: "nights"` counts a MULTI-DAY round trip (flexible up to the opts
- * ceiling, default 3) and reports NIGHTS away. A day is green ONLY when a bookable
- * round trip of that shape exists.
+ * site, rounded down; `metric: "nights"` counts a MULTI-DAY round trip and reports
+ * NIGHTS away: exactly `opts.nights`, or up to it with `opts.flexibleNights`, or up to
+ * 3 when no nights are given. A day is green ONLY when a bookable round trip of that
+ * shape exists.
*/
export function stayCalendar(
trains: MaxTrain[],
@@ -350,15 +354,17 @@ export function stayCalendar(
opts: GetawayOptions = {},
metric: "hours" | "nights" = "nights",
): CalendarDay[] {
- // For nights, flexibleNights keeps the longest feasible stay but needs a ceiling to
- // search up to (maxNights 0 collapses to same-day only) — default up to 3 nights.
+ // Nights 0 would collapse to same-day only, so a nights calendar with no stay length
+ // searches the longest feasible stay up to 3 nights.
const probe: GetawayOptions =
metric === "hours"
? { ...opts, nights: 0 }
- : { ...opts, nights: opts.nights && opts.nights > 0 ? opts.nights : 3, flexibleNights: true };
+ : opts.nights && opts.nights > 0
+ ? opts
+ : { ...opts, nights: 3, flexibleNights: true };
return dates.map((date) => {
const g = bestGetawayTo(trains, origin, dest, date, probe);
- const count = !g ? 0 : metric === "hours" ? (g.onSiteMin ? Math.round(g.onSiteMin / 60) : 0) : (g.nights ?? 0);
+ const count = !g ? 0 : metric === "hours" ? Math.floor((g.onSiteMin ?? 0) / 60) : g.nights;
return { date, available: g != null, count };
});
}
diff --git a/src/i18n/ar.ts b/src/i18n/ar.ts
index 3bb32fb..ea6a7c3 100644
--- a/src/i18n/ar.ts
+++ b/src/i18n/ar.ts
@@ -263,9 +263,10 @@ export const ar: Dict = {
trip_book_note: "يُحجز كل مقطع على حدة عبر SNCF Connect.",
outbound_change: "المغادرة: {date} · تغيير",
return_change: "العودة: {date} · تغيير",
- daytrip_cal_hours: "{h} ساعة هناك",
+ daytrip_cal_hours: "{dur} هناك",
cal_legend_hours: "الرقم = ساعات هناك",
cal_legend_nights: "الرقم = عدد الليالي",
+ cal_legend_return_stay: "الرقم = ساعات هناك في نفس اليوم، وإلا عدد الليالي",
cal_legend_depart: "العدد = قطارات الذهاب",
cal_legend_return: "العدد = قطارات العودة",
res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}",
diff --git a/src/i18n/de.ts b/src/i18n/de.ts
index 04313b1..df9fdc4 100644
--- a/src/i18n/de.ts
+++ b/src/i18n/de.ts
@@ -264,9 +264,10 @@ export const de: Dict = {
trip_book_note: "Jede Fahrt wird separat auf SNCF Connect gebucht.",
outbound_change: "Abfahrt: {date} · Ändern",
return_change: "Rückfahrt: {date} · Ändern",
- daytrip_cal_hours: "{h} Std. vor Ort",
+ daytrip_cal_hours: "{dur} vor Ort",
cal_legend_hours: "Zahl = Stunden vor Ort",
cal_legend_nights: "Zahl = Nächte",
+ cal_legend_return_stay: "Zahl = Stunden vor Ort am selben Tag, sonst Nächte",
cal_legend_depart: "Zahl = Hinfahrten",
cal_legend_return: "Zahl = Rückfahrten",
res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}",
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index 0d3c49e..a1b6cee 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -276,9 +276,10 @@ export const en: Dict = {
trip_book_note: "Each leg is booked separately on SNCF Connect.",
outbound_change: "Departure: {date} · Change",
return_change: "Return: {date} · Change",
- daytrip_cal_hours: "{h}h on site",
+ daytrip_cal_hours: "{dur} on site",
cal_legend_hours: "number = hours on site",
cal_legend_nights: "number = nights there",
+ cal_legend_return_stay: "number = hours on site the same day, else nights there",
cal_legend_depart: "number = outbound trains",
cal_legend_return: "number = return trains",
res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}",
diff --git a/src/i18n/es.ts b/src/i18n/es.ts
index 3d77426..fa74da6 100644
--- a/src/i18n/es.ts
+++ b/src/i18n/es.ts
@@ -264,9 +264,10 @@ export const es: Dict = {
trip_book_note: "Cada trayecto se reserva por separado en SNCF Connect.",
outbound_change: "Salida: {date} · Cambiar",
return_change: "Vuelta: {date} · Cambiar",
- daytrip_cal_hours: "{h} h en destino",
+ daytrip_cal_hours: "{dur} en destino",
cal_legend_hours: "número = horas en destino",
cal_legend_nights: "número = noches allí",
+ cal_legend_return_stay: "número = horas en destino el mismo día, si no noches",
cal_legend_depart: "número = trenes de ida",
cal_legend_return: "número = trenes de vuelta",
res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}",
diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts
index 7768b0c..5cb492e 100644
--- a/src/i18n/fr.ts
+++ b/src/i18n/fr.ts
@@ -278,9 +278,10 @@ export const fr = {
trip_book_note: "Chaque trajet se réserve séparément sur SNCF Connect.",
outbound_change: "Départ : {date} · Changer",
return_change: "Retour : {date} · Changer",
- daytrip_cal_hours: "{h} h sur place",
+ daytrip_cal_hours: "{dur} sur place",
cal_legend_hours: "nombre = heures sur place",
cal_legend_nights: "nombre = nuits sur place",
+ cal_legend_return_stay: "nombre = heures sur place le jour même, sinon nuits",
cal_legend_depart: "nombre = trains à l’aller",
cal_legend_return: "nombre = trains au retour",
res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}",
diff --git a/src/i18n/it.ts b/src/i18n/it.ts
index 0f6fe99..eb5067a 100644
--- a/src/i18n/it.ts
+++ b/src/i18n/it.ts
@@ -264,9 +264,10 @@ export const it: Dict = {
trip_book_note: "Ogni tratta si prenota separatamente su SNCF Connect.",
outbound_change: "Partenza: {date} · Cambia",
return_change: "Ritorno: {date} · Cambia",
- daytrip_cal_hours: "{h} h sul posto",
+ daytrip_cal_hours: "{dur} sul posto",
cal_legend_hours: "numero = ore sul posto",
cal_legend_nights: "numero = notti sul posto",
+ cal_legend_return_stay: "numero = ore sul posto in giornata, altrimenti notti",
cal_legend_depart: "numero = treni di andata",
cal_legend_return: "numero = treni di ritorno",
res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}",
diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts
index d28318b..65c15e4 100644
--- a/src/i18n/ja.ts
+++ b/src/i18n/ja.ts
@@ -263,9 +263,10 @@ export const ja: Dict = {
trip_book_note: "各区間は SNCF Connect で個別に予約します。",
outbound_change: "出発:{date} · 変更",
return_change: "復路:{date} · 変更",
- daytrip_cal_hours: "現地 {h} 時間",
+ daytrip_cal_hours: "現地 {dur}",
cal_legend_hours: "数字 = 現地滞在時間",
cal_legend_nights: "数字 = 泊数",
+ cal_legend_return_stay: "数字 = 日帰りは現地滞在時間、それ以外は泊数",
cal_legend_depart: "数字 = 往路の列車",
cal_legend_return: "数字 = 復路の列車",
res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}",
diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts
index b71b0d5..40c4d0a 100644
--- a/src/i18n/ko.ts
+++ b/src/i18n/ko.ts
@@ -264,9 +264,10 @@ export const ko: Dict = {
trip_book_note: "각 구간은 SNCF Connect에서 따로 예약합니다.",
outbound_change: "출발: {date} · 변경",
return_change: "돌아오기: {date} · 변경",
- daytrip_cal_hours: "현지 {h}시간",
+ daytrip_cal_hours: "현지 {dur}",
cal_legend_hours: "숫자 = 현지 체류 시간",
cal_legend_nights: "숫자 = 숙박 일수",
+ cal_legend_return_stay: "숫자 = 당일은 현지 체류 시간, 그 외는 숙박 일수",
cal_legend_depart: "숫자 = 출발 열차",
cal_legend_return: "숫자 = 귀환 열차",
res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}",
diff --git a/src/i18n/nl.ts b/src/i18n/nl.ts
index 008b444..0a702d1 100644
--- a/src/i18n/nl.ts
+++ b/src/i18n/nl.ts
@@ -263,9 +263,10 @@ export const nl: Dict = {
trip_book_note: "Elke rit wordt apart geboekt op SNCF Connect.",
outbound_change: "Vertrek: {date} · Wijzigen",
return_change: "Terugreis: {date} · Wijzigen",
- daytrip_cal_hours: "{h} u ter plaatse",
+ daytrip_cal_hours: "{dur} ter plaatse",
cal_legend_hours: "getal = uren ter plaatse",
cal_legend_nights: "getal = nachten ter plaatse",
+ cal_legend_return_stay: "getal = uren ter plaatse op dezelfde dag, anders nachten",
cal_legend_depart: "aantal = heentreinen",
cal_legend_return: "aantal = terugtreinen",
res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}",
diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts
index ce4c382..a310c00 100644
--- a/src/i18n/pt.ts
+++ b/src/i18n/pt.ts
@@ -263,9 +263,10 @@ export const pt: Dict = {
trip_book_note: "Cada trajeto é reservado separadamente no SNCF Connect.",
outbound_change: "Partida: {date} · Alterar",
return_change: "Volta: {date} · Alterar",
- daytrip_cal_hours: "{h} h no destino",
+ daytrip_cal_hours: "{dur} no destino",
cal_legend_hours: "número = horas no destino",
cal_legend_nights: "número = noites no local",
+ cal_legend_return_stay: "número = horas no destino no mesmo dia, senão noites",
cal_legend_depart: "número = comboios de ida",
cal_legend_return: "número = comboios de volta",
res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}",
diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts
index ee2ea1b..730952b 100644
--- a/src/i18n/zh.ts
+++ b/src/i18n/zh.ts
@@ -263,9 +263,10 @@ export const zh: Dict = {
trip_book_note: "每程在 SNCF Connect 上分别预订。",
outbound_change: "出发:{date} · 更改",
return_change: "返程:{date} · 更改",
- daytrip_cal_hours: "当地 {h} 小时",
+ daytrip_cal_hours: "当地 {dur}",
cal_legend_hours: "数字 = 当地小时数",
cal_legend_nights: "数字 = 停留晚数",
+ cal_legend_return_stay: "数字 = 当日为当地小时数,否则为停留晚数",
cal_legend_depart: "数字 = 出发列车",
cal_legend_return: "数字 = 返程列车",
res_rt_title: "{origin} ⇄ {destination} · {out} → {ret}",
diff --git a/src/styles.css b/src/styles.css
index 0dd27b2..23cd241 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -471,6 +471,10 @@ a {
.input::placeholder {
color: var(--faint);
}
+/* A field kept in place but inert (the same-day minimum while Flexible is on). */
+.input:disabled {
+ opacity: 0.45;
+}
/* A station field whose text matches no real station. */
.input.is-invalid {
border-color: #dc2626;
@@ -866,12 +870,15 @@ a {
.form-cal-toggle:hover {
border-color: var(--accent);
}
+/* The picked day sits on its own line under the title in every trip shape, so the longer
+ Flexible range never grows the header when Flexible toggles. */
.form-cal-heading {
display: flex;
- align-items: baseline;
- flex-wrap: wrap;
- gap: 0.5rem;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.1rem;
min-width: 0;
+ text-align: start;
}
.form-cal-title {
font-weight: 600;
diff --git a/src/ui/form.ts b/src/ui/form.ts
index 7d8cef4..87ce90d 100644
--- a/src/ui/form.ts
+++ b/src/ui/form.ts
@@ -1150,8 +1150,10 @@ export function createForm(props: FormProps): FormHandle {
};
// "Minimum time there" only means something for a SAME-DAY round trip (a fixed 0-night
// return), on the single-trip tab — a stay with nights or a one-way has no on-site gate.
+ // Flexible leaves it in place but inert, as it does the stepper, so the form never shifts.
const syncStayMinField = (): void => {
- stayMinField.style.display = currentTrip === "simple" && roundTrip && !flexible && nights === 0 ? "" : "none";
+ stayMinField.style.display = currentTrip === "simple" && roundTrip && nights === 0 ? "" : "none";
+ stayMin.disabled = flexible;
};
/** The current shape as a TripShape: one-way, Flexible (return picked on the calendar),
* or the fixed stay the nights imply. */
@@ -1627,7 +1629,8 @@ export function createForm(props: FormProps): FormHandle {
setFlexible: (n) => {
roundTrip = true;
flexible = true;
- if (n != null) nights = Math.max(0, Math.min(NIGHTS_MAX, n));
+ // The inert stepper reads the picked range, which may run past the stepper's own max.
+ if (n != null) nights = Math.max(0, n);
syncTripShape();
},
toggleRound: () => {
diff --git a/tests/calendar-truth.test.ts b/tests/calendar-truth.test.ts
new file mode 100644
index 0000000..78348ba
--- /dev/null
+++ b/tests/calendar-truth.test.ts
@@ -0,0 +1,284 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+// Leaflet needs a real browser canvas; stub the map module so the UI runs 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 { findJourneys } from "../src/core/connections";
+import { bestGetawayTo, getawayIdeas, reverseGetawayIdeas, stayCalendar } from "../src/core/getaways";
+import { reachableGroups, windowStats } from "../src/core/destinations";
+import { formatDuration } from "../src/util/time";
+import stations from "../data/stations.json";
+
+const P = "PARIS (intramuros)";
+const L = "LYON (intramuros)";
+const row = (date: string, from: string, to: string, dep: string, arr: string, no: string): RawRecord => ({
+ date,
+ origine: from,
+ destination: to,
+ heure_depart: dep,
+ heure_arrivee: arr,
+ train_no: no,
+ od_happy_card: "OUI",
+});
+
+describe("a green day and a number tell the truth (core)", () => {
+ it("a fixed N-night stay is green only when a return N nights later exists", () => {
+ // The only way back is one night later: a 1-night stay works, a 2-night stay does not.
+ const trains = normalizeRecords([
+ row("2026-07-01", P, "ROUEN", "09:00", "10:00", "X1"),
+ row("2026-07-02", "ROUEN", P, "18:00", "19:00", "X2"),
+ ]);
+ const two = stayCalendar(trains, P, "ROUEN", ["2026-07-01"], { maxConnections: 0, nights: 2 }, "nights");
+ expect(two[0]!.available).toBe(false);
+ const one = stayCalendar(trains, P, "ROUEN", ["2026-07-01"], { maxConnections: 0, nights: 1 }, "nights");
+ expect(one[0]).toMatchObject({ available: true, count: 1 });
+ // Flexible still keeps the longest feasible stay up to the ceiling.
+ const flex = stayCalendar(trains, P, "ROUEN", ["2026-07-01"], { maxConnections: 0, nights: 2, flexibleNights: true }, "nights");
+ expect(flex[0]).toMatchObject({ available: true, count: 1 });
+ });
+
+ it("counts whole hours on site rounded down, never more than the trip gives", () => {
+ const trains = normalizeRecords([
+ row("2026-07-01", P, "ARRAS", "09:00", "10:00", "A1"),
+ row("2026-07-01", "ARRAS", P, "20:54", "21:54", "A2"),
+ ]);
+ const cal = stayCalendar(trains, P, "ARRAS", ["2026-07-01"], { maxConnections: 0 }, "hours");
+ expect(cal[0]!.count).toBe(10); // 10:00 → 20:54 is 10 h 54
+ });
+
+ it("reverse same-day discovery pairs the earliest arrival with the latest return, as forward does", () => {
+ // Two ways into ARRAS: a slow one landing at 10:00 and a fast one landing at 12:00. Two
+ // ways home: 14:00 and 21:00, plus one landing after midnight.
+ const trains = normalizeRecords([
+ row("2026-07-01", P, "ARRAS", "07:00", "10:00", "S1"),
+ row("2026-07-01", P, "ARRAS", "11:00", "12:00", "F1"),
+ row("2026-07-01", "ARRAS", P, "14:00", "15:00", "R1"),
+ row("2026-07-01", "ARRAS", P, "21:00", "22:00", "R2"),
+ row("2026-07-01", "ARRAS", P, "23:30", "00:30", "R3"),
+ ]);
+ const opts = { maxConnections: 0 };
+ const forward = bestGetawayTo(trains, P, "ARRAS", "2026-07-01", opts)!;
+ expect(forward.onSiteMin).toBe(11 * 60);
+ const reverse = reverseGetawayIdeas(trains, "ARRAS", ["2026-07-01"], opts);
+ const paris = reverse.trips.find((g) => g.destination === P);
+ expect(paris?.onSiteMin).toBe(forward.onSiteMin);
+ expect(paris?.outbound.legs[0]!.trainNo).toBe("S1");
+ expect(paris?.back.legs[0]!.trainNo).toBe("R2"); // not R3: that one gets home after midnight
+ expect(reverse.perDay[0]!.count).toBe(1);
+ });
+
+ it("keeps the shorter ride when two outbounds land at the same time", () => {
+ const trains = normalizeRecords([
+ row("2026-07-01", P, "ARRAS", "06:00", "10:00", "SLOW"),
+ row("2026-07-01", P, "ARRAS", "09:00", "10:00", "FAST"),
+ row("2026-07-01", "ARRAS", P, "21:00", "22:00", "R"),
+ ]);
+ const g = bestGetawayTo(trains, P, "ARRAS", "2026-07-01", { maxConnections: 0 })!;
+ expect(g.outbound.legs[0]!.trainNo).toBe("FAST");
+ expect(g.travelMin).toBe(120);
+ expect(getawayIdeas(trains, P, ["2026-07-01"], { maxConnections: 0 }).trips[0]!.travelMin).toBe(g.travelMin);
+ });
+
+ it("lists a train seen at two stations of one group once, on its shortest row", () => {
+ // Train 6627 reaches LYON (intramuros) twice (Part-Dieu 19:54, Perrache 20:10); train
+ // 5380 leaves it twice (Perrache 06:12, Part-Dieu 06:30). The later row comes first.
+ const trains = normalizeRecords([
+ row("2026-09-30", P, L, "17:52", "20:10", "6627"),
+ row("2026-09-30", P, L, "17:52", "19:54", "6627"),
+ row("2026-09-30", L, "MASSY TGV", "06:12", "08:34", "5380"),
+ row("2026-09-30", L, "MASSY TGV", "06:30", "08:34", "5380"),
+ ]);
+ const toLyon = findJourneys(trains, P, L, "2026-09-30", { maxConnections: 0 });
+ expect(toLyon.map((j) => j.legs[0]!.arrive)).toEqual(["19:54"]);
+ const toMassy = findJourneys(trains, L, "MASSY TGV", "2026-09-30", { maxConnections: 0 });
+ expect(toMassy.map((j) => j.legs[0]!.depart)).toEqual(["06:30"]);
+ });
+
+ it("counts and times browse cards over the given days only", () => {
+ const trains = normalizeRecords([
+ row("2026-06-24", P, L, "08:00", "09:50", "OLD"), // yesterday: not bookable
+ row("2026-06-25", P, L, "08:00", "10:14", "A"),
+ row("2026-06-26", P, L, "08:00", "10:05", "B"),
+ ]);
+ const window = ["2026-06-25", "2026-06-26"];
+ expect(windowStats(trains, P, "from", window).get(L)).toEqual({ trains: 2, days: 2 });
+ const day = reachableGroups(trains, P, "from", ["2026-06-25"]);
+ expect(day.map((g) => [g.station, g.count, g.minDurationMin])).toEqual([[L, 1, 134]]);
+ });
+});
+
+// --- the app ------------------------------------------------------------------
+
+const meta: DataMeta = { updatedAt: "", source: "sample", recordCount: 0, isSample: true };
+
+// Paris ⇄ Lyon every day from 06-25 to 06-30; the way back leaves at 19:26 and 21:00, the
+// 21:00 one only from 06-27. A same-day trip on 06-25 leaves 10 h 26 on site.
+const days = ["2026-06-25", "2026-06-26", "2026-06-27", "2026-06-28", "2026-06-29", "2026-06-30"];
+const fixture: RawRecord[] = days.flatMap((d, i) => [
+ row(d, P, L, "07:00", "09:00", `O${i}`),
+ row(d, L, P, "19:26", "21:26", `R${i}`),
+ ...(d >= "2026-06-27" ? [row(d, L, P, "21:00", "23:00", `N${i}`)] : []),
+]);
+
+function setup(search: string, records: RawRecord[] = fixture): HTMLElement {
+ localStorage.clear();
+ document.body.innerHTML = '';
+ const root = document.getElementById("app") as HTMLElement;
+ history.replaceState(null, "", `/${search}`);
+ initApp(root, { trains: normalizeRecords(records), meta }, new StationRegistry(stations as Station[]));
+ return root;
+}
+
+const route = `mode=od&from=${encodeURIComponent(P)}&to=${encodeURIComponent(L)}`;
+const formCell = (root: HTMLElement, date: string): HTMLElement =>
+ root.querySelector(`.form-cal-mount .cal-cell[data-date="${date}"]`)!;
+const formSelected = (root: HTMLElement): string[] =>
+ Array.from(root.querySelectorAll(".form-cal-mount .cal-cell.sel")).map((c) => c.dataset.date!);
+const picked = (root: HTMLElement): string => root.querySelector(".form-cal-picked")!.textContent ?? "";
+const title = (root: HTMLElement): string => root.querySelector("#results-title")!.textContent ?? "";
+const param = (k: string): string | null => new URLSearchParams(location.search).get(k);
+
+beforeEach(() => {
+ 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 green day and a number tell the truth (app)", () => {
+ it("moves the form calendar when a day is picked under the results", () => {
+ const root = setup(`?${route}&date=2026-06-25`);
+ expect(formSelected(root)).toEqual(["2026-06-25"]);
+ root.querySelector('.results .cal-cell[data-date="2026-06-27"]')!.click();
+ expect(param("date")).toBe("2026-06-27");
+ expect(formSelected(root)).toEqual(["2026-06-27"]);
+ expect(picked(root)).toContain(title(root).split("—").pop()!.trim());
+ });
+
+ it("moves the Flexible range and stepper when the return is picked under the results", () => {
+ const root = setup(`?${route}&date=2026-06-25&stay=flex&rdate=2026-06-27`);
+ expect(formSelected(root)).toEqual(["2026-06-25", "2026-06-27"]);
+ root.querySelector('.od-return-cal .cal-cell[data-date="2026-06-29"]')!.click();
+ expect(formSelected(root)).toEqual(["2026-06-25", "2026-06-29"]);
+ expect(picked(root).endsWith(title(root).split("→").pop()!.trim())).toBe(true);
+ expect(root.querySelector(".nights-val")!.textContent).toBe("4 nights");
+ });
+
+ it("Flexible: header, date pill, URL and results agree before and after each range tap", () => {
+ // No return in the link: the results propose departure + 2, so the header does too.
+ const root = setup(`?${route}&date=2026-06-25&stay=flex`);
+ const ret = (): string => title(root).split("→").pop()!.trim();
+ expect(picked(root).endsWith(ret())).toBe(true);
+ expect(formSelected(root)).toEqual(["2026-06-25", "2026-06-27"]);
+ expect(root.querySelector(".nights-val")!.textContent).toBe("2 nights");
+
+ // First tap: the departure, pending on the form; the URL and the results keep their range.
+ const shown = title(root);
+ formCell(root, "2026-06-26").click();
+ expect(param("date")).toBe("2026-06-25");
+ expect(title(root)).toBe(shown);
+ expect(picked(root)).toBe("Departure: Fri, Jun 26 — pick the return");
+ expect(formSelected(root)).toEqual(["2026-06-26"]);
+ // While the return is awaited, a day before the departure is not a return day.
+ expect(formCell(root, "2026-06-25").classList.contains("ok")).toBe(false);
+ expect(formCell(root, "2026-06-27").classList.contains("ok")).toBe(true);
+
+ // Second tap: the return, run with the departure.
+ formCell(root, "2026-06-29").click();
+ expect(param("date")).toBe("2026-06-26");
+ expect(param("rdate")).toBe("2026-06-29");
+ expect(title(root)).not.toBe(shown);
+ expect(picked(root).endsWith(ret())).toBe(true);
+ expect(formSelected(root)).toEqual(["2026-06-26", "2026-06-29"]);
+ expect(root.querySelector(".nights-val")!.textContent).toBe("3 nights");
+ });
+
+ it("toggling Flexible keeps the same-day minimum in place, inert", () => {
+ const root = setup(`?${route}&date=2026-06-25&stay=day`);
+ const field = Array.from(root.querySelectorAll(".search-form .field")).find((f) =>
+ (f.textContent ?? "").includes("Minimum time there"),
+ )!;
+ const select = field.querySelector("select") as HTMLSelectElement;
+ expect(field.style.display).not.toBe("none");
+ expect(select.disabled).toBe(false);
+ root.querySelector(".nights-flex")!.click();
+ expect(field.style.display).not.toBe("none");
+ expect(select.disabled).toBe(true);
+ });
+
+ it("a stay from the last bookable day reads its real return, not the same day", () => {
+ const root = setup(`?${route}&date=2026-07-24&stay=1`);
+ const chips = Array.from(root.querySelectorAll(".results .mc-date")).map((e) => e.textContent);
+ expect(chips[0]).not.toBe(chips[1]);
+ expect(root.querySelector(".nights-val")!.textContent).toBe("1 night");
+ expect(root.querySelector(".return-list .empty")).not.toBeNull();
+ });
+
+ it("a stay's discovery card counts the return its trip picks by default, the fastest", () => {
+ // The sweep's latest return home by midnight (21:00, 2 h 30) is not the fastest (19:26, 2 h).
+ const records = [
+ row("2026-06-27", P, L, "07:00", "09:00", "OUT"),
+ row("2026-06-28", L, P, "19:26", "21:26", "FAST"),
+ row("2026-06-28", L, P, "21:00", "23:30", "LATE"),
+ ];
+ const root = setup(`?mode=from&from=${encodeURIComponent(P)}&date=2026-06-27&stay=1`, records);
+ const card = root.querySelector(`.results .group-card[data-station="${L}"]`)!;
+ const total = new RegExp(`${formatDuration(240)}(?!\\d)`); // 4 h, not 4 h 30
+ expect(card.querySelector(".dest-meta bdi")!.textContent).toBe(formatDuration(240));
+ card.querySelector(".dest-main")!.click();
+ expect(root.querySelector(".return-list .journey")?.textContent).toContain("19:26");
+ expect(root.querySelector(".rt-total")!.textContent).toMatch(total);
+ });
+
+ it("a discovery card shows the exact time on site of the trip it opens", () => {
+ const root = setup(`?mode=from&from=${encodeURIComponent(P)}&date=2026-06-25&stay=day`);
+ const card = root.querySelector(`.results .group-card[data-station="${L}"]`)!;
+ expect(card.textContent).toContain(formatDuration(626)); // 09:00 → 19:26
+ card.querySelector(".dest-main")!.click();
+ expect(root.querySelector(".rt-total")!.textContent).toContain(formatDuration(626));
+ });
+
+ it("a browse card's time is the chosen day's fastest train, not the month's", () => {
+ const records = [
+ row("2026-06-25", P, L, "07:00", "09:14", "SLOW"),
+ row("2026-06-26", P, L, "07:00", "09:05", "FAST"),
+ ];
+ const root = setup(`?mode=from&from=${encodeURIComponent(P)}&date=2026-06-25`, records);
+ const card = root.querySelector(`.results .group-card[data-station="${L}"]`)!;
+ expect(card.querySelector(".dest-meta")!.textContent).toContain(formatDuration(134));
+ });
+
+ it("counts a destination's trains from today on", () => {
+ const records = [
+ row("2026-06-24", P, L, "07:00", "09:00", "PAST"),
+ row("2026-06-25", P, L, "07:00", "09:00", "A"),
+ row("2026-06-26", P, L, "07:00", "09:00", "B"),
+ ];
+ const root = setup(`?mode=best&from=${encodeURIComponent(P)}`, records);
+ const card = root.querySelector(`.results .group-card[data-station="${L}"]`)!;
+ expect(card.querySelector(".stat-chip")!.textContent).toMatch(/^2 train/);
+ });
+});