diff --git a/app.py b/app.py index fd40f62..db0eeb0 100644 --- a/app.py +++ b/app.py @@ -7,7 +7,7 @@ from datetime import date, timedelta from cache import get_cached, set_cached import scraper.eurostar as eurostar_scraper import scraper.realtime_trains as rtt_scraper -from trip_planner import combine_trips +from trip_planner import combine_trips, find_unreachable_morning_eurostars RTT_PADDINGTON_URL = ( "https://www.realtimetrains.co.uk/search/detailed/" @@ -109,6 +109,18 @@ def results(slug, travel_date): error = f"{error}; {msg}" if error else msg trips = combine_trips(gwr_trains, eurostar_trains, travel_date, min_connection, max_connection) + unreachable_morning_services = find_unreachable_morning_eurostars( + gwr_trains, + eurostar_trains, + travel_date, + min_connection, + max_connection, + ) + result_rows = sorted( + [{'row_type': 'trip', **trip} for trip in trips] + + [{'row_type': 'unreachable', **service} for service in unreachable_morning_services], + key=lambda row: row['depart_st_pancras'], + ) dt = date.fromisoformat(travel_date) prev_date = (dt - timedelta(days=1)).isoformat() @@ -121,6 +133,8 @@ def results(slug, travel_date): return render_template( 'results.html', trips=trips, + result_rows=result_rows, + unreachable_morning_services=unreachable_morning_services, destinations=DESTINATIONS, destination=destination, travel_date=travel_date, diff --git a/templates/results.html b/templates/results.html index 3e38faa..d3ac918 100644 --- a/templates/results.html +++ b/templates/results.html @@ -75,6 +75,12 @@ {{ gwr_count }} GWR service{{ 's' if gwr_count != 1 }}  ·  {{ eurostar_count }} Eurostar service{{ 's' if eurostar_count != 1 }} + {% if unreachable_morning_services %} +  ·  + + {{ unreachable_morning_services | length }} morning service{{ 's' if unreachable_morning_services | length != 1 }} unavailable from Bristol + + {% endif %} {% if from_cache %}  ·  (cached) {% endif %} @@ -86,13 +92,14 @@ {% endif %} -{% if trips %} +{% if trips or unreachable_morning_services %}
+ + {% if trips %} {% set best_mins = trips | map(attribute='total_minutes') | min %} {% set worst_mins = trips | map(attribute='total_minutes') | max %} - {% for trip in trips %} - {% if trip.total_minutes == best_mins and trips | length > 1 %} + {% endif %} + {% for row in result_rows %} + {% if row.row_type == 'trip' and row.total_minutes <= best_mins + 5 and trips | length > 1 %} {% set row_bg = 'background:#f0fff4' %} - {% elif trip.total_minutes == worst_mins and trips | length > 1 %} + {% elif row.row_type == 'trip' and row.total_minutes >= worst_mins - 5 and trips | length > 1 %} {% set row_bg = 'background:#fff5f5' %} + {% elif row.row_type == 'unreachable' %} + {% set row_bg = 'background:#f7fafc;color:#a0aec0' %} {% elif loop.index is odd %} {% set row_bg = 'background:#f7fafc' %} {% else %} {% set row_bg = '' %} {% endif %} + {% if row.row_type == 'trip' %} + + {% else %} + + + + + + + + {% endif %} {% endfor %} @@ -150,6 +183,9 @@

Paddington → St Pancras connection: {{ min_connection }}–{{ max_connection }} min. + {% if unreachable_morning_services %} + Morning means Eurostar departures before 12:00 from St Pancras. + {% endif %} Eurostar times are from the general timetable and may vary; always check eurostar.com to book.  ·  diff --git a/tests/test_app.py b/tests/test_app.py index 0292dad..b79a992 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -89,3 +89,166 @@ def test_results_title_and_social_meta_include_destination(monkeypatch): 'to Lille Europe on Friday 10 April 2026 via Paddington, St Pancras, and Eurostar.">' ) in html assert '' in html + + +def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(monkeypatch): + monkeypatch.setattr(app_module, 'get_cached', lambda key: None) + monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None) + monkeypatch.setattr( + app_module.rtt_scraper, + 'fetch', + lambda travel_date, user_agent: [ + {'depart_bristol': '07:00', 'arrive_paddington': '08:30', 'headcode': '1A01'}, + {'depart_bristol': '07:05', 'arrive_paddington': '08:36', 'headcode': '1A02'}, + {'depart_bristol': '07:10', 'arrive_paddington': '08:46', 'headcode': '1A03'}, + {'depart_bristol': '07:15', 'arrive_paddington': '08:56', 'headcode': '1A04'}, + {'depart_bristol': '07:20', 'arrive_paddington': '09:06', 'headcode': '1A05'}, + ], + ) + monkeypatch.setattr( + app_module.eurostar_scraper, + 'fetch', + lambda destination, travel_date, user_agent: [ + { + 'depart_st_pancras': '09:30', + 'arrive_destination': '11:50', + 'destination': destination, + 'train_number': 'ES 1001', + }, + { + 'depart_st_pancras': '09:40', + 'arrive_destination': '12:00', + 'destination': destination, + 'train_number': 'ES 1002', + }, + { + 'depart_st_pancras': '09:50', + 'arrive_destination': '12:20', + 'destination': destination, + 'train_number': 'ES 1003', + }, + { + 'depart_st_pancras': '10:00', + 'arrive_destination': '12:35', + 'destination': destination, + 'train_number': 'ES 1004', + }, + { + 'depart_st_pancras': '10:10', + 'arrive_destination': '12:45', + 'destination': destination, + 'train_number': 'ES 1005', + }, + ], + ) + monkeypatch.setattr( + app_module.eurostar_scraper, + 'timetable_url', + lambda destination: f'https://example.test/{destination.lower().replace(" ", "-")}', + ) + client = _client() + + resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120') + 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 '4h 50m ⚡' in html + assert '4h 55m ⚡' in html + assert '5h 20m 🐢' in html + assert '5h 25m 🐢' in html + assert '5h 10m ⚡' not in html + assert '5h 10m 🐢' not in html + + +def test_results_shows_unreachable_morning_eurostar_services(monkeypatch): + monkeypatch.setattr(app_module, 'get_cached', lambda key: None) + monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None) + monkeypatch.setattr( + app_module.rtt_scraper, + 'fetch', + lambda travel_date, user_agent: [ + {'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'}, + ], + ) + monkeypatch.setattr( + app_module.eurostar_scraper, + 'fetch', + lambda destination, travel_date, user_agent: [ + { + 'depart_st_pancras': '09:30', + 'arrive_destination': '12:00', + 'destination': destination, + 'train_number': 'ES 9001', + }, + { + 'depart_st_pancras': '10:15', + 'arrive_destination': '13:40', + 'destination': destination, + 'train_number': 'ES 9002', + }, + { + 'depart_st_pancras': '12:30', + 'arrive_destination': '15:55', + 'destination': destination, + 'train_number': 'ES 9003', + }, + ], + ) + monkeypatch.setattr( + app_module.eurostar_scraper, + 'timetable_url', + lambda destination: f'https://example.test/{destination.lower().replace(" ", "-")}', + ) + client = _client() + + resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120') + html = resp.get_data(as_text=True) + + assert resp.status_code == 200 + assert '1 morning service 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') + + +def test_results_can_show_only_unreachable_morning_services(monkeypatch): + monkeypatch.setattr(app_module, 'get_cached', lambda key: None) + monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None) + monkeypatch.setattr( + app_module.rtt_scraper, + 'fetch', + lambda travel_date, user_agent: [ + {'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'}, + ], + ) + monkeypatch.setattr( + app_module.eurostar_scraper, + 'fetch', + lambda destination, travel_date, user_agent: [ + { + 'depart_st_pancras': '09:30', + 'arrive_destination': '12:00', + 'destination': destination, + 'train_number': 'ES 9001', + }, + ], + ) + monkeypatch.setattr( + app_module.eurostar_scraper, + 'timetable_url', + lambda destination: f'https://example.test/{destination.lower().replace(" ", "-")}', + ) + client = _client() + + resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120') + html = resp.get_data(as_text=True) + + assert resp.status_code == 200 + assert 'No valid journeys found.' not in html + assert '1 morning 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 391d422..5e1c93c 100644 --- a/tests/test_trip_planner.py +++ b/tests/test_trip_planner.py @@ -1,5 +1,5 @@ import pytest -from trip_planner import combine_trips, _fmt_duration +from trip_planner import combine_trips, find_unreachable_morning_eurostars, _fmt_duration, cheapest_gwr_ticket DATE = '2026-03-30' @@ -129,3 +129,88 @@ def test_connection_duration_in_trip(): # arrive Paddington 08:45, depart St Pancras 10:01 → 1h 16m trips = combine_trips([GWR_FAST], [ES_PARIS], DATE) assert trips[0]['connection_duration'] == '1h 16m' + + +def test_unreachable_morning_eurostars_lists_only_unreachable_morning_services(): + gwr = [ + {'depart_bristol': '07:00', 'arrive_paddington': '08:45'}, + ] + eurostar = [ + {'depart_st_pancras': '09:30', 'arrive_destination': '12:00', 'destination': 'Paris Gare du Nord', 'train_number': 'ES 9001'}, + {'depart_st_pancras': '10:15', 'arrive_destination': '13:40', 'destination': 'Paris Gare du Nord', 'train_number': 'ES 9002'}, + {'depart_st_pancras': '12:30', 'arrive_destination': '15:55', 'destination': 'Paris Gare du Nord', 'train_number': 'ES 9003'}, + ] + + unreachable = find_unreachable_morning_eurostars(gwr, eurostar, DATE) + + assert [service['depart_st_pancras'] for service in unreachable] == ['09:30'] + + +# --------------------------------------------------------------------------- +# cheapest_gwr_ticket — Bristol Temple Meads → Paddington +# --------------------------------------------------------------------------- + +# 2026-03-30 is a Monday; 2026-03-28 is a Saturday + +def test_cheapest_ticket_weekday_super_off_peak_morning(): + # 05:00 on Monday: dep ≤ 05:04 → Super Off-Peak + t = cheapest_gwr_ticket('05:00', '2026-03-30') + assert t['ticket'] == 'Super Off-Peak' + assert t['price'] == 45.00 + +def test_cheapest_ticket_weekday_anytime_window(): + # 07:00 on Monday: 05:05–08:25 → Anytime only + t = cheapest_gwr_ticket('07:00', '2026-03-30') + assert t['ticket'] == 'Anytime' + assert t['price'] == 138.70 + +def test_cheapest_ticket_weekday_off_peak(): + # 08:30 on Monday: dep ≥ 08:26 but < 09:58 → Off-Peak + t = cheapest_gwr_ticket('08:30', '2026-03-30') + assert t['ticket'] == 'Off-Peak' + assert t['price'] == 63.60 + +def test_cheapest_ticket_weekday_super_off_peak_late(): + # 10:00 on Monday: dep ≥ 09:58 → Super Off-Peak + t = cheapest_gwr_ticket('10:00', '2026-03-30') + assert t['ticket'] == 'Super Off-Peak' + assert t['price'] == 45.00 + +def test_cheapest_ticket_boundary_super_off_peak_cutoff(): + # 05:04 is last valid minute for early Super Off-Peak + assert cheapest_gwr_ticket('05:04', '2026-03-30')['ticket'] == 'Super Off-Peak' + # 05:05 falls into the Anytime window (off-peak starts at 08:26) + assert cheapest_gwr_ticket('05:05', '2026-03-30')['ticket'] == 'Anytime' + +def test_cheapest_ticket_boundary_off_peak_start(): + assert cheapest_gwr_ticket('08:25', '2026-03-30')['ticket'] == 'Anytime' + assert cheapest_gwr_ticket('08:26', '2026-03-30')['ticket'] == 'Off-Peak' + +def test_cheapest_ticket_boundary_super_off_peak_resumes(): + assert cheapest_gwr_ticket('09:57', '2026-03-30')['ticket'] == 'Off-Peak' + assert cheapest_gwr_ticket('09:58', '2026-03-30')['ticket'] == 'Super Off-Peak' + +def test_cheapest_ticket_weekend_always_super_off_peak(): + # Saturday — no restrictions + t = cheapest_gwr_ticket('07:00', '2026-03-28') + assert t['ticket'] == 'Super Off-Peak' + assert t['price'] == 45.00 + +def test_combine_trips_includes_ticket_fields(): + trips = combine_trips([GWR_FAST], [ES_PARIS], DATE) + assert len(trips) == 1 + t = trips[0] + assert 'ticket_name' in t + assert 'ticket_price' in t + assert 'ticket_code' in t + + +def test_unreachable_morning_eurostars_returns_empty_when_morning_service_is_reachable(): + gwr = [ + {'depart_bristol': '07:00', 'arrive_paddington': '08:45'}, + ] + eurostar = [ + {'depart_st_pancras': '10:15', 'arrive_destination': '13:40', 'destination': 'Paris Gare du Nord', 'train_number': 'ES 9002'}, + ] + + assert find_unreachable_morning_eurostars(gwr, eurostar, DATE) == [] diff --git a/trip_planner.py b/trip_planner.py index 128c6b4..ca178d6 100644 --- a/trip_planner.py +++ b/trip_planner.py @@ -1,15 +1,47 @@ """ Combine GWR Bristol→Paddington trains with Eurostar St Pancras→destination trains. """ -from datetime import datetime, timedelta +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' +# Bristol Temple Meads → London Paddington walk-on single fares. +# Weekday restrictions (Mon–Fri only): +# Super Off-Peak (£45.00, SSS): not valid if 05:05 ≤ dep ≤ 09:57 +# Off-Peak (£63.60, SVS): not valid if dep < 08:26, except 02:00–05:10 +# Anytime (£138.70, SDS): no restrictions +_TICKET_SUPER_OFF_PEAK = {'ticket': 'Super Off-Peak', 'price': 45.00, 'code': 'SSS'} +_TICKET_OFF_PEAK = {'ticket': 'Off-Peak', 'price': 63.60, 'code': 'SVS'} +_TICKET_ANYTIME = {'ticket': 'Anytime', 'price': 138.70, 'code': 'SDS'} + + +def cheapest_gwr_ticket(depart_time: str, travel_date: str) -> dict: + """ + Return the cheapest walk-on single for Bristol Temple Meads → Paddington. + + Weekday (Mon–Fri) restrictions derived from the SSS/SVS ticket conditions: + Super Off-Peak valid: dep ≤ 05:04 or dep ≥ 09:58 + Off-Peak valid: 02:00 ≤ dep ≤ 05:10 or dep ≥ 08:26 + Weekends: no restrictions — always Super Off-Peak. + """ + dt = datetime.strptime(f"{travel_date} {depart_time}", f"{DATE_FMT} {TIME_FMT}") + if dt.weekday() >= 5: # Saturday or Sunday + return _TICKET_SUPER_OFF_PEAK + + dep = dt.time() + if dep <= _time(5, 4) or dep >= _time(9, 58): + return _TICKET_SUPER_OFF_PEAK + if dep >= _time(8, 26): + return _TICKET_OFF_PEAK + return _TICKET_ANYTIME + + def _parse_dt(date: str, time: str) -> datetime: return datetime.strptime(f"{date} {time}", f"{DATE_FMT} {TIME_FMT}") @@ -23,6 +55,36 @@ def _fmt_duration(minutes: int) -> str: return f"{m}m" +def _is_viable_connection( + gwr: dict, + eurostar: dict, + travel_date: str, + min_connection_minutes: int, + max_connection_minutes: int, +) -> tuple[datetime, datetime, datetime, datetime] | None: + try: + arr_pad = _parse_dt(travel_date, gwr['arrive_paddington']) + dep_bri = _parse_dt(travel_date, gwr['depart_bristol']) + dep_stp = _parse_dt(travel_date, eurostar['depart_st_pancras']) + arr_dest = _parse_dt(travel_date, eurostar['arrive_destination']) + except (ValueError, KeyError): + return None + + if int((arr_pad - dep_bri).total_seconds() / 60) > MAX_GWR_MINUTES: + return None + + if arr_dest < dep_stp: + arr_dest += timedelta(days=1) + + connection_minutes = (dep_stp - arr_pad).total_seconds() / 60 + if connection_minutes < min_connection_minutes: + return None + if connection_minutes > max_connection_minutes: + return None + + return dep_bri, arr_pad, dep_stp, arr_dest + + def combine_trips( gwr_trains: list[dict], eurostar_trains: list[dict], @@ -46,35 +108,21 @@ def combine_trips( trips = [] for gwr in gwr_trains: - try: - arr_pad = _parse_dt(travel_date, gwr['arrive_paddington']) - dep_bri = _parse_dt(travel_date, gwr['depart_bristol']) - except (ValueError, KeyError): - continue - - if int((arr_pad - dep_bri).total_seconds() / 60) > MAX_GWR_MINUTES: - continue - - earliest_eurostar = arr_pad + timedelta(minutes=min_connection_minutes) - # Find only the earliest viable Eurostar for this GWR departure for es in eurostar_trains: - try: - dep_stp = _parse_dt(travel_date, es['depart_st_pancras']) - arr_dest = _parse_dt(travel_date, es['arrive_destination']) - except (ValueError, KeyError): - continue - - # Eurostar arrives next day? (e.g. night service — unlikely but handle it) - if arr_dest < dep_stp: - arr_dest += timedelta(days=1) - - if dep_stp < earliest_eurostar: - continue - if (dep_stp - arr_pad).total_seconds() / 60 > max_connection_minutes: + connection = _is_viable_connection( + gwr, + es, + travel_date, + min_connection_minutes, + max_connection_minutes, + ) + if not connection: continue + dep_bri, arr_pad, dep_stp, arr_dest = connection total_mins = int((arr_dest - dep_bri).total_seconds() / 60) + ticket = cheapest_gwr_ticket(gwr['depart_bristol'], travel_date) trips.append({ 'depart_bristol': gwr['depart_bristol'], 'arrive_paddington': gwr['arrive_paddington'], @@ -87,8 +135,46 @@ def combine_trips( 'total_duration': _fmt_duration(total_mins), 'total_minutes': total_mins, 'destination': es['destination'], + 'ticket_name': ticket['ticket'], + 'ticket_price': ticket['price'], + 'ticket_code': ticket['code'], }) break # Only the earliest valid Eurostar per GWR departure trips.sort(key=lambda t: (t['depart_bristol'], t['depart_st_pancras'])) return trips + + +def find_unreachable_morning_eurostars( + gwr_trains: list[dict], + eurostar_trains: list[dict], + travel_date: str, + min_connection_minutes: int = MIN_CONNECTION_MINUTES, + max_connection_minutes: int = MAX_CONNECTION_MINUTES, +) -> list[dict]: + 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, + es, + travel_date, + min_connection_minutes, + max_connection_minutes, + ) + for gwr in gwr_trains + ): + continue + + unreachable.append(es) + + return sorted(unreachable, key=lambda s: s['depart_st_pancras'])

Bristol PaddingtonGWR Fare Transfer Depart St Pancras {{ destination }} @@ -101,47 +108,73 @@
- {{ trip.depart_bristol }} - {% if trip.headcode %}
{{ trip.headcode }}{% endif %} + {{ row.depart_bristol }} + {% if row.headcode %}
{{ row.headcode }}{% endif %}
- {{ trip.arrive_paddington }} - ({{ trip.gwr_duration }}) + {{ row.arrive_paddington }} + ({{ row.gwr_duration }}) + + £{{ "%.2f"|format(row.ticket_price) }} +
{{ row.ticket_name }}
- {{ trip.connection_duration }} + {{ row.connection_duration }} - {{ trip.depart_st_pancras }} - {% if trip.train_number %}
{{ trip.train_number }}{% endif %} + {{ row.depart_st_pancras }} + {% if row.train_number %}
{{ row.train_number }}{% endif %}
- {{ trip.arrive_destination }} + {{ row.arrive_destination }} (CET) - {% if trip.total_minutes == best_mins and trips | length > 1 %} - {{ trip.total_duration }} ⚡ - {% elif trip.total_minutes == worst_mins and trips | length > 1 %} - {{ trip.total_duration }} 🐢 + {% if row.total_minutes <= best_mins + 5 and trips | length > 1 %} + {{ row.total_duration }} ⚡ + {% elif row.total_minutes >= worst_mins - 5 and trips | length > 1 %} + {{ row.total_duration }} 🐢 {% else %} - {{ trip.total_duration }} + {{ row.total_duration }} {% endif %} Unavailable + {{ row.depart_st_pancras }} + {% if row.train_number %}
{{ row.train_number }}{% endif %} +
+ {{ row.arrive_destination }} + (CET) + + Unavailable from Bristol +