Stream advance fares and selectable ticket classes

This commit is contained in:
Edward Betts 2026-05-20 15:52:53 +01:00
parent 2f3f01171c
commit a5023d0672
4 changed files with 441 additions and 204 deletions

View file

@ -15,10 +15,10 @@
{{ departure_station_name }} → {{ destination }}
</h2>
<div class="date-nav">
<a href="{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=prev_date, min_connection=url_min_connection, max_connection=url_max_connection) }}"
<a href="{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=prev_date, min_connection=url_min_connection, max_connection=url_max_connection, nr_class=url_nr_class, es_class=url_es_class) }}"
class="btn-nav">&larr; Prev</a>
<strong>{{ travel_date_display }}</strong>
<a href="{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=next_date, min_connection=url_min_connection, max_connection=url_max_connection) }}"
<a href="{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=next_date, min_connection=url_min_connection, max_connection=url_max_connection, nr_class=url_nr_class, es_class=url_es_class) }}"
class="btn-nav">Next &rarr;</a>
</div>
<div class="switcher-section">
@ -30,7 +30,7 @@
{% else %}
<a
class="chip-link"
href="{{ url_for('results', station_crs=station_crs, slug=destination_slug, travel_date=travel_date, min_connection=url_min_connection, max_connection=url_max_connection) }}"
href="{{ url_for('results', station_crs=station_crs, slug=destination_slug, travel_date=travel_date, min_connection=url_min_connection, max_connection=url_max_connection, nr_class=url_nr_class, es_class=url_es_class) }}"
>{{ destination_name }}</a>
{% endif %}
{% endfor %}
@ -38,40 +38,225 @@
</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">
<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">
<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>
<div class="filter-row" style="margin-top:0.5rem">
<div>
<span class="filter-label">NR ticket:</span>
<div class="btn-group">
<button class="btn-group-option {% if nr_class == 'walkon' %}active{% endif %}" onclick="setNrClass('walkon')">Walk-on Std</button>
<button class="btn-group-option {% if nr_class == 'advance_std' %}active{% endif %}" onclick="setNrClass('advance_std')">Advance Std</button>
<button class="btn-group-option {% if nr_class == 'advance_1st' %}active{% endif %}" onclick="setNrClass('advance_1st')">Advance 1st</button>
</div>
<span id="advance-loading" style="display:none">Loading&#8230;</span>
</div>
<div>
<span class="filter-label">Eurostar:</span>
<div class="btn-group">
<button class="btn-group-option {% if es_class == 'standard' %}active{% endif %}" onclick="setEsClass('standard')">Standard</button>
<button class="btn-group-option {% if es_class == 'plus' %}active{% endif %}" onclick="setEsClass('plus')">Standard Premier</button>
</div>
</div>
</div>
<script>
function applyConnectionFilter() {
const RESULTS_BASE = '{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=travel_date) }}';
const DEFAULT_MIN_CONN = {{ default_min_connection }};
const DEFAULT_MAX_CONN = {{ default_max_connection }};
const ADVANCE_FARES_STREAM_URL = '{{ advance_fares_stream_url }}';
let TRIP_FARES = {{ trip_fares_json | safe }};
let ADVANCE_FARES = {{ advance_fares_json | safe }};
let currentNrClass = '{{ nr_class }}';
let currentEsClass = '{{ es_class }}';
let advanceLoading = false;
function buildUrl(nrCls, esCls) {
var min = parseInt(document.getElementById('min_conn_select').value);
var max = parseInt(document.getElementById('max_conn_select').value);
var base = '{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=travel_date) }}';
var params = [];
if (min !== {{ default_min_connection }}) params.push('min_connection=' + min);
if (max !== {{ default_max_connection }}) params.push('max_connection=' + max);
window.location = params.length ? base + '?' + params.join('&') : base;
if (min !== DEFAULT_MIN_CONN) params.push('min_connection=' + min);
if (max !== DEFAULT_MAX_CONN) params.push('max_connection=' + max);
if (nrCls !== 'walkon') params.push('nr_class=' + nrCls);
if (esCls !== 'standard') params.push('es_class=' + esCls);
return params.length ? RESULTS_BASE + '?' + params.join('&') : RESULTS_BASE;
}
function applyConnectionFilter() {
window.location = buildUrl(currentNrClass, currentEsClass);
}
function setNrClass(cls) {
currentNrClass = cls;
document.querySelectorAll('.btn-group-option[onclick^="setNrClass"]').forEach(function(btn) {
btn.classList.toggle('active', btn.getAttribute('onclick') === "setNrClass('" + cls + "')");
});
history.replaceState(null, '', buildUrl(cls, currentEsClass));
if ((cls === 'advance_std' || cls === 'advance_1st') && ADVANCE_FARES === null) {
loadAdvanceFares();
} else {
updateDisplay();
}
}
function setEsClass(cls) {
currentEsClass = cls;
document.querySelectorAll('.btn-group-option[onclick^="setEsClass"]').forEach(function(btn) {
btn.classList.toggle('active', btn.getAttribute('onclick') === "setEsClass('" + cls + "')");
});
history.replaceState(null, '', buildUrl(currentNrClass, cls));
updateDisplay();
}
function loadAdvanceFares() {
advanceLoading = true;
if (ADVANCE_FARES === null) ADVANCE_FARES = {};
document.getElementById('advance-loading').style.display = 'inline';
var source = new EventSource(ADVANCE_FARES_STREAM_URL);
source.onmessage = function(event) {
var msg = JSON.parse(event.data);
if (msg.type === 'fares') {
for (var time in msg.fares) {
if (!ADVANCE_FARES[time]) ADVANCE_FARES[time] = {advance_std: null, advance_1st: null};
if (msg.fares[time].advance_std) ADVANCE_FARES[time].advance_std = msg.fares[time].advance_std;
if (msg.fares[time].advance_1st) ADVANCE_FARES[time].advance_1st = msg.fares[time].advance_1st;
}
updateDisplay();
} else if (msg.type === 'done') {
advanceLoading = false;
document.getElementById('advance-loading').style.display = 'none';
source.close();
updateDisplay();
} else if (msg.type === 'error') {
advanceLoading = false;
document.getElementById('advance-loading').textContent = 'Failed to load advance fares.';
source.close();
}
};
source.onerror = function() {
advanceLoading = false;
document.getElementById('advance-loading').style.display = 'none';
source.close();
};
}
function fmtPrice(p) {
return '\u00a3' + 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 + ' at this price</span>' : '');
}
function updateDisplay() {
// First pass: collect totals for min/max emoji badges
var totals = {};
document.querySelectorAll('tr[data-stp]').forEach(function(tr) {
var stp = tr.getAttribute('data-stp');
var row = TRIP_FARES[stp];
if (!row) return;
var advFares = ADVANCE_FARES && row.depart_bristol ? ADVANCE_FARES[row.depart_bristol] : null;
var nrFare = currentNrClass === 'walkon' ? row.walkon
: advFares ? (currentNrClass === 'advance_std' ? advFares.advance_std : advFares.advance_1st)
: null;
var esFare = currentEsClass === 'standard' ? row.es_standard : row.es_plus;
if (nrFare && esFare) totals[stp] = 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;
var flash = false;
document.querySelectorAll('tr[data-stp]').forEach(function(tr) {
var stp = tr.getAttribute('data-stp');
var row = TRIP_FARES[stp];
if (!row) return;
var advFares = ADVANCE_FARES && row.depart_bristol ? ADVANCE_FARES[row.depart_bristol] : null;
// NR fares — walk-on always shown; advance shown when loaded
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">\u2013</span>';
walkonEl.classList.toggle('fare-inactive', currentNrClass !== 'walkon');
}
if (advStdEl) {
var aStd = advFares && advFares.advance_std;
advStdEl.innerHTML = aStd ? fareHtml(aStd) : (advanceLoading ? '<span class="text-sm text-muted">\u2026</span>' : '');
advStdEl.classList.toggle('fare-inactive', currentNrClass !== 'advance_std');
}
if (adv1stEl) {
var a1st = advFares && advFares.advance_1st;
adv1stEl.innerHTML = a1st ? fareHtml(a1st) : (advanceLoading ? '<span class="text-sm text-muted">\u2026</span>' : '');
adv1stEl.classList.toggle('fare-inactive', currentNrClass !== 'advance_1st');
}
// ES fares — always show both
var esStdEl = tr.querySelector('.es-standard');
var esPlusEl = tr.querySelector('.es-plus');
if (esStdEl) {
esStdEl.innerHTML = row.es_standard ? fareHtml(row.es_standard) : '<span class="text-sm text-muted">\u2013</span>';
esStdEl.classList.toggle('fare-inactive', currentEsClass !== 'standard');
}
if (esPlusEl) {
esPlusEl.innerHTML = row.es_plus ? fareHtml(row.es_plus) : '<span class="text-sm text-muted">\u2013</span>';
esPlusEl.classList.toggle('fare-inactive', currentEsClass !== 'plus');
}
// Total
var totalSpan = tr.querySelector('.total-price');
if (totalSpan) {
if (stp in totals) {
var total = totals[stp];
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">\uD83E\uDE99</span>';
else if (total >= maxTotal - 10) html += ' <span title="Most expensive journey">\uD83D\uDCB8</span>';
}
html += '</span>';
totalSpan.innerHTML = html;
flash = true;
} else {
totalSpan.innerHTML = '';
}
}
});
if (flash) flashTotals();
}
function flashTotals() {
document.querySelectorAll('.total-price').forEach(function(el) {
el.classList.remove('price-flash');
void el.offsetWidth; // force reflow to restart animation
el.classList.add('price-flash');
});
}
document.addEventListener('DOMContentLoaded', function() {
updateDisplay();
if ((currentNrClass === 'advance_std' || currentNrClass === 'advance_1st') && ADVANCE_FARES === null) {
loadAdvanceFares();
}
});
</script>
<p class="card-meta">
{{ gwr_count }} GWR service{{ 's' if gwr_count != 1 }}
@ -95,28 +280,6 @@
{% if trips or unreachable_morning_services %}
<div class="card">
{% if trips %}
<div class="filter-row" style="margin-bottom:0.75rem;margin-top:0">
<div>
<label for="nr-type-select" class="filter-label">NR ticket:</label>
<select id="nr-type-select" class="select-inline" onchange="updatePrices()">
<option value="walkon">Walk-on</option>
<option value="advanceStd" disabled>Std Advance</option>
<option value="advanceFirst" disabled>1st Class Advance</option>
</select>
</div>
<div>
<label for="es-type-select" class="filter-label">Eurostar:</label>
<select id="es-type-select" class="select-inline" onchange="updatePrices()">
<option value="esStd">Standard</option>
<option value="esPlus">Plus</option>
</select>
</div>
<button id="load-advance-btn" class="btn-secondary" onclick="loadAdvanceFares()">
Load advance prices
</button>
</div>
{% endif %}
<table class="results-table">
<thead>
<tr>
@ -143,16 +306,7 @@
{% else %}
{% set row_class = '' %}
{% endif %}
<tr class="{{ row_class }}"
{% if row.row_type == 'trip' %}
data-depart="{{ row.depart_bristol }}"
data-walkon="{{ row.ticket_price if row.ticket_price is not none else '' }}"
data-advance-std=""
data-advance-first=""
data-es-std="{{ row.eurostar_price if row.eurostar_price is not none else '' }}"
data-es-plus="{{ row.eurostar_plus_price if row.eurostar_plus_price is not none else '' }}"
{% endif %}
>
<tr class="{{ row_class }}" data-stp="{{ row.depart_st_pancras }}">
{% if row.row_type == 'trip' %}
<td>
<span class="font-bold nowrap">{{ row.depart_bristol }} &rarr; {{ row.arrive_paddington }}</span>
@ -164,18 +318,9 @@
{%- if row.arrive_platform %}Plat {{ row.arrive_platform }}{% endif %}
</span>
{% endif %}
{% if row.ticket_price is not none %}
<br><span class="text-sm font-bold">£{{ "%.2f"|format(row.ticket_price) }}</span>
<span class="text-xs text-muted">{{ row.ticket_name }}</span>
{% else %}
<br><span class="text-sm text-muted">&ndash;</span>
{% endif %}
<span class="nr-advance-std-display" style="display:none">
<br><span class="text-xs text-muted">Adv std: </span><span class="text-sm nr-advance-std-text"></span>
</span>
<span class="nr-advance-first-display" style="display:none">
<br><span class="text-xs text-muted">Adv 1st: </span><span class="text-sm nr-advance-first-text"></span>
</span>
<span class="fare-line nr-walkon"></span>
<span class="fare-line nr-advance-std"></span>
<span class="fare-line nr-advance-1st"></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>
@ -197,16 +342,8 @@
{%- if row.train_number %}{% for part in row.train_number.split(' + ') %}<span class="nowrap">{{ part }}</span>{% if not loop.last %} + {% endif %}{% endfor %}{% endif %}
</span>
{% endif %}
{% if row.eurostar_price is not none %}
<br><span class="text-sm font-bold">£{{ "%.2f"|format(row.eurostar_price) }}</span>
<span class="text-xs text-muted">Std{% if row.eurostar_seats is not none %} &middot; {{ row.eurostar_seats }}{% endif %}</span>
{% else %}
<br><span class="text-sm text-muted">&ndash;</span>
{% endif %}
{% if row.eurostar_plus_price is not none %}
<br><span class="text-sm">£{{ "%.2f"|format(row.eurostar_plus_price) }}</span>
<span class="text-xs text-muted">Plus{% if row.eurostar_plus_seats is not none %} &middot; {{ row.eurostar_plus_seats }}{% endif %}</span>
{% endif %}
<span class="fare-line es-standard"></span>
<span class="fare-line es-plus"></span>
</td>
<td class="font-bold nowrap">
{% if row.total_minutes <= best_mins + 5 and trips | length > 1 %}
@ -216,7 +353,7 @@
{% else %}
<span class="text-blue">{{ row.total_duration }}</span>
{% endif %}
<span class="total-price-span"></span>
<br><span class="total-price"></span>
</td>
{% else %}
<td>
@ -232,16 +369,8 @@
{%- if row.train_number %}{% for part in row.train_number.split(' + ') %}<span class="nowrap">{{ part }}</span>{% if not loop.last %} + {% endif %}{% endfor %}{% endif %}
</span>
{% endif %}
{% if row.eurostar_price is not none %}
<br><span class="text-sm text-dimmed">£{{ "%.2f"|format(row.eurostar_price) }}</span>
<span class="text-xs text-dimmed">Std{% if row.eurostar_seats is not none %} &middot; {{ row.eurostar_seats }}{% endif %}</span>
{% else %}
<br><span class="text-sm text-dimmed">&ndash;</span>
{% endif %}
{% if row.eurostar_plus_price is not none %}
<br><span class="text-sm text-dimmed">£{{ "%.2f"|format(row.eurostar_plus_price) }}</span>
<span class="text-xs text-dimmed">Plus{% if row.eurostar_plus_seats is not none %} &middot; {{ row.eurostar_plus_seats }}{% endif %}</span>
{% endif %}
<span class="fare-line es-standard"></span>
<span class="fare-line es-plus"></span>
</td>
<td class="text-dimmed">&mdash;</td>
{% endif %}
@ -253,9 +382,9 @@
<p class="footnote">
Paddington &rarr; St&nbsp;Pancras connection: {{ min_connection }}&ndash;{{ max_connection }}&nbsp;min.
GWR walk-on single prices from
GWR walk-on and advance prices from
<a href="https://www.gwr.com/" target="_blank" rel="noopener">gwr.com</a>.
Eurostar Standard and Plus prices are for 1 adult in GBP; always check
Eurostar Standard and Standard Premier prices are for 1 adult in GBP; 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 }} departures on RTT</a>
@ -263,116 +392,6 @@
<a href="{{ rtt_url }}" target="_blank" rel="noopener">Paddington arrivals on RTT</a>
</p>
<script>
var advanceFaresUrl = {{ url_for('api_advance_fares', station_crs=station_crs, travel_date=travel_date) | tojson }};
var cachedAdvanceFares = {{ cached_advance_fares | tojson }};
function updatePrices() {
var nrSel = document.getElementById('nr-type-select').value;
var esSel = document.getElementById('es-type-select').value;
var rows = document.querySelectorAll('tr[data-depart]');
var validTotals = [];
rows.forEach(function(row) {
var nrVal = row.dataset[nrSel];
var esVal = row.dataset[esSel];
var totalSpan = row.querySelector('.total-price-span');
if (!totalSpan) return;
var nrPrice = nrVal !== '' && nrVal !== undefined ? parseFloat(nrVal) : null;
var esPrice = esVal !== '' && esVal !== undefined ? parseFloat(esVal) : null;
if (nrPrice !== null && esPrice !== null) {
var total = nrPrice + esPrice;
totalSpan.dataset.total = total;
validTotals.push(total);
} else {
delete totalSpan.dataset.total;
totalSpan.innerHTML = '';
}
});
var minTotal = validTotals.length ? Math.min.apply(null, validTotals) : null;
var maxTotal = validTotals.length ? Math.max.apply(null, validTotals) : null;
rows.forEach(function(row) {
var totalSpan = row.querySelector('.total-price-span');
if (!totalSpan || !('total' in totalSpan.dataset)) return;
var total = parseFloat(totalSpan.dataset.total);
var html = '<br><span class="text-sm text-green" style="font-weight:700">£' + total.toFixed(2);
if (minTotal !== null && maxTotal !== null && validTotals.length > 1) {
if (total <= minTotal + 10) html += ' <span title="Cheapest journey">\uD83E\uDE99</span>';
else if (total >= maxTotal - 10) html += ' <span title="Most expensive journey">\uD83D\uDCB8</span>';
}
html += '</span>';
totalSpan.innerHTML = html;
});
}
function loadAdvanceFares() {
var btn = document.getElementById('load-advance-btn');
btn.disabled = true;
btn.textContent = 'Loading\u2026';
fetch(advanceFaresUrl)
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
btn.textContent = 'Error';
btn.disabled = false;
return;
}
applyAdvanceFares(data);
updatePrices();
})
.catch(function() {
btn.textContent = 'Error \u2014 try again';
btn.disabled = false;
});
}
function applyAdvanceFares(data) {
var hasStd = false, hasFirst = false;
document.querySelectorAll('tr[data-depart]').forEach(function(row) {
var dep = row.dataset.depart;
var fares = data[dep];
if (!fares) return;
if (fares.advance_std) {
row.dataset.advanceStd = fares.advance_std.price;
var span = row.querySelector('.nr-advance-std-display');
var text = row.querySelector('.nr-advance-std-text');
if (span && text) {
text.textContent = '\u00a3' + fares.advance_std.price.toFixed(2) + ' ' + fares.advance_std.ticket;
span.style.display = '';
}
hasStd = true;
}
if (fares.advance_1st) {
row.dataset.advanceFirst = fares.advance_1st.price;
var span1 = row.querySelector('.nr-advance-first-display');
var text1 = row.querySelector('.nr-advance-first-text');
if (span1 && text1) {
text1.textContent = '\u00a3' + fares.advance_1st.price.toFixed(2) + ' ' + fares.advance_1st.ticket;
span1.style.display = '';
}
hasFirst = true;
}
});
var nrSelect = document.getElementById('nr-type-select');
if (nrSelect) {
if (hasStd) nrSelect.querySelector('[value="advanceStd"]').disabled = false;
if (hasFirst) nrSelect.querySelector('[value="advanceFirst"]').disabled = false;
}
var btn = document.getElementById('load-advance-btn');
if (btn) btn.style.display = 'none';
}
document.addEventListener('DOMContentLoaded', function() {
if (cachedAdvanceFares) applyAdvanceFares(cachedAdvanceFares);
updatePrices();
});
</script>
{% else %}
<div class="card empty-state">
<p>No valid journeys found.</p>