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

View 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') }}">&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>
{% if journey_type == 'return' %}
<div class="date-nav">
<span class="date-nav-label">Outbound:</span>
<a href="{{ prev_outbound_url }}" class="btn-nav">&larr; Prev</a>
<strong>{{ travel_date_display }}</strong>
<a href="{{ next_outbound_url }}" class="btn-nav">Next &rarr;</a>
</div>
<div class="date-nav">
<span class="date-nav-label">Return:</span>
<a href="{{ prev_return_url }}" class="btn-nav">&larr; Prev</a>
<strong>{{ return_date_display }}</strong>
<a href="{{ next_return_url }}" class="btn-nav">Next &rarr;</a>
</div>
{% else %}
<div class="date-nav">
<a href="{{ prev_results_url }}"
class="btn-nav">&larr; Prev</a>
<strong>{{ travel_date_display }}</strong>
<a href="{{ next_results_url }}"
class="btn-nav">Next &rarr;</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&nbsp;<strong>' + (allPrices ? fmtPrice(totalNr) : '') + '</strong>';
if (esEl) esEl.innerHTML = 'Eurostar&nbsp;<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 + '&nbsp;' + 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&hellip;</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 }}&ndash;{{ section.max_connection }}&nbsp;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.
&nbsp;&middot;&nbsp;
<a href="{{ rtt_station_url }}" target="_blank" rel="noopener">{{ departure_station_name }} on RTT</a>
&nbsp;&middot;&nbsp;
<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 %}