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 %} -
| Eurostar {{ destination }} → St Pancras |
- Transfer St Pancras → Paddington |
- National Rail Paddington → {{ departure_station_name }} |
- {% else %}
- National Rail {{ departure_station_name }} → Paddington |
- Transfer Paddington → St Pancras |
- Eurostar St Pancras → {{ destination }} |
- {% endif %}
- 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 %} - - - |
- {% else %}
-
- {{ 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 %} - |
- {% 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 %}
- - |
- {% else %}
- - 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 %} - - - |
- - {% endif %} - |
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 %} -
-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 = '
| Eurostar {{ destination }} → St Pancras |
+ Transfer St Pancras → Paddington |
+ National Rail Paddington → {{ departure_station_name }} |
+ {% else %}
+ National Rail {{ departure_station_name }} → Paddington |
+ Transfer Paddington → St Pancras |
+ Eurostar St Pancras → {{ destination }} |
+ {% endif %}
+ 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 %} + + + |
+ {% else %}
+
+ {{ 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 %} + |
+ {% 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 %}
+ + |
+ {% else %}
+ + 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 %} + + + |
+ + {% endif %} + |
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 %} +
++ ← New search +
+ ++ 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 +
+ +