From 453d6244ec73b90487467669efadbc8631d8405d Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Mon, 25 May 2026 21:24:40 +0100 Subject: [PATCH] Stream results progressively via SSE instead of waiting for full render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loading page now opens an EventSource to a new ?render=stream endpoint. The server immediately sends a shell event (full page chrome: nav, filters, JS — no external fetches needed), then a section event per direction as each one's NR + Eurostar data arrives, and finally a done event with the summary and timetable-refresh URL. The client slots each section card into a placeholder and calls initialiseResultsPage() only after done, so fares and advance-fare streaming start at the right moment. Adds results_shell.html (shell template with empty JS data globals and mergeSectionData/finaliseResults hooks), results_section.html (extracted section card partial used by both the full and stream render paths), and helper functions _section_trip_fares() and _build_summary_html() to avoid duplicating fare-dict assembly between the two paths. Co-Authored-By: Claude Sonnet 4.6 --- app.py | 258 +++++++++++- templates/results.html | 174 +------- templates/results_loading.html | 67 +++- templates/results_section.html | 172 ++++++++ templates/results_shell.html | 708 +++++++++++++++++++++++++++++++++ 5 files changed, 1182 insertions(+), 197 deletions(-) create mode 100644 templates/results_section.html create mode 100644 templates/results_shell.html diff --git a/app.py b/app.py index 9afcbc0..a73b979 100644 --- a/app.py +++ b/app.py @@ -232,6 +232,59 @@ def _parse_connection(raw, default, valid_set): return val if val in valid_set else default +def _section_trip_fares(section): + trip_fares = {} + for row in section["rows"]: + circle_svcs = row.get("circle_services") or [] + circle_fare = circle_svcs[0]["fare"] if circle_svcs else 0 + walkon = ( + {"price": row["ticket_price"], "ticket": row.get("ticket_name", "")} + if row.get("ticket_price") is not None + else None + ) + es_std = ( + {"price": row["eurostar_price"], "seats": row.get("eurostar_seats")} + if row.get("eurostar_price") is not None + else None + ) + es_plus = ( + {"price": row["eurostar_plus_price"], "seats": row.get("eurostar_plus_seats")} + if row.get("eurostar_plus_price") is not None + else None + ) + trip_fares[row["row_key"]] = { + "section": section["id"], + "eurostar_key": row.get("eurostar_key"), + "advance_key": row.get("depart_bristol") or row.get("depart_paddington"), + "walkon": walkon, + "es_standard": es_std, + "es_plus": es_plus, + "circle_fare": circle_fare, + } + return trip_fares + + +def _build_summary_html(sections, journey_type, from_cache_parts, provisional_timetable): + def pl(n, word): + return f"{n} {word}{'s' if n != 1 else ''}" + + if journey_type == "return": + parts = [] + for s in sections: + label = "Outbound" if s["direction"] == "outbound" else "Return" + parts.append(f"{label}: {pl(s['gwr_count'], 'National Rail service')}, {pl(s['eurostar_count'], 'Eurostar service')}") + html = "  ·  ".join(parts) + else: + s = sections[0] + html = f"{pl(s['gwr_count'], 'National Rail service')}  ·  {pl(s['eurostar_count'], 'Eurostar service')}" + + if from_cache_parts: + html += '  ·  (cached)' + if provisional_timetable: + html += '  ·  checking exact timetable' + return html + + def _results_url( station_crs, slug, @@ -382,8 +435,10 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): nr_class_out = nr_class_in = nr_class es_class_out = es_class_in = es_class + render = request.args.get("render") + if ( - request.args.get("render") != "full" + render not in ("full", "stream") and not ( app.config.get("TESTING") and request.args.get("progressive") != "1" @@ -396,11 +451,12 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): if return_date else None ) - 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" + base_args = dict(request.args) + base_args.pop("progressive", None) + base_args.pop("journey_type", None) + base_args.pop("return_date", None) + stream_args = {**base_args, "render": "stream"} + full_args = {**base_args, "render": "full"} return render_template( "results_loading.html", destination=destination, @@ -409,6 +465,14 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): travel_date_display=travel_date_display, return_date=return_date, return_date_display=return_date_display, + stream_url=_results_url( + station_crs=station_crs, + slug=slug, + travel_date=travel_date, + journey_type=journey_type, + return_date=return_date, + **stream_args, + ), full_results_url=_results_url( station_crs=station_crs, slug=slug, @@ -462,7 +526,9 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): es_return = None es_return_provisional = False - if journey_type == "return": + + def _fetch_es_return(): + nonlocal es_return, es_return_provisional es_return, es_return_provisional = cached_timetable_fetch( _eurostar_return_exact_cache_key(travel_date, return_date, destination), _eurostar_return_weekday_cache_key(travel_date, return_date, destination), @@ -634,7 +700,185 @@ def _results(station_crs, slug, travel_date, journey_type, return_date): ), } + if render == "stream": + def generate(): + dt = date.fromisoformat(travel_date) + prev_date = (dt - timedelta(days=1)).isoformat() + next_date = (dt + timedelta(days=1)).isoformat() + travel_date_display = dt.strftime("%A %-d %B %Y") + return_date_display = None + prev_return_date = return_date + next_return_date = return_date + if return_date: + return_dt = date.fromisoformat(return_date) + return_date_display = return_dt.strftime("%A %-d %B %Y") + prev_return_date = (return_dt - timedelta(days=1)).isoformat() + next_return_date = (return_dt + timedelta(days=1)).isoformat() + + eurostar_url = eurostar_scraper.search_url( + destination, travel_date, direction=journey_type, return_date=return_date + ) + rtt_url = RTT_PADDINGTON_URL.format(crs=station_crs, date=travel_date) + rtt_station_url = RTT_STATION_URL.format(crs=station_crs, date=travel_date) + + url_min = None if min_connection == default_min else min_connection + 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 + + if journey_type == "return": + common_url_args = { + "journey_type": journey_type, + "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, + "es_class_in": None if es_class_in == DEFAULT_ES_CLASS else es_class_in, + } + else: + 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, "return_date": prev_return_date}, + ) + next_results_url = _results_url( + station_crs, slug, 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, + 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, + ) + + if journey_type == "return": + shell_sections = [ + {"id": "outbound", "direction": "outbound", "min_connection": min_connection, "max_connection": max_connection}, + {"id": "inbound", "direction": "inbound", "min_connection": inbound_min_connection, "max_connection": INBOUND_MAX_CONNECTION_MINUTES}, + ] + shell_nr_classes = {"outbound": nr_class_out, "inbound": nr_class_in} + shell_es_classes = {"outbound": es_class_out, "inbound": es_class_in} + shell_section_directions = {"outbound": "outbound", "inbound": "inbound"} + else: + shell_sections = [ + {"id": "main", "direction": journey_type, "min_connection": min_connection, "max_connection": max_connection}, + ] + shell_nr_classes = {"main": nr_class} + shell_es_classes = {"main": es_class} + shell_section_directions = {"main": journey_type} + + shell_html = render_template( + "results_shell.html", + journey_type=journey_type, + destination=destination, + departure_station_name=departure_station_name, + travel_date=travel_date, + return_date=return_date, + travel_date_display=travel_date_display, + return_date_display=return_date_display, + slug=slug, + sections=shell_sections, + nr_classes=shell_nr_classes, + es_classes=shell_es_classes, + nr_classes_json=json.dumps(shell_nr_classes), + es_classes_json=json.dumps(shell_es_classes), + section_directions_json=json.dumps(shell_section_directions), + results_base_url=results_base_url, + 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, + eurostar_url=eurostar_url, + rtt_url=rtt_url, + rtt_station_url=rtt_station_url, + min_connection=min_connection, + max_connection=max_connection, + default_min_connection=default_min, + default_max_connection=default_max, + default_inbound_min_connection=INBOUND_MIN_CONNECTION_MINUTES, + valid_min_connections=sorted(valid_min), + valid_max_connections=sorted(valid_max), + inbound_min_connection=inbound_min_connection, + valid_inbound_return_min_connections=sorted(VALID_INBOUND_RETURN_MIN_CONNECTIONS), + ) + yield f"data: {json.dumps({'type': 'shell', 'html': shell_html})}\n\n" + + if journey_type == "return": + _fetch_es_return() + sections_spec = [ + ("outbound", "outbound", travel_date, es_return.get("outbound", [])), + ("inbound", "inbound", return_date, es_return.get("inbound", [])), + ] + else: + sections_spec = [("main", journey_type, travel_date, None)] + + built_sections = [] + for section_id, direction, section_date, eurostar_services in sections_spec: + section = build_section(section_id, direction, section_date, eurostar_services) + built_sections.append(section) + section_html = render_template( + "results_section.html", + section=section, + destination=destination, + departure_station_name=departure_station_name, + ) + yield f"data: {json.dumps({'type': 'section', 'id': section_id, 'html': section_html, 'trip_fares': _section_trip_fares(section), 'advance_fares': section['advance_fares'], 'walkon_cached_fares': section.get('cached_walkon_fares'), 'walkon_api_url': section['walkon_api_url'], 'advance_api_url': section['advance_api_url'], 'advance_stream_url': section['advance_stream_url']})}\n\n" + + if journey_type == "return": + timetable_refresh_url = url_for( + "api_return_results_refresh", + station_crs=station_crs, + slug=slug, + travel_date=travel_date, + return_date=return_date, + ) + else: + timetable_refresh_url = url_for( + "api_results_refresh", + station_crs=station_crs, + slug=slug, + travel_date=travel_date, + journey_type=journey_type if journey_type == "inbound" else None, + ) + + summary_html = _build_summary_html(built_sections, journey_type, from_cache_parts, provisional_timetable) + yield f"data: {json.dumps({'type': 'done', 'timetable_refresh_url': timetable_refresh_url, 'provisional_timetable': provisional_timetable, 'summary_html': summary_html})}\n\n" + + return Response(stream_with_context(generate()), mimetype="text/event-stream") + if journey_type == "return": + _fetch_es_return() sections = [ build_section("outbound", "outbound", travel_date, es_return.get("outbound", [])), build_section("inbound", "inbound", return_date, es_return.get("inbound", [])), diff --git a/templates/results.html b/templates/results.html index 093104a..510b246 100644 --- a/templates/results.html +++ b/templates/results.html @@ -681,180 +681,10 @@ {% if sections %} {% for section in sections %} -
-

