Add per-direction NR ticket and Eurostar class selectors for return journeys

Return pages now show separate NR/Eurostar fare class buttons for outbound
and inbound, so you can compare walk-on vs advance for each leg independently.
URL uses nr_class_out/nr_class_in/es_class_out/es_class_in params for returns;
single-direction pages keep the existing nr_class/es_class params.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Edward Betts 2026-05-21 20:17:57 +01:00
parent 8d998aad71
commit 298ce14812
2 changed files with 145 additions and 53 deletions

53
app.py
View file

@ -357,6 +357,17 @@ def _results(station_crs, slug, travel_date, journey_type, return_date):
if es_class not in VALID_ES_CLASSES:
es_class = DEFAULT_ES_CLASS
if journey_type == "return":
def _p(raw, default, valid):
return raw if raw in valid else default
nr_class_out = _p(request.args.get("nr_class_out"), DEFAULT_NR_CLASS, VALID_NR_CLASSES)
nr_class_in = _p(request.args.get("nr_class_in"), DEFAULT_NR_CLASS, VALID_NR_CLASSES)
es_class_out = _p(request.args.get("es_class_out"), DEFAULT_ES_CLASS, VALID_ES_CLASSES)
es_class_in = _p(request.args.get("es_class_in"), DEFAULT_ES_CLASS, VALID_ES_CLASSES)
else:
nr_class_out = nr_class_in = nr_class
es_class_out = es_class_in = es_class
if (
request.args.get("render") != "full"
and not (
@ -613,6 +624,15 @@ def _results(station_crs, slug, travel_date, journey_type, return_date):
else:
sections = [build_section("main", journey_type, travel_date)]
nr_classes = {}
es_classes = {}
section_directions = {}
for section in sections:
direction = section["direction"]
section_directions[section["id"]] = direction
nr_classes[section["id"]] = nr_class_out if direction == "outbound" else nr_class_in
es_classes[section["id"]] = es_class_out if direction == "outbound" else es_class_in
no_prices_note = None
all_es_prices = [
row.get("eurostar_price")
@ -646,14 +666,26 @@ def _results(station_crs, slug, travel_date, journey_type, return_date):
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
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,
}
if journey_type == "return":
common_url_args = {
"journey_type": journey_type,
"return_date": return_date,
"min_connection": url_min,
"max_connection": url_max,
"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,
@ -813,6 +845,11 @@ def _results(station_crs, slug, travel_date, journey_type, return_date):
es_class=es_class,
url_nr_class=url_nr,
url_es_class=url_es,
nr_classes=nr_classes,
es_classes=es_classes,
nr_classes_json=json.dumps(nr_classes),
es_classes_json=json.dumps(es_classes),
section_directions_json=json.dumps(section_directions),
trip_fares_json=json.dumps(trip_fares),
advance_fares_json=json.dumps(advance_fares),
walkon_api_urls_json=json.dumps(walkon_api_urls),

View file

@ -75,28 +75,56 @@
</select>
</div>
</div>
<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_class == 'walkon' %}active{% endif %}" onclick="setNrClass('walkon')">Walk-on Std</button>
<button type="button" class="btn-group-option {% if nr_class == 'advance_std' %}active{% endif %}" onclick="setNrClass('advance_std')">Advance Std</button>
<button type="button" class="btn-group-option {% if nr_class == 'advance_1st' %}active{% endif %}" onclick="setNrClass('advance_1st')">Advance 1st</button>
{% 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>
<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>
<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_class == 'standard' %}active{% endif %}" onclick="setEsClass('standard')">Standard</button>
<button type="button" class="btn-group-option {% if es_class == 'plus' %}active{% endif %}" onclick="setEsClass('plus')">Standard Premier</button>
{% 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>
</div>
{% endif %}
<script>
const RESULTS_BASE = '{{ results_base_url }}';
const DEFAULT_MIN_CONN = {{ default_min_connection }};
@ -109,8 +137,9 @@
const TIMETABLE_REFRESH_URL = {{ timetable_refresh_url|tojson }};
const HAS_PROVISIONAL_TIMETABLE = {{ 'true' if provisional_timetable else 'false' }};
let cachedAdvanceFares = ADVANCE_FARES;
let currentNrClass = '{{ nr_class }}';
let currentEsClass = '{{ es_class }}';
let currentNrClasses = {{ nr_classes_json | safe }};
let currentEsClasses = {{ es_classes_json | safe }};
const SECTION_DIRECTIONS = {{ section_directions_json | safe }};
let advanceLoadingSections = {};
function updateAdvanceLoadingStatus() {
@ -121,37 +150,51 @@
if (el) el.style.display = loading ? 'inline-flex' : 'none';
}
function buildUrl(nrCls, esCls) {
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);
if (nrCls !== 'walkon') params.push('nr_class=' + nrCls);
if (esCls !== 'standard') params.push('es_class=' + esCls);
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);
});
}
return params.length ? RESULTS_BASE + '?' + params.join('&') : RESULTS_BASE;
}
function applyConnectionFilter() {
window.location = buildUrl(currentNrClass, currentEsClass);
window.location = buildUrl();
}
function setNrClass(cls) {
currentNrClass = cls;
document.querySelectorAll('.btn-group-option[onclick^="setNrClass"]').forEach(function(btn) {
btn.classList.toggle('active', btn.getAttribute('onclick') === "setNrClass('" + cls + "')");
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(cls, currentEsClass));
if (cls === 'advance_std' || cls === 'advance_1st') loadMissingAdvanceFares();
history.replaceState(null, '', buildUrl());
if (cls === 'advance_std' || cls === 'advance_1st') loadAdvanceFaresForSectionStreaming(sectionId);
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 + "')");
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(currentNrClass, cls));
history.replaceState(null, '', buildUrl());
updateDisplay();
}
@ -198,14 +241,15 @@
}
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 (currentNrClass === 'advance_std' && !advanceFares.advance_std) return true;
if (currentNrClass === 'advance_1st' && !advanceFares.advance_1st) return true;
if (nrClass === 'advance_std' && !advanceFares.advance_std) return true;
if (nrClass === 'advance_1st' && !advanceFares.advance_1st) return true;
}
return false;
}
@ -270,16 +314,20 @@
function loadMissingAdvanceFares() {
for (var sectionId in ADVANCE_STREAM_URLS) {
if (sectionNeedsAdvance(sectionId)) loadAdvanceFaresForSectionStreaming(sectionId);
var nrClass = currentNrClasses[sectionId] || 'walkon';
if ((nrClass === 'advance_std' || nrClass === 'advance_1st') && sectionNeedsAdvance(sectionId)) {
loadAdvanceFaresForSectionStreaming(sectionId);
}
}
}
function currentNrFare(row) {
if (currentNrClass === 'walkon') return row.walkon;
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 (currentNrClass === 'advance_std' ? advFares.advance_std : advFares.advance_1st) || row.walkon;
return (nrClass === 'advance_std' ? advFares.advance_std : advFares.advance_1st) || row.walkon;
}
function updateDisplay() {
@ -289,7 +337,8 @@
var row = TRIP_FARES[key];
if (!row) return;
var nrFare = currentNrFare(row);
var esFare = currentEsClass === 'standard' ? row.es_standard : row.es_plus;
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);
@ -301,24 +350,26 @@
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">\u2013</span>';
walkonEl.classList.toggle('fare-inactive', currentNrClass !== 'walkon');
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', currentNrClass !== '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', currentNrClass !== 'advance_1st');
adv1stEl.classList.toggle('fare-inactive', nrClass !== 'advance_1st');
}
}
@ -326,11 +377,11 @@
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');
esStdEl.classList.toggle('fare-inactive', esClass !== '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');
esPlusEl.classList.toggle('fare-inactive', esClass !== 'plus');
}
var totalSpan = tr.querySelector('.total-price');
@ -374,7 +425,11 @@
}
function initialiseResultsPage() {
if (currentNrClass === 'advance_std' || currentNrClass === 'advance_1st') loadMissingAdvanceFares();
var needsAdvance = Object.keys(currentNrClasses).some(function(sid) {
var c = currentNrClasses[sid];
return c === 'advance_std' || c === 'advance_1st';
});
if (needsAdvance) loadMissingAdvanceFares();
updateDisplay();
loadWalkonFares();
startTimetableRefresh();