diff --git a/app.py b/app.py
index 4fc7e34..51331f0 100644
--- a/app.py
+++ b/app.py
@@ -261,15 +261,29 @@ def _eurostar_prices_by_row(
if service.get("price") is not None
else None
),
+ "es_standard_status": _eurostar_price_status(
+ service.get("price"), service.get("seats")
+ ),
"es_plus": (
{"price": service.get("plus_price"), "seats": service.get("plus_seats")}
if service.get("plus_price") is not None
else None
),
+ "es_plus_status": _eurostar_price_status(
+ service.get("plus_price"), service.get("plus_seats")
+ ),
}
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]:
return (
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
else None
)
+ es_std_status = _eurostar_price_status(
+ row.get("eurostar_price"), row.get("eurostar_seats")
+ )
es_plus = (
{
"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
else None
)
+ es_plus_status = _eurostar_price_status(
+ row.get("eurostar_plus_price"), row.get("eurostar_plus_seats")
+ )
trip_fares[row["row_key"]] = {
"section": section["id"],
"eurostar_key": row.get("eurostar_key"),
"advance_key": row.get("depart_bristol") or row.get("depart_paddington"),
"walkon": walkon,
"es_standard": es_std,
+ "es_standard_status": es_std_status,
"es_plus": es_plus,
+ "es_plus_status": es_plus_status,
"circle_fare": circle_fare,
}
return trip_fares
@@ -1268,6 +1290,9 @@ def _results(
if row.get("eurostar_price") is not None
else None
)
+ es_std_status = _eurostar_price_status(
+ row.get("eurostar_price"), row.get("eurostar_seats")
+ )
es_plus = (
{
"price": row["eurostar_plus_price"],
@@ -1276,6 +1301,9 @@ def _results(
if row.get("eurostar_plus_price") is not None
else None
)
+ es_plus_status = _eurostar_price_status(
+ row.get("eurostar_plus_price"), row.get("eurostar_plus_seats")
+ )
trip_fares[row["row_key"]] = {
"section": section["id"],
"eurostar_key": row.get("eurostar_key"),
@@ -1283,7 +1311,9 @@ def _results(
or row.get("depart_paddington"),
"walkon": walkon,
"es_standard": es_std,
+ "es_standard_status": es_std_status,
"es_plus": es_plus,
+ "es_plus_status": es_plus_status,
"circle_fare": circle_fare,
}
diff --git a/templates/results.html b/templates/results.html
index 510b246..7f3da71 100644
--- a/templates/results.html
+++ b/templates/results.html
@@ -148,6 +148,7 @@
let ADVANCE_STREAM_URLS = {{ advance_stream_urls_json | safe }};
const TIMETABLE_REFRESH_URL = {{ timetable_refresh_url|tojson }};
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 currentNrClasses = {{ nr_classes_json | safe }};
let currentEsClasses = {{ es_classes_json | safe }};
@@ -260,11 +261,34 @@
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_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_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 'checking';
+ if (status === 'sold_out') return 'sold out';
+ return '\u2013';
+ }
+
function sectionNeedsAdvance(sectionId) {
var nrClass = currentNrClasses[sectionId] || 'walkon';
for (var key in TRIP_FARES) {
@@ -401,11 +425,11 @@
var esStdEl = tr.querySelector('.es-standard');
var esPlusEl = tr.querySelector('.es-plus');
if (esStdEl) {
- esStdEl.innerHTML = 'Std ' + (row.es_standard ? fareHtml(row.es_standard) : '\u2013');
+ esStdEl.innerHTML = 'Std ' + (row.es_standard ? fareHtml(row.es_standard) : eurostarMissingFareHtml(row, 'standard'));
esStdEl.classList.toggle('fare-inactive', esClass !== 'standard');
}
if (esPlusEl) {
- esPlusEl.innerHTML = 'SP ' + (row.es_plus ? fareHtml(row.es_plus) : '\u2013');
+ esPlusEl.innerHTML = 'SP ' + (row.es_plus ? fareHtml(row.es_plus) : eurostarMissingFareHtml(row, 'plus'));
esPlusEl.classList.toggle('fare-inactive', esClass !== 'plus');
}
@@ -426,8 +450,10 @@
totalSpan.innerHTML = '';
} else if (!nrFare) {
totalSpan.innerHTML = 'NR fare not available';
+ } else if (eurostarRefreshPending) {
+ totalSpan.innerHTML = 'Checking Eurostar price';
} else {
- totalSpan.innerHTML = 'No Eurostar price';
+ totalSpan.innerHTML = '' + eurostarMissingText(row, esClass) + '';
}
}
});
@@ -620,6 +646,8 @@
function startTimetableRefresh() {
if (!HAS_PROVISIONAL_TIMETABLE || !TIMETABLE_REFRESH_URL || !window.EventSource) return;
+ eurostarRefreshPending = true;
+ updateDisplay();
var source = new EventSource(TIMETABLE_REFRESH_URL);
source.onmessage = function(event) {
var msg = JSON.parse(event.data);
@@ -633,11 +661,15 @@
mergeWalkonFares(msg.section, msg.fares);
updateDisplay();
} else if (msg.type === 'done' || msg.type === 'error') {
+ eurostarRefreshPending = false;
source.close();
+ updateDisplay();
}
};
source.onerror = function() {
+ eurostarRefreshPending = false;
source.close();
+ updateDisplay();
};
}
diff --git a/templates/results_shell.html b/templates/results_shell.html
index 634994d..b3715c1 100644
--- a/templates/results_shell.html
+++ b/templates/results_shell.html
@@ -148,6 +148,7 @@
let ADVANCE_STREAM_URLS = {};
let TIMETABLE_REFRESH_URL = null;
let HAS_PROVISIONAL_TIMETABLE = false;
+ let eurostarRefreshPending = false;
let cachedAdvanceFares = null;
let currentNrClasses = {{ nr_classes_json | safe }};
let currentEsClasses = {{ es_classes_json | safe }};
@@ -260,11 +261,34 @@
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_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_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 'checking';
+ if (status === 'sold_out') return 'sold out';
+ return '–';
+ }
+
function sectionNeedsAdvance(sectionId) {
var nrClass = currentNrClasses[sectionId] || 'walkon';
for (var key in TRIP_FARES) {
@@ -401,11 +425,11 @@
var esStdEl = tr.querySelector('.es-standard');
var esPlusEl = tr.querySelector('.es-plus');
if (esStdEl) {
- esStdEl.innerHTML = 'Std ' + (row.es_standard ? fareHtml(row.es_standard) : '–');
+ esStdEl.innerHTML = 'Std ' + (row.es_standard ? fareHtml(row.es_standard) : eurostarMissingFareHtml(row, 'standard'));
esStdEl.classList.toggle('fare-inactive', esClass !== 'standard');
}
if (esPlusEl) {
- esPlusEl.innerHTML = 'SP ' + (row.es_plus ? fareHtml(row.es_plus) : '–');
+ esPlusEl.innerHTML = 'SP ' + (row.es_plus ? fareHtml(row.es_plus) : eurostarMissingFareHtml(row, 'plus'));
esPlusEl.classList.toggle('fare-inactive', esClass !== 'plus');
}
@@ -426,8 +450,10 @@
totalSpan.innerHTML = '';
} else if (!nrFare) {
totalSpan.innerHTML = 'NR fare not available';
+ } else if (eurostarRefreshPending) {
+ totalSpan.innerHTML = 'Checking Eurostar price';
} else {
- totalSpan.innerHTML = 'No Eurostar price';
+ totalSpan.innerHTML = '' + eurostarMissingText(row, esClass) + '';
}
}
});
@@ -618,6 +644,8 @@
function startTimetableRefresh() {
if (!HAS_PROVISIONAL_TIMETABLE || !TIMETABLE_REFRESH_URL || !window.EventSource) return;
+ eurostarRefreshPending = true;
+ updateDisplay();
var source = new EventSource(TIMETABLE_REFRESH_URL);
source.onmessage = function(event) {
var msg = JSON.parse(event.data);
@@ -631,11 +659,15 @@
mergeWalkonFares(msg.section, msg.fares);
updateDisplay();
} else if (msg.type === 'done' || msg.type === 'error') {
+ eurostarRefreshPending = false;
source.close();
+ updateDisplay();
}
};
source.onerror = function() {
+ eurostarRefreshPending = false;
source.close();
+ updateDisplay();
};
}
@@ -655,6 +687,7 @@
function finaliseResults(msg) {
TIMETABLE_REFRESH_URL = msg.timetable_refresh_url || null;
HAS_PROVISIONAL_TIMETABLE = msg.provisional_timetable || false;
+ eurostarRefreshPending = HAS_PROVISIONAL_TIMETABLE && !!TIMETABLE_REFRESH_URL && !!window.EventSource;
var summaryEl = document.getElementById('results-summary');
if (summaryEl && msg.summary_html) summaryEl.innerHTML = msg.summary_html;
initialiseResultsPage();
diff --git a/tests/test_app.py b/tests/test_app.py
index 9b19b31..b484e8b 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -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 "refreshFullResults()" in html
assert "window.location.reload()" not in html
+ assert "Checking Eurostar price" 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:
travel_date = "2026-06-22"
cache: dict[str, Any] = {