Stream results progressively via SSE instead of waiting for full render

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 <noreply@anthropic.com>
This commit is contained in:
Edward Betts 2026-05-25 21:24:40 +01:00
parent 5f0d2c71b1
commit 453d6244ec
5 changed files with 1182 additions and 197 deletions

258
app.py
View file

@ -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 = " &nbsp;&middot;&nbsp; ".join(parts)
else:
s = sections[0]
html = f"{pl(s['gwr_count'], 'National Rail service')} &nbsp;&middot;&nbsp; {pl(s['eurostar_count'], 'Eurostar service')}"
if from_cache_parts:
html += ' &nbsp;&middot;&nbsp; <span class="text-muted text-sm">(cached)</span>'
if provisional_timetable:
html += ' &nbsp;&middot;&nbsp; <span class="text-muted text-sm">checking exact timetable</span>'
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", [])),