Five UI and data features for return journeys and results page

- Replace native date inputs with always-open custom calendar; return
  journeys show two months side-by-side with Airbnb-style range selection
- Add min-connection filter (30/40/50/60 min) for the inbound leg of
  return journeys, separate from the outbound connection filter
- Fix total journey time: naive datetime subtraction across CET/BST was
  1 h too long outbound and 1 h too short inbound
- Filter inbound circle line suggestions when connection ≥ 40 min: only
  show services arriving ≥ 5 min before GWR departure at Paddington
- Add Std / SP labels to Eurostar fare lines so users can distinguish
  Standard from Standard Premier
- Row selection with a fixed summary bar showing NR + Eurostar + circle
  totals; selection is preserved in the URL
- Load walk-on fares sequentially, outbound section first
- Mobile: card-grid table layout, hide headcode/platform on small screens

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Edward Betts 2026-05-25 14:58:32 +01:00
parent a88b19fa4c
commit 1bc7631863
5 changed files with 695 additions and 122 deletions

View file

@ -44,11 +44,24 @@ def _circle_line_services(arrive_paddington: datetime) -> list[dict]:
]
def _circle_line_services_to_paddington(arrive_st_pancras: datetime) -> list[dict]:
PAD_WALK_FROM_UNDERGROUND_MINUTES = 5 # Circle line platform → GWR platform at Paddington
INBOUND_COMFORTABLE_MIN_CONN = 40 # threshold above which we apply the platform walk buffer
def _circle_line_services_to_paddington(
arrive_st_pancras: datetime,
dep_paddington: datetime | None = None,
min_conn_minutes: int = INBOUND_MIN_CONNECTION_MINUTES,
) -> list[dict]:
earliest_board = arrive_st_pancras + timedelta(
minutes=KX_WALK_TO_UNDERGROUND_MINUTES
)
services = circle_line.upcoming_services(earliest_board, count=1, direction='kx_to_pad', preceding=1)
if min_conn_minutes >= INBOUND_COMFORTABLE_MIN_CONN and dep_paddington is not None:
cutoff = dep_paddington - timedelta(minutes=PAD_WALK_FROM_UNDERGROUND_MINUTES)
candidates = circle_line.upcoming_services(earliest_board, count=4, direction='kx_to_pad')
services = [(dep, arr) for dep, arr in candidates if arr <= cutoff][:2]
else:
services = circle_line.upcoming_services(earliest_board, count=1, direction='kx_to_pad', preceding=1)
return [
{
"depart": dep.strftime(TIME_FMT),
@ -166,8 +179,8 @@ def combine_trips(
continue
dep_bri, arr_pad, dep_stp, arr_dest = connection
total_mins = int((arr_dest - dep_bri).total_seconds() / 60)
# Destination time is CET/CEST, departure is GMT/BST; Europe is always 1h ahead.
total_mins = int((arr_dest - dep_bri).total_seconds() / 60) - 60
eurostar_mins = int((arr_dest - dep_stp).total_seconds() / 60) - 60
fare = (gwr_fares or {}).get(gwr["depart_bristol"])
circle_svcs = _circle_line_services(arr_pad)
@ -226,11 +239,11 @@ def combine_inbound_trips(
if not connection:
continue
dep_dest, arr_stp, dep_pad, arr_station = connection
total_mins = int((arr_station - dep_dest).total_seconds() / 60)
# Destination time is CET/CEST, arrival at London is GMT/BST.
# Destination time is CET/CEST, arrival at London is GMT/BST; Europe is always 1h ahead.
total_mins = int((arr_station - dep_dest).total_seconds() / 60) + 60
eurostar_mins = int((arr_stp - dep_dest).total_seconds() / 60) + 60
fare = (gwr_fares or {}).get(gwr["depart_paddington"])
circle_svcs = _circle_line_services_to_paddington(arr_stp)
circle_svcs = _circle_line_services_to_paddington(arr_stp, dep_pad, min_connection_minutes)
trips.append(
{
"direction": "inbound",