- {% if section.direction == 'inbound' %} - Return: {{ destination }} → {{ departure_station_name }} - {% else %} - Outbound: {{ departure_station_name }} → {{ destination }} - {% endif %} -

-

{{ section.date_display }}

- {% if section.rows %} - - - - {% if section.direction == 'inbound' %} - - - - {% else %} - - - - {% endif %} - - - - - {% set trip_rows = section.rows | selectattr('row_type', 'equalto', 'trip') | list %} - {% if trip_rows %} - {% set best_mins = trip_rows | map(attribute='total_minutes') | min %} - {% set worst_mins = trip_rows | map(attribute='total_minutes') | max %} - {% endif %} - {% for row in section.rows %} - {% if row.row_type == 'trip' and row.total_minutes <= best_mins + 5 and trip_rows | length > 1 %} - {% set row_class = 'row-fast' %} - {% elif row.row_type == 'trip' and row.total_minutes >= worst_mins - 5 and trip_rows | length > 1 %} - {% set row_class = 'row-slow' %} - {% elif row.row_type == 'unreachable' %} - {% set row_class = 'row-unreachable' %} - {% elif loop.index is odd %} - {% set row_class = 'row-alt' %} - {% else %} - {% set row_class = '' %} - {% endif %} - - {% if row.row_type == 'trip' %} - {% if section.direction == 'inbound' %} - - - - {% else %} - - - - {% endif %} - - {% else %} - - - - - {% endif %} - - {% endfor %} - -
Eurostar
{{ destination }} → St Pancras
Transfer
St Pancras → Paddington
National Rail
Paddington → {{ departure_station_name }}
National Rail
{{ departure_station_name }} → Paddington
Transfer
Paddington → St Pancras
Eurostar
St Pancras → {{ destination }}
Total
click row to select
- (CET) {{ row.depart_destination }} → {{ row.arrive_st_pancras }} (UK) -
check in by {{ row.check_in_by }} - {% if row.eurostar_duration or row.train_number %} -
- {%- if row.eurostar_duration %}({{ row.eurostar_duration }}){% endif %} - {%- if row.eurostar_duration and row.train_number %} · {% endif %} - {%- if row.train_number %}{{ row.train_number }}{% endif %} - - {% 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 %} - {% if row.circle_services %} - {% if row.circle_services | length > 1 %} - {% set c_early = row.circle_services[0] %} - {% set c = row.circle_services[1] %} -
Circle {{ c_early.depart }} → PAD {{ c_early.arrive_pad }} · £{{ "%.2f"|format(c_early.fare) }} -
next {{ c.depart }} → PAD {{ c.arrive_pad }} - {% else %} - {% set c = row.circle_services[0] %} -
Circle {{ c.depart }} → PAD {{ c.arrive_pad }} · £{{ "%.2f"|format(c.fare) }} - {% endif %} - {% endif %} -
- {{ 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 %} - {% endif %} - {% if row.ticket_price is not none %}£{{ "%.2f"|format(row.ticket_price) }}{% endif %} - - -
- {{ 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 %} - {% 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 %} - {% 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 %} -
- {{ row.depart_st_pancras }} → {{ row.arrive_destination }} (CET) - {% if row.eurostar_duration or row.train_number %} -
- {%- if row.eurostar_duration %}({{ row.eurostar_duration }}){% endif %} - {%- if row.eurostar_duration and row.train_number %} · {% endif %} - {%- if row.train_number %}{{ row.train_number }}{% endif %} - - {% 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 %} -
- {% if row.total_minutes <= best_mins + 5 and trip_rows | length > 1 %} - {{ row.total_duration }} ⚡ - {% elif row.total_minutes >= worst_mins - 5 and trip_rows | length > 1 %} - {{ row.total_duration }} 🐢 - {% else %} - {{ row.total_duration }} - {% endif %} -
-
- Too early - - {% if section.direction == 'inbound' %} - {{ row.depart_destination }} → {{ row.arrive_st_pancras }} - {% if row.train_number %}
{{ row.train_number }}{% endif %} - {% else %} - {{ row.depart_st_pancras }} → {{ row.arrive_destination }} - {% if row.train_number %}
{{ row.train_number }}{% endif %} - {% endif %} - - -
- {% else %} -
-

No valid journeys found.

-

- {% if section.gwr_count == 0 and section.eurostar_count == 0 %} - Could not retrieve train data. Check your network connection or try again. - {% elif section.gwr_count == 0 %} - No National Rail trains found for this date. - {% elif section.eurostar_count == 0 %} - No Eurostar services found for {{ destination }} on this date. - {% else %} - No National Rail + Eurostar combination has a {{ section.min_connection }}-{{ section.max_connection }} minute connection. - {% endif %} -

-
- {% endif %} -
+ {% include "results_section.html" %} {% endfor %} +

Connection windows: {% for section in sections %} diff --git a/templates/results_loading.html b/templates/results_loading.html index bb5b90b..4f20c12 100644 --- a/templates/results_loading.html +++ b/templates/results_loading.html @@ -51,33 +51,64 @@ }); } + function showError() { + var panel = document.querySelector('.loading-panel'); + if (panel) { + panel.innerHTML = '

Could not load results

Try loading the full results page.

'; + } + } + function loadResults() { + if (!window.EventSource) { + window.location.href = {{ full_results_url|tojson }}; + return; + } + attempts += 1; - fetch({{ full_results_url|tojson }}, {headers: {'X-Requested-With': 'fetch'}}) - .then(function(response) { - if (!response.ok) throw new Error('Could not load results'); - return response.text(); - }) - .then(function(html) { - var doc = new DOMParser().parseFromString(html, 'text/html'); + var source = new EventSource({{ stream_url|tojson }}); + + source.onmessage = function(event) { + var msg; + try { msg = JSON.parse(event.data); } catch(e) { return; } + + if (msg.type === 'shell') { + var doc = new DOMParser().parseFromString(msg.html, 'text/html'); document.title = doc.title; var nextMain = doc.querySelector('main'); var currentMain = document.querySelector('main'); - if (!nextMain || !currentMain) throw new Error('Results page was incomplete'); + if (!nextMain || !currentMain) { source.close(); return; } currentMain.innerHTML = nextMain.innerHTML; runScripts(currentMain); history.replaceState(null, '', window.location.href); - }) - .catch(function() { - if (attempts < 3) { - window.setTimeout(loadResults, attempts * 2000); - return; + + } else if (msg.type === 'section') { + var placeholder = document.getElementById('section-placeholder-' + msg.id); + if (placeholder) { + var tmp = document.createElement('div'); + tmp.innerHTML = msg.html; + var card = tmp.firstElementChild; + if (card) placeholder.parentNode.replaceChild(card, placeholder); } - var panel = document.querySelector('.loading-panel'); - if (panel) { - panel.innerHTML = '
Could not load results

Try loading the full results page.

'; - } - }); + if (typeof mergeSectionData === 'function') mergeSectionData(msg); + + } else if (msg.type === 'done') { + if (typeof finaliseResults === 'function') finaliseResults(msg); + source.close(); + + } else if (msg.type === 'error') { + source.close(); + showError(); + } + }; + + source.onerror = function() { + source.close(); + if (attempts < 3) { + window.setTimeout(loadResults, attempts * 2000); + } else { + showError(); + } + }; } loadResults(); diff --git a/templates/results_section.html b/templates/results_section.html new file mode 100644 index 0000000..be0782b --- /dev/null +++ b/templates/results_section.html @@ -0,0 +1,172 @@ +
+

+ {% if section.direction == 'inbound' %} + Return: {{ destination }} → {{ departure_station_name }} + {% else %} + Outbound: {{ departure_station_name }} → {{ destination }} + {% endif %} +

+

{{ section.date_display }}

+ {% if section.rows %} + + + + {% if section.direction == 'inbound' %} + + + + {% else %} + + + + {% endif %} + + + + + {% set trip_rows = section.rows | selectattr('row_type', 'equalto', 'trip') | list %} + {% if trip_rows %} + {% set best_mins = trip_rows | map(attribute='total_minutes') | min %} + {% set worst_mins = trip_rows | map(attribute='total_minutes') | max %} + {% endif %} + {% for row in section.rows %} + {% if row.row_type == 'trip' and row.total_minutes <= best_mins + 5 and trip_rows | length > 1 %} + {% set row_class = 'row-fast' %} + {% elif row.row_type == 'trip' and row.total_minutes >= worst_mins - 5 and trip_rows | length > 1 %} + {% set row_class = 'row-slow' %} + {% elif row.row_type == 'unreachable' %} + {% set row_class = 'row-unreachable' %} + {% elif loop.index is odd %} + {% set row_class = 'row-alt' %} + {% else %} + {% set row_class = '' %} + {% endif %} + + {% if row.row_type == 'trip' %} + {% if section.direction == 'inbound' %} + + + + {% else %} + + + + {% endif %} + + {% else %} + + + + + {% endif %} + + {% endfor %} + +
Eurostar
{{ destination }} → St Pancras
Transfer
St Pancras → Paddington
National Rail
Paddington → {{ departure_station_name }}
National Rail
{{ departure_station_name }} → Paddington
Transfer
Paddington → St Pancras
Eurostar
St Pancras → {{ destination }}
Total
click row to select
+ (CET) {{ row.depart_destination }} → {{ row.arrive_st_pancras }} (UK) +
check in by {{ row.check_in_by }} + {% if row.eurostar_duration or row.train_number %} +
+ {%- if row.eurostar_duration %}({{ row.eurostar_duration }}){% endif %} + {%- if row.eurostar_duration and row.train_number %} · {% endif %} + {%- if row.train_number %}{{ row.train_number }}{% endif %} + + {% 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 %} + {% if row.circle_services %} + {% if row.circle_services | length > 1 %} + {% set c_early = row.circle_services[0] %} + {% set c = row.circle_services[1] %} +
Circle {{ c_early.depart }} → PAD {{ c_early.arrive_pad }} · £{{ "%.2f"|format(c_early.fare) }} +
next {{ c.depart }} → PAD {{ c.arrive_pad }} + {% else %} + {% set c = row.circle_services[0] %} +
Circle {{ c.depart }} → PAD {{ c.arrive_pad }} · £{{ "%.2f"|format(c.fare) }} + {% endif %} + {% endif %} +
+ {{ 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 %} + {% endif %} + {% if row.ticket_price is not none %}£{{ "%.2f"|format(row.ticket_price) }}{% endif %} + + +
+ {{ 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 %} + {% 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 %} + {% 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 %} +
+ {{ row.depart_st_pancras }} → {{ row.arrive_destination }} (CET) + {% if row.eurostar_duration or row.train_number %} +
+ {%- if row.eurostar_duration %}({{ row.eurostar_duration }}){% endif %} + {%- if row.eurostar_duration and row.train_number %} · {% endif %} + {%- if row.train_number %}{{ row.train_number }}{% endif %} + + {% 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 %} +
+ {% if row.total_minutes <= best_mins + 5 and trip_rows | length > 1 %} + {{ row.total_duration }} ⚡ + {% elif row.total_minutes >= worst_mins - 5 and trip_rows | length > 1 %} + {{ row.total_duration }} 🐢 + {% else %} + {{ row.total_duration }} + {% endif %} +
+
+ Too early + + {% if section.direction == 'inbound' %} + {{ row.depart_destination }} → {{ row.arrive_st_pancras }} + {% if row.train_number %}
{{ row.train_number }}{% endif %} + {% else %} + {{ row.depart_st_pancras }} → {{ row.arrive_destination }} + {% if row.train_number %}
{{ row.train_number }}{% endif %} + {% endif %} + + +
+ {% else %} +
+

No valid journeys found.

+

+ {% if section.gwr_count == 0 and section.eurostar_count == 0 %} + Could not retrieve train data. Check your network connection or try again. + {% elif section.gwr_count == 0 %} + No National Rail trains found for this date. + {% elif section.eurostar_count == 0 %} + No Eurostar services found for {{ destination }} on this date. + {% else %} + No National Rail + Eurostar combination has a {{ section.min_connection }}-{{ section.max_connection }} minute connection. + {% endif %} +

+
+ {% endif %} +
diff --git a/templates/results_shell.html b/templates/results_shell.html new file mode 100644 index 0000000..634994d --- /dev/null +++ b/templates/results_shell.html @@ -0,0 +1,708 @@ +{% 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 }} on {{ travel_date_display }} 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 }} on {{ travel_date_display }} 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 %} +

