Remove obsolete connection template context

This commit is contained in:
Edward Betts 2026-05-27 00:27:16 +01:00
parent 1c230ce0d6
commit 5272956050
9 changed files with 423 additions and 229 deletions

140
app.py
View file

@ -25,7 +25,6 @@ import scraper.eurostar as eurostar_scraper
import scraper.gwr_fares as gwr_fares_scraper
import scraper.realtime_trains as rtt_scraper
from trip_planner import (
INBOUND_MAX_CONNECTION_MINUTES,
INBOUND_MIN_CONNECTION_MINUTES,
combine_inbound_trips,
combine_trips,
@ -35,6 +34,8 @@ from trip_planner import (
import cache
import circle_line
CONNECTION_WINDOW_MINUTES = 90
RTT_PADDINGTON_URL = (
"https://www.realtimetrains.co.uk/search/detailed/"
"gb-nr:PAD/from/gb-nr:{crs}/{date}/0000-2359"
@ -88,47 +89,31 @@ DESTINATION_OPTIONS = [
{"slug": "cologne", "city": "Cologne", "destination": "Cologne Hbf"},
]
DESTINATIONS = {
option["slug"]: option["destination"] for option in DESTINATION_OPTIONS
}
DESTINATIONS = {option["slug"]: option["destination"] for option in DESTINATION_OPTIONS}
@app.route("/")
def index() -> ResponseReturnValue:
today = date.today().isoformat()
default_min, default_max = _get_defaults()
default_min = _get_default_min_connection()
return render_template(
"index.html",
destination_options=DESTINATION_OPTIONS,
today=today,
stations=STATIONS,
default_min_connection=default_min,
default_max_connection=default_max,
valid_min_connections=sorted(VALID_MIN_CONNECTIONS),
valid_max_connections=sorted(VALID_MAX_CONNECTIONS),
default_return_date=(date.today() + timedelta(days=7)).isoformat(),
default_inbound_min_connection=INBOUND_MIN_CONNECTION_MINUTES,
valid_inbound_min_connections=sorted(VALID_INBOUND_MIN_CONNECTIONS),
valid_inbound_return_min_connections=sorted(
VALID_INBOUND_RETURN_MIN_CONNECTIONS
),
)
VALID_MIN_CONNECTIONS = {45, 50, 60, 70, 80, 90, 100, 110, 120}
VALID_MAX_CONNECTIONS = {60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180}
VALID_INBOUND_MIN_CONNECTIONS = {20, 30, 40, 45, 50, 60, 70, 80, 90, 100, 110, 120}
VALID_INBOUND_RETURN_MIN_CONNECTIONS = {30, 40, 50, 60}
VALID_INBOUND_MAX_CONNECTIONS = {
60,
70,
80,
90,
100,
110,
120,
130,
140,
150,
160,
170,
180,
}
VALID_JOURNEY_TYPES = {"outbound", "inbound", "return"}
VALID_NR_CLASSES = {"walkon", "advance_std", "advance_1st"}
VALID_ES_CLASSES = {"standard", "plus"}
@ -284,11 +269,12 @@ def _eurostar_price_status(price: Any, seats: Any) -> str | None:
return "price_not_returned"
def _get_defaults() -> tuple[int, int]:
return (
app.config["DEFAULT_MIN_CONNECTION"],
app.config["DEFAULT_MAX_CONNECTION"],
)
def _get_default_min_connection() -> int:
return app.config["DEFAULT_MIN_CONNECTION"]
def _max_connection_for(min_connection: int) -> int:
return min_connection + CONNECTION_WINDOW_MINUTES
def _parse_connection(raw: str | None, default: int, valid_set: set[int]) -> int:
@ -417,22 +403,21 @@ def search() -> ResponseReturnValue:
if station_crs not in STATION_BY_CRS:
station_crs = "BRI"
if journey_type == "inbound":
default_min, default_max = (
INBOUND_MIN_CONNECTION_MINUTES,
INBOUND_MAX_CONNECTION_MINUTES,
)
valid_min, valid_max = (
VALID_INBOUND_MIN_CONNECTIONS,
VALID_INBOUND_MAX_CONNECTIONS,
)
default_min = INBOUND_MIN_CONNECTION_MINUTES
valid_min = VALID_INBOUND_MIN_CONNECTIONS
else:
default_min, default_max = _get_defaults()
valid_min, valid_max = VALID_MIN_CONNECTIONS, VALID_MAX_CONNECTIONS
min_conn = _parse_connection(
request.args.get("min_connection"), default_min, valid_min
default_min = _get_default_min_connection()
valid_min = VALID_MIN_CONNECTIONS
min_connection_arg = (
request.args.get("min_connection_back")
if journey_type == "inbound"
else request.args.get("min_connection")
)
max_conn = _parse_connection(
request.args.get("max_connection"), default_max, valid_max
min_conn = _parse_connection(min_connection_arg, default_min, valid_min)
inbound_min_conn = _parse_connection(
request.args.get("min_connection_in"),
INBOUND_MIN_CONNECTION_MINUTES,
VALID_INBOUND_RETURN_MIN_CONNECTIONS,
)
nr_class = request.args.get("nr_class", DEFAULT_NR_CLASS)
if nr_class not in VALID_NR_CLASSES:
@ -461,7 +446,12 @@ def search() -> ResponseReturnValue:
journey_type=journey_type,
return_date=return_date if journey_type == "return" else None,
min_connection=None if min_conn == default_min else min_conn,
max_connection=None if max_conn == default_max else max_conn,
min_connection_in=(
None
if journey_type != "return"
or inbound_min_conn == INBOUND_MIN_CONNECTION_MINUTES
else inbound_min_conn
),
nr_class=None if nr_class == DEFAULT_NR_CLASS else nr_class,
es_class=None if es_class == DEFAULT_ES_CLASS else es_class,
)
@ -512,23 +502,15 @@ def _results(
return redirect(url_for("index"))
if journey_type == "inbound":
default_min, default_max = (
INBOUND_MIN_CONNECTION_MINUTES,
INBOUND_MAX_CONNECTION_MINUTES,
)
valid_min, valid_max = (
VALID_INBOUND_MIN_CONNECTIONS,
VALID_INBOUND_MAX_CONNECTIONS,
)
default_min = INBOUND_MIN_CONNECTION_MINUTES
valid_min = VALID_INBOUND_MIN_CONNECTIONS
else:
default_min, default_max = _get_defaults()
valid_min, valid_max = VALID_MIN_CONNECTIONS, VALID_MAX_CONNECTIONS
default_min = _get_default_min_connection()
valid_min = VALID_MIN_CONNECTIONS
min_connection = _parse_connection(
request.args.get("min_connection"), default_min, valid_min
)
max_connection = _parse_connection(
request.args.get("max_connection"), default_max, valid_max
)
max_connection = _max_connection_for(min_connection)
nr_class = request.args.get("nr_class", DEFAULT_NR_CLASS)
if nr_class not in VALID_NR_CLASSES:
nr_class = DEFAULT_NR_CLASS
@ -587,7 +569,6 @@ def _results(
departure_station_name=departure_station_name,
journey_type=journey_type,
travel_date_display=travel_date_display,
return_date=return_date,
return_date_display=return_date_display,
stream_url=_results_url(
station_crs=station_crs,
@ -684,7 +665,7 @@ def _results(
section_max_connection = max_connection
if journey_type == "return" and direction == "inbound":
section_min_connection = inbound_min_connection
section_max_connection = INBOUND_MAX_CONNECTION_MINUTES
section_max_connection = _max_connection_for(inbound_min_connection)
rtt_direction = (
"to_paddington" if direction == "outbound" else "from_paddington"
)
@ -752,6 +733,8 @@ def _results(
cached_walkon = (
exact_walkon if exact_walkon is not None else get_cached(walkon_weekday_key)
)
if isinstance(cached_walkon, dict):
gwr_fares = cached_walkon
if direction == "outbound":
trips = combine_trips(
@ -899,7 +882,6 @@ def _results(
rtt_station_url = RTT_STATION_URL.format(crs=station_crs, date=travel_date)
url_min = None if min_connection == default_min else min_connection
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
@ -908,7 +890,6 @@ def _results(
"journey_type": journey_type,
"return_date": return_date,
"min_connection": url_min,
"max_connection": url_max,
"min_connection_in": (
None
if inbound_min_connection == INBOUND_MIN_CONNECTION_MINUTES
@ -932,7 +913,6 @@ def _results(
"journey_type": journey_type,
"return_date": return_date,
"min_connection": url_min,
"max_connection": url_max,
"nr_class": url_nr,
"es_class": url_es,
}
@ -1005,7 +985,7 @@ def _results(
"id": "inbound",
"direction": "inbound",
"min_connection": inbound_min_connection,
"max_connection": INBOUND_MAX_CONNECTION_MINUTES,
"max_connection": _max_connection_for(inbound_min_connection),
},
]
shell_nr_classes = {"outbound": nr_class_out, "inbound": nr_class_in}
@ -1032,8 +1012,6 @@ def _results(
journey_type=journey_type,
destination=destination,
departure_station_name=departure_station_name,
travel_date=travel_date,
return_date=return_date,
travel_date_display=travel_date_display,
return_date_display=return_date_display,
slug=slug,
@ -1055,12 +1033,9 @@ def _results(
rtt_url=rtt_url,
rtt_station_url=rtt_station_url,
min_connection=min_connection,
max_connection=max_connection,
default_min_connection=default_min,
default_max_connection=default_max,
default_inbound_min_connection=INBOUND_MIN_CONNECTION_MINUTES,
valid_min_connections=sorted(valid_min),
valid_max_connections=sorted(valid_max),
inbound_min_connection=inbound_min_connection,
valid_inbound_return_min_connections=sorted(
VALID_INBOUND_RETURN_MIN_CONNECTIONS
@ -1096,6 +1071,8 @@ def _results(
section=section,
destination=destination,
departure_station_name=departure_station_name,
nr_classes=shell_nr_classes,
es_classes=shell_es_classes,
)
yield f"data: {json.dumps({'type': 'section', 'id': section_id, 'html': section_html, 'trip_fares': _section_trip_fares(section), 'advance_fares': section['advance_fares'], 'walkon_cached_fares': section.get('cached_walkon_fares'), 'walkon_api_url': section['walkon_api_url'], 'advance_api_url': section['advance_api_url'], 'advance_stream_url': section['advance_stream_url']})}\n\n"
@ -1186,7 +1163,6 @@ def _results(
rtt_station_url = RTT_STATION_URL.format(crs=station_crs, date=travel_date)
url_min = None if min_connection == default_min else min_connection
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: dict[str, Any]
@ -1195,7 +1171,6 @@ def _results(
"journey_type": journey_type,
"return_date": return_date,
"min_connection": url_min,
"max_connection": url_max,
"min_connection_in": (
None
if inbound_min_connection == INBOUND_MIN_CONNECTION_MINUTES
@ -1211,7 +1186,6 @@ def _results(
"journey_type": journey_type,
"return_date": return_date,
"min_connection": url_min,
"max_connection": url_max,
"nr_class": url_nr,
"es_class": url_es,
}
@ -1342,19 +1316,10 @@ def _results(
return render_template(
"results.html",
sections=sections,
trips=sections[0]["trips"] if sections else [],
result_rows=sections[0]["rows"] if sections else [],
unreachable_morning_services=[],
destinations=DESTINATIONS,
destination=destination,
travel_date=travel_date,
return_date=return_date,
journey_type=journey_type,
slug=slug,
station_crs=station_crs,
departure_station_name=departure_station_name,
prev_date=prev_date,
next_date=next_date,
prev_results_url=prev_results_url,
next_results_url=next_results_url,
prev_outbound_url=prev_outbound_url,
@ -1375,15 +1340,7 @@ def _results(
rtt_url=rtt_url,
rtt_station_url=rtt_station_url,
min_connection=min_connection,
max_connection=max_connection,
default_min_connection=default_min,
default_max_connection=default_max,
url_min_connection=url_min,
url_max_connection=url_max,
nr_class=nr_class,
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),
@ -1396,14 +1353,7 @@ def _results(
advance_api_urls_json=json.dumps(advance_api_urls),
advance_stream_urls_json=json.dumps(advance_stream_urls),
timetable_refresh_url=timetable_refresh_url,
advance_fares_api_url=url_for(
"api_advance_fares", station_crs=station_crs, travel_date=travel_date
),
advance_fares_stream_url=url_for(
"api_advance_fares_stream", station_crs=station_crs, travel_date=travel_date
),
valid_min_connections=sorted(valid_min),
valid_max_connections=sorted(valid_max),
inbound_min_connection=inbound_min_connection,
default_inbound_min_connection=INBOUND_MIN_CONNECTION_MINUTES,
valid_inbound_return_min_connections=sorted(

View file

@ -11,4 +11,3 @@ CIRCLE_LINE_XML = os.path.join(TFL_DATA_DIR, "output_txc_01CIR_.xml")
# Default connection window (minutes) between Paddington arrival and St Pancras departure
DEFAULT_MIN_CONNECTION = 70
DEFAULT_MAX_CONNECTION = 150

View file

@ -342,7 +342,7 @@
tr.row-selectable:hover:not(.row-selected) { filter: brightness(0.97); }
/* Journey flow arrow between column headers */
.results-table thead th.flow-step { position: relative; padding-right: 1.4rem; }
.results-table thead th.flow-step { position: sticky; padding-right: 1.4rem; }
.results-table thead th.flow-step::after {
content: '';
position: absolute; right: 0.2rem; top: 50%; transform: translateY(-50%);

View file

@ -1,6 +1,8 @@
{% extends "base.html" %}
{% block content %}
<style>
.form-row[hidden] { display: none !important; }
/* ── Calendar ───────────────────────────────────────────────────── */
.cal-wrap { margin-top: .5rem; }
@ -127,10 +129,10 @@
<input type="hidden" id="return_date" name="">
</div>
<div class="form-row">
<div class="form-row" id="outbound-connection-fields">
<div class="form-group">
<label for="min_connection" class="field-label">
Minimum connection time (Paddington &rarr; St&nbsp;Pancras)
Minimum outbound connection (Paddington &rarr; St&nbsp;Pancras)
</label>
<select id="min_connection" name="min_connection" class="form-control">
{% for mins in valid_min_connections %}
@ -138,14 +140,29 @@
{% endfor %}
</select>
</div>
</div>
<div class="form-row" id="back-connection-fields" hidden>
<div class="form-group">
<label for="max_connection" class="field-label">
Maximum connection time (Paddington &rarr; St&nbsp;Pancras)
<label for="min_connection_back" class="field-label">
Minimum back connection (St&nbsp;Pancras &rarr; Paddington)
</label>
<select id="max_connection" name="max_connection" class="form-control">
{% for mins in valid_max_connections %}
<option value="{{ mins }}" {% if mins == default_max_connection %}selected{% endif %}>{{ mins }} min</option>
<select id="min_connection_back" class="form-control" disabled>
{% for mins in valid_inbound_min_connections %}
<option value="{{ mins }}" {% if mins == default_inbound_min_connection %}selected{% endif %}>{{ mins }} min</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-row" id="return-connection-fields" hidden>
<div class="form-group">
<label for="min_connection_in" class="field-label">
Minimum return-leg connection (St&nbsp;Pancras &rarr; Paddington)
</label>
<select id="min_connection_in" class="form-control" disabled>
{% for mins in valid_inbound_return_min_connections %}
<option value="{{ mins }}" {% if mins == default_inbound_min_connection %}selected{% endif %}>{{ mins }} min</option>
{% endfor %}
</select>
</div>
@ -207,6 +224,28 @@
}
}
function setConnectionSelect(id, enabled, name) {
var el = document.getElementById(id);
if (!el) return;
el.disabled = !enabled;
el.name = enabled ? name : '';
}
function syncConnectionFields() {
var selected = document.querySelector('input[name="journey_type"]:checked');
var journeyType = selected ? selected.value : 'outbound';
var isBack = journeyType === 'inbound';
var isRet = journeyType === 'return';
document.getElementById('outbound-connection-fields').hidden = isBack;
document.getElementById('back-connection-fields').hidden = !isBack;
document.getElementById('return-connection-fields').hidden = !isRet;
setConnectionSelect('min_connection', !isBack, 'min_connection');
setConnectionSelect('min_connection_back', isBack, 'min_connection_back');
setConnectionSelect('min_connection_in', isRet, 'min_connection_in');
}
/* ── day click ───────────────────────────────────────────────────── */
function onDayClick(d) {
@ -448,6 +487,7 @@
retPhase = false;
}
syncInputs();
syncConnectionFields();
render();
});
});
@ -475,6 +515,7 @@
retPhase = isReturn && outDate !== null;
syncInputs();
syncConnectionFields();
render();
}());
</script>

View file

@ -57,38 +57,47 @@
{% endfor %}
</div>
</div>
{% set connection_direction = 'St Pancras → Paddington' if journey_type == 'inbound' else 'Paddington → St Pancras' %}
{% if journey_type != 'return' %}
<div class="filter-row">
<div>
<label for="min_conn_select" class="filter-label">Min connection:</label>
<label for="min_conn_select" class="filter-label">Min connection ({{ connection_direction }}):</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">
{% for mins in valid_max_connections %}
<option value="{{ mins }}" {% if mins == max_connection %}selected{% endif %}>{{ mins }} min</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
{% if journey_type == 'return' %}
{% for section in sections %}
<div class="filter-row" style="margin-top:0.5rem">
<div class="filter-row" style="margin-top:0.5rem;gap:0.75rem">
<span class="filter-label" style="min-width:5.5rem">{{ 'Outbound' if section.direction == 'outbound' else 'Return' }}:</span>
{% if section.direction == 'inbound' %}
<div>
<label for="min_conn_in_select" class="filter-label">Min connection:</label>
<select id="min_conn_in_select" onchange="applyConnectionFilter()" class="select-inline">
{% for mins in valid_inbound_return_min_connections %}
<option value="{{ mins }}" {% if mins == inbound_min_connection %}selected{% endif %}>{{ mins }} min</option>
{% endfor %}
</select>
<span class="filter-label">ES:</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>
{% if section.direction == 'inbound' %}
<label for="min_conn_in_select" class="filter-label">Min connection:</label>
<select id="min_conn_in_select" onchange="applyConnectionFilter()" class="select-inline">
{% for mins in valid_inbound_return_min_connections %}
<option value="{{ mins }}" {% if mins == inbound_min_connection %}selected{% endif %}>{{ mins }} min</option>
{% endfor %}
</select>
{% else %}
<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>
{% endif %}
</div>
{% endif %}
<div>
<span class="filter-label">NR:</span>
<div class="btn-group">
@ -97,13 +106,6 @@
<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">
@ -126,7 +128,7 @@
<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 class="filter-label">ES:</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>
@ -136,26 +138,25 @@
</div>
{% endif %}
<script>
const RESULTS_BASE = '{{ results_base_url }}';
const DEFAULT_MIN_CONN = {{ default_min_connection }};
const DEFAULT_MAX_CONN = {{ default_max_connection }};
const DEFAULT_MIN_CONN_IN = {{ default_inbound_min_connection }};
let TRIP_FARES = {{ trip_fares_json | safe }};
let ADVANCE_FARES = {{ advance_fares_json | safe }};
const WALKON_CACHED_FARES = {{ walkon_cached_fares_json | safe }};
let WALKON_API_URLS = {{ walkon_api_urls_json | safe }};
let ADVANCE_API_URLS = {{ advance_api_urls_json | safe }};
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 }};
const SECTION_DIRECTIONS = {{ section_directions_json | safe }};
let advanceLoadingSections = {};
let walkonLoadingSections = {};
let selectedRowKeys = {};
var RESULTS_BASE = '{{ results_base_url }}';
var DEFAULT_MIN_CONN = {{ default_min_connection }};
var DEFAULT_MIN_CONN_IN = {{ default_inbound_min_connection }};
var TRIP_FARES = {{ trip_fares_json | safe }};
var ADVANCE_FARES = {{ advance_fares_json | safe }};
var WALKON_CACHED_FARES = {{ walkon_cached_fares_json | safe }};
var WALKON_API_URLS = {{ walkon_api_urls_json | safe }};
var ADVANCE_API_URLS = {{ advance_api_urls_json | safe }};
var ADVANCE_STREAM_URLS = {{ advance_stream_urls_json | safe }};
var TIMETABLE_REFRESH_URL = {{ timetable_refresh_url|tojson }};
var HAS_PROVISIONAL_TIMETABLE = {{ 'true' if provisional_timetable else 'false' }};
var eurostarRefreshPending = HAS_PROVISIONAL_TIMETABLE && !!TIMETABLE_REFRESH_URL && !!window.EventSource;
var cachedAdvanceFares = ADVANCE_FARES;
var currentNrClasses = {{ nr_classes_json | safe }};
var currentEsClasses = {{ es_classes_json | safe }};
var SECTION_DIRECTIONS = {{ section_directions_json | safe }};
var advanceLoadingSections = {};
var walkonLoadingSections = {};
var selectedRowKeys = {};
function updateAdvanceLoadingStatus() {
var loading = Object.keys(advanceLoadingSections).some(function(sectionId) {
@ -167,10 +168,8 @@
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);
var minInEl = document.getElementById('min_conn_in_select');
if (minInEl) {
var minIn = parseInt(minInEl.value);
@ -260,10 +259,19 @@
for (var rowKey in TRIP_FARES) {
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;
var price = prices[key];
if (price.es_standard) {
row.es_standard = price.es_standard;
row.es_standard_status = price.es_standard_status;
} else if (!row.es_standard && price.es_standard_status !== undefined) {
row.es_standard_status = price.es_standard_status;
}
if (price.es_plus) {
row.es_plus = price.es_plus;
row.es_plus_status = price.es_plus_status;
} else if (!row.es_plus && price.es_plus_status !== undefined) {
row.es_plus_status = price.es_plus_status;
}
}
}
}
@ -450,7 +458,7 @@
totalSpan.innerHTML = '';
} 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>';
} else if (eurostarRefreshPending) {
} else if (eurostarRefreshPending && !row.es_standard_status && !row.es_plus_status) {
totalSpan.innerHTML = '<span class="text-xs text-muted" title="Checking exact Eurostar data for this service">Checking Eurostar price</span>';
} else {
totalSpan.innerHTML = '<span class="text-xs text-muted" title="' + eurostarMissingTitle(row, esClass) + ' Check eurostar.com.">' + eurostarMissingText(row, esClass) + '</span>';

View file

@ -81,7 +81,7 @@
{% if row.headcode or row.arrive_platform %}
<br><span class="text-xs text-muted mobile-hide">{{ row.headcode }}{% if row.headcode and row.arrive_platform %} &middot; {% endif %}{% if row.arrive_platform %}Plat {{ row.arrive_platform }}{% endif %}</span>
{% endif %}
<span class="fare-line nr-walkon">{% if row.ticket_price is not none %}<span class="text-sm font-bold">£{{ "%.2f"|format(row.ticket_price) }}</span>{% endif %}</span>
<span class="fare-line nr-walkon">{% if row.ticket_price is not none %}<span class="text-sm font-bold">£{{ "%.2f"|format(row.ticket_price) }}</span>{% if row.ticket_name %} <span class="text-xs text-muted">{{ row.ticket_name }}</span>{% endif %}{% endif %}</span>
<span class="fare-line nr-advance-std"></span>
<span class="fare-line nr-advance-1st"></span>
</td>
@ -92,7 +92,7 @@
{% if row.headcode or row.arrive_platform %}
<br><span class="text-xs text-muted mobile-hide">{{ row.headcode }}{% if row.headcode and row.arrive_platform %} &middot; {% endif %}{% if row.arrive_platform %}Plat {{ row.arrive_platform }}{% endif %}</span>
{% endif %}
<span class="fare-line nr-walkon">{% if row.ticket_price is not none %}<span class="text-sm font-bold">£{{ "%.2f"|format(row.ticket_price) }}</span>{% endif %}</span>
<span class="fare-line nr-walkon">{% if row.ticket_price is not none %}<span class="text-sm font-bold">£{{ "%.2f"|format(row.ticket_price) }}</span>{% if row.ticket_name %} <span class="text-xs text-muted">{{ row.ticket_name }}</span>{% endif %}{% endif %}</span>
<span class="fare-line nr-advance-std"></span>
<span class="fare-line nr-advance-1st"></span>
<span class="text-xs text-muted mobile-conn">then {{ row.connection_duration }} to Eurostar{% if row.connection_minutes < 80 %} {% endif %}</span>
@ -129,7 +129,16 @@
{% else %}
<span class="text-blue">{{ row.total_duration }}</span>
{% endif %}
<br><span class="total-price"></span>
{% set nr_class = nr_classes.get(section.id, 'walkon') %}
{% set es_class = es_classes.get(section.id, 'standard') %}
{% set circle_fare = (row.circle_services[0].fare if row.circle_services else 0) %}
{% set selected_es_price = row.eurostar_plus_price if es_class == 'plus' else row.eurostar_price %}
{% if nr_class == 'walkon' and row.ticket_price is not none and selected_es_price is not none %}
{% set initial_total = row.ticket_price + selected_es_price + circle_fare %}
<br><span class="total-price"><span class="text-sm text-green" style="font-weight:700">£{{ "%.2f"|format(initial_total) }}</span></span>
{% else %}
<br><span class="total-price"></span>
{% endif %}
</td>
{% else %}
<td>

View file

@ -57,38 +57,47 @@
{% endfor %}
</div>
</div>
{% set connection_direction = 'St Pancras → Paddington' if journey_type == 'inbound' else 'Paddington → St Pancras' %}
{% if journey_type != 'return' %}
<div class="filter-row">
<div>
<label for="min_conn_select" class="filter-label">Min connection:</label>
<label for="min_conn_select" class="filter-label">Min connection ({{ connection_direction }}):</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">
{% for mins in valid_max_connections %}
<option value="{{ mins }}" {% if mins == max_connection %}selected{% endif %}>{{ mins }} min</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
{% if journey_type == 'return' %}
{% for section in sections %}
<div class="filter-row" style="margin-top:0.5rem">
<div class="filter-row" style="margin-top:0.5rem;gap:0.75rem">
<span class="filter-label" style="min-width:5.5rem">{{ 'Outbound' if section.direction == 'outbound' else 'Return' }}:</span>
{% if section.direction == 'inbound' %}
<div>
<label for="min_conn_in_select" class="filter-label">Min connection:</label>
<select id="min_conn_in_select" onchange="applyConnectionFilter()" class="select-inline">
{% for mins in valid_inbound_return_min_connections %}
<option value="{{ mins }}" {% if mins == inbound_min_connection %}selected{% endif %}>{{ mins }} min</option>
{% endfor %}
</select>
<span class="filter-label">ES:</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>
{% if section.direction == 'inbound' %}
<label for="min_conn_in_select" class="filter-label">Min connection:</label>
<select id="min_conn_in_select" onchange="applyConnectionFilter()" class="select-inline">
{% for mins in valid_inbound_return_min_connections %}
<option value="{{ mins }}" {% if mins == inbound_min_connection %}selected{% endif %}>{{ mins }} min</option>
{% endfor %}
</select>
{% else %}
<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>
{% endif %}
</div>
{% endif %}
<div>
<span class="filter-label">NR:</span>
<div class="btn-group">
@ -97,13 +106,6 @@
<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">
@ -126,7 +128,7 @@
<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 class="filter-label">ES:</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>
@ -136,26 +138,25 @@
</div>
{% endif %}
<script>
const RESULTS_BASE = '{{ results_base_url }}';
const DEFAULT_MIN_CONN = {{ default_min_connection }};
const DEFAULT_MAX_CONN = {{ default_max_connection }};
const DEFAULT_MIN_CONN_IN = {{ default_inbound_min_connection }};
let TRIP_FARES = {};
let ADVANCE_FARES = null;
let WALKON_CACHED_FARES = {};
let WALKON_API_URLS = {};
let ADVANCE_API_URLS = {};
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 }};
const SECTION_DIRECTIONS = {{ section_directions_json | safe }};
let advanceLoadingSections = {};
let walkonLoadingSections = {};
let selectedRowKeys = {};
var RESULTS_BASE = '{{ results_base_url }}';
var DEFAULT_MIN_CONN = {{ default_min_connection }};
var DEFAULT_MIN_CONN_IN = {{ default_inbound_min_connection }};
var TRIP_FARES = {};
var ADVANCE_FARES = null;
var WALKON_CACHED_FARES = {};
var WALKON_API_URLS = {};
var ADVANCE_API_URLS = {};
var ADVANCE_STREAM_URLS = {};
var TIMETABLE_REFRESH_URL = null;
var HAS_PROVISIONAL_TIMETABLE = false;
var eurostarRefreshPending = false;
var cachedAdvanceFares = null;
var currentNrClasses = {{ nr_classes_json | safe }};
var currentEsClasses = {{ es_classes_json | safe }};
var SECTION_DIRECTIONS = {{ section_directions_json | safe }};
var advanceLoadingSections = {};
var walkonLoadingSections = {};
var selectedRowKeys = {};
function updateAdvanceLoadingStatus() {
var loading = Object.keys(advanceLoadingSections).some(function(sectionId) {
@ -167,10 +168,8 @@
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);
var minInEl = document.getElementById('min_conn_in_select');
if (minInEl) {
var minIn = parseInt(minInEl.value);
@ -260,10 +259,19 @@
for (var rowKey in TRIP_FARES) {
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;
var price = prices[key];
if (price.es_standard) {
row.es_standard = price.es_standard;
row.es_standard_status = price.es_standard_status;
} else if (!row.es_standard && price.es_standard_status !== undefined) {
row.es_standard_status = price.es_standard_status;
}
if (price.es_plus) {
row.es_plus = price.es_plus;
row.es_plus_status = price.es_plus_status;
} else if (!row.es_plus && price.es_plus_status !== undefined) {
row.es_plus_status = price.es_plus_status;
}
}
}
}
@ -450,7 +458,7 @@
totalSpan.innerHTML = '';
} 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>';
} else if (eurostarRefreshPending) {
} else if (eurostarRefreshPending && !row.es_standard_status && !row.es_plus_status) {
totalSpan.innerHTML = '<span class="text-xs text-muted" title="Checking exact Eurostar data for this service">Checking Eurostar price</span>';
} else {
totalSpan.innerHTML = '<span class="text-xs text-muted" title="' + eurostarMissingTitle(row, esClass) + ' Check eurostar.com.">' + eurostarMissingText(row, esClass) + '</span>';

View file

@ -73,12 +73,12 @@ def test_search_redirects_to_results_with_selected_params() -> None:
client = _client()
resp = client.get(
"/search?destination=rotterdam&travel_date=2026-04-10&min_connection=60&max_connection=120&station_crs=BRI"
"/search?destination=rotterdam&travel_date=2026-04-10&min_connection=60&station_crs=BRI"
)
assert resp.status_code == 302
assert resp.headers["Location"].endswith(
"/results/BRI/rotterdam/out/2026-04-10?min_connection=60&max_connection=120"
"/results/BRI/rotterdam/out/2026-04-10?min_connection=60"
)
@ -106,6 +106,35 @@ def test_search_redirects_inbound_to_back_path() -> None:
assert resp.headers["Location"].endswith("/results/BRI/paris/back/2026-04-10")
def test_search_uses_back_connection_fields_for_inbound() -> None:
client = _client()
resp = client.get(
"/search?journey_type=inbound&destination=paris&travel_date=2026-04-10"
"&station_crs=BRI&min_connection=50"
"&min_connection_back=40"
)
assert resp.status_code == 302
assert resp.headers["Location"].endswith(
"/results/BRI/paris/back/2026-04-10?min_connection=40"
)
def test_search_redirects_return_with_return_leg_connection() -> None:
client = _client()
resp = client.get(
"/search?journey_type=return&destination=paris&travel_date=2026-04-10"
"&return_date=2026-04-17&station_crs=BRI&min_connection_in=40"
)
assert resp.status_code == 302
assert resp.headers["Location"].endswith(
"/results/BRI/paris/return/2026-04-10/2026-04-17?min_connection_in=40"
)
def test_nr_weekday_cache_key_includes_timetable_period() -> None:
key = app_module._nr_weekday_cache_key("to_paddington", "BRI", "2026-06-22")
@ -117,7 +146,7 @@ def test_results_shows_same_day_destination_switcher(monkeypatch: Any) -> None:
client = _client()
resp = client.get(
"/results/BRI/paris/out/2026-04-10?min_connection=60&max_connection=120"
"/results/BRI/paris/out/2026-04-10?min_connection=60"
)
html = resp.get_data(as_text=True)
@ -125,11 +154,11 @@ def test_results_shows_same_day_destination_switcher(monkeypatch: Any) -> None:
assert "Switch destination for Friday 10 April 2026" in html
assert '<span class="chip-current">Paris Gare du Nord</span>' in html
assert (
"/results/BRI/brussels/out/2026-04-10?min_connection=60&amp;max_connection=120"
"/results/BRI/brussels/out/2026-04-10?min_connection=60"
in html
)
assert (
"/results/BRI/rotterdam/out/2026-04-10?min_connection=60&amp;max_connection=120"
"/results/BRI/rotterdam/out/2026-04-10?min_connection=60"
in html
)
assert "ES 9014" in html
@ -155,6 +184,13 @@ def test_results_can_render_from_weekday_timetable_cache(monkeypatch: Any) -> No
"train_number": "ES 9014",
},
],
app_module._walkon_weekday_cache_key("to_paddington", "BRI", travel_date): {
"07:00": {
"ticket": "Anytime Day Single",
"price": 138.70,
"code": "SDS",
},
},
}
monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: cache.get(key))
monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
@ -182,7 +218,7 @@ def test_results_can_render_from_weekday_timetable_cache(monkeypatch: Any) -> No
client = _client()
resp = client.get(
"/results/BRI/paris/out/2026-06-22?min_connection=60&max_connection=120"
"/results/BRI/paris/out/2026-06-22?min_connection=60"
)
html = resp.get_data(as_text=True)
@ -190,6 +226,8 @@ def test_results_can_render_from_weekday_timetable_cache(monkeypatch: Any) -> No
assert "07:00 &rarr; 08:45" in html
assert "10:01 &rarr; 13:34" in html
assert "ES 9014" in html
assert "£138.70" in html
assert "Anytime Day Single" in html
assert "checking exact timetable" in html
assert "/api/results_refresh/BRI/paris/out/2026-06-22" in html
assert "refreshFullResults()" in html
@ -403,7 +441,7 @@ def test_results_title_and_social_meta_include_destination(monkeypatch: Any) ->
client = _client()
resp = client.get(
"/results/BRI/lille/out/2026-04-10?min_connection=60&max_connection=120"
"/results/BRI/lille/out/2026-04-10?min_connection=60"
)
html = resp.get_data(as_text=True)
@ -418,7 +456,7 @@ def test_results_title_and_social_meta_include_destination(monkeypatch: Any) ->
'to Lille Europe on Friday 10 April 2026 via Paddington, St Pancras, and Eurostar.">'
) in html
assert (
'<meta property="og:url" content="http://localhost/results/BRI/lille/out/2026-04-10?min_connection=60&amp;max_connection=120">'
'<meta property="og:url" content="http://localhost/results/BRI/lille/out/2026-04-10?min_connection=60">'
in html
)
@ -509,7 +547,7 @@ def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(
client = _client()
resp = client.get(
"/results/BRI/paris/out/2026-04-10?min_connection=60&max_connection=120"
"/results/BRI/paris/out/2026-04-10?min_connection=60"
)
html = resp.get_data(as_text=True)
@ -578,7 +616,7 @@ def test_results_shows_only_pre_first_reachable_unreachable_services(
client = _client()
resp = client.get(
"/results/BRI/paris/out/2026-04-10?min_connection=60&max_connection=120"
"/results/BRI/paris/out/2026-04-10?min_connection=60"
)
html = resp.get_data(as_text=True)
@ -594,7 +632,7 @@ def test_results_shows_eurostar_price_and_total(monkeypatch: Any) -> None:
client = _client()
resp = client.get(
"/results/BRI/paris/out/2026-04-10?min_connection=60&max_connection=120"
"/results/BRI/paris/out/2026-04-10?min_connection=60"
)
html = resp.get_data(as_text=True)
@ -651,7 +689,7 @@ def test_results_uses_unique_row_keys_for_same_eurostar(monkeypatch: Any) -> Non
client = _client()
resp = client.get(
"/results/BRI/paris/out/2026-04-10?min_connection=60&max_connection=120"
"/results/BRI/paris/out/2026-04-10?min_connection=60"
)
html = resp.get_data(as_text=True)
@ -696,7 +734,7 @@ def test_results_shows_unreachable_service_when_no_trips(monkeypatch: Any) -> No
client = _client()
resp = client.get(
"/results/BRI/paris/out/2026-04-10?min_connection=60&max_connection=120"
"/results/BRI/paris/out/2026-04-10?min_connection=60"
)
html = resp.get_data(as_text=True)
@ -714,7 +752,7 @@ def test_results_shows_eurostar_plus_price(monkeypatch: Any) -> None:
client = _client()
resp = client.get(
"/results/BRI/paris/out/2026-04-10?min_connection=60&max_connection=120"
"/results/BRI/paris/out/2026-04-10?min_connection=60"
)
html = resp.get_data(as_text=True)
@ -729,7 +767,7 @@ def test_results_selectors_present(monkeypatch: Any) -> None:
client = _client()
resp = client.get(
"/results/BRI/paris/out/2026-04-10?min_connection=60&max_connection=120"
"/results/BRI/paris/out/2026-04-10?min_connection=60"
)
html = resp.get_data(as_text=True)
@ -786,7 +824,7 @@ def test_results_preloads_cached_advance_fares(monkeypatch: Any) -> None:
client = _client()
resp = client.get(
"/results/BRI/paris/out/2026-04-10?min_connection=60&max_connection=120"
"/results/BRI/paris/out/2026-04-10?min_connection=60"
)
html = resp.get_data(as_text=True)

View file

@ -239,6 +239,72 @@ def _launch_browser(playwright: Any) -> Any:
pytest.skip(f"Chromium browser unavailable for Playwright: {exc}")
def test_back_search_uses_back_connection_defaults(local_server: str) -> None:
with sync_playwright() as p:
browser = _launch_browser(p)
page = browser.new_page()
page.goto(f"{local_server}/", wait_until="domcontentloaded")
page.locator("#journey-inbound").check(force=True)
assert page.locator("#outbound-connection-fields").is_hidden()
assert page.locator("#back-connection-fields").is_visible()
assert page.locator("#min_connection_back").input_value() == "30"
page.locator("#cal-next").click()
page.get_by_role("button", name="10 June 2026").click()
page.locator('button[type="submit"]').click()
page.wait_for_url("**/results/BRI/paris/back/2026-06-10", timeout=10000)
assert "min_connection=50" not in page.url
browser.close()
def test_progressive_results_do_not_redeclare_trip_fares(local_server: str) -> None:
with sync_playwright() as p:
browser = _launch_browser(p)
page = browser.new_page()
errors: list[str] = []
page.on("pageerror", lambda exc: errors.append(str(exc)))
page.on(
"console",
lambda msg: errors.append(msg.text) if msg.type == "error" else None,
)
page.goto(
f"{local_server}/results/BRI/brussels/return/2026-08-04/2026-08-11?progressive=1",
wait_until="domcontentloaded",
)
page.get_by_role(
"heading", name="Outbound: Bristol Temple Meads → Brussels Midi"
).wait_for(timeout=10000)
page.get_by_role(
"heading", name="Return: Brussels Midi → Bristol Temple Meads"
).wait_for(timeout=10000)
assert errors == []
assert "Could not load results" not in page.locator("body").inner_text()
browser.close()
def test_all_return_table_headers_are_sticky(local_server: str) -> None:
with sync_playwright() as p:
browser = _launch_browser(p)
page = browser.new_page()
page.goto(
f"{local_server}/results/BRI/brussels/return/2026-08-04/2026-08-11",
wait_until="domcontentloaded",
)
tables = page.locator(".results-table")
assert tables.count() == 2
for index in range(2):
positions = tables.nth(index).locator("thead th").evaluate_all(
"(headers) => headers.map((header) => getComputedStyle(header).position)"
)
assert positions == ["sticky", "sticky", "sticky", "sticky"]
browser.close()
def test_single_advance_standard_totals_after_click(single_server: str) -> None:
with sync_playwright() as p:
browser = _launch_browser(p)
@ -261,6 +327,81 @@ def test_single_advance_standard_totals_after_click(single_server: str) -> None:
browser.close()
def test_gwr_fare_update_keeps_existing_eurostar_price(single_server: str) -> None:
with sync_playwright() as p:
browser = _launch_browser(p)
page = browser.new_page()
page.goto(
f"{single_server}/results/BRI/paris/out/2026-07-20",
wait_until="domcontentloaded",
)
page.evaluate(
"""
() => {
eurostarRefreshPending = true;
mergeWalkonFares('main', {
'07:00': {
price: 138.70,
ticket: 'Anytime Day Single'
}
});
updateDisplay();
}
"""
)
page.wait_for_function(
"Array.from(document.querySelectorAll('.total-price'))"
".some(el => el.textContent.includes('£200.80'))",
timeout=10000,
)
assert "Checking Eurostar price" not in page.locator("body").inner_text()
browser.close()
def test_eurostar_fare_update_uses_existing_gwr_price(single_server: str) -> None:
with sync_playwright() as p:
browser = _launch_browser(p)
page = browser.new_page()
page.goto(
f"{single_server}/results/BRI/paris/out/2026-07-20",
wait_until="domcontentloaded",
)
page.evaluate(
"""
() => {
for (const key in TRIP_FARES) {
const row = TRIP_FARES[key];
row.walkon = {
price: 138.70,
ticket: 'Anytime Day Single'
};
row.es_standard = null;
row.es_standard_status = undefined;
}
eurostarRefreshPending = true;
updateDisplay();
mergeEurostarPrices({
'main:10:01': {
es_standard: {price: 59, seats: 42},
es_standard_status: null
}
});
updateDisplay();
}
"""
)
page.wait_for_function(
"Array.from(document.querySelectorAll('.total-price'))"
".some(el => el.textContent.includes('£200.80'))",
timeout=10000,
)
browser.close()
def test_single_next_date_advance_standard_labels_unreachable_rows(
monkeypatch: Any,
) -> None: