paddington-eurostar/templates/results_loading.html
Edward Betts 453d6244ec 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>
2026-05-25 21:24:40 +01:00

118 lines
4.3 KiB
HTML

{% 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 }} 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 }} via Paddington, St Pancras, and Eurostar.{% endblock %}
{% block content %}
<p class="back-link">
<a href="{{ index_url }}">&larr; New search</a>
</p>
<div class="card" style="margin-bottom:1.5rem">
<h2>
{% if journey_type == 'inbound' %}
{{ destination }} &rarr; {{ departure_station_name }}
{% elif journey_type == 'return' %}
{{ departure_station_name }} &harr; {{ destination }}
{% else %}
{{ departure_station_name }} &rarr; {{ destination }}
{% endif %}
</h2>
<p class="card-meta">
{{ travel_date_display }}{% if return_date_display %} to {{ return_date_display }}{% endif %}
</p>
<div class="loading-panel" role="status" aria-live="polite">
<span class="spinner" aria-hidden="true"></span>
<div>
<strong>Loading train times and fares</strong>
<p class="text-muted text-sm">Fetching National Rail, Eurostar, and fare data. Results will appear here as soon as they are ready.</p>
</div>
</div>
<noscript>
<p><a href="{{ full_results_url }}">Load results</a></p>
</noscript>
</div>
<script>
(function() {
var attempts = 0;
function runScripts(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 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;
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) { source.close(); return; }
currentMain.innerHTML = nextMain.innerHTML;
runScripts(currentMain);
history.replaceState(null, '', window.location.href);
} 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);
}
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();
})();
</script>
{% endblock %}