From a859b96a23ebf65a06159422f4fe1de3fd53fa9d Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Thu, 21 May 2026 14:09:36 +0100 Subject: [PATCH] Split return date nav into separate outbound/return rows; show earlier tube option on inbound For return journeys, replace the single combined date navigation row with two separate rows so outbound and return dates can be adjusted independently. For inbound underground options, show one service before the earliest catchable (as an "aim for this" option) rather than the next service after it, which often arrived too late to connect with the GWR train. Co-Authored-By: Claude Sonnet 4.6 --- app.py | 30 ++++++++++++++++++++++++++++++ circle_line.py | 28 ++++++++++++++++++---------- templates/base.html | 1 + templates/results.html | 28 +++++++++++++++++++++++----- tests/test_app.py | 15 +++++++++------ trip_planner.py | 2 +- 6 files changed, 82 insertions(+), 22 deletions(-) diff --git a/app.py b/app.py index 03eff12..ed9ed86 100644 --- a/app.py +++ b/app.py @@ -681,6 +681,32 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): next_date, **{**common_url_args, "return_date": next_return_date}, ) + prev_outbound_url = _results_url( + station_crs, slug, prev_date, **common_url_args + ) + next_outbound_url = _results_url( + station_crs, slug, next_date, **common_url_args + ) + prev_return_url = ( + _results_url( + station_crs, + slug, + travel_date, + **{**common_url_args, "return_date": prev_return_date}, + ) + if return_date + else None + ) + next_return_url = ( + _results_url( + station_crs, + slug, + travel_date, + **{**common_url_args, "return_date": next_return_date}, + ) + if return_date + else None + ) destination_links = [ ( destination_slug, @@ -775,6 +801,10 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): next_date=next_date, prev_results_url=prev_results_url, next_results_url=next_results_url, + prev_outbound_url=prev_outbound_url, + next_outbound_url=next_outbound_url, + prev_return_url=prev_return_url, + next_return_url=next_return_url, destination_links=destination_links, results_base_url=results_base_url, travel_date_display=travel_date_display, diff --git a/circle_line.py b/circle_line.py index c0d945c..76baaf9 100644 --- a/circle_line.py +++ b/circle_line.py @@ -165,13 +165,17 @@ def next_service( def upcoming_services( - earliest_board: datetime, count: int = 2, direction: str = 'pad_to_kx' + earliest_board: datetime, + count: int = 2, + direction: str = 'pad_to_kx', + preceding: int = 0, ) -> list[tuple[datetime, datetime]]: """ - Return up to *count* Circle line services for *direction*, starting from - *earliest_board*. + Return Circle line services for *direction* around *earliest_board*. - Each element is (depart_origin, arrive_destination) as datetimes. + Returns up to *preceding* services before earliest_board followed by up to + *count* services at or after earliest_board. Each element is + (depart_origin, arrive_destination) as datetimes. """ timetable = _get_timetable().get(direction, {})[_day_type(earliest_board.weekday())] board_secs = ( @@ -180,13 +184,17 @@ def upcoming_services( + earliest_board.second ) midnight = earliest_board.replace(hour=0, minute=0, second=0, microsecond=0) + pre_results = [] results = [] for pad_secs, kxp_secs in timetable: - if pad_secs >= board_secs: - results.append(( - midnight + timedelta(seconds=pad_secs), - midnight + timedelta(seconds=kxp_secs), - )) + entry = ( + midnight + timedelta(seconds=pad_secs), + midnight + timedelta(seconds=kxp_secs), + ) + if pad_secs < board_secs: + pre_results.append(entry) + else: + results.append(entry) if len(results) == count: break - return results + return pre_results[-preceding:] + results if preceding else results diff --git a/templates/base.html b/templates/base.html index 53b3f70..5009f0c 100644 --- a/templates/base.html +++ b/templates/base.html @@ -246,6 +246,7 @@ /* Results page layout */ .back-link { margin-bottom: 1rem; } .date-nav { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; } + .date-nav-label { min-width: 6rem; font-weight: 600; font-size: 0.9rem; } .switcher-section { margin: 0.9rem 0 1rem; } .section-label { font-size: 0.9rem; font-weight: 600; margin-bottom: 0.45rem; } .filter-row { margin-top: 0.75rem; display: flex; gap: 1.5rem; align-items: center; flex-wrap: wrap; } diff --git a/templates/results.html b/templates/results.html index 96b2e8a..067a1e7 100644 --- a/templates/results.html +++ b/templates/results.html @@ -20,13 +20,28 @@ {{ departure_station_name }} → {{ destination }} {% endif %} + {% if journey_type == 'return' %} +
+ Outbound: + ← Prev + {{ travel_date_display }} + Next → +
+
+ Return: + ← Prev + {{ return_date_display }} + Next → +
+ {% else %}
← Prev - {{ travel_date_display }}{% if return_date_display %} to {{ return_date_display }}{% endif %} + {{ travel_date_display }} Next →
+ {% endif %}
@@ -496,11 +511,14 @@ {{ row.connection_duration }}{% if row.connection_minutes < 45 %} ⚠️{% endif %} {% if row.circle_services %} - {% set c = row.circle_services[0] %} -
Circle {{ c.depart }} → PAD {{ c.arrive_pad }} · £{{ "%.2f"|format(c.fare) }} {% if row.circle_services | length > 1 %} - {% set c2 = row.circle_services[1] %} -
next {{ c2.depart }} → PAD {{ c2.arrive_pad }} · £{{ "%.2f"|format(c2.fare) }} + {% set c_early = row.circle_services[0] %} + {% set c = row.circle_services[1] %} +
earlier {{ c_early.depart }} → PAD {{ c_early.arrive_pad }} · £{{ "%.2f"|format(c_early.fare) }} +
Circle {{ c.depart }} → PAD {{ c.arrive_pad }} · £{{ "%.2f"|format(c.fare) }} + {% else %} + {% set c = row.circle_services[0] %} +
Circle {{ c.depart }} → PAD {{ c.arrive_pad }} · £{{ "%.2f"|format(c.fare) }} {% endif %} {% endif %} diff --git a/tests/test_app.py b/tests/test_app.py index 1adca0f..13cdae4 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -599,7 +599,7 @@ def test_results_return_renders_outbound_and_inbound_tables(monkeypatch): monkeypatch.setattr( trip_planner_module.circle_line, 'upcoming_services', - lambda earliest_board, count=2, direction='pad_to_kx': ( + lambda earliest_board, count=2, direction='pad_to_kx', preceding=0: ( [ (datetime(2026, 4, 10, 9, 10), datetime(2026, 4, 10, 9, 25)), (datetime(2026, 4, 10, 9, 15), datetime(2026, 4, 10, 9, 30)), @@ -619,16 +619,19 @@ def test_results_return_renders_outbound_and_inbound_tables(monkeypatch): assert resp.status_code == 200 assert 'Outbound: Bristol Temple Meads → Paris Gare du Nord' in html assert 'Return: Paris Gare du Nord → Bristol Temple Meads' in html - assert 'Friday 10 April 2026 to Friday 17 April 2026' in html - assert '/results/BRI/paris/2026-04-09/return/2026-04-16' in html - assert '/results/BRI/paris/2026-04-11/return/2026-04-18' in html + assert 'Friday 10 April 2026' in html + assert 'Friday 17 April 2026' in html + assert '/results/BRI/paris/2026-04-09/return/2026-04-17' in html + assert '/results/BRI/paris/2026-04-11/return/2026-04-17' in html + assert '/results/BRI/paris/2026-04-10/return/2026-04-16' in html + assert '/results/BRI/paris/2026-04-10/return/2026-04-18' in html assert "/results/BRI/paris/2026-04-10/return/2026-04-17" in html assert 'journey_type=return' not in html assert 'return_date=2026-04-17' not in html assert 'Circle 09:10 → KX 09:25' in html assert 'next 09:15 → KX 09:30' in html - assert 'Circle 16:40 → PAD 16:55' in html - assert 'next 16:45 → PAD 17:00' in html + assert 'earlier 16:40 → PAD 16:55' in html + assert 'Circle 16:45 → PAD 17:00' in html assert 'title="Tight connection">⚠️' in html assert 'ES 9014' in html assert 'ES 9035' in html diff --git a/trip_planner.py b/trip_planner.py index 50de19d..d03b767 100644 --- a/trip_planner.py +++ b/trip_planner.py @@ -48,7 +48,7 @@ def _circle_line_services_to_paddington(arrive_st_pancras: datetime) -> list[dic earliest_board = arrive_st_pancras + timedelta( minutes=KX_WALK_TO_UNDERGROUND_MINUTES ) - services = circle_line.upcoming_services(earliest_board, count=2, direction='kx_to_pad') + services = circle_line.upcoming_services(earliest_board, count=1, direction='kx_to_pad', preceding=1) return [ { "depart": dep.strftime(TIME_FMT),