From df94e822ae87f35f0ccfd2b022c28d6c9dd07ea6 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sat, 4 Apr 2026 10:43:31 +0100 Subject: [PATCH 1/6] Try 45 minute min connection time. --- app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app.py b/app.py index cdfa4ef..eff2f76 100644 --- a/app.py +++ b/app.py @@ -33,7 +33,7 @@ def index(): return render_template('index.html', destinations=DESTINATIONS, today=today) -VALID_MIN_CONNECTIONS = {50, 60, 70, 80, 90, 100, 110, 120} +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} From 4e4202e220a5f1b45708d1511fa45759ae21b6a6 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sat, 4 Apr 2026 10:43:47 +0100 Subject: [PATCH 2/6] Drop MORNING_CUTOFF_HOUR, consider all services. --- templates/results.html | 3 --- trip_planner.py | 9 --------- 2 files changed, 12 deletions(-) diff --git a/templates/results.html b/templates/results.html index c1a0a1a..cce67cd 100644 --- a/templates/results.html +++ b/templates/results.html @@ -205,9 +205,6 @@

Paddington → St Pancras connection: {{ min_connection }}–{{ max_connection }} min. - {% if unreachable_morning_services %} - Morning means Eurostar departures before 12:00 from St Pancras. - {% endif %} GWR walk-on single prices for Bristol Temple Meads → Paddington. Eurostar Standard prices are for 1 adult in GBP; always check eurostar.com to book. diff --git a/trip_planner.py b/trip_planner.py index ca178d6..4aa2ed2 100644 --- a/trip_planner.py +++ b/trip_planner.py @@ -6,7 +6,6 @@ from datetime import datetime, timedelta, time as _time MIN_CONNECTION_MINUTES = 50 MAX_CONNECTION_MINUTES = 110 MAX_GWR_MINUTES = 110 -MORNING_CUTOFF_HOUR = 12 DATE_FMT = '%Y-%m-%d' TIME_FMT = '%H:%M' @@ -155,14 +154,6 @@ def find_unreachable_morning_eurostars( unreachable = [] for es in eurostar_trains: - try: - dep_stp = _parse_dt(travel_date, es['depart_st_pancras']) - except (ValueError, KeyError): - continue - - if dep_stp.hour >= MORNING_CUTOFF_HOUR: - continue - if any( _is_viable_connection( gwr, From 06afd57957b46a971f49975ffacdd5c09f196adb Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sat, 4 Apr 2026 10:47:46 +0100 Subject: [PATCH 3/6] Link to Eurostar search page with pricing; add Bristol departures RTT link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Eurostar timetable link with the search URL (eurostar.com/search/uk-en?adult=1&origin=…&destination=…&outbound=…) so the footer links directly to the page that shows prices for the specific date and destination. Add a Bristol Temple Meads → Paddington departures link on RTT alongside the existing Paddington arrivals link. Also update "morning service unavailable" badge and tests to reflect the removal of the morning-only cutoff filter from find_unreachable_morning_eurostars. Co-Authored-By: Claude Sonnet 4.6 --- app.py | 10 +++++++++- scraper/eurostar.py | 8 ++++++++ templates/results.html | 4 +++- tests/test_app.py | 5 ++--- tests/test_trip_planner.py | 8 +++++--- 5 files changed, 27 insertions(+), 8 deletions(-) diff --git a/app.py b/app.py index eff2f76..2ad3fce 100644 --- a/app.py +++ b/app.py @@ -16,6 +16,12 @@ RTT_PADDINGTON_URL = ( "?stp=WVS&show=pax-calls&order=wtt" ) +RTT_BRISTOL_URL = ( + "https://www.realtimetrains.co.uk/search/detailed/" + "gb-nr:BRI/to/gb-nr:PAD/{date}/0000-2359" + "?stp=WVS&show=pax-calls&order=wtt" +) + app = Flask(__name__) DESTINATIONS = { @@ -154,8 +160,9 @@ def results(slug, travel_date): next_date = (dt + timedelta(days=1)).isoformat() travel_date_display = dt.strftime('%A %-d %B %Y') - eurostar_url = eurostar_scraper.timetable_url(destination) + f"?date={travel_date}" + eurostar_url = eurostar_scraper.search_url(destination, travel_date) rtt_url = RTT_PADDINGTON_URL.format(date=travel_date) + rtt_bristol_url = RTT_BRISTOL_URL.format(date=travel_date) return render_template( 'results.html', @@ -175,6 +182,7 @@ def results(slug, travel_date): error=error, eurostar_url=eurostar_url, rtt_url=rtt_url, + rtt_bristol_url=rtt_bristol_url, min_connection=min_connection, max_connection=max_connection, valid_min_connections=sorted(VALID_MIN_CONNECTIONS), diff --git a/scraper/eurostar.py b/scraper/eurostar.py index 8ae5129..4bbdfd9 100644 --- a/scraper/eurostar.py +++ b/scraper/eurostar.py @@ -48,6 +48,14 @@ def _slugify_station_name(name: str) -> str: return re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-') +def search_url(destination: str, travel_date: str) -> str: + dest_id = DESTINATION_STATION_IDS[destination] + return ( + f'https://www.eurostar.com/search/uk-en' + f'?adult=1&origin={ORIGIN_STATION_ID}&destination={dest_id}&outbound={travel_date}' + ) + + def timetable_url(destination: str) -> str: dest_id = DESTINATION_STATION_IDS[destination] dest_slug = _slugify_station_name(destination) diff --git a/templates/results.html b/templates/results.html index cce67cd..a9a244d 100644 --- a/templates/results.html +++ b/templates/results.html @@ -83,7 +83,7 @@ {% if unreachable_morning_services %}  ·  - {{ unreachable_morning_services | length }} morning service{{ 's' if unreachable_morning_services | length != 1 }} unavailable from Bristol + {{ unreachable_morning_services | length }} Eurostar service{{ 's' if unreachable_morning_services | length != 1 }} unavailable from Bristol {% endif %} {% if from_cache %} @@ -209,6 +209,8 @@ Eurostar Standard prices are for 1 adult in GBP; always check eurostar.com to book.  ·  + Bristol departures on RTT +  ·  Paddington arrivals on RTT

diff --git a/tests/test_app.py b/tests/test_app.py index cd21232..937432f 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -211,11 +211,10 @@ def test_results_shows_unreachable_morning_eurostar_services(monkeypatch): html = resp.get_data(as_text=True) assert resp.status_code == 200 - assert '1 morning service unavailable from Bristol' in html + assert '2 Eurostar services unavailable from Bristol' in html assert '09:30' in html assert 'ES 9001' in html assert 'Unavailable from Bristol' in html - assert 'Morning means Eurostar departures before 12:00 from St Pancras.' in html assert html.index('09:30') < html.index('10:15') @@ -267,6 +266,6 @@ def test_results_can_show_only_unreachable_morning_services(monkeypatch): assert resp.status_code == 200 assert 'No valid journeys found.' not in html - assert '1 morning service unavailable from Bristol' in html + assert '1 Eurostar service unavailable from Bristol' in html assert '09:30' in html assert 'Unavailable from Bristol' in html diff --git a/tests/test_trip_planner.py b/tests/test_trip_planner.py index 5e1c93c..a04d8d8 100644 --- a/tests/test_trip_planner.py +++ b/tests/test_trip_planner.py @@ -131,7 +131,9 @@ def test_connection_duration_in_trip(): assert trips[0]['connection_duration'] == '1h 16m' -def test_unreachable_morning_eurostars_lists_only_unreachable_morning_services(): +def test_find_unreachable_eurostars_excludes_connectable_services(): + # GWR arrives 08:45; default min=50/max=110 → viable window 09:35–10:35. + # 09:30 too early, 10:15 connectable, 12:30 beyond max connection. gwr = [ {'depart_bristol': '07:00', 'arrive_paddington': '08:45'}, ] @@ -143,7 +145,7 @@ def test_unreachable_morning_eurostars_lists_only_unreachable_morning_services() unreachable = find_unreachable_morning_eurostars(gwr, eurostar, DATE) - assert [service['depart_st_pancras'] for service in unreachable] == ['09:30'] + assert [s['depart_st_pancras'] for s in unreachable] == ['09:30', '12:30'] # --------------------------------------------------------------------------- @@ -205,7 +207,7 @@ def test_combine_trips_includes_ticket_fields(): assert 'ticket_code' in t -def test_unreachable_morning_eurostars_returns_empty_when_morning_service_is_reachable(): +def test_find_unreachable_eurostars_returns_empty_when_all_connectable(): gwr = [ {'depart_bristol': '07:00', 'arrive_paddington': '08:45'}, ] From 19656f412ab287a07f5b34e5356b48b831b2c2c1 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sat, 4 Apr 2026 10:48:49 +0100 Subject: [PATCH 4/6] Prevent GWR duration from wrapping on small screens Co-Authored-By: Claude Sonnet 4.6 --- templates/results.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/results.html b/templates/results.html index a9a244d..9e71bbb 100644 --- a/templates/results.html +++ b/templates/results.html @@ -137,7 +137,7 @@ {{ row.arrive_paddington }} - ({{ row.gwr_duration }}) + ({{ row.gwr_duration }}) £{{ "%.2f"|format(row.ticket_price) }} From e7695a5e49c3a3c760e26de66fa46666289d4d72 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sat, 4 Apr 2026 10:52:32 +0100 Subject: [PATCH 5/6] Drive search form dropdowns from VALID_MIN/MAX_CONNECTIONS; warn on short transfers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Index page connection time dropdowns now iterate over valid_min_connections and valid_max_connections passed from the view, so any change to the sets in app.py is reflected automatically (also adds the missing 45 min option). Add ⚠️ next to transfer times under 80 minutes in the results table; store connection_minutes in each trip dict to support the comparison. Co-Authored-By: Claude Sonnet 4.6 --- app.py | 8 +++++++- templates/index.html | 4 ++-- templates/results.html | 2 +- trip_planner.py | 1 + 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app.py b/app.py index 2ad3fce..2d2a2e2 100644 --- a/app.py +++ b/app.py @@ -36,7 +36,13 @@ DESTINATIONS = { @app.route('/') def index(): today = date.today().isoformat() - return render_template('index.html', destinations=DESTINATIONS, today=today) + return render_template( + 'index.html', + destinations=DESTINATIONS, + today=today, + valid_min_connections=sorted(VALID_MIN_CONNECTIONS), + valid_max_connections=sorted(VALID_MAX_CONNECTIONS), + ) VALID_MIN_CONNECTIONS = {45, 50, 60, 70, 80, 90, 100, 110, 120} diff --git a/templates/index.html b/templates/index.html index c8300a4..306fb90 100644 --- a/templates/index.html +++ b/templates/index.html @@ -47,7 +47,7 @@ Minimum connection time (Paddington → St Pancras) @@ -58,7 +58,7 @@ Maximum connection time (Paddington → St Pancras) diff --git a/templates/results.html b/templates/results.html index 9e71bbb..999fe2a 100644 --- a/templates/results.html +++ b/templates/results.html @@ -144,7 +144,7 @@
{{ row.ticket_name }} - {{ row.connection_duration }} + {{ row.connection_duration }}{% if row.connection_minutes < 80 %} ⚠️{% endif %} {{ row.depart_st_pancras }} diff --git a/trip_planner.py b/trip_planner.py index 4aa2ed2..c7e3286 100644 --- a/trip_planner.py +++ b/trip_planner.py @@ -127,6 +127,7 @@ def combine_trips( 'arrive_paddington': gwr['arrive_paddington'], 'headcode': gwr.get('headcode', ''), 'gwr_duration': _fmt_duration(int((arr_pad - dep_bri).total_seconds() / 60)), + 'connection_minutes': int((dep_stp - arr_pad).total_seconds() / 60), 'connection_duration': _fmt_duration(int((dep_stp - arr_pad).total_seconds() / 60)), 'depart_st_pancras': es['depart_st_pancras'], 'arrive_destination': es['arrive_destination'], From d089d3d7166a16c34fe15256740c969d53aaa930 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sat, 4 Apr 2026 12:27:23 +0100 Subject: [PATCH 6/6] Add price emoji indicators and hover text to results table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show 💰 on total prices within £10 of the cheapest journey and 💸 within £10 of the most expensive, mirroring the ⚡/🐢 logic for journey time. Only applied when more than one priced trip exists. Add title attributes to ⚠️ ("Tight connection"), ⚡ ("Fastest journey"), and 🐢 ("Slowest journey") for accessibility and discoverability. Co-Authored-By: Claude Sonnet 4.6 --- templates/results.html | 13 +++++++++---- tests/test_app.py | 4 ++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/templates/results.html b/templates/results.html index 999fe2a..12a2f97 100644 --- a/templates/results.html +++ b/templates/results.html @@ -116,6 +116,11 @@ {% if trips %} {% set best_mins = trips | map(attribute='total_minutes') | min %} {% set worst_mins = trips | map(attribute='total_minutes') | max %} + {% set priced_trips = trips | selectattr('total_price') | list %} + {% if priced_trips | length > 1 %} + {% set min_price = priced_trips | map(attribute='total_price') | min %} + {% set max_price = priced_trips | map(attribute='total_price') | max %} + {% endif %} {% endif %} {% for row in result_rows %} {% if row.row_type == 'trip' and row.total_minutes <= best_mins + 5 and trips | length > 1 %} @@ -144,7 +149,7 @@
{{ row.ticket_name }} - {{ row.connection_duration }}{% if row.connection_minutes < 80 %} ⚠️{% endif %} + {{ row.connection_duration }}{% if row.connection_minutes < 80 %} ⚠️{% endif %} {{ row.depart_st_pancras }} @@ -163,14 +168,14 @@ {% if row.total_minutes <= best_mins + 5 and trips | length > 1 %} - {{ row.total_duration }} ⚡ + {{ row.total_duration }} ⚡ {% elif row.total_minutes >= worst_mins - 5 and trips | length > 1 %} - {{ row.total_duration }} 🐢 + {{ row.total_duration }} 🐢 {% else %} {{ row.total_duration }} {% endif %} {% if row.total_price is not none %} -
£{{ "%.2f"|format(row.total_price) }} +
£{{ "%.2f"|format(row.total_price) }}{% if min_price is defined and max_price is defined %}{% if row.total_price <= min_price + 10 %} 💰{% elif row.total_price >= max_price - 10 %} 💸{% endif %}{% endif %} {% endif %} {% else %} diff --git a/tests/test_app.py b/tests/test_app.py index 937432f..d4de5c2 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -155,8 +155,8 @@ def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(monkeypa html = resp.get_data(as_text=True) assert resp.status_code == 200 - assert html.count('title="Fastest option"') == 2 - assert html.count('title="Slowest option"') == 2 + assert html.count('title="Fastest journey"') == 2 + assert html.count('title="Slowest journey"') == 2 assert '4h 50m ⚡' in html assert '4h 55m ⚡' in html assert '5h 20m 🐢' in html