Clarify missing Eurostar price states

This commit is contained in:
Edward Betts 2026-05-26 12:55:32 +01:00
parent ed8a5626a4
commit 2b475aa726
4 changed files with 122 additions and 6 deletions

30
app.py
View file

@ -261,15 +261,29 @@ def _eurostar_prices_by_row(
if service.get("price") is not None if service.get("price") is not None
else None else None
), ),
"es_standard_status": _eurostar_price_status(
service.get("price"), service.get("seats")
),
"es_plus": ( "es_plus": (
{"price": service.get("plus_price"), "seats": service.get("plus_seats")} {"price": service.get("plus_price"), "seats": service.get("plus_seats")}
if service.get("plus_price") is not None if service.get("plus_price") is not None
else None else None
), ),
"es_plus_status": _eurostar_price_status(
service.get("plus_price"), service.get("plus_seats")
),
} }
return prices return prices
def _eurostar_price_status(price: Any, seats: Any) -> str | None:
if price is not None:
return None
if seats == 0:
return "sold_out"
return "price_not_returned"
def _get_defaults() -> tuple[int, int]: def _get_defaults() -> tuple[int, int]:
return ( return (
app.config["DEFAULT_MIN_CONNECTION"], app.config["DEFAULT_MIN_CONNECTION"],
@ -300,6 +314,9 @@ def _section_trip_fares(section: dict[str, Any]) -> dict[str, Any]:
if row.get("eurostar_price") is not None if row.get("eurostar_price") is not None
else None else None
) )
es_std_status = _eurostar_price_status(
row.get("eurostar_price"), row.get("eurostar_seats")
)
es_plus = ( es_plus = (
{ {
"price": row["eurostar_plus_price"], "price": row["eurostar_plus_price"],
@ -308,13 +325,18 @@ def _section_trip_fares(section: dict[str, Any]) -> dict[str, Any]:
if row.get("eurostar_plus_price") is not None if row.get("eurostar_plus_price") is not None
else None else None
) )
es_plus_status = _eurostar_price_status(
row.get("eurostar_plus_price"), row.get("eurostar_plus_seats")
)
trip_fares[row["row_key"]] = { trip_fares[row["row_key"]] = {
"section": section["id"], "section": section["id"],
"eurostar_key": row.get("eurostar_key"), "eurostar_key": row.get("eurostar_key"),
"advance_key": row.get("depart_bristol") or row.get("depart_paddington"), "advance_key": row.get("depart_bristol") or row.get("depart_paddington"),
"walkon": walkon, "walkon": walkon,
"es_standard": es_std, "es_standard": es_std,
"es_standard_status": es_std_status,
"es_plus": es_plus, "es_plus": es_plus,
"es_plus_status": es_plus_status,
"circle_fare": circle_fare, "circle_fare": circle_fare,
} }
return trip_fares return trip_fares
@ -1268,6 +1290,9 @@ def _results(
if row.get("eurostar_price") is not None if row.get("eurostar_price") is not None
else None else None
) )
es_std_status = _eurostar_price_status(
row.get("eurostar_price"), row.get("eurostar_seats")
)
es_plus = ( es_plus = (
{ {
"price": row["eurostar_plus_price"], "price": row["eurostar_plus_price"],
@ -1276,6 +1301,9 @@ def _results(
if row.get("eurostar_plus_price") is not None if row.get("eurostar_plus_price") is not None
else None else None
) )
es_plus_status = _eurostar_price_status(
row.get("eurostar_plus_price"), row.get("eurostar_plus_seats")
)
trip_fares[row["row_key"]] = { trip_fares[row["row_key"]] = {
"section": section["id"], "section": section["id"],
"eurostar_key": row.get("eurostar_key"), "eurostar_key": row.get("eurostar_key"),
@ -1283,7 +1311,9 @@ def _results(
or row.get("depart_paddington"), or row.get("depart_paddington"),
"walkon": walkon, "walkon": walkon,
"es_standard": es_std, "es_standard": es_std,
"es_standard_status": es_std_status,
"es_plus": es_plus, "es_plus": es_plus,
"es_plus_status": es_plus_status,
"circle_fare": circle_fare, "circle_fare": circle_fare,
} }

View file

@ -148,6 +148,7 @@
let ADVANCE_STREAM_URLS = {{ advance_stream_urls_json | safe }}; let ADVANCE_STREAM_URLS = {{ advance_stream_urls_json | safe }};
const TIMETABLE_REFRESH_URL = {{ timetable_refresh_url|tojson }}; const TIMETABLE_REFRESH_URL = {{ timetable_refresh_url|tojson }};
const HAS_PROVISIONAL_TIMETABLE = {{ 'true' if provisional_timetable else 'false' }}; const HAS_PROVISIONAL_TIMETABLE = {{ 'true' if provisional_timetable else 'false' }};
let eurostarRefreshPending = HAS_PROVISIONAL_TIMETABLE && !!TIMETABLE_REFRESH_URL && !!window.EventSource;
let cachedAdvanceFares = ADVANCE_FARES; let cachedAdvanceFares = ADVANCE_FARES;
let currentNrClasses = {{ nr_classes_json | safe }}; let currentNrClasses = {{ nr_classes_json | safe }};
let currentEsClasses = {{ es_classes_json | safe }}; let currentEsClasses = {{ es_classes_json | safe }};
@ -260,11 +261,34 @@
var row = TRIP_FARES[rowKey]; var row = TRIP_FARES[rowKey];
if (rowKey !== key && row.eurostar_key !== key) continue; if (rowKey !== key && row.eurostar_key !== key) continue;
if (prices[key].es_standard) row.es_standard = prices[key].es_standard; if (prices[key].es_standard) row.es_standard = prices[key].es_standard;
if (prices[key].es_standard_status !== undefined) row.es_standard_status = prices[key].es_standard_status;
if (prices[key].es_plus) row.es_plus = prices[key].es_plus; if (prices[key].es_plus) row.es_plus = prices[key].es_plus;
if (prices[key].es_plus_status !== undefined) row.es_plus_status = prices[key].es_plus_status;
} }
} }
} }
function eurostarMissingText(row, esClass) {
var status = esClass === 'standard' ? row.es_standard_status : row.es_plus_status;
if (status === 'sold_out') return 'Eurostar sold out';
if (status === 'price_not_returned') return 'No Eurostar price returned';
return 'No Eurostar price returned';
}
function eurostarMissingTitle(row, esClass) {
var status = esClass === 'standard' ? row.es_standard_status : row.es_plus_status;
if (status === 'sold_out') return 'Eurostar returned 0 seats at this price for the selected class.';
if (status === 'price_not_returned') return 'Eurostar returned this service without a price for the selected class.';
return 'Eurostar did not return a price for the selected class. Check eurostar.com.';
}
function eurostarMissingFareHtml(row, esClass) {
var status = esClass === 'standard' ? row.es_standard_status : row.es_plus_status;
if (eurostarRefreshPending && !status) return '<span class="text-sm text-muted">checking</span>';
if (status === 'sold_out') return '<span class="text-sm text-muted">sold out</span>';
return '<span class="text-sm text-muted">\u2013</span>';
}
function sectionNeedsAdvance(sectionId) { function sectionNeedsAdvance(sectionId) {
var nrClass = currentNrClasses[sectionId] || 'walkon'; var nrClass = currentNrClasses[sectionId] || 'walkon';
for (var key in TRIP_FARES) { for (var key in TRIP_FARES) {
@ -401,11 +425,11 @@
var esStdEl = tr.querySelector('.es-standard'); var esStdEl = tr.querySelector('.es-standard');
var esPlusEl = tr.querySelector('.es-plus'); var esPlusEl = tr.querySelector('.es-plus');
if (esStdEl) { 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">\u2013</span>'); esStdEl.innerHTML = '<span class="text-xs text-muted">Std</span> ' + (row.es_standard ? fareHtml(row.es_standard) : eurostarMissingFareHtml(row, 'standard'));
esStdEl.classList.toggle('fare-inactive', esClass !== 'standard'); esStdEl.classList.toggle('fare-inactive', esClass !== 'standard');
} }
if (esPlusEl) { 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">\u2013</span>'); esPlusEl.innerHTML = '<span class="text-xs text-muted">SP</span> ' + (row.es_plus ? fareHtml(row.es_plus) : eurostarMissingFareHtml(row, 'plus'));
esPlusEl.classList.toggle('fare-inactive', esClass !== 'plus'); esPlusEl.classList.toggle('fare-inactive', esClass !== 'plus');
} }
@ -426,8 +450,10 @@
totalSpan.innerHTML = ''; totalSpan.innerHTML = '';
} else if (!nrFare) { } 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>'; 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 if (eurostarRefreshPending) {
totalSpan.innerHTML = '<span class="text-xs text-muted" title="Checking exact Eurostar data for this service">Checking Eurostar price</span>';
} else { } 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>'; totalSpan.innerHTML = '<span class="text-xs text-muted" title="' + eurostarMissingTitle(row, esClass) + ' Check eurostar.com.">' + eurostarMissingText(row, esClass) + '</span>';
} }
} }
}); });
@ -620,6 +646,8 @@
function startTimetableRefresh() { function startTimetableRefresh() {
if (!HAS_PROVISIONAL_TIMETABLE || !TIMETABLE_REFRESH_URL || !window.EventSource) return; if (!HAS_PROVISIONAL_TIMETABLE || !TIMETABLE_REFRESH_URL || !window.EventSource) return;
eurostarRefreshPending = true;
updateDisplay();
var source = new EventSource(TIMETABLE_REFRESH_URL); var source = new EventSource(TIMETABLE_REFRESH_URL);
source.onmessage = function(event) { source.onmessage = function(event) {
var msg = JSON.parse(event.data); var msg = JSON.parse(event.data);
@ -633,11 +661,15 @@
mergeWalkonFares(msg.section, msg.fares); mergeWalkonFares(msg.section, msg.fares);
updateDisplay(); updateDisplay();
} else if (msg.type === 'done' || msg.type === 'error') { } else if (msg.type === 'done' || msg.type === 'error') {
eurostarRefreshPending = false;
source.close(); source.close();
updateDisplay();
} }
}; };
source.onerror = function() { source.onerror = function() {
eurostarRefreshPending = false;
source.close(); source.close();
updateDisplay();
}; };
} }

View file

@ -148,6 +148,7 @@
let ADVANCE_STREAM_URLS = {}; let ADVANCE_STREAM_URLS = {};
let TIMETABLE_REFRESH_URL = null; let TIMETABLE_REFRESH_URL = null;
let HAS_PROVISIONAL_TIMETABLE = false; let HAS_PROVISIONAL_TIMETABLE = false;
let eurostarRefreshPending = false;
let cachedAdvanceFares = null; let cachedAdvanceFares = null;
let currentNrClasses = {{ nr_classes_json | safe }}; let currentNrClasses = {{ nr_classes_json | safe }};
let currentEsClasses = {{ es_classes_json | safe }}; let currentEsClasses = {{ es_classes_json | safe }};
@ -260,11 +261,34 @@
var row = TRIP_FARES[rowKey]; var row = TRIP_FARES[rowKey];
if (rowKey !== key && row.eurostar_key !== key) continue; if (rowKey !== key && row.eurostar_key !== key) continue;
if (prices[key].es_standard) row.es_standard = prices[key].es_standard; if (prices[key].es_standard) row.es_standard = prices[key].es_standard;
if (prices[key].es_standard_status !== undefined) row.es_standard_status = prices[key].es_standard_status;
if (prices[key].es_plus) row.es_plus = prices[key].es_plus; if (prices[key].es_plus) row.es_plus = prices[key].es_plus;
if (prices[key].es_plus_status !== undefined) row.es_plus_status = prices[key].es_plus_status;
} }
} }
} }
function eurostarMissingText(row, esClass) {
var status = esClass === 'standard' ? row.es_standard_status : row.es_plus_status;
if (status === 'sold_out') return 'Eurostar sold out';
if (status === 'price_not_returned') return 'No Eurostar price returned';
return 'No Eurostar price returned';
}
function eurostarMissingTitle(row, esClass) {
var status = esClass === 'standard' ? row.es_standard_status : row.es_plus_status;
if (status === 'sold_out') return 'Eurostar returned 0 seats at this price for the selected class.';
if (status === 'price_not_returned') return 'Eurostar returned this service without a price for the selected class.';
return 'Eurostar did not return a price for the selected class. Check eurostar.com.';
}
function eurostarMissingFareHtml(row, esClass) {
var status = esClass === 'standard' ? row.es_standard_status : row.es_plus_status;
if (eurostarRefreshPending && !status) return '<span class="text-sm text-muted">checking</span>';
if (status === 'sold_out') return '<span class="text-sm text-muted">sold out</span>';
return '<span class="text-sm text-muted"></span>';
}
function sectionNeedsAdvance(sectionId) { function sectionNeedsAdvance(sectionId) {
var nrClass = currentNrClasses[sectionId] || 'walkon'; var nrClass = currentNrClasses[sectionId] || 'walkon';
for (var key in TRIP_FARES) { for (var key in TRIP_FARES) {
@ -401,11 +425,11 @@
var esStdEl = tr.querySelector('.es-standard'); var esStdEl = tr.querySelector('.es-standard');
var esPlusEl = tr.querySelector('.es-plus'); var esPlusEl = tr.querySelector('.es-plus');
if (esStdEl) { 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.innerHTML = '<span class="text-xs text-muted">Std</span> ' + (row.es_standard ? fareHtml(row.es_standard) : eurostarMissingFareHtml(row, 'standard'));
esStdEl.classList.toggle('fare-inactive', esClass !== 'standard'); esStdEl.classList.toggle('fare-inactive', esClass !== 'standard');
} }
if (esPlusEl) { 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.innerHTML = '<span class="text-xs text-muted">SP</span> ' + (row.es_plus ? fareHtml(row.es_plus) : eurostarMissingFareHtml(row, 'plus'));
esPlusEl.classList.toggle('fare-inactive', esClass !== 'plus'); esPlusEl.classList.toggle('fare-inactive', esClass !== 'plus');
} }
@ -426,8 +450,10 @@
totalSpan.innerHTML = ''; totalSpan.innerHTML = '';
} else if (!nrFare) { } 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>'; 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 if (eurostarRefreshPending) {
totalSpan.innerHTML = '<span class="text-xs text-muted" title="Checking exact Eurostar data for this service">Checking Eurostar price</span>';
} else { } 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>'; totalSpan.innerHTML = '<span class="text-xs text-muted" title="' + eurostarMissingTitle(row, esClass) + ' Check eurostar.com.">' + eurostarMissingText(row, esClass) + '</span>';
} }
} }
}); });
@ -618,6 +644,8 @@
function startTimetableRefresh() { function startTimetableRefresh() {
if (!HAS_PROVISIONAL_TIMETABLE || !TIMETABLE_REFRESH_URL || !window.EventSource) return; if (!HAS_PROVISIONAL_TIMETABLE || !TIMETABLE_REFRESH_URL || !window.EventSource) return;
eurostarRefreshPending = true;
updateDisplay();
var source = new EventSource(TIMETABLE_REFRESH_URL); var source = new EventSource(TIMETABLE_REFRESH_URL);
source.onmessage = function(event) { source.onmessage = function(event) {
var msg = JSON.parse(event.data); var msg = JSON.parse(event.data);
@ -631,11 +659,15 @@
mergeWalkonFares(msg.section, msg.fares); mergeWalkonFares(msg.section, msg.fares);
updateDisplay(); updateDisplay();
} else if (msg.type === 'done' || msg.type === 'error') { } else if (msg.type === 'done' || msg.type === 'error') {
eurostarRefreshPending = false;
source.close(); source.close();
updateDisplay();
} }
}; };
source.onerror = function() { source.onerror = function() {
eurostarRefreshPending = false;
source.close(); source.close();
updateDisplay();
}; };
} }
@ -655,6 +687,7 @@
function finaliseResults(msg) { function finaliseResults(msg) {
TIMETABLE_REFRESH_URL = msg.timetable_refresh_url || null; TIMETABLE_REFRESH_URL = msg.timetable_refresh_url || null;
HAS_PROVISIONAL_TIMETABLE = msg.provisional_timetable || false; HAS_PROVISIONAL_TIMETABLE = msg.provisional_timetable || false;
eurostarRefreshPending = HAS_PROVISIONAL_TIMETABLE && !!TIMETABLE_REFRESH_URL && !!window.EventSource;
var summaryEl = document.getElementById('results-summary'); var summaryEl = document.getElementById('results-summary');
if (summaryEl && msg.summary_html) summaryEl.innerHTML = msg.summary_html; if (summaryEl && msg.summary_html) summaryEl.innerHTML = msg.summary_html;
initialiseResultsPage(); initialiseResultsPage();

View file

@ -183,9 +183,30 @@ def test_results_can_render_from_weekday_timetable_cache(monkeypatch: Any) -> No
assert "/api/results_refresh/BRI/paris/2026-06-22" in html assert "/api/results_refresh/BRI/paris/2026-06-22" in html
assert "refreshFullResults()" in html assert "refreshFullResults()" in html
assert "window.location.reload()" not in html assert "window.location.reload()" not in html
assert "Checking Eurostar price" in html
assert "Eurostar prices not yet available" not in html assert "Eurostar prices not yet available" not in html
def test_eurostar_price_status_distinguishes_sold_out() -> None:
prices = app_module._eurostar_prices_by_row(
"outbound",
"outbound",
[
{
"depart_st_pancras": "10:01",
"price": None,
"seats": 0,
"plus_price": None,
"plus_seats": None,
}
],
)
assert prices["outbound:10:01"]["es_standard"] is None
assert prices["outbound:10:01"]["es_standard_status"] == "sold_out"
assert prices["outbound:10:01"]["es_plus_status"] == "price_not_returned"
def test_results_refresh_reloads_when_exact_timetable_differs(monkeypatch: Any) -> None: def test_results_refresh_reloads_when_exact_timetable_differs(monkeypatch: Any) -> None:
travel_date = "2026-06-22" travel_date = "2026-06-22"
cache: dict[str, Any] = { cache: dict[str, Any] = {