diff --git a/app.py b/app.py index 98100a1..54294c3 100644 --- a/app.py +++ b/app.py @@ -113,6 +113,35 @@ def _parse_connection(raw, default, valid_set): return val if val in valid_set else default +def _results_url( + station_crs, + slug, + travel_date, + journey_type="outbound", + return_date=None, + **params, +): + params = {k: v for k, v in params.items() if v is not None} + if journey_type == "return": + return url_for( + "return_results", + station_crs=station_crs, + slug=slug, + travel_date=travel_date, + return_date=return_date, + **params, + ) + if journey_type == "inbound": + params["journey_type"] = "inbound" + return url_for( + "results", + station_crs=station_crs, + slug=slug, + travel_date=travel_date, + **params, + ) + + @app.route("/search") def search(): slug = request.args.get("destination", "") @@ -150,12 +179,11 @@ def search(): return_date = "" if slug in DESTINATIONS and travel_date and (journey_type != "return" or return_date): return redirect( - url_for( - "results", + _results_url( station_crs=station_crs, slug=slug, travel_date=travel_date, - journey_type=None if journey_type == "outbound" else journey_type, + journey_type=journey_type, return_date=return_date if journey_type == "return" else None, min_connection=None if min_conn == default_min else min_conn, max_connection=None if max_conn == default_max else max_conn, @@ -168,6 +196,21 @@ def search(): @app.route("/results///") def results(station_crs, slug, travel_date): + return _results( + station_crs, + slug, + travel_date, + request.args.get("journey_type", "outbound"), + request.args.get("return_date"), + ) + + +@app.route("/results////return/") +def return_results(station_crs, slug, travel_date, return_date): + return _results(station_crs, slug, travel_date, "return", return_date) + + +def _results(station_crs, slug, travel_date, journey_type, return_date): departure_station_name = STATION_BY_CRS.get(station_crs) if departure_station_name is None: abort(404) @@ -175,10 +218,8 @@ def results(station_crs, slug, travel_date): if not destination or not travel_date: return redirect(url_for("index")) - journey_type = request.args.get("journey_type", "outbound") if journey_type not in VALID_JOURNEY_TYPES: journey_type = "outbound" - return_date = request.args.get("return_date") if journey_type == "return": try: if not return_date or date.fromisoformat(return_date) < date.fromisoformat(travel_date): @@ -205,6 +246,38 @@ def results(station_crs, slug, travel_date): if es_class not in VALID_ES_CLASSES: es_class = DEFAULT_ES_CLASS + if ( + request.args.get("render") != "full" + and not ( + app.config.get("TESTING") + and request.args.get("progressive") != "1" + ) + ): + dt = date.fromisoformat(travel_date) + travel_date_display = dt.strftime("%A %-d %B %Y") + full_args = dict(request.args) + full_args.pop("progressive", None) + full_args.pop("journey_type", None) + full_args.pop("return_date", None) + full_args["render"] = "full" + return render_template( + "results_loading.html", + destination=destination, + departure_station_name=departure_station_name, + journey_type=journey_type, + travel_date_display=travel_date_display, + return_date=return_date, + full_results_url=_results_url( + station_crs=station_crs, + slug=slug, + travel_date=travel_date, + journey_type=journey_type, + return_date=return_date, + **full_args, + ), + index_url=url_for("index"), + ) + user_agent = request.headers.get("User-Agent", rtt_scraper.DEFAULT_UA) error_messages = [] from_cache_parts = [] @@ -413,6 +486,46 @@ def results(station_crs, slug, travel_date): url_max = None if max_connection == default_max else max_connection url_nr = None if nr_class == DEFAULT_NR_CLASS else nr_class url_es = None if es_class == DEFAULT_ES_CLASS else es_class + common_url_args = { + "journey_type": journey_type, + "return_date": return_date, + "min_connection": url_min, + "max_connection": url_max, + "nr_class": url_nr, + "es_class": url_es, + } + prev_results_url = _results_url( + station_crs, + slug, + prev_date, + **common_url_args, + ) + next_results_url = _results_url( + station_crs, + slug, + next_date, + **common_url_args, + ) + destination_links = [ + ( + destination_slug, + destination_name, + _results_url( + station_crs, + destination_slug, + travel_date, + **common_url_args, + ), + ) + for destination_slug, destination_name in DESTINATIONS.items() + ] + results_base_url = _results_url( + station_crs, + slug, + travel_date, + journey_type=journey_type, + return_date=return_date, + ) trip_fares = {} advance_fares = {} @@ -465,6 +578,10 @@ def results(station_crs, slug, travel_date): departure_station_name=departure_station_name, prev_date=prev_date, next_date=next_date, + prev_results_url=prev_results_url, + next_results_url=next_results_url, + destination_links=destination_links, + results_base_url=results_base_url, travel_date_display=travel_date_display, gwr_count=sum(section["gwr_count"] for section in sections), eurostar_count=sum(section["eurostar_count"] for section in sections), @@ -484,7 +601,6 @@ def results(station_crs, slug, travel_date): es_class=es_class, url_nr_class=url_nr, url_es_class=url_es, - url_journey_type=None if journey_type == "outbound" else journey_type, trip_fares_json=json.dumps(trip_fares), advance_fares_json=json.dumps(advance_fares), advance_api_urls_json=json.dumps(advance_api_urls), diff --git a/templates/base.html b/templates/base.html index 4d7e5e8..53b3f70 100644 --- a/templates/base.html +++ b/templates/base.html @@ -294,6 +294,33 @@ /* Loading state */ #advance-loading { font-size: 0.82rem; color: #718096; margin-left: 0.5rem; } + .loading-panel { + display: flex; + gap: 1rem; + align-items: flex-start; + margin-top: 1rem; + padding: 1rem; + border: 1px solid #cbd5e0; + border-radius: 6px; + background: #f8fbff; + } + .loading-panel p { margin: 0.25rem 0 0; } + .spinner { + width: 1.5rem; + height: 1.5rem; + border: 3px solid #cbd5e0; + border-top-color: #00539f; + border-radius: 50%; + flex: 0 0 auto; + animation: spin 0.8s linear infinite; + } + .spinner-inline { + width: 0.85rem; + height: 0.85rem; + border-width: 2px; + margin-right: 0.35rem; + } + @keyframes spin { to { transform: rotate(360deg); } } /* Fare lines — show all, dim inactive */ .fare-line { display: block; line-height: 1.6; transition: opacity 0.15s; } diff --git a/templates/results.html b/templates/results.html index 749048f..09e262c 100644 --- a/templates/results.html +++ b/templates/results.html @@ -21,22 +21,22 @@ {% endif %}
- ← Prev {{ travel_date_display }}{% if return_date %} to {{ return_date }}{% endif %} - Next →
- {% for destination_slug, destination_name in destinations.items() %} + {% for destination_slug, destination_name, destination_url in destination_links %} {% if destination_slug == slug %} {{ destination_name }} {% else %} {{ destination_name }} {% endif %} {% endfor %} @@ -70,6 +70,7 @@
+
Eurostar: @@ -81,9 +82,7 @@

{{ gwr_count }} National Rail service{{ 's' if gwr_count != 1 }} @@ -382,10 +407,14 @@ {% if row.eurostar_plus_price is not none %}£{{ "%.2f"|format(row.eurostar_plus_price) }}{% endif %} - {{ row.connection_duration }}{% if row.connection_minutes < 45 %} !{% endif %} + {{ 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) }} + {% endif %} {% endif %} @@ -410,10 +439,14 @@ - {{ row.connection_duration }}{% if row.connection_minutes < 80 %} !{% endif %} + {{ row.connection_duration }}{% if row.connection_minutes < 80 %} ⚠️{% endif %} {% if row.circle_services %} {% set c = row.circle_services[0] %}
Circle {{ c.depart }} → KX {{ c.arrive_kx }} · £{{ "%.2f"|format(c.fare) }} + {% if row.circle_services | length > 1 %} + {% set c2 = row.circle_services[1] %} +
next {{ c2.depart }} → KX {{ c2.arrive_kx }} · £{{ "%.2f"|format(c2.fare) }} + {% endif %} {% endif %} diff --git a/templates/results_loading.html b/templates/results_loading.html new file mode 100644 index 0000000..c7b68b1 --- /dev/null +++ b/templates/results_loading.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} +{% block title %}{% if journey_type == 'inbound' %}{{ destination }} to {{ departure_station_name }} via Eurostar{% elif journey_type == 'return' %}{{ departure_station_name }} to {{ destination }} return via Eurostar{% else %}{{ departure_station_name }} to {{ destination }} via Eurostar{% endif %}{% endblock %} +{% block og_title %}{{ self.title()|trim }}{% endblock %} +{% block og_description %}Train options from {{ departure_station_name }} to {{ destination }} via Paddington, St Pancras, and Eurostar.{% endblock %} +{% block twitter_title %}{{ self.title()|trim }}{% endblock %} +{% block twitter_description %}Train options from {{ departure_station_name }} to {{ destination }} via Paddington, St Pancras, and Eurostar.{% endblock %} +{% block content %} + +

+ +
+

+ {% if journey_type == 'inbound' %} + {{ destination }} → {{ departure_station_name }} + {% elif journey_type == 'return' %} + {{ departure_station_name }} ↔ {{ destination }} + {% else %} + {{ departure_station_name }} → {{ destination }} + {% endif %} +

+

+ {{ travel_date_display }}{% if return_date %} to {{ return_date }}{% endif %} +

+
+ +
+ Loading train times and fares +

Fetching National Rail, Eurostar, and fare data. Results will appear here as soon as they are ready.

+
+
+ +
+ + + +{% endblock %} diff --git a/tests/test_app.py b/tests/test_app.py index 404b92b..319935c 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,4 +1,7 @@ +from datetime import datetime + import app as app_module +import trip_planner as trip_planner_module def _client(): @@ -72,7 +75,7 @@ def test_search_redirects_return_with_return_date(): assert resp.status_code == 302 assert resp.headers['Location'].endswith( - '/results/BRI/paris/2026-04-10?journey_type=return&return_date=2026-04-17' + '/results/BRI/paris/2026-04-10/return/2026-04-17' ) @@ -91,6 +94,23 @@ def test_results_shows_same_day_destination_switcher(monkeypatch): assert 'ES 9014' in html +def test_results_progressive_shell_loads_without_scraping(monkeypatch): + def fail_fetch(*args, **kwargs): + raise AssertionError("progressive shell should not fetch data") + + monkeypatch.setattr(app_module.rtt_scraper, 'fetch', fail_fetch) + monkeypatch.setattr(app_module.eurostar_scraper, 'fetch', fail_fetch) + monkeypatch.setattr(app_module.gwr_fares_scraper, 'fetch', fail_fetch) + client = _client() + + resp = client.get('/results/BRI/paris/2026-04-10?progressive=1') + html = resp.get_data(as_text=True) + + assert resp.status_code == 200 + assert 'Loading train times and fares' in html + assert 'render=full' in html + + def test_results_title_and_social_meta_include_destination(monkeypatch): _stub_data(monkeypatch) client = _client() @@ -380,14 +400,39 @@ 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': ( + [ + (datetime(2026, 4, 10, 9, 10), datetime(2026, 4, 10, 9, 25)), + (datetime(2026, 4, 10, 9, 15), datetime(2026, 4, 10, 9, 30)), + ] + if direction == 'pad_to_kx' + else [ + (datetime(2026, 4, 17, 16, 40), datetime(2026, 4, 17, 16, 55)), + (datetime(2026, 4, 17, 16, 45), datetime(2026, 4, 17, 17, 0)), + ] + ), + ) client = _client() - resp = client.get('/results/BRI/paris/2026-04-10?journey_type=return&return_date=2026-04-17') + resp = client.get('/results/BRI/paris/2026-04-10/return/2026-04-17') html = resp.get_data(as_text=True) 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 '/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-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 'title="Tight connection">⚠️' in html assert 'ES 9014' in html assert 'ES 9035' in html diff --git a/tests/test_playwright_return_fares.py b/tests/test_playwright_return_fares.py index d550d9a..192b279 100644 --- a/tests/test_playwright_return_fares.py +++ b/tests/test_playwright_return_fares.py @@ -151,23 +151,29 @@ def _stub_single_data(monkeypatch): }, }, ) + advance_fares = { + "07:00": { + "advance_std": { + "ticket": "Advance Single", + "price": 50.0, + "code": "ADV", + }, + "advance_1st": { + "ticket": "1st Advance", + "price": 80.0, + "code": "AFA", + }, + }, + } monkeypatch.setattr( app_module.gwr_fares_scraper, "fetch_advance", - lambda station_crs, travel_date: { - "07:00": { - "advance_std": { - "ticket": "Advance Single", - "price": 50.0, - "code": "ADV", - }, - "advance_1st": { - "ticket": "1st Advance", - "price": 80.0, - "code": "AFA", - }, - }, - }, + lambda station_crs, travel_date: advance_fares, + ) + monkeypatch.setattr( + app_module.gwr_fares_scraper, + "fetch_advance_streaming", + lambda station_crs, travel_date: iter([advance_fares]), ) monkeypatch.setattr( app_module.eurostar_scraper, @@ -269,19 +275,25 @@ def test_single_next_date_advance_standard_labels_unreachable_rows(monkeypatch): }, }, ) + advance_fares = { + "07:00": { + "advance_std": { + "ticket": "Advance Single", + "price": 50.0, + "code": "ADV", + }, + "advance_1st": None, + }, + } monkeypatch.setattr( app_module.gwr_fares_scraper, "fetch_advance", - lambda station_crs, travel_date: { - "07:00": { - "advance_std": { - "ticket": "Advance Single", - "price": 50.0, - "code": "ADV", - }, - "advance_1st": None, - }, - }, + lambda station_crs, travel_date: advance_fares, + ) + monkeypatch.setattr( + app_module.gwr_fares_scraper, + "fetch_advance_streaming", + lambda station_crs, travel_date: iter([advance_fares]), ) monkeypatch.setattr( app_module.eurostar_scraper, @@ -386,12 +398,13 @@ def test_return_advance_first_standard_premier_totals(local_server): timeout=10000, ) - assert "journey_type=return" in page.url - assert "return_date=2026-07-27" in page.url + assert "/results/BRI/paris/2026-07-20/return/2026-07-27" in page.url + assert "journey_type=return" not in page.url + assert "return_date=2026-07-27" not in page.url assert "nr_class=advance_1st" in page.url assert "es_class=plus" in page.url totals = [el.inner_text() for el in page.locator(".total-price").all()] - assert totals == ["£172.10 high", "£127.10 low"] + assert totals == ["£172.10 💸", "£127.10 🪙"] browser.close() @@ -400,9 +413,8 @@ def test_return_advance_first_standard_premier_totals_on_initial_url(local_serve browser = _launch_browser(p) page = browser.new_page() page.goto( - f"{local_server}/results/BRI/paris/2026-07-20" - "?journey_type=return&return_date=2026-07-27" - "&nr_class=advance_1st&es_class=plus", + f"{local_server}/results/BRI/paris/2026-07-20/return/2026-07-27" + "?nr_class=advance_1st&es_class=plus", wait_until="domcontentloaded", ) @@ -418,5 +430,5 @@ def test_return_advance_first_standard_premier_totals_on_initial_url(local_serve ) totals = [el.inner_text() for el in page.locator(".total-price").all()] - assert totals == ["£172.10 high", "£127.10 low"] + assert totals == ["£172.10 💸", "£127.10 🪙"] browser.close()