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] = {