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:
parent
5f0d2c71b1
commit
453d6244ec
5 changed files with 1182 additions and 197 deletions
258
app.py
258
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 += ' · <span class="text-muted text-sm">(cached)</span>'
|
||||
if provisional_timetable:
|
||||
html += ' · <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", [])),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue