From 9b99fc68002dd086152abb3fc73de4fac6bcc48b Mon Sep 17 00:00:00 2001
From: davd-gzl <60177543+davd-gzl@users.noreply.github.com>
Date: Tue, 29 Sep 2026 15:48:35 +0900
Subject: [PATCH 1/2] Calendars: green days and counts match the trip they open
A fixed 2 or 3 night stay graded days by any stay up to that length, so
green days led to an empty return. The stay calendar now keeps the exact
length unless the stay is flexible.
Reverse same-day discovery now pairs the earliest arrival with the
latest return home, as the forward search does, so no origin is lost
and the hours on site match the trip. A train listed at two stations of
one group keeps its shortest row.
Browse and Ideas count trains from today only, and a card's time is the
chosen day's. Discovery cards and trip rows show the exact time on site,
and a stay's first return is the one the card counts.
The form calendar follows days picked under the results. In Flexible,
every range tap runs at once, so the header, date pill, URL and results
agree; days before the departure are not offered as a return, the inert
stepper reads the range, and the same-day minimum stays in place.
---
docs/user-flows.md | 36 +++--
src/app.ts | 155 +++++++++-----------
src/core/connections.ts | 52 ++++---
src/core/destinations.ts | 32 ++--
src/core/getaways.ts | 40 ++---
src/i18n/ar.ts | 3 +-
src/i18n/de.ts | 3 +-
src/i18n/en.ts | 3 +-
src/i18n/es.ts | 3 +-
src/i18n/fr.ts | 3 +-
src/i18n/it.ts | 3 +-
src/i18n/ja.ts | 3 +-
src/i18n/ko.ts | 3 +-
src/i18n/nl.ts | 3 +-
src/i18n/pt.ts | 3 +-
src/i18n/zh.ts | 3 +-
src/styles.css | 13 +-
src/ui/form.ts | 7 +-
tests/app.smoke.test.ts | 5 +-
tests/calendar-truth.test.ts | 273 +++++++++++++++++++++++++++++++++++
20 files changed, 480 insertions(+), 166 deletions(-)
create mode 100644 tests/calendar-truth.test.ts
diff --git a/docs/user-flows.md b/docs/user-flows.md
index db09608..e28be9d 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) |
@@ -87,14 +91,17 @@ stays as the exact-date / ±flex keyboard entry for power users.
**In Flexible mode the same inline calendar becomes a departure→return RANGE picker**
(`pickFormRange`, driven by `calendarEl`'s `range` option). The **first tap sets the
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
-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
+it sets the return** — `query.returnDate` with `stay: "flexible"` — while an earlier tap just
+restarts. A **third tap begins a fresh range**. Every tap runs like a single-day pick (in
+place for the same route), so the header, the date pill, the URL and the results always
+agree: on an exact route the first tap keeps the current span as the return until the
+second tap moves it, and a link with no `rdate` shows the departure + 2 its results propose.
+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 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**.
@@ -125,6 +132,9 @@ 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.
+ The list puts first the latest return home by midnight (the most time there — the same
+ trip a discovery card counts), 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
diff --git a/src/app.ts b/src/app.ts
index 7f4aa90..4d1bd8c 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -121,13 +121,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));
}
@@ -1050,19 +1049,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),
@@ -1304,9 +1302,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();
@@ -1410,20 +1411,24 @@ 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. 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 ? (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 });
@@ -1458,7 +1463,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
@@ -1466,8 +1471,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);
@@ -1518,6 +1526,12 @@ function repaintFormCalendar(): void {
function pickFormDay(date: string): void {
refs.date.value = date;
refs.departDate.setDate(date);
+ commitFormPick(true);
+}
+
+/** Run the form's picked day(s): refresh the results for the same route, else search. A
+ * finished pick (`reveal`) scrolls the refreshed list into view when it sits below the fold. */
+function commitFormPick(reveal: boolean): void {
const fq = readQueryFromForm();
const sameRoute =
query.origin === fq.origin && query.destination === fq.destination && (query.mode === "od" || tripIsRound());
@@ -1528,7 +1542,7 @@ function pickFormDay(date: string): void {
// day, the calendar selection doesn't update the map"). Only a truly empty query stays
// staged.
if (queryIsRenderable(query)) {
- if (sameRoute) refreshInPlace(true);
+ if (sameRoute) refreshInPlace(reveal);
else applyAndRun();
}
repaintFormCalendar();
@@ -1536,42 +1550,25 @@ 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 sets the departure and arms the calendar for the return,
+ * an exact route keeping the current span meanwhile; the next tap on/after the departure
+ * sets the return (query.returnDate) with stay "flexible", while an earlier tap restarts. A
+ * tap once the range is complete begins a fresh range. Each tap runs like a single-day pick,
+ * so the header, the date pill, the URL and the results always agree.
*/
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();
- return;
+ } else {
+ formRangeAwait = true;
+ refs.date.value = date;
+ refs.departDate.setDate(date);
+ query = { ...query, date, returnDate: undefined };
}
- // 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.
- formRangeAwait = true;
- refs.date.value = date;
- refs.departDate.setDate(date);
- query = { ...query, date, returnDate: undefined };
- repaintFormCalendar();
+ // The first tap keeps the calendar in view for the return tap.
+ commitFormPick(!formRangeAwait);
}
// --- search execution -------------------------------------------------------
@@ -1923,26 +1920,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) ||
@@ -2011,7 +1996,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));
}
@@ -2091,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 });
});
@@ -2125,7 +2110,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 });
@@ -2391,7 +2376,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());
// Fastest first (bestTripsAcrossWindow's own order) unless another key is picked.
const origin = query.origin;
const sorted = applySort(trips, {
@@ -2845,7 +2830,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)
@@ -2872,7 +2859,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
+ // a later day keeps every return, the latest home by midnight first — the return the
+ // discovery cards count — then the later arrivals. 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);
@@ -2887,9 +2875,10 @@ function runTripSearch(c: RenderCtx): void {
.sort((a, b) => b.departMin - a.departMin);
return { list, sameDay: true, arrAbs };
}
+ const late = (j: Journey): number => (journeyArriveAbs(j) > 24 * 60 ? 1 : 0);
const list = findJourneys(trains, destination, origin, retDate, journeyOpts)
.filter(accept)
- .sort((a, b) => a.totalDurationMin - b.totalDurationMin || a.departMin - b.departMin);
+ .sort((a, b) => late(a) - late(b) || b.departMin - a.departMin);
return { list, sameDay: false, arrAbs: 0 };
};
const renderReturns = (retDate: string): void => {
@@ -2910,7 +2899,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,
}),
),
);
@@ -2928,8 +2917,8 @@ function runTripSearch(c: RenderCtx): void {
render.calendarEl(gradeForCard(retCal, query.card), 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);
@@ -2970,6 +2959,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.
};
@@ -2990,8 +2980,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
@@ -3024,7 +3013,7 @@ function runTripSearch(c: RenderCtx): void {
const outCalCtx: RenderCtx = { ...c, onSelectDay: onOutboundDay };
const outCalEl = render.calendarEl(gradeForCard(outCal, query.card), 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
@@ -3121,13 +3110,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..00e0718 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;
@@ -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 6aa02ea..dbf24c6 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 caeb4d2..9bc08c8 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 e2232ae..0a63e79 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 cb1c769..e30d838 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 101e39f..ca6c658 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 7185431..41a480d 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 d0dbcd9..7aeabe9 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 5292123..b9b602f 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 9641b8b..25ef518 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 85eb61e..b692a33 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 af25314..cd56aa9 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/app.smoke.test.ts b/tests/app.smoke.test.ts
index b9ac808..e7b40d7 100644
--- a/tests/app.smoke.test.ts
+++ b/tests/app.smoke.test.ts
@@ -876,11 +876,10 @@ describe("app (jsdom smoke)", () => {
const dayOf = (c: Element | null): string | undefined => c?.querySelector(".cal-day")?.textContent ?? undefined;
const cellFor = (day: number): HTMLElement | undefined =>
Array.from(block.querySelectorAll(".form-cal-body .cal-cell")).find((c) => Number(dayOf(c)) === day);
- // First tap = the departure (28). No return yet, so it's the sole selected endpoint
- // and no day carries the range band yet (staged inline, no navigation).
+ // First tap = the departure (28), run at once so the URL and results follow it.
cellFor(28)!.click();
expect(cellFor(28)!.classList.contains("sel")).toBe(true);
- expect(Array.from(block.querySelectorAll(".form-cal-body .cal-cell.range")).length).toBe(0);
+ expect(new URLSearchParams(location.search).get("date")).toBe("2026-06-28");
// Second tap on a LATER day = the return (30): sets rdate and highlights the in-between.
cellFor(30)!.click();
const params = new URLSearchParams(location.search);
diff --git a/tests/calendar-truth.test.ts b/tests/calendar-truth.test.ts
new file mode 100644
index 0000000..509b731
--- /dev/null
+++ b/tests/calendar-truth.test.ts
@@ -0,0 +1,273 @@
+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: a new departure, run at once, keeping the 2-night span until the return tap.
+ formCell(root, "2026-06-26").click();
+ expect(param("date")).toBe("2026-06-26");
+ expect(param("rdate")).toBe("2026-06-28");
+ expect((root.querySelector('.search-form input[type="date"]') as HTMLInputElement).value).toBe("2026-06-26");
+ expect(picked(root).endsWith(ret())).toBe(true);
+ expect(formSelected(root)).toEqual(["2026-06-26", "2026-06-28"]);
+ // 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.
+ formCell(root, "2026-06-29").click();
+ expect(param("rdate")).toBe("2026-06-29");
+ expect(picked(root).endsWith(ret())).toBe(true);
+ 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 default return is the latest home by midnight, as its discovery card counts", () => {
+ const root = setup(`?${route}&date=2026-06-27&stay=1`);
+ const first = root.querySelector(".return-list .journey");
+ expect(first?.textContent).toContain("21:00");
+ const card = getawayIdeas(normalizeRecords(fixture), P, ["2026-06-27"], { maxConnections: 1, nights: 1 }).trips[0]!;
+ expect(root.querySelector(".rt-total")!.textContent).toContain(formatDuration(card.travelMin));
+ });
+
+ 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/);
+ });
+});
From c79217e832f0526afe4685da563b03a5824dcc05 Mon Sep 17 00:00:00 2001
From: davd-gzl <60177543+davd-gzl@users.noreply.github.com>
Date: Tue, 29 Sep 2026 16:41:47 +0900
Subject: [PATCH 2/2] Calendars: keep main's return order and two-tap Flexible
pick
A stay's return list is fastest first again, and its first stays the
default return. A stay's discovery card now counts that return, so its
travel time matches the trip it opens.
The first Flexible range tap stages the departure on the form again and
runs nothing. It no longer rewrites the query, so the URL and the results
keep the range on screen while the form header reads the departure as
pending. The return tap runs the range.
---
docs/user-flows.md | 32 +++++++------
src/app.ts | 89 +++++++++++++++++++++++-------------
src/core/getaways.ts | 2 +-
tests/app.smoke.test.ts | 5 +-
tests/calendar-truth.test.ts | 37 +++++++++------
5 files changed, 102 insertions(+), 63 deletions(-)
diff --git a/docs/user-flows.md b/docs/user-flows.md
index e28be9d..ca75e39 100644
--- a/docs/user-flows.md
+++ b/docs/user-flows.md
@@ -91,18 +91,20 @@ stays as the exact-date / ±flex keyboard entry for power users.
**In Flexible mode the same inline calendar becomes a departure→return RANGE picker**
(`pickFormRange`, driven by `calendarEl`'s `range` option). The **first tap sets the
departure** and arms the calendar for the return (`formRangeAwait`); the **next tap on/after
-it sets the return** — `query.returnDate` with `stay: "flexible"` — while an earlier tap just
-restarts. A **third tap begins a fresh range**. Every tap runs like a single-day pick (in
-place for the same route), so the header, the date pill, the URL and the results always
-agree: on an exact route the first tap keeps the current span as the return until the
-second tap moves it, and a link with no `rdate` shows the departure + 2 its results propose.
-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 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 —
+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 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 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
@@ -132,8 +134,7 @@ 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.
- The list puts first the latest return home by midnight (the most time there — the same
- trip a discovery card counts), and a stay from the last bookable day keeps its 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
@@ -164,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 4d1bd8c..1503b86 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";
@@ -1412,13 +1419,14 @@ function repaintFormCalendar(): void {
const round = nights !== null;
// Flexible → the inline month becomes a departure→return RANGE picker (requirement 2):
// `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. Other
- // shapes keep the single-date picker.
+ // 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 pickedReturn = query.returnDate && query.returnDate >= selected ? query.returnDate : undefined;
- const rangeEnd = flexRange ? (pickedReturn ?? (o && d ? proposedReturn(selected) : undefined)) : 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();
@@ -1526,12 +1534,11 @@ function repaintFormCalendar(): void {
function pickFormDay(date: string): void {
refs.date.value = date;
refs.departDate.setDate(date);
- commitFormPick(true);
+ commitFormPick();
}
-/** Run the form's picked day(s): refresh the results for the same route, else search. A
- * finished pick (`reveal`) scrolls the refreshed list into view when it sits below the fold. */
-function commitFormPick(reveal: boolean): void {
+/** 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());
@@ -1542,7 +1549,7 @@ function commitFormPick(reveal: boolean): void {
// day, the calendar selection doesn't update the map"). Only a truly empty query stays
// staged.
if (queryIsRenderable(query)) {
- if (sameRoute) refreshInPlace(reveal);
+ if (sameRoute) refreshInPlace(true);
else applyAndRun();
}
repaintFormCalendar();
@@ -1550,25 +1557,25 @@ function commitFormPick(reveal: boolean): void {
/**
* A day tapped on the Flexible Trip-tab calendar, which is a departure→return RANGE picker
- * (requirement 2). The first tap sets the departure and arms the calendar for the return,
- * an exact route keeping the current span meanwhile; the next tap on/after the departure
- * sets the return (query.returnDate) with stay "flexible", while an earlier tap restarts. A
- * tap once the range is complete begins a fresh range. Each tap runs like a single-day pick,
- * so the header, the date pill, the URL and the results always agree.
+ * (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) {
formRangeAwait = false;
query = { ...query, returnDate: date };
- } else {
- formRangeAwait = true;
- refs.date.value = date;
- refs.departDate.setDate(date);
- query = { ...query, date, returnDate: undefined };
+ commitFormPick();
+ return;
}
- // The first tap keeps the calendar in view for the return tap.
- commitFormPick(!formRangeAwait);
+ // 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);
+ repaintFormCalendar();
}
// --- search execution -------------------------------------------------------
@@ -2058,8 +2065,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.
@@ -2087,6 +2093,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
@@ -2098,9 +2127,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, []);
@@ -2859,9 +2887,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, the latest home by midnight first — the return the
- // discovery cards count — then the later arrivals. 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) {
@@ -2875,11 +2902,7 @@ function runTripSearch(c: RenderCtx): void {
.sort((a, b) => b.departMin - a.departMin);
return { list, sameDay: true, arrAbs };
}
- const late = (j: Journey): number => (journeyArriveAbs(j) > 24 * 60 ? 1 : 0);
- const list = findJourneys(trains, destination, origin, retDate, journeyOpts)
- .filter(accept)
- .sort((a, b) => late(a) - late(b) || b.departMin - a.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);
diff --git a/src/core/getaways.ts b/src/core/getaways.ts
index 00e0718..edf9253 100644
--- a/src/core/getaways.ts
+++ b/src/core/getaways.ts
@@ -160,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)
diff --git a/tests/app.smoke.test.ts b/tests/app.smoke.test.ts
index e7b40d7..b9ac808 100644
--- a/tests/app.smoke.test.ts
+++ b/tests/app.smoke.test.ts
@@ -876,10 +876,11 @@ describe("app (jsdom smoke)", () => {
const dayOf = (c: Element | null): string | undefined => c?.querySelector(".cal-day")?.textContent ?? undefined;
const cellFor = (day: number): HTMLElement | undefined =>
Array.from(block.querySelectorAll(".form-cal-body .cal-cell")).find((c) => Number(dayOf(c)) === day);
- // First tap = the departure (28), run at once so the URL and results follow it.
+ // First tap = the departure (28). No return yet, so it's the sole selected endpoint
+ // and no day carries the range band yet (staged inline, no navigation).
cellFor(28)!.click();
expect(cellFor(28)!.classList.contains("sel")).toBe(true);
- expect(new URLSearchParams(location.search).get("date")).toBe("2026-06-28");
+ expect(Array.from(block.querySelectorAll(".form-cal-body .cal-cell.range")).length).toBe(0);
// Second tap on a LATER day = the return (30): sets rdate and highlights the in-between.
cellFor(30)!.click();
const params = new URLSearchParams(location.search);
diff --git a/tests/calendar-truth.test.ts b/tests/calendar-truth.test.ts
index 509b731..78348ba 100644
--- a/tests/calendar-truth.test.ts
+++ b/tests/calendar-truth.test.ts
@@ -195,21 +195,24 @@ describe("a green day and a number tell the truth (app)", () => {
expect(formSelected(root)).toEqual(["2026-06-25", "2026-06-27"]);
expect(root.querySelector(".nights-val")!.textContent).toBe("2 nights");
- // First tap: a new departure, run at once, keeping the 2-night span until the return tap.
+ // 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-26");
- expect(param("rdate")).toBe("2026-06-28");
- expect((root.querySelector('.search-form input[type="date"]') as HTMLInputElement).value).toBe("2026-06-26");
- expect(picked(root).endsWith(ret())).toBe(true);
- expect(formSelected(root)).toEqual(["2026-06-26", "2026-06-28"]);
+ 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.
+ // 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");
});
@@ -234,12 +237,20 @@ describe("a green day and a number tell the truth (app)", () => {
expect(root.querySelector(".return-list .empty")).not.toBeNull();
});
- it("a stay's default return is the latest home by midnight, as its discovery card counts", () => {
- const root = setup(`?${route}&date=2026-06-27&stay=1`);
- const first = root.querySelector(".return-list .journey");
- expect(first?.textContent).toContain("21:00");
- const card = getawayIdeas(normalizeRecords(fixture), P, ["2026-06-27"], { maxConnections: 1, nights: 1 }).trips[0]!;
- expect(root.querySelector(".rt-total")!.textContent).toContain(formatDuration(card.travelMin));
+ 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", () => {