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/); + }); +});