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", [])),
|
||||
|
|
|
|||
|
|
@ -681,180 +681,10 @@
|
|||
|
||||
{% if sections %}
|
||||
{% for section in sections %}
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<h2>
|
||||
{% if section.direction == 'inbound' %}
|
||||
Return: {{ destination }} → {{ departure_station_name }}
|
||||
{% else %}
|
||||
Outbound: {{ departure_station_name }} → {{ destination }}
|
||||
{% endif %}
|
||||
</h2>
|
||||
<p class="card-meta">{{ section.date_display }}</p>
|
||||
{% if section.rows %}
|
||||
<table class="results-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if section.direction == 'inbound' %}
|
||||
<th class="flow-step">Eurostar<br><span class="text-xs font-normal text-muted">{{ destination }} → St Pancras</span></th>
|
||||
<th class="col-transfer flow-step">Transfer<br><span class="text-xs font-normal text-muted">St Pancras → Paddington</span></th>
|
||||
<th>National Rail<br><span class="text-xs font-normal text-muted">Paddington → {{ departure_station_name }}</span></th>
|
||||
{% else %}
|
||||
<th class="flow-step">National Rail<br><span class="text-xs font-normal text-muted">{{ departure_station_name }} → Paddington</span></th>
|
||||
<th class="col-transfer flow-step">Transfer<br><span class="text-xs font-normal text-muted">Paddington → St Pancras</span></th>
|
||||
<th>Eurostar<br><span class="text-xs font-normal text-muted">St Pancras → {{ destination }}</span></th>
|
||||
{% endif %}
|
||||
<th class="nowrap">Total<br><span class="text-xs font-normal text-muted">click row to select</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% 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 %}
|
||||
<tr class="{{ row_class }}{% if row.row_type == 'trip' %} row-selectable{% endif %}"
|
||||
data-row-key="{{ row.row_key }}"
|
||||
{% if row.eurostar_price is not none %}data-es-std="{{ row.eurostar_price }}"{% endif %}
|
||||
{% if row.row_type == 'trip' %}onclick="selectRow(this)"{% endif %}>
|
||||
{% if row.row_type == 'trip' %}
|
||||
{% if section.direction == 'inbound' %}
|
||||
<td>
|
||||
<span class="font-bold nowrap"><span class="font-normal text-muted" style="font-size:0.85em">(CET)</span> {{ row.depart_destination }} → {{ row.arrive_st_pancras }} <span class="font-normal text-muted" style="font-size:0.85em">(UK)</span></span>
|
||||
<br><span class="text-xs text-muted">check in by {{ row.check_in_by }}</span>
|
||||
{% if row.eurostar_duration or row.train_number %}
|
||||
<br><span class="text-xs text-muted">
|
||||
{%- if row.eurostar_duration %}<span class="nowrap">({{ row.eurostar_duration }})</span>{% endif %}
|
||||
{%- if row.eurostar_duration and row.train_number %} · {% endif %}
|
||||
{%- if row.train_number %}{{ row.train_number }}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="fare-line es-standard"><span class="text-xs text-muted">Std</span>{% if row.eurostar_price is not none %} <span class="text-sm font-bold">£{{ "%.2f"|format(row.eurostar_price) }}</span>{% endif %}</span>
|
||||
<span class="fare-line es-plus"><span class="text-xs text-muted">SP</span>{% if row.eurostar_plus_price is not none %} <span class="text-sm font-bold">£{{ "%.2f"|format(row.eurostar_plus_price) }}</span>{% endif %}</span>
|
||||
<span class="text-xs text-muted mobile-conn">then {{ row.connection_duration }} to station{% if row.connection_minutes < 45 %} ⚠️{% endif %}</span>
|
||||
</td>
|
||||
<td class="col-transfer" style="color:#4a5568">
|
||||
<span class="nowrap">{{ row.connection_duration }}{% if row.connection_minutes < 45 %} <span title="Tight connection">⚠️</span>{% endif %}</span>
|
||||
{% if row.circle_services %}
|
||||
{% if row.circle_services | length > 1 %}
|
||||
{% set c_early = row.circle_services[0] %}
|
||||
{% set c = row.circle_services[1] %}
|
||||
<br><span class="text-xs text-muted nowrap">Circle {{ c_early.depart }} → PAD {{ c_early.arrive_pad }} · £{{ "%.2f"|format(c_early.fare) }}</span>
|
||||
<br><span class="text-xs text-muted nowrap" style="opacity:0.7">next {{ c.depart }} → PAD {{ c.arrive_pad }}</span>
|
||||
{% else %}
|
||||
{% set c = row.circle_services[0] %}
|
||||
<br><span class="text-xs text-muted nowrap">Circle {{ c.depart }} → PAD {{ c.arrive_pad }} · £{{ "%.2f"|format(c.fare) }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="font-bold nowrap">{{ row.depart_paddington }} → {{ row.arrive_uk_station }}</span>
|
||||
<span class="text-sm text-muted nowrap">({{ row.gwr_duration }})</span>
|
||||
{% if row.headcode or row.arrive_platform %}
|
||||
<br><span class="text-xs text-muted mobile-hide">{{ row.headcode }}{% if row.headcode and row.arrive_platform %} · {% endif %}{% if row.arrive_platform %}Plat {{ row.arrive_platform }}{% endif %}</span>
|
||||
{% endif %}
|
||||
<span class="fare-line nr-walkon">{% if row.ticket_price is not none %}<span class="text-sm font-bold">£{{ "%.2f"|format(row.ticket_price) }}</span>{% endif %}</span>
|
||||
<span class="fare-line nr-advance-std"></span>
|
||||
<span class="fare-line nr-advance-1st"></span>
|
||||
</td>
|
||||
{% else %}
|
||||
<td>
|
||||
<span class="font-bold nowrap">{{ row.depart_bristol }} → {{ row.arrive_paddington }}</span>
|
||||
<span class="text-sm text-muted nowrap">({{ row.gwr_duration }})</span>
|
||||
{% if row.headcode or row.arrive_platform %}
|
||||
<br><span class="text-xs text-muted mobile-hide">{{ row.headcode }}{% if row.headcode and row.arrive_platform %} · {% endif %}{% if row.arrive_platform %}Plat {{ row.arrive_platform }}{% endif %}</span>
|
||||
{% endif %}
|
||||
<span class="fare-line nr-walkon">{% if row.ticket_price is not none %}<span class="text-sm font-bold">£{{ "%.2f"|format(row.ticket_price) }}</span>{% endif %}</span>
|
||||
<span class="fare-line nr-advance-std"></span>
|
||||
<span class="fare-line nr-advance-1st"></span>
|
||||
<span class="text-xs text-muted mobile-conn">then {{ row.connection_duration }} to Eurostar{% if row.connection_minutes < 80 %} ⚠️{% endif %}</span>
|
||||
</td>
|
||||
<td class="col-transfer" style="color:#4a5568">
|
||||
<span class="nowrap">{{ row.connection_duration }}{% if row.connection_minutes < 80 %} <span title="Tight connection">⚠️</span>{% endif %}</span>
|
||||
{% if row.circle_services %}
|
||||
{% set c = row.circle_services[0] %}
|
||||
<br><span class="text-xs text-muted nowrap">Circle {{ c.depart }} → KX {{ c.arrive_kx }} · £{{ "%.2f"|format(c.fare) }}</span>
|
||||
{% if row.circle_services | length > 1 %}
|
||||
{% set c2 = row.circle_services[1] %}
|
||||
<br><span class="text-xs text-muted nowrap" style="opacity:0.7">next {{ c2.depart }} → KX {{ c2.arrive_kx }} · £{{ "%.2f"|format(c2.fare) }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="font-bold nowrap">{{ row.depart_st_pancras }} → {{ row.arrive_destination }} <span class="font-normal text-muted" style="font-size:0.85em">(CET)</span></span>
|
||||
{% if row.eurostar_duration or row.train_number %}
|
||||
<br><span class="text-xs text-muted">
|
||||
{%- if row.eurostar_duration %}<span class="nowrap">({{ row.eurostar_duration }})</span>{% endif %}
|
||||
{%- if row.eurostar_duration and row.train_number %} · {% endif %}
|
||||
{%- if row.train_number %}{{ row.train_number }}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="fare-line es-standard"><span class="text-xs text-muted">Std</span>{% if row.eurostar_price is not none %} <span class="text-sm font-bold">£{{ "%.2f"|format(row.eurostar_price) }}</span>{% endif %}</span>
|
||||
<span class="fare-line es-plus"><span class="text-xs text-muted">SP</span>{% if row.eurostar_plus_price is not none %} <span class="text-sm font-bold">£{{ "%.2f"|format(row.eurostar_plus_price) }}</span>{% endif %}</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="font-bold nowrap">
|
||||
{% if row.total_minutes <= best_mins + 5 and trip_rows | length > 1 %}
|
||||
<span class="text-green" title="Fastest journey">{{ row.total_duration }} ⚡</span>
|
||||
{% elif row.total_minutes >= worst_mins - 5 and trip_rows | length > 1 %}
|
||||
<span class="text-red" title="Slowest journey">{{ row.total_duration }} 🐢</span>
|
||||
{% else %}
|
||||
<span class="text-blue">{{ row.total_duration }}</span>
|
||||
{% endif %}
|
||||
<br><span class="total-price"></span>
|
||||
</td>
|
||||
{% else %}
|
||||
<td>
|
||||
<span class="text-dimmed text-sm" title="No National Rail service from {{ departure_station_name }} arrives at Paddington in time for this Eurostar">Too early</span>
|
||||
</td>
|
||||
<td class="col-transfer text-dimmed">—</td>
|
||||
<td>
|
||||
{% if section.direction == 'inbound' %}
|
||||
<span class="font-bold nowrap text-dimmed">{{ row.depart_destination }} → {{ row.arrive_st_pancras }}</span>
|
||||
{% if row.train_number %}<br><span class="text-xs text-dimmed">{{ row.train_number }}</span>{% endif %}
|
||||
{% else %}
|
||||
<span class="font-bold nowrap text-dimmed">{{ row.depart_st_pancras }} → {{ row.arrive_destination }}</span>
|
||||
{% if row.train_number %}<br><span class="text-xs text-dimmed">{{ row.train_number }}</span>{% endif %}
|
||||
{% endif %}
|
||||
<span class="fare-line es-standard"></span>
|
||||
<span class="fare-line es-plus"></span>
|
||||
</td>
|
||||
<td class="text-dimmed nowrap"><span class="total-price"></span></td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>No valid journeys found.</p>
|
||||
<p>
|
||||
{% 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 %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include "results_section.html" %}
|
||||
{% endfor %}
|
||||
|
||||
|
||||
<p class="footnote">
|
||||
Connection windows:
|
||||
{% for section in sections %}
|
||||
|
|
|
|||
|
|
@ -51,33 +51,64 @@
|
|||
});
|
||||
}
|
||||
|
||||
function showError() {
|
||||
var panel = document.querySelector('.loading-panel');
|
||||
if (panel) {
|
||||
panel.innerHTML = '<div><strong>Could not load results</strong><p class="text-muted text-sm"><a href="{{ full_results_url }}">Try loading the full results page</a>.</p></div>';
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<div><strong>Could not load results</strong><p class="text-muted text-sm"><a href="{{ full_results_url }}">Try loading the full results page</a>.</p></div>';
|
||||
}
|
||||
});
|
||||
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();
|
||||
|
|
|
|||
172
templates/results_section.html
Normal file
172
templates/results_section.html
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<h2>
|
||||
{% if section.direction == 'inbound' %}
|
||||
Return: {{ destination }} → {{ departure_station_name }}
|
||||
{% else %}
|
||||
Outbound: {{ departure_station_name }} → {{ destination }}
|
||||
{% endif %}
|
||||
</h2>
|
||||
<p class="card-meta">{{ section.date_display }}</p>
|
||||
{% if section.rows %}
|
||||
<table class="results-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if section.direction == 'inbound' %}
|
||||
<th class="flow-step">Eurostar<br><span class="text-xs font-normal text-muted">{{ destination }} → St Pancras</span></th>
|
||||
<th class="col-transfer flow-step">Transfer<br><span class="text-xs font-normal text-muted">St Pancras → Paddington</span></th>
|
||||
<th>National Rail<br><span class="text-xs font-normal text-muted">Paddington → {{ departure_station_name }}</span></th>
|
||||
{% else %}
|
||||
<th class="flow-step">National Rail<br><span class="text-xs font-normal text-muted">{{ departure_station_name }} → Paddington</span></th>
|
||||
<th class="col-transfer flow-step">Transfer<br><span class="text-xs font-normal text-muted">Paddington → St Pancras</span></th>
|
||||
<th>Eurostar<br><span class="text-xs font-normal text-muted">St Pancras → {{ destination }}</span></th>
|
||||
{% endif %}
|
||||
<th class="nowrap">Total<br><span class="text-xs font-normal text-muted">click row to select</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% 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 %}
|
||||
<tr class="{{ row_class }}{% if row.row_type == 'trip' %} row-selectable{% endif %}"
|
||||
data-row-key="{{ row.row_key }}"
|
||||
{% if row.eurostar_price is not none %}data-es-std="{{ row.eurostar_price }}"{% endif %}
|
||||
{% if row.row_type == 'trip' %}onclick="selectRow(this)"{% endif %}>
|
||||
{% if row.row_type == 'trip' %}
|
||||
{% if section.direction == 'inbound' %}
|
||||
<td>
|
||||
<span class="font-bold nowrap"><span class="font-normal text-muted" style="font-size:0.85em">(CET)</span> {{ row.depart_destination }} → {{ row.arrive_st_pancras }} <span class="font-normal text-muted" style="font-size:0.85em">(UK)</span></span>
|
||||
<br><span class="text-xs text-muted">check in by {{ row.check_in_by }}</span>
|
||||
{% if row.eurostar_duration or row.train_number %}
|
||||
<br><span class="text-xs text-muted">
|
||||
{%- if row.eurostar_duration %}<span class="nowrap">({{ row.eurostar_duration }})</span>{% endif %}
|
||||
{%- if row.eurostar_duration and row.train_number %} · {% endif %}
|
||||
{%- if row.train_number %}{{ row.train_number }}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="fare-line es-standard"><span class="text-xs text-muted">Std</span>{% if row.eurostar_price is not none %} <span class="text-sm font-bold">£{{ "%.2f"|format(row.eurostar_price) }}</span>{% endif %}</span>
|
||||
<span class="fare-line es-plus"><span class="text-xs text-muted">SP</span>{% if row.eurostar_plus_price is not none %} <span class="text-sm font-bold">£{{ "%.2f"|format(row.eurostar_plus_price) }}</span>{% endif %}</span>
|
||||
<span class="text-xs text-muted mobile-conn">then {{ row.connection_duration }} to station{% if row.connection_minutes < 45 %} ⚠️{% endif %}</span>
|
||||
</td>
|
||||
<td class="col-transfer" style="color:#4a5568">
|
||||
<span class="nowrap">{{ row.connection_duration }}{% if row.connection_minutes < 45 %} <span title="Tight connection">⚠️</span>{% endif %}</span>
|
||||
{% if row.circle_services %}
|
||||
{% if row.circle_services | length > 1 %}
|
||||
{% set c_early = row.circle_services[0] %}
|
||||
{% set c = row.circle_services[1] %}
|
||||
<br><span class="text-xs text-muted nowrap">Circle {{ c_early.depart }} → PAD {{ c_early.arrive_pad }} · £{{ "%.2f"|format(c_early.fare) }}</span>
|
||||
<br><span class="text-xs text-muted nowrap" style="opacity:0.7">next {{ c.depart }} → PAD {{ c.arrive_pad }}</span>
|
||||
{% else %}
|
||||
{% set c = row.circle_services[0] %}
|
||||
<br><span class="text-xs text-muted nowrap">Circle {{ c.depart }} → PAD {{ c.arrive_pad }} · £{{ "%.2f"|format(c.fare) }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="font-bold nowrap">{{ row.depart_paddington }} → {{ row.arrive_uk_station }}</span>
|
||||
<span class="text-sm text-muted nowrap">({{ row.gwr_duration }})</span>
|
||||
{% if row.headcode or row.arrive_platform %}
|
||||
<br><span class="text-xs text-muted mobile-hide">{{ row.headcode }}{% if row.headcode and row.arrive_platform %} · {% endif %}{% if row.arrive_platform %}Plat {{ row.arrive_platform }}{% endif %}</span>
|
||||
{% endif %}
|
||||
<span class="fare-line nr-walkon">{% if row.ticket_price is not none %}<span class="text-sm font-bold">£{{ "%.2f"|format(row.ticket_price) }}</span>{% endif %}</span>
|
||||
<span class="fare-line nr-advance-std"></span>
|
||||
<span class="fare-line nr-advance-1st"></span>
|
||||
</td>
|
||||
{% else %}
|
||||
<td>
|
||||
<span class="font-bold nowrap">{{ row.depart_bristol }} → {{ row.arrive_paddington }}</span>
|
||||
<span class="text-sm text-muted nowrap">({{ row.gwr_duration }})</span>
|
||||
{% if row.headcode or row.arrive_platform %}
|
||||
<br><span class="text-xs text-muted mobile-hide">{{ row.headcode }}{% if row.headcode and row.arrive_platform %} · {% endif %}{% if row.arrive_platform %}Plat {{ row.arrive_platform }}{% endif %}</span>
|
||||
{% endif %}
|
||||
<span class="fare-line nr-walkon">{% if row.ticket_price is not none %}<span class="text-sm font-bold">£{{ "%.2f"|format(row.ticket_price) }}</span>{% endif %}</span>
|
||||
<span class="fare-line nr-advance-std"></span>
|
||||
<span class="fare-line nr-advance-1st"></span>
|
||||
<span class="text-xs text-muted mobile-conn">then {{ row.connection_duration }} to Eurostar{% if row.connection_minutes < 80 %} ⚠️{% endif %}</span>
|
||||
</td>
|
||||
<td class="col-transfer" style="color:#4a5568">
|
||||
<span class="nowrap">{{ row.connection_duration }}{% if row.connection_minutes < 80 %} <span title="Tight connection">⚠️</span>{% endif %}</span>
|
||||
{% if row.circle_services %}
|
||||
{% set c = row.circle_services[0] %}
|
||||
<br><span class="text-xs text-muted nowrap">Circle {{ c.depart }} → KX {{ c.arrive_kx }} · £{{ "%.2f"|format(c.fare) }}</span>
|
||||
{% if row.circle_services | length > 1 %}
|
||||
{% set c2 = row.circle_services[1] %}
|
||||
<br><span class="text-xs text-muted nowrap" style="opacity:0.7">next {{ c2.depart }} → KX {{ c2.arrive_kx }} · £{{ "%.2f"|format(c2.fare) }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="font-bold nowrap">{{ row.depart_st_pancras }} → {{ row.arrive_destination }} <span class="font-normal text-muted" style="font-size:0.85em">(CET)</span></span>
|
||||
{% if row.eurostar_duration or row.train_number %}
|
||||
<br><span class="text-xs text-muted">
|
||||
{%- if row.eurostar_duration %}<span class="nowrap">({{ row.eurostar_duration }})</span>{% endif %}
|
||||
{%- if row.eurostar_duration and row.train_number %} · {% endif %}
|
||||
{%- if row.train_number %}{{ row.train_number }}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="fare-line es-standard"><span class="text-xs text-muted">Std</span>{% if row.eurostar_price is not none %} <span class="text-sm font-bold">£{{ "%.2f"|format(row.eurostar_price) }}</span>{% endif %}</span>
|
||||
<span class="fare-line es-plus"><span class="text-xs text-muted">SP</span>{% if row.eurostar_plus_price is not none %} <span class="text-sm font-bold">£{{ "%.2f"|format(row.eurostar_plus_price) }}</span>{% endif %}</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="font-bold nowrap">
|
||||
{% if row.total_minutes <= best_mins + 5 and trip_rows | length > 1 %}
|
||||
<span class="text-green" title="Fastest journey">{{ row.total_duration }} ⚡</span>
|
||||
{% elif row.total_minutes >= worst_mins - 5 and trip_rows | length > 1 %}
|
||||
<span class="text-red" title="Slowest journey">{{ row.total_duration }} 🐢</span>
|
||||
{% else %}
|
||||
<span class="text-blue">{{ row.total_duration }}</span>
|
||||
{% endif %}
|
||||
<br><span class="total-price"></span>
|
||||
</td>
|
||||
{% else %}
|
||||
<td>
|
||||
<span class="text-dimmed text-sm" title="No National Rail service from {{ departure_station_name }} arrives at Paddington in time for this Eurostar">Too early</span>
|
||||
</td>
|
||||
<td class="col-transfer text-dimmed">—</td>
|
||||
<td>
|
||||
{% if section.direction == 'inbound' %}
|
||||
<span class="font-bold nowrap text-dimmed">{{ row.depart_destination }} → {{ row.arrive_st_pancras }}</span>
|
||||
{% if row.train_number %}<br><span class="text-xs text-dimmed">{{ row.train_number }}</span>{% endif %}
|
||||
{% else %}
|
||||
<span class="font-bold nowrap text-dimmed">{{ row.depart_st_pancras }} → {{ row.arrive_destination }}</span>
|
||||
{% if row.train_number %}<br><span class="text-xs text-dimmed">{{ row.train_number }}</span>{% endif %}
|
||||
{% endif %}
|
||||
<span class="fare-line es-standard"></span>
|
||||
<span class="fare-line es-plus"></span>
|
||||
</td>
|
||||
<td class="text-dimmed nowrap"><span class="total-price"></span></td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>No valid journeys found.</p>
|
||||
<p>
|
||||
{% 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 %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
708
templates/results_shell.html
Normal file
708
templates/results_shell.html
Normal file
|
|
@ -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 %}
|
||||
|
||||
<p class="back-link">
|
||||
<a href="{{ url_for('index') }}">← New search</a>
|
||||
</p>
|
||||
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<h2>
|
||||
{% if journey_type == 'inbound' %}
|
||||
{{ destination }} → {{ departure_station_name }}
|
||||
{% elif journey_type == 'return' %}
|
||||
{{ departure_station_name }} ↔ {{ destination }}
|
||||
{% else %}
|
||||
{{ departure_station_name }} → {{ destination }}
|
||||
{% endif %}
|
||||
</h2>
|
||||
{% if journey_type == 'return' %}
|
||||
<div class="date-nav">
|
||||
<span class="date-nav-label">Outbound:</span>
|
||||
<a href="{{ prev_outbound_url }}" class="btn-nav">← Prev</a>
|
||||
<strong>{{ travel_date_display }}</strong>
|
||||
<a href="{{ next_outbound_url }}" class="btn-nav">Next →</a>
|
||||
</div>
|
||||
<div class="date-nav">
|
||||
<span class="date-nav-label">Return:</span>
|
||||
<a href="{{ prev_return_url }}" class="btn-nav">← Prev</a>
|
||||
<strong>{{ return_date_display }}</strong>
|
||||
<a href="{{ next_return_url }}" class="btn-nav">Next →</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="date-nav">
|
||||
<a href="{{ prev_results_url }}"
|
||||
class="btn-nav">← Prev</a>
|
||||
<strong>{{ travel_date_display }}</strong>
|
||||
<a href="{{ next_results_url }}"
|
||||
class="btn-nav">Next →</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="switcher-section">
|
||||
<div class="section-label">Switch destination for {{ travel_date_display }}</div>
|
||||
<div class="chip-row">
|
||||
{% for destination_slug, destination_name, destination_url in destination_links %}
|
||||
{% if destination_slug == slug %}
|
||||
<span class="chip-current">{{ destination_name }}</span>
|
||||
{% else %}
|
||||
<a
|
||||
class="chip-link"
|
||||
href="{{ destination_url }}"
|
||||
>{{ destination_name }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<div>
|
||||
<label for="min_conn_select" class="filter-label">Min connection:</label>
|
||||
<select id="min_conn_select" onchange="applyConnectionFilter()" class="select-inline">
|
||||
{% for mins in valid_min_connections %}
|
||||
<option value="{{ mins }}" {% if mins == min_connection %}selected{% endif %}>{{ mins }} min</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="max_conn_select" class="filter-label">Max connection:</label>
|
||||
<select id="max_conn_select" onchange="applyConnectionFilter()" class="select-inline">
|
||||
{% for mins in valid_max_connections %}
|
||||
<option value="{{ mins }}" {% if mins == max_connection %}selected{% endif %}>{{ mins }} min</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{% if journey_type == 'return' %}
|
||||
{% for section in sections %}
|
||||
<div class="filter-row" style="margin-top:0.5rem">
|
||||
<span class="filter-label" style="min-width:5.5rem">{{ 'Outbound' if section.direction == 'outbound' else 'Return' }}:</span>
|
||||
{% if section.direction == 'inbound' %}
|
||||
<div>
|
||||
<label for="min_conn_in_select" class="filter-label">Min connection:</label>
|
||||
<select id="min_conn_in_select" onchange="applyConnectionFilter()" class="select-inline">
|
||||
{% for mins in valid_inbound_return_min_connections %}
|
||||
<option value="{{ mins }}" {% if mins == inbound_min_connection %}selected{% endif %}>{{ mins }} min</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<span class="filter-label">NR:</span>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn-group-option {% if nr_classes[section.id] == 'walkon' %}active{% endif %}" data-section="{{ section.id }}" data-nr-class="walkon" onclick="setNrClass('walkon', '{{ section.id }}')">Walk-on Std</button>
|
||||
<button type="button" class="btn-group-option {% if nr_classes[section.id] == 'advance_std' %}active{% endif %}" data-section="{{ section.id }}" data-nr-class="advance_std" onclick="setNrClass('advance_std', '{{ section.id }}')">Advance Std</button>
|
||||
<button type="button" class="btn-group-option {% if nr_classes[section.id] == 'advance_1st' %}active{% endif %}" data-section="{{ section.id }}" data-nr-class="advance_1st" onclick="setNrClass('advance_1st', '{{ section.id }}')">Advance 1st</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="filter-label">Eurostar:</span>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn-group-option {% if es_classes[section.id] == 'standard' %}active{% endif %}" data-section="{{ section.id }}" data-es-class="standard" onclick="setEsClass('standard', '{{ section.id }}')">Standard</button>
|
||||
<button type="button" class="btn-group-option {% if es_classes[section.id] == 'plus' %}active{% endif %}" data-section="{{ section.id }}" data-es-class="plus" onclick="setEsClass('plus', '{{ section.id }}')">Standard Premier</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div style="font-size:0.82rem;color:#718096;margin-top:0.4rem">
|
||||
<span id="walkon-loading"><span class="spinner spinner-inline" aria-hidden="true"></span>Loading fares</span>
|
||||
<span id="advance-loading" style="display:none"><span class="spinner spinner-inline" aria-hidden="true"></span>Loading advance fares</span>
|
||||
</div>
|
||||
{% else %}
|
||||
{% set section = sections[0] %}
|
||||
<div class="filter-row" style="margin-top:0.5rem">
|
||||
<div>
|
||||
<span class="filter-label">NR ticket:</span>
|
||||
<span id="nr-type-select" style="display:none"></span>
|
||||
<span style="display:none">Load advance prices</span>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn-group-option {% if nr_classes[section.id] == 'walkon' %}active{% endif %}" data-section="{{ section.id }}" data-nr-class="walkon" onclick="setNrClass('walkon', '{{ section.id }}')">Walk-on Std</button>
|
||||
<button type="button" class="btn-group-option {% if nr_classes[section.id] == 'advance_std' %}active{% endif %}" data-section="{{ section.id }}" data-nr-class="advance_std" onclick="setNrClass('advance_std', '{{ section.id }}')">Advance Std</button>
|
||||
<button type="button" class="btn-group-option {% if nr_classes[section.id] == 'advance_1st' %}active{% endif %}" data-section="{{ section.id }}" data-nr-class="advance_1st" onclick="setNrClass('advance_1st', '{{ section.id }}')">Advance 1st</button>
|
||||
</div>
|
||||
<span id="walkon-loading"><span class="spinner spinner-inline" aria-hidden="true"></span>Loading fares</span>
|
||||
<span id="advance-loading" style="display:none"><span class="spinner spinner-inline" aria-hidden="true"></span>Loading advance fares</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="filter-label">Eurostar:</span>
|
||||
<span id="es-type-select" style="display:none"></span>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn-group-option {% if es_classes[section.id] == 'standard' %}active{% endif %}" data-section="{{ section.id }}" data-es-class="standard" onclick="setEsClass('standard', '{{ section.id }}')">Standard</button>
|
||||
<button type="button" class="btn-group-option {% if es_classes[section.id] == 'plus' %}active{% endif %}" data-section="{{ section.id }}" data-es-class="plus" onclick="setEsClass('plus', '{{ section.id }}')">Standard Premier</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<script>
|
||||
const RESULTS_BASE = '{{ results_base_url }}';
|
||||
const DEFAULT_MIN_CONN = {{ default_min_connection }};
|
||||
const DEFAULT_MAX_CONN = {{ default_max_connection }};
|
||||
const DEFAULT_MIN_CONN_IN = {{ default_inbound_min_connection }};
|
||||
let TRIP_FARES = {};
|
||||
let ADVANCE_FARES = null;
|
||||
let WALKON_CACHED_FARES = {};
|
||||
let WALKON_API_URLS = {};
|
||||
let ADVANCE_API_URLS = {};
|
||||
let ADVANCE_STREAM_URLS = {};
|
||||
let TIMETABLE_REFRESH_URL = null;
|
||||
let HAS_PROVISIONAL_TIMETABLE = false;
|
||||
let cachedAdvanceFares = null;
|
||||
let currentNrClasses = {{ nr_classes_json | safe }};
|
||||
let currentEsClasses = {{ es_classes_json | safe }};
|
||||
const SECTION_DIRECTIONS = {{ section_directions_json | safe }};
|
||||
let advanceLoadingSections = {};
|
||||
let walkonLoadingSections = {};
|
||||
let selectedRowKeys = {};
|
||||
|
||||
function updateAdvanceLoadingStatus() {
|
||||
var loading = Object.keys(advanceLoadingSections).some(function(sectionId) {
|
||||
return advanceLoadingSections[sectionId];
|
||||
});
|
||||
var el = document.getElementById('advance-loading');
|
||||
if (el) el.style.display = loading ? 'inline-flex' : 'none';
|
||||
}
|
||||
|
||||
function buildUrl() {
|
||||
var min = parseInt(document.getElementById('min_conn_select').value);
|
||||
var max = parseInt(document.getElementById('max_conn_select').value);
|
||||
var params = [];
|
||||
if (min !== DEFAULT_MIN_CONN) params.push('min_connection=' + min);
|
||||
if (max !== DEFAULT_MAX_CONN) params.push('max_connection=' + max);
|
||||
var minInEl = document.getElementById('min_conn_in_select');
|
||||
if (minInEl) {
|
||||
var minIn = parseInt(minInEl.value);
|
||||
if (minIn !== DEFAULT_MIN_CONN_IN) params.push('min_connection_in=' + minIn);
|
||||
}
|
||||
var sectionIds = Object.keys(currentNrClasses);
|
||||
if (sectionIds.length === 1) {
|
||||
var nrCls = currentNrClasses[sectionIds[0]];
|
||||
var esCls = currentEsClasses[sectionIds[0]];
|
||||
if (nrCls !== 'walkon') params.push('nr_class=' + nrCls);
|
||||
if (esCls !== 'standard') params.push('es_class=' + esCls);
|
||||
} else {
|
||||
sectionIds.forEach(function(sid) {
|
||||
var dir = SECTION_DIRECTIONS[sid];
|
||||
var suffix = dir === 'outbound' ? '_out' : '_in';
|
||||
var nrC = currentNrClasses[sid];
|
||||
var esC = currentEsClasses[sid];
|
||||
if (nrC !== 'walkon') params.push('nr_class' + suffix + '=' + nrC);
|
||||
if (esC !== 'standard') params.push('es_class' + suffix + '=' + esC);
|
||||
});
|
||||
}
|
||||
for (var _sid in selectedRowKeys) {
|
||||
var _sel = selectedRowKeys[_sid];
|
||||
if (!_sel || !SECTION_DIRECTIONS[_sid]) continue;
|
||||
var _pname = SECTION_DIRECTIONS[_sid] === 'outbound' ? 'out' : 'ret';
|
||||
params.push(_pname + '=' + encodeURIComponent(_sel.slice(_sid.length + 1)));
|
||||
}
|
||||
return params.length ? RESULTS_BASE + '?' + params.join('&') : RESULTS_BASE;
|
||||
}
|
||||
|
||||
function applyConnectionFilter() {
|
||||
window.location = buildUrl();
|
||||
}
|
||||
|
||||
function setNrClass(cls, sectionId) {
|
||||
currentNrClasses[sectionId] = cls;
|
||||
document.querySelectorAll('.btn-group-option[data-section="' + sectionId + '"][data-nr-class]').forEach(function(btn) {
|
||||
btn.classList.toggle('active', btn.getAttribute('data-nr-class') === cls);
|
||||
});
|
||||
history.replaceState(null, '', buildUrl());
|
||||
if (cls === 'advance_std' || cls === 'advance_1st') loadAdvanceFaresForSectionStreaming(sectionId);
|
||||
updateDisplay();
|
||||
}
|
||||
|
||||
function setEsClass(cls, sectionId) {
|
||||
currentEsClasses[sectionId] = cls;
|
||||
document.querySelectorAll('.btn-group-option[data-section="' + sectionId + '"][data-es-class]').forEach(function(btn) {
|
||||
btn.classList.toggle('active', btn.getAttribute('data-es-class') === cls);
|
||||
});
|
||||
history.replaceState(null, '', buildUrl());
|
||||
updateDisplay();
|
||||
}
|
||||
|
||||
function fmtPrice(p) {
|
||||
return '£' + p.toFixed(2);
|
||||
}
|
||||
|
||||
function fareHtml(fare) {
|
||||
return '<span class="text-sm font-bold">' + fmtPrice(fare.price) + '</span>'
|
||||
+ (fare.ticket ? ' <span class="text-xs text-muted">' + fare.ticket + '</span>' : '')
|
||||
+ (fare.seats != null ? ' <span class="text-xs text-muted fare-seats">' + fare.seats + ' at this price</span>' : '');
|
||||
}
|
||||
|
||||
function mergeAdvanceFares(sectionId, fares) {
|
||||
if (!ADVANCE_FARES) ADVANCE_FARES = {};
|
||||
if (!ADVANCE_FARES[sectionId]) ADVANCE_FARES[sectionId] = {};
|
||||
for (var time in fares) {
|
||||
if (!ADVANCE_FARES[sectionId][time]) {
|
||||
ADVANCE_FARES[sectionId][time] = {advance_std: null, advance_1st: null};
|
||||
}
|
||||
if (fares[time].advance_std) ADVANCE_FARES[sectionId][time].advance_std = fares[time].advance_std;
|
||||
if (fares[time].advance_1st) ADVANCE_FARES[sectionId][time].advance_1st = fares[time].advance_1st;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeWalkonFares(sectionId, fares) {
|
||||
for (var key in TRIP_FARES) {
|
||||
var row = TRIP_FARES[key];
|
||||
if (row.section !== sectionId || !row.advance_key || !fares[row.advance_key]) continue;
|
||||
var fare = fares[row.advance_key];
|
||||
row.walkon = {price: fare.price, ticket: fare.ticket || ''};
|
||||
}
|
||||
}
|
||||
|
||||
function mergeEurostarPrices(prices) {
|
||||
for (var key in prices) {
|
||||
for (var rowKey in TRIP_FARES) {
|
||||
var row = TRIP_FARES[rowKey];
|
||||
if (rowKey !== key && row.eurostar_key !== key) continue;
|
||||
if (prices[key].es_standard) row.es_standard = prices[key].es_standard;
|
||||
if (prices[key].es_plus) row.es_plus = prices[key].es_plus;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sectionNeedsAdvance(sectionId) {
|
||||
var nrClass = currentNrClasses[sectionId] || 'walkon';
|
||||
for (var key in TRIP_FARES) {
|
||||
var row = TRIP_FARES[key];
|
||||
if (row.section !== sectionId || !row.advance_key) continue;
|
||||
var sectionFares = ADVANCE_FARES && ADVANCE_FARES[sectionId];
|
||||
var advanceFares = sectionFares && sectionFares[row.advance_key];
|
||||
if (!advanceFares) return true;
|
||||
if (nrClass === 'advance_std' && !advanceFares.advance_std) return true;
|
||||
if (nrClass === 'advance_1st' && !advanceFares.advance_1st) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function loadAdvanceFaresForSection(sectionId) {
|
||||
if (advanceLoadingSections[sectionId] || !ADVANCE_API_URLS[sectionId]) return;
|
||||
advanceLoadingSections[sectionId] = true;
|
||||
updateAdvanceLoadingStatus();
|
||||
if (!ADVANCE_FARES) ADVANCE_FARES = {};
|
||||
if (!ADVANCE_FARES[sectionId]) ADVANCE_FARES[sectionId] = {};
|
||||
|
||||
fetch(ADVANCE_API_URLS[sectionId])
|
||||
.then(function(response) {
|
||||
if (!response.ok) throw new Error('advance fare request failed');
|
||||
return response.json();
|
||||
})
|
||||
.then(function(fares) {
|
||||
mergeAdvanceFares(sectionId, fares);
|
||||
})
|
||||
.catch(function() {})
|
||||
.finally(function() {
|
||||
advanceLoadingSections[sectionId] = false;
|
||||
updateAdvanceLoadingStatus();
|
||||
updateDisplay();
|
||||
});
|
||||
}
|
||||
|
||||
function loadAdvanceFaresForSectionStreaming(sectionId) {
|
||||
if (advanceLoadingSections[sectionId] || !ADVANCE_STREAM_URLS[sectionId]) return;
|
||||
advanceLoadingSections[sectionId] = true;
|
||||
updateAdvanceLoadingStatus();
|
||||
if (!ADVANCE_FARES) ADVANCE_FARES = {};
|
||||
if (!ADVANCE_FARES[sectionId]) ADVANCE_FARES[sectionId] = {};
|
||||
|
||||
var hadMessage = false;
|
||||
var source = new EventSource(ADVANCE_STREAM_URLS[sectionId]);
|
||||
source.onmessage = function(event) {
|
||||
hadMessage = true;
|
||||
var msg = JSON.parse(event.data);
|
||||
if (msg.type === 'fares') {
|
||||
mergeAdvanceFares(sectionId, msg.fares);
|
||||
updateDisplay();
|
||||
}
|
||||
if (msg.type === 'done' || msg.type === 'error') {
|
||||
advanceLoadingSections[sectionId] = false;
|
||||
source.close();
|
||||
updateAdvanceLoadingStatus();
|
||||
updateDisplay();
|
||||
}
|
||||
};
|
||||
source.onerror = function() {
|
||||
advanceLoadingSections[sectionId] = false;
|
||||
source.close();
|
||||
updateAdvanceLoadingStatus();
|
||||
if (!hadMessage && ADVANCE_API_URLS[sectionId]) {
|
||||
loadAdvanceFaresForSection(sectionId);
|
||||
} else {
|
||||
updateDisplay();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function loadMissingAdvanceFares() {
|
||||
for (var sectionId in ADVANCE_STREAM_URLS) {
|
||||
var nrClass = currentNrClasses[sectionId] || 'walkon';
|
||||
if ((nrClass === 'advance_std' || nrClass === 'advance_1st') && sectionNeedsAdvance(sectionId)) {
|
||||
loadAdvanceFaresForSectionStreaming(sectionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function currentNrFare(row) {
|
||||
var nrClass = currentNrClasses[row.section] || 'walkon';
|
||||
if (nrClass === 'walkon') return row.walkon;
|
||||
var sectionFares = ADVANCE_FARES && ADVANCE_FARES[row.section];
|
||||
var advFares = sectionFares && row.advance_key ? sectionFares[row.advance_key] : null;
|
||||
if (!advFares) return row.walkon;
|
||||
return (nrClass === 'advance_std' ? advFares.advance_std : advFares.advance_1st) || row.walkon;
|
||||
}
|
||||
|
||||
function updateDisplay() {
|
||||
var totals = {};
|
||||
document.querySelectorAll('tr[data-row-key]').forEach(function(tr) {
|
||||
var key = tr.getAttribute('data-row-key');
|
||||
var row = TRIP_FARES[key];
|
||||
if (!row) return;
|
||||
var nrFare = currentNrFare(row);
|
||||
var esClass = currentEsClasses[row.section] || 'standard';
|
||||
var esFare = esClass === 'standard' ? row.es_standard : row.es_plus;
|
||||
if (nrFare && esFare) totals[key] = nrFare.price + esFare.price + (row.circle_fare || 0);
|
||||
});
|
||||
var totalValues = Object.values(totals);
|
||||
var minTotal = totalValues.length > 1 ? Math.min.apply(null, totalValues) : null;
|
||||
var maxTotal = totalValues.length > 1 ? Math.max.apply(null, totalValues) : null;
|
||||
|
||||
document.querySelectorAll('tr[data-row-key]').forEach(function(tr) {
|
||||
var key = tr.getAttribute('data-row-key');
|
||||
var row = TRIP_FARES[key];
|
||||
if (!row) return;
|
||||
|
||||
var nrClass = currentNrClasses[row.section] || 'walkon';
|
||||
var esClass = currentEsClasses[row.section] || 'standard';
|
||||
var nrFare = currentNrFare(row);
|
||||
var walkonEl = tr.querySelector('.nr-walkon');
|
||||
var advStdEl = tr.querySelector('.nr-advance-std');
|
||||
var adv1stEl = tr.querySelector('.nr-advance-1st');
|
||||
if (walkonEl) {
|
||||
walkonEl.innerHTML = row.walkon ? fareHtml(row.walkon) : '<span class="text-sm text-muted">–</span>';
|
||||
walkonEl.classList.toggle('fare-inactive', nrClass !== 'walkon');
|
||||
}
|
||||
if (advStdEl || adv1stEl) {
|
||||
var sectionFares = ADVANCE_FARES && ADVANCE_FARES[row.section];
|
||||
var advFares = sectionFares && row.advance_key ? sectionFares[row.advance_key] : null;
|
||||
if (advStdEl) {
|
||||
advStdEl.innerHTML = advFares && advFares.advance_std ? fareHtml(advFares.advance_std) : '';
|
||||
advStdEl.classList.toggle('fare-inactive', nrClass !== 'advance_std');
|
||||
}
|
||||
if (adv1stEl) {
|
||||
adv1stEl.innerHTML = advFares && advFares.advance_1st ? fareHtml(advFares.advance_1st) : '';
|
||||
adv1stEl.classList.toggle('fare-inactive', nrClass !== 'advance_1st');
|
||||
}
|
||||
}
|
||||
|
||||
var esStdEl = tr.querySelector('.es-standard');
|
||||
var esPlusEl = tr.querySelector('.es-plus');
|
||||
if (esStdEl) {
|
||||
esStdEl.innerHTML = '<span class="text-xs text-muted">Std</span> ' + (row.es_standard ? fareHtml(row.es_standard) : '<span class="text-sm text-muted">–</span>');
|
||||
esStdEl.classList.toggle('fare-inactive', esClass !== 'standard');
|
||||
}
|
||||
if (esPlusEl) {
|
||||
esPlusEl.innerHTML = '<span class="text-xs text-muted">SP</span> ' + (row.es_plus ? fareHtml(row.es_plus) : '<span class="text-sm text-muted">–</span>');
|
||||
esPlusEl.classList.toggle('fare-inactive', esClass !== 'plus');
|
||||
}
|
||||
|
||||
var totalSpan = tr.querySelector('.total-price');
|
||||
if (totalSpan) {
|
||||
if (key.indexOf(':unreachable:') !== -1) {
|
||||
totalSpan.innerHTML = '<span class="text-xs text-muted" title="No National Rail service from {{ departure_station_name }} arrives at Paddington in time for this Eurostar">No rail connection</span>';
|
||||
} else if (key in totals) {
|
||||
var total = totals[key];
|
||||
var html = '<span class="text-sm text-green" style="font-weight:700">' + fmtPrice(total);
|
||||
if (minTotal !== null && maxTotal !== null) {
|
||||
if (total <= minTotal + 10) html += ' <span title="Cheapest journey">🪙</span>';
|
||||
else if (total >= maxTotal - 10) html += ' <span title="Most expensive journey">💸</span>';
|
||||
}
|
||||
html += '</span>';
|
||||
totalSpan.innerHTML = html;
|
||||
} else if (!nrFare && walkonLoadingSections[row.section]) {
|
||||
totalSpan.innerHTML = '';
|
||||
} else if (!nrFare) {
|
||||
totalSpan.innerHTML = '<span class="text-xs text-muted" title="National Rail fare data is not available for this service">NR fare not available</span>';
|
||||
} else {
|
||||
totalSpan.innerHTML = '<span class="text-xs text-muted" title="No Eurostar price found for this service — it may be sold out in the selected class. Check eurostar.com.">No Eurostar price</span>';
|
||||
}
|
||||
}
|
||||
});
|
||||
updateSelectionBar();
|
||||
}
|
||||
|
||||
function loadWalkonFares() {
|
||||
var urls = WALKON_API_URLS;
|
||||
var ids = Object.keys(urls);
|
||||
if (!ids.length) return;
|
||||
/* outbound first, then inbound */
|
||||
ids.sort(function(a, b) {
|
||||
return (SECTION_DIRECTIONS[a] === 'outbound' ? 0 : 1) -
|
||||
(SECTION_DIRECTIONS[b] === 'outbound' ? 0 : 1);
|
||||
});
|
||||
ids.forEach(function(sid) { walkonLoadingSections[sid] = true; });
|
||||
var pending = ids.length;
|
||||
function done(id) {
|
||||
walkonLoadingSections[id] = false;
|
||||
if (--pending === 0) {
|
||||
var el = document.getElementById('walkon-loading');
|
||||
if (el) el.style.display = 'none';
|
||||
}
|
||||
}
|
||||
/* sequential: each fetch starts only after the previous one finishes */
|
||||
ids.reduce(function(chain, id) {
|
||||
return chain.then(function() {
|
||||
return fetch(urls[id])
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(fares) { mergeWalkonFares(id, fares); done(id); updateDisplay(); })
|
||||
.catch(function() { done(id); updateDisplay(); });
|
||||
});
|
||||
}, Promise.resolve());
|
||||
}
|
||||
|
||||
function initSelectionFromUrl() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
for (var sid in SECTION_DIRECTIONS) {
|
||||
var dir = SECTION_DIRECTIONS[sid];
|
||||
var val = params.get(dir === 'outbound' ? 'out' : 'ret');
|
||||
if (val) {
|
||||
var rowKey = sid + ':' + val;
|
||||
if (TRIP_FARES[rowKey]) selectedRowKeys[sid] = rowKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectRow(tr) {
|
||||
var key = tr.getAttribute('data-row-key');
|
||||
if (!key || key.indexOf(':unreachable:') !== -1) return;
|
||||
var row = TRIP_FARES[key];
|
||||
if (!row) return;
|
||||
selectedRowKeys[row.section] = (selectedRowKeys[row.section] === key) ? null : key;
|
||||
updateRowHighlights();
|
||||
updateSelectionBar();
|
||||
history.replaceState(null, '', buildUrl());
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedRowKeys = {};
|
||||
updateRowHighlights();
|
||||
updateSelectionBar();
|
||||
history.replaceState(null, '', buildUrl());
|
||||
}
|
||||
|
||||
function updateRowHighlights() {
|
||||
document.querySelectorAll('tr[data-row-key]').forEach(function(tr) {
|
||||
var key = tr.getAttribute('data-row-key');
|
||||
var row = TRIP_FARES[key];
|
||||
if (!row) return;
|
||||
tr.classList.toggle('row-selected', selectedRowKeys[row.section] === key);
|
||||
});
|
||||
}
|
||||
|
||||
function updateSelectionBar() {
|
||||
var bar = document.getElementById('selection-bar');
|
||||
if (!bar) return;
|
||||
var allSids = Object.keys(SECTION_DIRECTIONS);
|
||||
var activeSids = allSids.filter(function(sid) { return selectedRowKeys[sid]; });
|
||||
if (activeSids.length === 0) { bar.style.display = 'none'; return; }
|
||||
bar.style.display = 'block';
|
||||
|
||||
var totalNr = 0, totalEs = 0, totalCircle = 0, allPrices = true;
|
||||
var parts = [];
|
||||
activeSids.forEach(function(sid) {
|
||||
var rowKey = selectedRowKeys[sid];
|
||||
var row = TRIP_FARES[rowKey];
|
||||
if (!row) return;
|
||||
var nrFare = currentNrFare(row);
|
||||
var esClass = currentEsClasses[sid] || 'standard';
|
||||
var esFare = esClass === 'standard' ? row.es_standard : row.es_plus;
|
||||
if (nrFare) totalNr += nrFare.price; else allPrices = false;
|
||||
if (esFare) totalEs += esFare.price; else allPrices = false;
|
||||
totalCircle += row.circle_fare || 0;
|
||||
var kp = rowKey.split(':');
|
||||
var depTime = SECTION_DIRECTIONS[sid] === 'outbound'
|
||||
? kp[1] + ':' + kp[2]
|
||||
: kp[3] + ':' + kp[4];
|
||||
parts.push((SECTION_DIRECTIONS[sid] === 'outbound' ? 'Out ' : 'Ret ') + depTime);
|
||||
});
|
||||
|
||||
var descEl = document.getElementById('sel-desc');
|
||||
if (descEl) descEl.textContent = parts.join(' · ');
|
||||
|
||||
var hintEl = document.getElementById('sel-hint');
|
||||
if (hintEl) {
|
||||
if (allSids.length > 1 && activeSids.length < allSids.length) {
|
||||
var missingDir = SECTION_DIRECTIONS[allSids.filter(function(s) { return !selectedRowKeys[s]; })[0]];
|
||||
hintEl.textContent = 'Select a ' + (missingDir === 'outbound' ? 'outbound' : 'return') + ' journey to see combined total';
|
||||
hintEl.style.display = '';
|
||||
} else {
|
||||
hintEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
var grandTotal = totalNr + totalEs + totalCircle;
|
||||
var nrEl = document.getElementById('sel-nr');
|
||||
var esEl = document.getElementById('sel-es');
|
||||
var grandEl = document.getElementById('sel-grand');
|
||||
if (nrEl) nrEl.innerHTML = 'NR <strong>' + (allPrices ? fmtPrice(totalNr) : '–') + '</strong>';
|
||||
if (esEl) esEl.innerHTML = 'Eurostar <strong>' + (allPrices ? fmtPrice(totalEs) : '–') + '</strong>';
|
||||
if (grandEl) {
|
||||
var label = (activeSids.length === allSids.length && allSids.length > 1) ? 'Grand total' : 'Total';
|
||||
var priceHtml = allPrices
|
||||
? '<strong style="font-size:1.05rem;color:#276749">' + fmtPrice(grandTotal) + '</strong>'
|
||||
: '<strong>–</strong>';
|
||||
grandEl.innerHTML = label + ' ' + priceHtml;
|
||||
}
|
||||
}
|
||||
|
||||
function initialiseResultsPage() {
|
||||
initSelectionFromUrl();
|
||||
var needsAdvance = Object.keys(currentNrClasses).some(function(sid) {
|
||||
var c = currentNrClasses[sid];
|
||||
return c === 'advance_std' || c === 'advance_1st';
|
||||
});
|
||||
if (needsAdvance) loadMissingAdvanceFares();
|
||||
/* Pre-populate walk-on fares from weekday cache so prices show immediately */
|
||||
var hasPreloaded = false;
|
||||
for (var sid in WALKON_CACHED_FARES) {
|
||||
if (WALKON_CACHED_FARES[sid]) { mergeWalkonFares(sid, WALKON_CACHED_FARES[sid]); hasPreloaded = true; }
|
||||
}
|
||||
updateDisplay();
|
||||
updateRowHighlights();
|
||||
if (hasPreloaded) {
|
||||
var loadingEl = document.getElementById('walkon-loading');
|
||||
if (loadingEl) loadingEl.innerHTML = '<span class="spinner spinner-inline" aria-hidden="true"></span>Verifying fares';
|
||||
}
|
||||
loadWalkonFares();
|
||||
startTimetableRefresh();
|
||||
}
|
||||
|
||||
function runInsertedScripts(root) {
|
||||
root.querySelectorAll('script').forEach(function(oldScript) {
|
||||
var script = document.createElement('script');
|
||||
for (var i = 0; i < oldScript.attributes.length; i++) {
|
||||
var attr = oldScript.attributes[i];
|
||||
script.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
script.text = oldScript.text;
|
||||
oldScript.parentNode.replaceChild(script, oldScript);
|
||||
});
|
||||
}
|
||||
|
||||
function fullResultsUrl() {
|
||||
var url = new URL(window.location.href);
|
||||
url.searchParams.set('render', 'full');
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function refreshFullResults() {
|
||||
fetch(fullResultsUrl(), {headers: {'X-Requested-With': 'fetch'}})
|
||||
.then(function(response) {
|
||||
if (!response.ok) throw new Error('Could not refresh results');
|
||||
return response.text();
|
||||
})
|
||||
.then(function(html) {
|
||||
var doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
var nextMain = doc.querySelector('main');
|
||||
var currentMain = document.querySelector('main');
|
||||
if (!nextMain || !currentMain) throw new Error('Results page was incomplete');
|
||||
document.title = doc.title;
|
||||
currentMain.innerHTML = nextMain.innerHTML;
|
||||
runInsertedScripts(currentMain);
|
||||
})
|
||||
.catch(function() {});
|
||||
}
|
||||
|
||||
function startTimetableRefresh() {
|
||||
if (!HAS_PROVISIONAL_TIMETABLE || !TIMETABLE_REFRESH_URL || !window.EventSource) return;
|
||||
var source = new EventSource(TIMETABLE_REFRESH_URL);
|
||||
source.onmessage = function(event) {
|
||||
var msg = JSON.parse(event.data);
|
||||
if (msg.type === 'reload') {
|
||||
source.close();
|
||||
refreshFullResults();
|
||||
} else if (msg.type === 'eurostar_prices') {
|
||||
mergeEurostarPrices(msg.prices);
|
||||
updateDisplay();
|
||||
} else if (msg.type === 'walkon_fares') {
|
||||
mergeWalkonFares(msg.section, msg.fares);
|
||||
updateDisplay();
|
||||
} else if (msg.type === 'done' || msg.type === 'error') {
|
||||
source.close();
|
||||
}
|
||||
};
|
||||
source.onerror = function() {
|
||||
source.close();
|
||||
};
|
||||
}
|
||||
|
||||
function mergeSectionData(msg) {
|
||||
if (msg.trip_fares) Object.assign(TRIP_FARES, msg.trip_fares);
|
||||
var sid = msg.id;
|
||||
if (msg.advance_fares !== undefined) {
|
||||
if (!ADVANCE_FARES) ADVANCE_FARES = {};
|
||||
ADVANCE_FARES[sid] = msg.advance_fares;
|
||||
}
|
||||
if (msg.walkon_cached_fares !== undefined) WALKON_CACHED_FARES[sid] = msg.walkon_cached_fares;
|
||||
if (msg.walkon_api_url) WALKON_API_URLS[sid] = msg.walkon_api_url;
|
||||
if (msg.advance_api_url) ADVANCE_API_URLS[sid] = msg.advance_api_url;
|
||||
if (msg.advance_stream_url) ADVANCE_STREAM_URLS[sid] = msg.advance_stream_url;
|
||||
}
|
||||
|
||||
function finaliseResults(msg) {
|
||||
TIMETABLE_REFRESH_URL = msg.timetable_refresh_url || null;
|
||||
HAS_PROVISIONAL_TIMETABLE = msg.provisional_timetable || false;
|
||||
var summaryEl = document.getElementById('results-summary');
|
||||
if (summaryEl && msg.summary_html) summaryEl.innerHTML = msg.summary_html;
|
||||
initialiseResultsPage();
|
||||
}
|
||||
</script>
|
||||
<p id="results-summary" class="card-meta">
|
||||
<span class="spinner spinner-inline" aria-hidden="true"></span>
|
||||
</p>
|
||||
<div id="results-alerts"></div>
|
||||
</div>
|
||||
|
||||
{% for section in sections %}
|
||||
<div id="section-placeholder-{{ section.id }}" class="card" style="margin-bottom:1.5rem">
|
||||
<div class="loading-panel" role="status">
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
<div><strong>Loading {{ 'return' if section.direction == 'inbound' else 'outbound' }} results…</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<p class="footnote">
|
||||
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 <a href="https://www.gwr.com/" target="_blank" rel="noopener">gwr.com</a>.
|
||||
Eurostar prices are for 1 adult in GBP; return searches use Eurostar return-search prices.
|
||||
Always check <a href="{{ eurostar_url }}" target="_blank" rel="noopener">eurostar.com</a> to book.
|
||||
·
|
||||
<a href="{{ rtt_station_url }}" target="_blank" rel="noopener">{{ departure_station_name }} on RTT</a>
|
||||
·
|
||||
<a href="{{ rtt_url }}" target="_blank" rel="noopener">Paddington on RTT</a>
|
||||
</p>
|
||||
|
||||
<div id="selection-bar">
|
||||
<div class="sel-bar-inner">
|
||||
<div>
|
||||
<span id="sel-desc" style="color:#2d3748"></span>
|
||||
<span id="sel-hint" style="display:none; margin-left:1rem; color:#a0aec0; font-size:0.8rem"></span>
|
||||
</div>
|
||||
<div class="sel-totals">
|
||||
<span id="sel-nr" class="text-muted"></span>
|
||||
<span id="sel-es" class="text-muted"></span>
|
||||
<span id="sel-grand"></span>
|
||||
<button class="sel-clear" onclick="clearSelection()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue