From 1bc763186338a697d3def929293d2114beaa91ad Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Mon, 25 May 2026 14:58:32 +0100 Subject: [PATCH] Five UI and data features for return journeys and results page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app.py | 13 +- templates/base.html | 78 ++++++- templates/index.html | 479 ++++++++++++++++++++++++++++++++++------- templates/results.html | 222 ++++++++++++++++--- trip_planner.py | 25 ++- 5 files changed, 695 insertions(+), 122 deletions(-) diff --git a/app.py b/app.py index aae6b8c..b29439c 100644 --- a/app.py +++ b/app.py @@ -90,6 +90,7 @@ def index(): VALID_MIN_CONNECTIONS = {45, 50, 60, 70, 80, 90, 100, 110, 120} VALID_MAX_CONNECTIONS = {60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180} VALID_INBOUND_MIN_CONNECTIONS = {20, 30, 40, 45, 50, 60, 70, 80, 90, 100, 110, 120} +VALID_INBOUND_RETURN_MIN_CONNECTIONS = {30, 40, 50, 60} VALID_INBOUND_MAX_CONNECTIONS = {60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180} VALID_JOURNEY_TYPES = {"outbound", "inbound", "return"} VALID_NR_CLASSES = {'walkon', 'advance_std', 'advance_1st'} @@ -357,6 +358,7 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): if es_class not in VALID_ES_CLASSES: es_class = DEFAULT_ES_CLASS + inbound_min_connection = INBOUND_MIN_CONNECTION_MINUTES if journey_type == "return": def _p(raw, default, valid): return raw if raw in valid else default @@ -364,6 +366,11 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): nr_class_in = _p(request.args.get("nr_class_in"), DEFAULT_NR_CLASS, VALID_NR_CLASSES) es_class_out = _p(request.args.get("es_class_out"), DEFAULT_ES_CLASS, VALID_ES_CLASSES) es_class_in = _p(request.args.get("es_class_in"), DEFAULT_ES_CLASS, VALID_ES_CLASSES) + inbound_min_connection = _parse_connection( + request.args.get("min_connection_in"), + INBOUND_MIN_CONNECTION_MINUTES, + VALID_INBOUND_RETURN_MIN_CONNECTIONS, + ) else: nr_class_out = nr_class_in = nr_class es_class_out = es_class_in = es_class @@ -464,7 +471,7 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): section_min_connection = min_connection section_max_connection = max_connection if journey_type == "return" and direction == "inbound": - section_min_connection = INBOUND_MIN_CONNECTION_MINUTES + section_min_connection = inbound_min_connection section_max_connection = INBOUND_MAX_CONNECTION_MINUTES rtt_direction = "to_paddington" if direction == "outbound" else "from_paddington" rtt_cache_key = _nr_exact_cache_key(rtt_direction, station_crs, section_date) @@ -672,6 +679,7 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): "return_date": return_date, "min_connection": url_min, "max_connection": url_max, + "min_connection_in": None if inbound_min_connection == INBOUND_MIN_CONNECTION_MINUTES else inbound_min_connection, "nr_class_out": None if nr_class_out == DEFAULT_NR_CLASS else nr_class_out, "nr_class_in": None if nr_class_in == DEFAULT_NR_CLASS else nr_class_in, "es_class_out": None if es_class_out == DEFAULT_ES_CLASS else es_class_out, @@ -860,6 +868,9 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): advance_fares_stream_url=url_for("api_advance_fares_stream", station_crs=station_crs, travel_date=travel_date), valid_min_connections=sorted(valid_min), valid_max_connections=sorted(valid_max), + inbound_min_connection=inbound_min_connection, + default_inbound_min_connection=INBOUND_MIN_CONNECTION_MINUTES, + valid_inbound_return_min_connections=sorted(VALID_INBOUND_RETURN_MIN_CONNECTIONS), ) diff --git a/templates/base.html b/templates/base.html index bf73706..242196a 100644 --- a/templates/base.html +++ b/templates/base.html @@ -182,10 +182,47 @@ } @media (max-width: 640px) { - .card { - padding: 1.25rem; + .card { padding: 1.25rem; } + + /* Convert results table to a 2-column card layout per row */ + .results-table, .results-table tbody { display: block; } + .results-table thead { display: none; } + .results-table tr { + display: grid; + grid-template-columns: 1fr 1fr; + border-bottom: 2px solid #e2e8f0; + padding: 0.1rem 0; } - .col-transfer { display: none; } + .results-table td { padding: 0.35rem 0.45rem; font-size: 0.8rem; border-bottom: none; } + + /* First journey leg (NR outbound / Eurostar inbound) */ + .results-table td:nth-child(1) { grid-column: 1; grid-row: 1; } + /* Transfer column: hidden */ + .col-transfer { display: none !important; } + /* Second journey leg */ + .results-table td:nth-child(3) { grid-column: 2; grid-row: 1; } + /* Total: spans both columns, right-aligned */ + .results-table td:nth-child(4) { + grid-column: 1 / -1; grid-row: 2; + text-align: right; + border-top: 1px solid #e2e8f0; + padding: 0.25rem 0.45rem 0.3rem; + } + + /* Hide non-essential detail on mobile */ + .mobile-hide { display: none !important; } + .fare-seats { display: none !important; } + + /* Show connection time hint */ + .mobile-conn { display: block !important; } + + /* Flow arrow: hide on mobile */ + .results-table thead th.flow-step::after { display: none; } + .results-table thead th.flow-step { padding-right: 0; } + + /* Selection bar: smaller on mobile */ + #selection-bar { padding: 0.5rem 0.75rem; font-size: 0.8rem; } + .sel-totals { gap: 0.75rem; } } a { color: #00539f; } @@ -277,6 +314,41 @@ .row-slow { background: #fff5f5; } .row-alt { background: #f7fafc; } .row-unreachable { background: #f7fafc; color: #a0aec0; } + .row-selected { background: #ebf8ff !important; } + tr.row-selectable { cursor: pointer; } + tr.row-selectable:hover:not(.row-selected) { filter: brightness(0.97); } + + /* Journey flow arrow between column headers */ + .results-table thead th.flow-step { position: relative; padding-right: 1.4rem; } + .results-table thead th.flow-step::after { + content: '›'; + position: absolute; right: 0.2rem; top: 50%; transform: translateY(-50%); + color: #cbd5e0; font-size: 1.5rem; font-weight: 300; line-height: 1; + } + + /* Mobile: hidden by default, shown on mobile */ + .mobile-conn { display: none; } + .fare-seats { display: inline; } + + /* Selection summary bar */ + #selection-bar { + display: none; position: fixed; bottom: 0; left: 0; right: 0; + background: #fff; border-top: 2px solid #00539f; + padding: 0.65rem 1rem; + box-shadow: 0 -2px 10px rgba(0,0,0,0.12); z-index: 200; + font-size: 0.88rem; + } + .sel-bar-inner { + max-width: 1100px; margin: 0 auto; + display: flex; justify-content: space-between; align-items: center; + flex-wrap: wrap; gap: 0.5rem; + } + .sel-totals { display: flex; gap: 1.25rem; align-items: center; flex-wrap: wrap; } + .sel-clear { + background: none; border: 1px solid #cbd5e0; border-radius: 4px; + padding: 0.2rem 0.6rem; font-size: 0.8rem; cursor: pointer; color: #718096; + } + .sel-clear:hover { background: #f0f4f8; } /* Empty state */ .empty-state { color: #4a5568; text-align: center; padding: 3rem 2rem; } diff --git a/templates/index.html b/templates/index.html index 3a3317f..898a6e4 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,8 +1,75 @@ {% extends "base.html" %} {% block content %} + +

Plan your journey

+
- + +
- - + +
- - + +
@@ -43,40 +101,31 @@ Eurostar destination
{% for slug, name in destinations.items() %} - {% set city = name.replace(' Gare du Nord', '').replace(' Centraal', '').replace(' Midi', '').replace(' Europe', '') %} + {% set city = name.replace(' Gare du Nord','').replace(' Centraal','').replace(' Midi','').replace(' Europe','') %}
- - + +
{% endfor %}
+
- - -
- -
- - + Travel dates +
+
+ +
+ +
+
+
+
+ + +
@@ -101,60 +150,330 @@
- + + {% endblock %} diff --git a/templates/results.html b/templates/results.html index d176481..afe5d03 100644 --- a/templates/results.html +++ b/templates/results.html @@ -79,6 +79,16 @@ {% for section in sections %}
{{ 'Outbound' if section.direction == 'outbound' else 'Return' }}: + {% if section.direction == 'inbound' %} +
+ + +
+ {% endif %}
NR:
@@ -129,6 +139,7 @@ const RESULTS_BASE = '{{ results_base_url }}'; const DEFAULT_MIN_CONN = {{ default_min_connection }}; const DEFAULT_MAX_CONN = {{ default_max_connection }}; + const DEFAULT_MIN_CONN_IN = {{ default_inbound_min_connection }}; let TRIP_FARES = {{ trip_fares_json | safe }}; let ADVANCE_FARES = {{ advance_fares_json | safe }}; let WALKON_API_URLS = {{ walkon_api_urls_json | safe }}; @@ -141,6 +152,8 @@ let currentEsClasses = {{ es_classes_json | safe }}; const SECTION_DIRECTIONS = {{ section_directions_json | safe }}; let advanceLoadingSections = {}; + let walkonLoadingSections = {}; + let selectedRowKeys = {}; function updateAdvanceLoadingStatus() { var loading = Object.keys(advanceLoadingSections).some(function(sectionId) { @@ -156,6 +169,11 @@ var params = []; if (min !== DEFAULT_MIN_CONN) params.push('min_connection=' + min); if (max !== DEFAULT_MAX_CONN) params.push('max_connection=' + max); + var minInEl = document.getElementById('min_conn_in_select'); + if (minInEl) { + var minIn = parseInt(minInEl.value); + if (minIn !== DEFAULT_MIN_CONN_IN) params.push('min_connection_in=' + minIn); + } var sectionIds = Object.keys(currentNrClasses); if (sectionIds.length === 1) { var nrCls = currentNrClasses[sectionIds[0]]; @@ -172,6 +190,12 @@ if (esC !== 'standard') params.push('es_class' + suffix + '=' + esC); }); } + for (var _sid in selectedRowKeys) { + var _sel = selectedRowKeys[_sid]; + if (!_sel || !SECTION_DIRECTIONS[_sid]) continue; + var _pname = SECTION_DIRECTIONS[_sid] === 'outbound' ? 'out' : 'ret'; + params.push(_pname + '=' + encodeURIComponent(_sel.slice(_sid.length + 1))); + } return params.length ? RESULTS_BASE + '?' + params.join('&') : RESULTS_BASE; } @@ -205,7 +229,7 @@ function fareHtml(fare) { return '' + fmtPrice(fare.price) + '' + (fare.ticket ? ' ' + fare.ticket + '' : '') - + (fare.seats != null ? ' ' + fare.seats + ' at this price' : ''); + + (fare.seats != null ? ' ' + fare.seats + ' at this price' : ''); } function mergeAdvanceFares(sectionId, fares) { @@ -376,17 +400,19 @@ var esStdEl = tr.querySelector('.es-standard'); var esPlusEl = tr.querySelector('.es-plus'); if (esStdEl) { - esStdEl.innerHTML = row.es_standard ? fareHtml(row.es_standard) : '\u2013'; + esStdEl.innerHTML = 'Std ' + (row.es_standard ? fareHtml(row.es_standard) : '\u2013'); esStdEl.classList.toggle('fare-inactive', esClass !== 'standard'); } if (esPlusEl) { - esPlusEl.innerHTML = row.es_plus ? fareHtml(row.es_plus) : '\u2013'; + esPlusEl.innerHTML = 'SP ' + (row.es_plus ? fareHtml(row.es_plus) : '\u2013'); esPlusEl.classList.toggle('fare-inactive', esClass !== 'plus'); } var totalSpan = tr.querySelector('.total-price'); if (totalSpan) { - if (key in totals) { + if (key.indexOf(':unreachable:') !== -1) { + totalSpan.innerHTML = 'No rail connection'; + } else if (key in totals) { var total = totals[key]; var html = '' + fmtPrice(total); if (minTotal !== null && maxTotal !== null) { @@ -395,42 +421,153 @@ } html += ''; totalSpan.innerHTML = html; + } else if (!nrFare && walkonLoadingSections[row.section]) { + totalSpan.innerHTML = ''; + } else if (!nrFare) { + totalSpan.innerHTML = 'NR fare not available'; } else { - var missing = !nrFare ? 'No NR fare' : 'No Eurostar fare'; - totalSpan.innerHTML = '' + missing + ''; + totalSpan.innerHTML = 'No Eurostar price'; } } }); + updateSelectionBar(); } function loadWalkonFares() { var urls = WALKON_API_URLS; - var pending = Object.keys(urls).length; - if (!pending) return; - function done() { + var ids = Object.keys(urls); + if (!ids.length) return; + /* outbound first, then inbound */ + ids.sort(function(a, b) { + return (SECTION_DIRECTIONS[a] === 'outbound' ? 0 : 1) - + (SECTION_DIRECTIONS[b] === 'outbound' ? 0 : 1); + }); + ids.forEach(function(sid) { walkonLoadingSections[sid] = true; }); + var pending = ids.length; + function done(id) { + walkonLoadingSections[id] = false; if (--pending === 0) { var el = document.getElementById('walkon-loading'); if (el) el.style.display = 'none'; } } - for (var sectionId in urls) { - (function(id, url) { - fetch(url).then(function(r) { return r.json(); }).then(function(fares) { - mergeWalkonFares(id, fares); - updateDisplay(); - done(); - }).catch(done); - })(sectionId, urls[sectionId]); + /* sequential: each fetch starts only after the previous one finishes */ + ids.reduce(function(chain, id) { + return chain.then(function() { + return fetch(urls[id]) + .then(function(r) { return r.json(); }) + .then(function(fares) { mergeWalkonFares(id, fares); done(id); updateDisplay(); }) + .catch(function() { done(id); updateDisplay(); }); + }); + }, Promise.resolve()); + } + + function initSelectionFromUrl() { + var params = new URLSearchParams(window.location.search); + for (var sid in SECTION_DIRECTIONS) { + var dir = SECTION_DIRECTIONS[sid]; + var val = params.get(dir === 'outbound' ? 'out' : 'ret'); + if (val) { + var rowKey = sid + ':' + val; + if (TRIP_FARES[rowKey]) selectedRowKeys[sid] = rowKey; + } + } + } + + function selectRow(tr) { + var key = tr.getAttribute('data-row-key'); + if (!key || key.indexOf(':unreachable:') !== -1) return; + var row = TRIP_FARES[key]; + if (!row) return; + selectedRowKeys[row.section] = (selectedRowKeys[row.section] === key) ? null : key; + updateRowHighlights(); + updateSelectionBar(); + history.replaceState(null, '', buildUrl()); + } + + function clearSelection() { + selectedRowKeys = {}; + updateRowHighlights(); + updateSelectionBar(); + history.replaceState(null, '', buildUrl()); + } + + function updateRowHighlights() { + document.querySelectorAll('tr[data-row-key]').forEach(function(tr) { + var key = tr.getAttribute('data-row-key'); + var row = TRIP_FARES[key]; + if (!row) return; + tr.classList.toggle('row-selected', selectedRowKeys[row.section] === key); + }); + } + + function updateSelectionBar() { + var bar = document.getElementById('selection-bar'); + if (!bar) return; + var allSids = Object.keys(SECTION_DIRECTIONS); + var activeSids = allSids.filter(function(sid) { return selectedRowKeys[sid]; }); + if (activeSids.length === 0) { bar.style.display = 'none'; return; } + bar.style.display = 'block'; + + var totalNr = 0, totalEs = 0, totalCircle = 0, allPrices = true; + var parts = []; + activeSids.forEach(function(sid) { + var rowKey = selectedRowKeys[sid]; + var row = TRIP_FARES[rowKey]; + if (!row) return; + var nrFare = currentNrFare(row); + var esClass = currentEsClasses[sid] || 'standard'; + var esFare = esClass === 'standard' ? row.es_standard : row.es_plus; + if (nrFare) totalNr += nrFare.price; else allPrices = false; + if (esFare) totalEs += esFare.price; else allPrices = false; + totalCircle += row.circle_fare || 0; + var kp = rowKey.split(':'); + // outbound key: section:NRdep:ESdep — show NR dep (Bristol) + // inbound key: section:NRdep:ESdep — show ES dep (destination, CET) + var depTime = SECTION_DIRECTIONS[sid] === 'outbound' + ? kp[1] + ':' + kp[2] + : kp[3] + ':' + kp[4]; + parts.push((SECTION_DIRECTIONS[sid] === 'outbound' ? 'Out ' : 'Ret ') + depTime); + }); + + var descEl = document.getElementById('sel-desc'); + if (descEl) descEl.textContent = parts.join(' · '); + + var hintEl = document.getElementById('sel-hint'); + if (hintEl) { + if (allSids.length > 1 && activeSids.length < allSids.length) { + var missingDir = SECTION_DIRECTIONS[allSids.filter(function(s) { return !selectedRowKeys[s]; })[0]]; + hintEl.textContent = 'Select a ' + (missingDir === 'outbound' ? 'outbound' : 'return') + ' journey to see combined total'; + hintEl.style.display = ''; + } else { + hintEl.style.display = 'none'; + } + } + + var grandTotal = totalNr + totalEs + totalCircle; + var nrEl = document.getElementById('sel-nr'); + var esEl = document.getElementById('sel-es'); + var grandEl = document.getElementById('sel-grand'); + if (nrEl) nrEl.innerHTML = 'NR ' + (allPrices ? fmtPrice(totalNr) : '–') + ''; + if (esEl) esEl.innerHTML = 'Eurostar ' + (allPrices ? fmtPrice(totalEs) : '–') + ''; + if (grandEl) { + var label = (activeSids.length === allSids.length && allSids.length > 1) ? 'Grand total' : 'Total'; + var priceHtml = allPrices + ? '' + fmtPrice(grandTotal) + '' + : ''; + grandEl.innerHTML = label + ' ' + priceHtml; } } function initialiseResultsPage() { + initSelectionFromUrl(); var needsAdvance = Object.keys(currentNrClasses).some(function(sid) { var c = currentNrClasses[sid]; return c === 'advance_std' || c === 'advance_1st'; }); if (needsAdvance) loadMissingAdvanceFares(); updateDisplay(); + updateRowHighlights(); loadWalkonFares(); startTimetableRefresh(); } @@ -548,15 +685,15 @@ {% if section.direction == 'inbound' %} - Eurostar
{{ destination }} → St Pancras - Transfer
St Pancras → Paddington + Eurostar
{{ destination }} → St Pancras + Transfer
St Pancras → Paddington National Rail
Paddington → {{ departure_station_name }} {% else %} - National Rail
{{ departure_station_name }} → Paddington - Transfer
Paddington → St Pancras + National Rail
{{ departure_station_name }} → Paddington + Transfer
Paddington → St Pancras Eurostar
St Pancras → {{ destination }} {% endif %} - Total + Total
click row to select @@ -577,8 +714,10 @@ {% else %} {% set row_class = '' %} {% endif %} - + {% if row.row_type == 'trip' %} {% if section.direction == 'inbound' %} @@ -591,8 +730,9 @@ {%- if row.train_number %}{{ row.train_number }}{% endif %} {% endif %} - {% if row.eurostar_price is not none %}£{{ "%.2f"|format(row.eurostar_price) }}{% endif %} - {% if row.eurostar_plus_price is not none %}£{{ "%.2f"|format(row.eurostar_plus_price) }}{% endif %} + Std{% if row.eurostar_price is not none %} £{{ "%.2f"|format(row.eurostar_price) }}{% endif %} + SP{% if row.eurostar_plus_price is not none %} £{{ "%.2f"|format(row.eurostar_plus_price) }}{% endif %} + then {{ row.connection_duration }} to station{% if row.connection_minutes < 45 %} ⚠️{% endif %} {{ row.connection_duration }}{% if row.connection_minutes < 45 %} ⚠️{% endif %} @@ -612,7 +752,7 @@ {{ row.depart_paddington }} → {{ row.arrive_uk_station }} ({{ row.gwr_duration }}) {% if row.headcode or row.arrive_platform %} -
{{ row.headcode }}{% if row.headcode and row.arrive_platform %} · {% endif %}{% if row.arrive_platform %}Plat {{ row.arrive_platform }}{% endif %} +
{{ row.headcode }}{% if row.headcode and row.arrive_platform %} · {% endif %}{% if row.arrive_platform %}Plat {{ row.arrive_platform }}{% endif %} {% endif %} {% if row.ticket_price is not none %}£{{ "%.2f"|format(row.ticket_price) }}{% endif %} @@ -623,11 +763,12 @@ {{ row.depart_bristol }} → {{ row.arrive_paddington }} ({{ row.gwr_duration }}) {% if row.headcode or row.arrive_platform %} -
{{ row.headcode }}{% if row.headcode and row.arrive_platform %} · {% endif %}{% if row.arrive_platform %}Plat {{ row.arrive_platform }}{% endif %} +
{{ row.headcode }}{% if row.headcode and row.arrive_platform %} · {% endif %}{% if row.arrive_platform %}Plat {{ row.arrive_platform }}{% endif %} {% endif %} {% if row.ticket_price is not none %}£{{ "%.2f"|format(row.ticket_price) }}{% endif %} + then {{ row.connection_duration }} to Eurostar{% if row.connection_minutes < 80 %} ⚠️{% endif %} {{ row.connection_duration }}{% if row.connection_minutes < 80 %} ⚠️{% endif %} @@ -649,8 +790,8 @@ {%- if row.train_number %}{{ row.train_number }}{% endif %} {% endif %} - {% if row.eurostar_price is not none %}£{{ "%.2f"|format(row.eurostar_price) }}{% endif %} - {% if row.eurostar_plus_price is not none %}£{{ "%.2f"|format(row.eurostar_plus_price) }}{% endif %} + Std{% if row.eurostar_price is not none %} £{{ "%.2f"|format(row.eurostar_price) }}{% endif %} + SP{% if row.eurostar_plus_price is not none %} £{{ "%.2f"|format(row.eurostar_plus_price) }}{% endif %} {% endif %} @@ -665,7 +806,7 @@ {% else %} - Too early + Too early — @@ -676,8 +817,10 @@ {{ row.depart_st_pancras }} → {{ row.arrive_destination }} {% if row.train_number %}
{{ row.train_number }}{% endif %} {% endif %} + + - No connection + {% endif %} {% endfor %} @@ -718,4 +861,19 @@

{% endif %} +
+
+
+ + +
+
+ + + + +
+
+
+ {% endblock %} diff --git a/trip_planner.py b/trip_planner.py index d03b767..d858a82 100644 --- a/trip_planner.py +++ b/trip_planner.py @@ -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",