+ {% if journey_type == 'return' %} +
+ Outbound: + ← Prev + {{ travel_date_display }} + Next → +
+
+ Return: + ← Prev + {{ return_date_display }} + Next → +
+ {% else %} +
+ ← Prev + {{ travel_date_display }} + Next → +
+ {% endif %} +
+ +
+ {% for destination_slug, destination_name, destination_url in destination_links %} + {% if destination_slug == slug %} + {{ destination_name }} + {% else %} + {{ destination_name }} + {% endif %} + {% endfor %} +
+
+
+
+ + +
+
+ + +
+
+ {% if journey_type == 'return' %} + {% for section in sections %} +
+ {{ 'Outbound' if section.direction == 'outbound' else 'Return' }}: + {% if section.direction == 'inbound' %} +
+ + +
+ {% endif %} +
+ NR: +
+ + + +
+
+
+ Eurostar: +
+ + +
+
+
+ {% endfor %} +
+ Loading fares + +
+ {% else %} + {% set section = sections[0] %} +
+
+ NR ticket: + + Load advance prices +
+ + + +
+ Loading fares + +
+
+ Eurostar: + +
+ + +
+
+
+ {% endif %} + +

+ +

+
+
+ +{% for section in sections %} +
+
+ +
Loading {{ 'return' if section.direction == 'inbound' else 'outbound' }} results…
+
+
+{% endfor %} + +

+ Connection windows: + {% for section in sections %} + {% if section.direction == 'inbound' %}return{% else %}outbound{% endif %} + {{ section.min_connection }}–{{ section.max_connection }} min{% if not loop.last %}; {% endif %} + {% endfor %}. + National Rail prices from gwr.com. + Eurostar prices are for 1 adult in GBP; return searches use Eurostar return-search prices. + Always check eurostar.com to book. +  ·  + {{ departure_station_name }} on RTT +  ·  + Paddington on RTT +

+ +
+
+
+ + +
+
+ + + + +
+
+
+ +{% endblock %}