Compare commits

...

3 commits

11 changed files with 512 additions and 277 deletions

190
app.py
View file

@ -25,7 +25,6 @@ import scraper.eurostar as eurostar_scraper
import scraper.gwr_fares as gwr_fares_scraper import scraper.gwr_fares as gwr_fares_scraper
import scraper.realtime_trains as rtt_scraper import scraper.realtime_trains as rtt_scraper
from trip_planner import ( from trip_planner import (
INBOUND_MAX_CONNECTION_MINUTES,
INBOUND_MIN_CONNECTION_MINUTES, INBOUND_MIN_CONNECTION_MINUTES,
combine_inbound_trips, combine_inbound_trips,
combine_trips, combine_trips,
@ -35,6 +34,8 @@ from trip_planner import (
import cache import cache
import circle_line import circle_line
CONNECTION_WINDOW_MINUTES = 90
RTT_PADDINGTON_URL = ( RTT_PADDINGTON_URL = (
"https://www.realtimetrains.co.uk/search/detailed/" "https://www.realtimetrains.co.uk/search/detailed/"
"gb-nr:PAD/from/gb-nr:{crs}/{date}/0000-2359" "gb-nr:PAD/from/gb-nr:{crs}/{date}/0000-2359"
@ -88,47 +89,31 @@ DESTINATION_OPTIONS = [
{"slug": "cologne", "city": "Cologne", "destination": "Cologne Hbf"}, {"slug": "cologne", "city": "Cologne", "destination": "Cologne Hbf"},
] ]
DESTINATIONS = { DESTINATIONS = {option["slug"]: option["destination"] for option in DESTINATION_OPTIONS}
option["slug"]: option["destination"] for option in DESTINATION_OPTIONS
}
@app.route("/") @app.route("/")
def index() -> ResponseReturnValue: def index() -> ResponseReturnValue:
today = date.today().isoformat() today = date.today().isoformat()
default_min, default_max = _get_defaults() default_min = _get_default_min_connection()
return render_template( return render_template(
"index.html", "index.html",
destination_options=DESTINATION_OPTIONS, destination_options=DESTINATION_OPTIONS,
today=today, today=today,
stations=STATIONS, stations=STATIONS,
default_min_connection=default_min, default_min_connection=default_min,
default_max_connection=default_max,
valid_min_connections=sorted(VALID_MIN_CONNECTIONS), valid_min_connections=sorted(VALID_MIN_CONNECTIONS),
valid_max_connections=sorted(VALID_MAX_CONNECTIONS), default_inbound_min_connection=INBOUND_MIN_CONNECTION_MINUTES,
default_return_date=(date.today() + timedelta(days=7)).isoformat(), 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_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_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_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_JOURNEY_TYPES = {"outbound", "inbound", "return"}
VALID_NR_CLASSES = {"walkon", "advance_std", "advance_1st"} VALID_NR_CLASSES = {"walkon", "advance_std", "advance_1st"}
VALID_ES_CLASSES = {"standard", "plus"} VALID_ES_CLASSES = {"standard", "plus"}
@ -284,11 +269,12 @@ def _eurostar_price_status(price: Any, seats: Any) -> str | None:
return "price_not_returned" return "price_not_returned"
def _get_defaults() -> tuple[int, int]: def _get_default_min_connection() -> int:
return ( return app.config["DEFAULT_MIN_CONNECTION"]
app.config["DEFAULT_MIN_CONNECTION"],
app.config["DEFAULT_MAX_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: def _parse_connection(raw: str | None, default: int, valid_set: set[int]) -> int:
@ -389,9 +375,15 @@ def _results_url(
**params, **params,
) )
if journey_type == "inbound": if journey_type == "inbound":
params["journey_type"] = "inbound" return url_for(
"back_results",
station_crs=station_crs,
slug=slug,
travel_date=travel_date,
**params,
)
return url_for( return url_for(
"results", "out_results",
station_crs=station_crs, station_crs=station_crs,
slug=slug, slug=slug,
travel_date=travel_date, travel_date=travel_date,
@ -411,22 +403,21 @@ def search() -> ResponseReturnValue:
if station_crs not in STATION_BY_CRS: if station_crs not in STATION_BY_CRS:
station_crs = "BRI" station_crs = "BRI"
if journey_type == "inbound": if journey_type == "inbound":
default_min, default_max = ( default_min = INBOUND_MIN_CONNECTION_MINUTES
INBOUND_MIN_CONNECTION_MINUTES, valid_min = VALID_INBOUND_MIN_CONNECTIONS
INBOUND_MAX_CONNECTION_MINUTES,
)
valid_min, valid_max = (
VALID_INBOUND_MIN_CONNECTIONS,
VALID_INBOUND_MAX_CONNECTIONS,
)
else: else:
default_min, default_max = _get_defaults() default_min = _get_default_min_connection()
valid_min, valid_max = VALID_MIN_CONNECTIONS, VALID_MAX_CONNECTIONS valid_min = VALID_MIN_CONNECTIONS
min_conn = _parse_connection( min_connection_arg = (
request.args.get("min_connection"), default_min, valid_min request.args.get("min_connection_back")
if journey_type == "inbound"
else request.args.get("min_connection")
) )
max_conn = _parse_connection( min_conn = _parse_connection(min_connection_arg, default_min, valid_min)
request.args.get("max_connection"), default_max, valid_max 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) nr_class = request.args.get("nr_class", DEFAULT_NR_CLASS)
if nr_class not in VALID_NR_CLASSES: if nr_class not in VALID_NR_CLASSES:
@ -455,7 +446,12 @@ def search() -> ResponseReturnValue:
journey_type=journey_type, journey_type=journey_type,
return_date=return_date if journey_type == "return" else None, return_date=return_date if journey_type == "return" else None,
min_connection=None if min_conn == default_min else min_conn, 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, nr_class=None if nr_class == DEFAULT_NR_CLASS else nr_class,
es_class=None if es_class == DEFAULT_ES_CLASS else es_class, es_class=None if es_class == DEFAULT_ES_CLASS else es_class,
) )
@ -463,18 +459,17 @@ def search() -> ResponseReturnValue:
return redirect(url_for("index")) return redirect(url_for("index"))
@app.route("/results/<station_crs>/<slug>/<travel_date>") @app.route("/results/<station_crs>/<slug>/out/<travel_date>")
def results(station_crs: str, slug: str, travel_date: str) -> ResponseReturnValue: def out_results(station_crs: str, slug: str, travel_date: str) -> ResponseReturnValue:
return _results( return _results(station_crs, slug, travel_date, "outbound", None)
station_crs,
slug,
travel_date,
request.args.get("journey_type", "outbound"),
request.args.get("return_date"),
)
@app.route("/results/<station_crs>/<slug>/<travel_date>/return/<return_date>") @app.route("/results/<station_crs>/<slug>/back/<travel_date>")
def back_results(station_crs: str, slug: str, travel_date: str) -> ResponseReturnValue:
return _results(station_crs, slug, travel_date, "inbound", None)
@app.route("/results/<station_crs>/<slug>/return/<travel_date>/<return_date>")
def return_results( def return_results(
station_crs: str, slug: str, travel_date: str, return_date: str station_crs: str, slug: str, travel_date: str, return_date: str
) -> ResponseReturnValue: ) -> ResponseReturnValue:
@ -507,23 +502,15 @@ def _results(
return redirect(url_for("index")) return redirect(url_for("index"))
if journey_type == "inbound": if journey_type == "inbound":
default_min, default_max = ( default_min = INBOUND_MIN_CONNECTION_MINUTES
INBOUND_MIN_CONNECTION_MINUTES, valid_min = VALID_INBOUND_MIN_CONNECTIONS
INBOUND_MAX_CONNECTION_MINUTES,
)
valid_min, valid_max = (
VALID_INBOUND_MIN_CONNECTIONS,
VALID_INBOUND_MAX_CONNECTIONS,
)
else: else:
default_min, default_max = _get_defaults() default_min = _get_default_min_connection()
valid_min, valid_max = VALID_MIN_CONNECTIONS, VALID_MAX_CONNECTIONS valid_min = VALID_MIN_CONNECTIONS
min_connection = _parse_connection( min_connection = _parse_connection(
request.args.get("min_connection"), default_min, valid_min request.args.get("min_connection"), default_min, valid_min
) )
max_connection = _parse_connection( max_connection = _max_connection_for(min_connection)
request.args.get("max_connection"), default_max, valid_max
)
nr_class = request.args.get("nr_class", DEFAULT_NR_CLASS) nr_class = request.args.get("nr_class", DEFAULT_NR_CLASS)
if nr_class not in VALID_NR_CLASSES: if nr_class not in VALID_NR_CLASSES:
nr_class = DEFAULT_NR_CLASS nr_class = DEFAULT_NR_CLASS
@ -582,7 +569,6 @@ def _results(
departure_station_name=departure_station_name, departure_station_name=departure_station_name,
journey_type=journey_type, journey_type=journey_type,
travel_date_display=travel_date_display, travel_date_display=travel_date_display,
return_date=return_date,
return_date_display=return_date_display, return_date_display=return_date_display,
stream_url=_results_url( stream_url=_results_url(
station_crs=station_crs, station_crs=station_crs,
@ -679,7 +665,7 @@ def _results(
section_max_connection = max_connection section_max_connection = max_connection
if journey_type == "return" and direction == "inbound": if journey_type == "return" and direction == "inbound":
section_min_connection = inbound_min_connection section_min_connection = inbound_min_connection
section_max_connection = INBOUND_MAX_CONNECTION_MINUTES section_max_connection = _max_connection_for(inbound_min_connection)
rtt_direction = ( rtt_direction = (
"to_paddington" if direction == "outbound" else "from_paddington" "to_paddington" if direction == "outbound" else "from_paddington"
) )
@ -747,6 +733,8 @@ def _results(
cached_walkon = ( cached_walkon = (
exact_walkon if exact_walkon is not None else get_cached(walkon_weekday_key) 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": if direction == "outbound":
trips = combine_trips( trips = combine_trips(
@ -894,7 +882,6 @@ def _results(
rtt_station_url = RTT_STATION_URL.format(crs=station_crs, date=travel_date) 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_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_nr = None if nr_class == DEFAULT_NR_CLASS else nr_class
url_es = None if es_class == DEFAULT_ES_CLASS else es_class url_es = None if es_class == DEFAULT_ES_CLASS else es_class
@ -903,7 +890,6 @@ def _results(
"journey_type": journey_type, "journey_type": journey_type,
"return_date": return_date, "return_date": return_date,
"min_connection": url_min, "min_connection": url_min,
"max_connection": url_max,
"min_connection_in": ( "min_connection_in": (
None None
if inbound_min_connection == INBOUND_MIN_CONNECTION_MINUTES if inbound_min_connection == INBOUND_MIN_CONNECTION_MINUTES
@ -927,7 +913,6 @@ def _results(
"journey_type": journey_type, "journey_type": journey_type,
"return_date": return_date, "return_date": return_date,
"min_connection": url_min, "min_connection": url_min,
"max_connection": url_max,
"nr_class": url_nr, "nr_class": url_nr,
"es_class": url_es, "es_class": url_es,
} }
@ -1000,7 +985,7 @@ def _results(
"id": "inbound", "id": "inbound",
"direction": "inbound", "direction": "inbound",
"min_connection": inbound_min_connection, "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} shell_nr_classes = {"outbound": nr_class_out, "inbound": nr_class_in}
@ -1027,8 +1012,6 @@ def _results(
journey_type=journey_type, journey_type=journey_type,
destination=destination, destination=destination,
departure_station_name=departure_station_name, departure_station_name=departure_station_name,
travel_date=travel_date,
return_date=return_date,
travel_date_display=travel_date_display, travel_date_display=travel_date_display,
return_date_display=return_date_display, return_date_display=return_date_display,
slug=slug, slug=slug,
@ -1050,12 +1033,9 @@ def _results(
rtt_url=rtt_url, rtt_url=rtt_url,
rtt_station_url=rtt_station_url, rtt_station_url=rtt_station_url,
min_connection=min_connection, min_connection=min_connection,
max_connection=max_connection,
default_min_connection=default_min, default_min_connection=default_min,
default_max_connection=default_max,
default_inbound_min_connection=INBOUND_MIN_CONNECTION_MINUTES, default_inbound_min_connection=INBOUND_MIN_CONNECTION_MINUTES,
valid_min_connections=sorted(valid_min), valid_min_connections=sorted(valid_min),
valid_max_connections=sorted(valid_max),
inbound_min_connection=inbound_min_connection, inbound_min_connection=inbound_min_connection,
valid_inbound_return_min_connections=sorted( valid_inbound_return_min_connections=sorted(
VALID_INBOUND_RETURN_MIN_CONNECTIONS VALID_INBOUND_RETURN_MIN_CONNECTIONS
@ -1091,6 +1071,8 @@ def _results(
section=section, section=section,
destination=destination, destination=destination,
departure_station_name=departure_station_name, 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" 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"
@ -1108,7 +1090,7 @@ def _results(
station_crs=station_crs, station_crs=station_crs,
slug=slug, slug=slug,
travel_date=travel_date, travel_date=travel_date,
journey_type=journey_type if journey_type == "inbound" else None, path_journey_type="back" if journey_type == "inbound" else "out",
) )
summary_html = _build_summary_html( summary_html = _build_summary_html(
@ -1181,7 +1163,6 @@ def _results(
rtt_station_url = RTT_STATION_URL.format(crs=station_crs, date=travel_date) 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_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_nr = None if nr_class == DEFAULT_NR_CLASS else nr_class
url_es = None if es_class == DEFAULT_ES_CLASS else es_class url_es = None if es_class == DEFAULT_ES_CLASS else es_class
common_url_args: dict[str, Any] common_url_args: dict[str, Any]
@ -1190,7 +1171,6 @@ def _results(
"journey_type": journey_type, "journey_type": journey_type,
"return_date": return_date, "return_date": return_date,
"min_connection": url_min, "min_connection": url_min,
"max_connection": url_max,
"min_connection_in": ( "min_connection_in": (
None None
if inbound_min_connection == INBOUND_MIN_CONNECTION_MINUTES if inbound_min_connection == INBOUND_MIN_CONNECTION_MINUTES
@ -1206,7 +1186,6 @@ def _results(
"journey_type": journey_type, "journey_type": journey_type,
"return_date": return_date, "return_date": return_date,
"min_connection": url_min, "min_connection": url_min,
"max_connection": url_max,
"nr_class": url_nr, "nr_class": url_nr,
"es_class": url_es, "es_class": url_es,
} }
@ -1331,25 +1310,16 @@ def _results(
station_crs=station_crs, station_crs=station_crs,
slug=slug, slug=slug,
travel_date=travel_date, travel_date=travel_date,
journey_type=journey_type if journey_type == "inbound" else None, path_journey_type="back" if journey_type == "inbound" else "out",
) )
return render_template( return render_template(
"results.html", "results.html",
sections=sections, sections=sections,
trips=sections[0]["trips"] if sections else [],
result_rows=sections[0]["rows"] if sections else [],
unreachable_morning_services=[],
destinations=DESTINATIONS,
destination=destination, destination=destination,
travel_date=travel_date,
return_date=return_date,
journey_type=journey_type, journey_type=journey_type,
slug=slug, slug=slug,
station_crs=station_crs,
departure_station_name=departure_station_name, departure_station_name=departure_station_name,
prev_date=prev_date,
next_date=next_date,
prev_results_url=prev_results_url, prev_results_url=prev_results_url,
next_results_url=next_results_url, next_results_url=next_results_url,
prev_outbound_url=prev_outbound_url, prev_outbound_url=prev_outbound_url,
@ -1370,15 +1340,7 @@ def _results(
rtt_url=rtt_url, rtt_url=rtt_url,
rtt_station_url=rtt_station_url, rtt_station_url=rtt_station_url,
min_connection=min_connection, min_connection=min_connection,
max_connection=max_connection,
default_min_connection=default_min, 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, nr_classes=nr_classes,
es_classes=es_classes, es_classes=es_classes,
nr_classes_json=json.dumps(nr_classes), nr_classes_json=json.dumps(nr_classes),
@ -1391,14 +1353,7 @@ def _results(
advance_api_urls_json=json.dumps(advance_api_urls), advance_api_urls_json=json.dumps(advance_api_urls),
advance_stream_urls_json=json.dumps(advance_stream_urls), advance_stream_urls_json=json.dumps(advance_stream_urls),
timetable_refresh_url=timetable_refresh_url, 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_min_connections=sorted(valid_min),
valid_max_connections=sorted(valid_max),
inbound_min_connection=inbound_min_connection, inbound_min_connection=inbound_min_connection,
default_inbound_min_connection=INBOUND_MIN_CONNECTION_MINUTES, default_inbound_min_connection=INBOUND_MIN_CONNECTION_MINUTES,
valid_inbound_return_min_connections=sorted( valid_inbound_return_min_connections=sorted(
@ -1480,15 +1435,20 @@ def api_walkon_fares(
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@app.route("/api/results_refresh/<station_crs>/<slug>/<travel_date>") @app.route(
def api_results_refresh(station_crs: str, slug: str, travel_date: str) -> Response: "/api/results_refresh/<station_crs>/<slug>/<path_journey_type>/<travel_date>"
return _api_results_refresh( )
station_crs, slug, travel_date, request.args.get("return_date") def api_results_refresh(
) station_crs: str, slug: str, path_journey_type: str, travel_date: str
) -> Response:
if path_journey_type not in {"out", "back"}:
abort(404)
journey_type = "inbound" if path_journey_type == "back" else "outbound"
return _api_results_refresh(station_crs, slug, travel_date, None, journey_type)
@app.route( @app.route(
"/api/results_refresh/<station_crs>/<slug>/<travel_date>/return/<return_date>" "/api/results_refresh/<station_crs>/<slug>/return/<travel_date>/<return_date>"
) )
def api_return_results_refresh( def api_return_results_refresh(
station_crs: str, slug: str, travel_date: str, return_date: str station_crs: str, slug: str, travel_date: str, return_date: str

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 connection window (minutes) between Paddington arrival and St Pancras departure
DEFAULT_MIN_CONNECTION = 70 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); } tr.row-selectable:hover:not(.row-selected) { filter: brightness(0.97); }
/* Journey flow arrow between column headers */ /* 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 { .results-table thead th.flow-step::after {
content: ''; content: '';
position: absolute; right: 0.2rem; top: 50%; transform: translateY(-50%); position: absolute; right: 0.2rem; top: 50%; transform: translateY(-50%);

View file

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

View file

@ -57,38 +57,47 @@
{% endfor %} {% endfor %}
</div> </div>
</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 class="filter-row">
<div> <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"> <select id="min_conn_select" onchange="applyConnectionFilter()" class="select-inline">
{% for mins in valid_min_connections %} {% for mins in valid_min_connections %}
<option value="{{ mins }}" {% if mins == min_connection %}selected{% endif %}>{{ mins }} min</option> <option value="{{ mins }}" {% if mins == min_connection %}selected{% endif %}>{{ mins }} min</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </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> </div>
{% endif %}
{% if journey_type == 'return' %} {% if journey_type == 'return' %}
{% for section in sections %} {% 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> <span class="filter-label" style="min-width:5.5rem">{{ 'Outbound' if section.direction == 'outbound' else 'Return' }}:</span>
{% if section.direction == 'inbound' %}
<div> <div>
<label for="min_conn_in_select" class="filter-label">Min connection:</label> <span class="filter-label">ES:</span>
<select id="min_conn_in_select" onchange="applyConnectionFilter()" class="select-inline"> <div class="btn-group">
{% for mins in valid_inbound_return_min_connections %} <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>
<option value="{{ mins }}" {% if mins == inbound_min_connection %}selected{% endif %}>{{ mins }} min</option> <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>
{% endfor %} </div>
</select> </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> </div>
{% endif %}
<div> <div>
<span class="filter-label">NR:</span> <span class="filter-label">NR:</span>
<div class="btn-group"> <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> <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> </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> </div>
{% endfor %} {% endfor %}
<div style="font-size:0.82rem;color:#718096;margin-top:0.4rem"> <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> <span id="advance-loading" style="display:none"><span class="spinner spinner-inline" aria-hidden="true"></span>Loading advance fares</span>
</div> </div>
<div> <div>
<span class="filter-label">Eurostar:</span> <span class="filter-label">ES:</span>
<span id="es-type-select" style="display:none"></span> <span id="es-type-select" style="display:none"></span>
<div class="btn-group"> <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] == 'standard' %}active{% endif %}" data-section="{{ section.id }}" data-es-class="standard" onclick="setEsClass('standard', '{{ section.id }}')">Standard</button>
@ -136,26 +138,25 @@
</div> </div>
{% endif %} {% endif %}
<script> <script>
const RESULTS_BASE = '{{ results_base_url }}'; var RESULTS_BASE = '{{ results_base_url }}';
const DEFAULT_MIN_CONN = {{ default_min_connection }}; var DEFAULT_MIN_CONN = {{ default_min_connection }};
const DEFAULT_MAX_CONN = {{ default_max_connection }}; var DEFAULT_MIN_CONN_IN = {{ default_inbound_min_connection }};
const DEFAULT_MIN_CONN_IN = {{ default_inbound_min_connection }}; var TRIP_FARES = {{ trip_fares_json | safe }};
let TRIP_FARES = {{ trip_fares_json | safe }}; var ADVANCE_FARES = {{ advance_fares_json | safe }};
let ADVANCE_FARES = {{ advance_fares_json | safe }}; var WALKON_CACHED_FARES = {{ walkon_cached_fares_json | safe }};
const WALKON_CACHED_FARES = {{ walkon_cached_fares_json | safe }}; var WALKON_API_URLS = {{ walkon_api_urls_json | safe }};
let WALKON_API_URLS = {{ walkon_api_urls_json | safe }}; var ADVANCE_API_URLS = {{ advance_api_urls_json | safe }};
let ADVANCE_API_URLS = {{ advance_api_urls_json | safe }}; var ADVANCE_STREAM_URLS = {{ advance_stream_urls_json | safe }};
let ADVANCE_STREAM_URLS = {{ advance_stream_urls_json | safe }}; var TIMETABLE_REFRESH_URL = {{ timetable_refresh_url|tojson }};
const TIMETABLE_REFRESH_URL = {{ timetable_refresh_url|tojson }}; var HAS_PROVISIONAL_TIMETABLE = {{ 'true' if provisional_timetable else 'false' }};
const HAS_PROVISIONAL_TIMETABLE = {{ 'true' if provisional_timetable else 'false' }}; var eurostarRefreshPending = HAS_PROVISIONAL_TIMETABLE && !!TIMETABLE_REFRESH_URL && !!window.EventSource;
let eurostarRefreshPending = HAS_PROVISIONAL_TIMETABLE && !!TIMETABLE_REFRESH_URL && !!window.EventSource; var cachedAdvanceFares = ADVANCE_FARES;
let cachedAdvanceFares = ADVANCE_FARES; var currentNrClasses = {{ nr_classes_json | safe }};
let currentNrClasses = {{ nr_classes_json | safe }}; var currentEsClasses = {{ es_classes_json | safe }};
let currentEsClasses = {{ es_classes_json | safe }}; var SECTION_DIRECTIONS = {{ section_directions_json | safe }};
const SECTION_DIRECTIONS = {{ section_directions_json | safe }}; var advanceLoadingSections = {};
let advanceLoadingSections = {}; var walkonLoadingSections = {};
let walkonLoadingSections = {}; var selectedRowKeys = {};
let selectedRowKeys = {};
function updateAdvanceLoadingStatus() { function updateAdvanceLoadingStatus() {
var loading = Object.keys(advanceLoadingSections).some(function(sectionId) { var loading = Object.keys(advanceLoadingSections).some(function(sectionId) {
@ -167,10 +168,8 @@
function buildUrl() { function buildUrl() {
var min = parseInt(document.getElementById('min_conn_select').value); var min = parseInt(document.getElementById('min_conn_select').value);
var max = parseInt(document.getElementById('max_conn_select').value);
var params = []; var params = [];
if (min !== DEFAULT_MIN_CONN) params.push('min_connection=' + min); 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'); var minInEl = document.getElementById('min_conn_in_select');
if (minInEl) { if (minInEl) {
var minIn = parseInt(minInEl.value); var minIn = parseInt(minInEl.value);
@ -260,10 +259,19 @@
for (var rowKey in TRIP_FARES) { for (var rowKey in TRIP_FARES) {
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; var price = prices[key];
if (prices[key].es_standard_status !== undefined) row.es_standard_status = prices[key].es_standard_status; if (price.es_standard) {
if (prices[key].es_plus) row.es_plus = prices[key].es_plus; row.es_standard = price.es_standard;
if (prices[key].es_plus_status !== undefined) row.es_plus_status = prices[key].es_plus_status; 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 = ''; 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) { } 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>'; 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="' + eurostarMissingTitle(row, esClass) + ' Check eurostar.com.">' + eurostarMissingText(row, esClass) + '</span>'; 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 %} {% 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> <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 %} {% 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-std"></span>
<span class="fare-line nr-advance-1st"></span> <span class="fare-line nr-advance-1st"></span>
</td> </td>
@ -92,7 +92,7 @@
{% if row.headcode or row.arrive_platform %} {% 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> <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 %} {% 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-std"></span>
<span class="fare-line nr-advance-1st"></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> <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 %} {% else %}
<span class="text-blue">{{ row.total_duration }}</span> <span class="text-blue">{{ row.total_duration }}</span>
{% endif %} {% 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> </td>
{% else %} {% else %}
<td> <td>

View file

@ -57,38 +57,47 @@
{% endfor %} {% endfor %}
</div> </div>
</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 class="filter-row">
<div> <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"> <select id="min_conn_select" onchange="applyConnectionFilter()" class="select-inline">
{% for mins in valid_min_connections %} {% for mins in valid_min_connections %}
<option value="{{ mins }}" {% if mins == min_connection %}selected{% endif %}>{{ mins }} min</option> <option value="{{ mins }}" {% if mins == min_connection %}selected{% endif %}>{{ mins }} min</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </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> </div>
{% endif %}
{% if journey_type == 'return' %} {% if journey_type == 'return' %}
{% for section in sections %} {% 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> <span class="filter-label" style="min-width:5.5rem">{{ 'Outbound' if section.direction == 'outbound' else 'Return' }}:</span>
{% if section.direction == 'inbound' %}
<div> <div>
<label for="min_conn_in_select" class="filter-label">Min connection:</label> <span class="filter-label">ES:</span>
<select id="min_conn_in_select" onchange="applyConnectionFilter()" class="select-inline"> <div class="btn-group">
{% for mins in valid_inbound_return_min_connections %} <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>
<option value="{{ mins }}" {% if mins == inbound_min_connection %}selected{% endif %}>{{ mins }} min</option> <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>
{% endfor %} </div>
</select> </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> </div>
{% endif %}
<div> <div>
<span class="filter-label">NR:</span> <span class="filter-label">NR:</span>
<div class="btn-group"> <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> <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> </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> </div>
{% endfor %} {% endfor %}
<div style="font-size:0.82rem;color:#718096;margin-top:0.4rem"> <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> <span id="advance-loading" style="display:none"><span class="spinner spinner-inline" aria-hidden="true"></span>Loading advance fares</span>
</div> </div>
<div> <div>
<span class="filter-label">Eurostar:</span> <span class="filter-label">ES:</span>
<span id="es-type-select" style="display:none"></span> <span id="es-type-select" style="display:none"></span>
<div class="btn-group"> <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] == 'standard' %}active{% endif %}" data-section="{{ section.id }}" data-es-class="standard" onclick="setEsClass('standard', '{{ section.id }}')">Standard</button>
@ -136,26 +138,25 @@
</div> </div>
{% endif %} {% endif %}
<script> <script>
const RESULTS_BASE = '{{ results_base_url }}'; var RESULTS_BASE = '{{ results_base_url }}';
const DEFAULT_MIN_CONN = {{ default_min_connection }}; var DEFAULT_MIN_CONN = {{ default_min_connection }};
const DEFAULT_MAX_CONN = {{ default_max_connection }}; var DEFAULT_MIN_CONN_IN = {{ default_inbound_min_connection }};
const DEFAULT_MIN_CONN_IN = {{ default_inbound_min_connection }}; var TRIP_FARES = {};
let TRIP_FARES = {}; var ADVANCE_FARES = null;
let ADVANCE_FARES = null; var WALKON_CACHED_FARES = {};
let WALKON_CACHED_FARES = {}; var WALKON_API_URLS = {};
let WALKON_API_URLS = {}; var ADVANCE_API_URLS = {};
let ADVANCE_API_URLS = {}; var ADVANCE_STREAM_URLS = {};
let ADVANCE_STREAM_URLS = {}; var TIMETABLE_REFRESH_URL = null;
let TIMETABLE_REFRESH_URL = null; var HAS_PROVISIONAL_TIMETABLE = false;
let HAS_PROVISIONAL_TIMETABLE = false; var eurostarRefreshPending = false;
let eurostarRefreshPending = false; var cachedAdvanceFares = null;
let cachedAdvanceFares = null; var currentNrClasses = {{ nr_classes_json | safe }};
let currentNrClasses = {{ nr_classes_json | safe }}; var currentEsClasses = {{ es_classes_json | safe }};
let currentEsClasses = {{ es_classes_json | safe }}; var SECTION_DIRECTIONS = {{ section_directions_json | safe }};
const SECTION_DIRECTIONS = {{ section_directions_json | safe }}; var advanceLoadingSections = {};
let advanceLoadingSections = {}; var walkonLoadingSections = {};
let walkonLoadingSections = {}; var selectedRowKeys = {};
let selectedRowKeys = {};
function updateAdvanceLoadingStatus() { function updateAdvanceLoadingStatus() {
var loading = Object.keys(advanceLoadingSections).some(function(sectionId) { var loading = Object.keys(advanceLoadingSections).some(function(sectionId) {
@ -167,10 +168,8 @@
function buildUrl() { function buildUrl() {
var min = parseInt(document.getElementById('min_conn_select').value); var min = parseInt(document.getElementById('min_conn_select').value);
var max = parseInt(document.getElementById('max_conn_select').value);
var params = []; var params = [];
if (min !== DEFAULT_MIN_CONN) params.push('min_connection=' + min); 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'); var minInEl = document.getElementById('min_conn_in_select');
if (minInEl) { if (minInEl) {
var minIn = parseInt(minInEl.value); var minIn = parseInt(minInEl.value);
@ -260,10 +259,19 @@
for (var rowKey in TRIP_FARES) { for (var rowKey in TRIP_FARES) {
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; var price = prices[key];
if (prices[key].es_standard_status !== undefined) row.es_standard_status = prices[key].es_standard_status; if (price.es_standard) {
if (prices[key].es_plus) row.es_plus = prices[key].es_plus; row.es_standard = price.es_standard;
if (prices[key].es_plus_status !== undefined) row.es_plus_status = prices[key].es_plus_status; 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 = ''; 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) { } 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>'; 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="' + eurostarMissingTitle(row, esClass) + ' Check eurostar.com.">' + eurostarMissingText(row, esClass) + '</span>'; 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() client = _client()
resp = client.get( 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.status_code == 302
assert resp.headers["Location"].endswith( assert resp.headers["Location"].endswith(
"/results/BRI/rotterdam/2026-04-10?min_connection=60&max_connection=120" "/results/BRI/rotterdam/out/2026-04-10?min_connection=60"
) )
@ -91,7 +91,47 @@ def test_search_redirects_return_with_return_date() -> None:
assert resp.status_code == 302 assert resp.status_code == 302
assert resp.headers["Location"].endswith( assert resp.headers["Location"].endswith(
"/results/BRI/paris/2026-04-10/return/2026-04-17" "/results/BRI/paris/return/2026-04-10/2026-04-17"
)
def test_search_redirects_inbound_to_back_path() -> None:
client = _client()
resp = client.get(
"/search?journey_type=inbound&destination=paris&travel_date=2026-04-10&station_crs=BRI"
)
assert resp.status_code == 302
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"
) )
@ -106,7 +146,7 @@ def test_results_shows_same_day_destination_switcher(monkeypatch: Any) -> None:
client = _client() client = _client()
resp = client.get( resp = client.get(
"/results/BRI/paris/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) html = resp.get_data(as_text=True)
@ -114,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 "Switch destination for Friday 10 April 2026" in html
assert '<span class="chip-current">Paris Gare du Nord</span>' in html assert '<span class="chip-current">Paris Gare du Nord</span>' in html
assert ( assert (
"/results/BRI/brussels/2026-04-10?min_connection=60&amp;max_connection=120" "/results/BRI/brussels/out/2026-04-10?min_connection=60"
in html in html
) )
assert ( assert (
"/results/BRI/rotterdam/2026-04-10?min_connection=60&amp;max_connection=120" "/results/BRI/rotterdam/out/2026-04-10?min_connection=60"
in html in html
) )
assert "ES 9014" in html assert "ES 9014" in html
@ -144,6 +184,13 @@ def test_results_can_render_from_weekday_timetable_cache(monkeypatch: Any) -> No
"train_number": "ES 9014", "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, "get_cached", lambda key, ttl=None: cache.get(key))
monkeypatch.setattr(app_module, "set_cached", lambda key, data: None) monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
@ -171,7 +218,7 @@ def test_results_can_render_from_weekday_timetable_cache(monkeypatch: Any) -> No
client = _client() client = _client()
resp = client.get( resp = client.get(
"/results/BRI/paris/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) html = resp.get_data(as_text=True)
@ -179,8 +226,10 @@ def test_results_can_render_from_weekday_timetable_cache(monkeypatch: Any) -> No
assert "07:00 &rarr; 08:45" in html assert "07:00 &rarr; 08:45" in html
assert "10:01 &rarr; 13:34" in html assert "10:01 &rarr; 13:34" in html
assert "ES 9014" 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 "checking exact timetable" in html
assert "/api/results_refresh/BRI/paris/2026-06-22" in html assert "/api/results_refresh/BRI/paris/out/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 "Checking Eurostar price" in html
@ -268,7 +317,7 @@ def test_results_refresh_reloads_when_exact_timetable_differs(monkeypatch: Any)
) )
client = _client() client = _client()
resp = client.get("/api/results_refresh/BRI/paris/2026-06-22") resp = client.get("/api/results_refresh/BRI/paris/out/2026-06-22")
body = resp.get_data(as_text=True) body = resp.get_data(as_text=True)
assert resp.status_code == 200 assert resp.status_code == 200
@ -341,7 +390,7 @@ def test_results_refresh_streams_prices_when_timetable_matches(
) )
client = _client() client = _client()
resp = client.get("/api/results_refresh/BRI/paris/2026-06-22") resp = client.get("/api/results_refresh/BRI/paris/out/2026-06-22")
body = resp.get_data(as_text=True) body = resp.get_data(as_text=True)
assert resp.status_code == 200 assert resp.status_code == 200
@ -362,7 +411,7 @@ def test_results_progressive_shell_loads_without_scraping(monkeypatch: Any) -> N
monkeypatch.setattr(gwr_fares_scraper, "fetch", fail_fetch) monkeypatch.setattr(gwr_fares_scraper, "fetch", fail_fetch)
client = _client() client = _client()
resp = client.get("/results/BRI/paris/2026-04-10?progressive=1") resp = client.get("/results/BRI/paris/out/2026-04-10?progressive=1")
html = resp.get_data(as_text=True) html = resp.get_data(as_text=True)
assert resp.status_code == 200 assert resp.status_code == 200
@ -379,7 +428,7 @@ def test_return_progressive_shell_formats_return_date(monkeypatch: Any) -> None:
monkeypatch.setattr(gwr_fares_scraper, "fetch", fail_fetch) monkeypatch.setattr(gwr_fares_scraper, "fetch", fail_fetch)
client = _client() client = _client()
resp = client.get("/results/BRI/paris/2026-04-10/return/2026-04-17?progressive=1") resp = client.get("/results/BRI/paris/return/2026-04-10/2026-04-17?progressive=1")
html = resp.get_data(as_text=True) html = resp.get_data(as_text=True)
assert resp.status_code == 200 assert resp.status_code == 200
@ -392,7 +441,7 @@ def test_results_title_and_social_meta_include_destination(monkeypatch: Any) ->
client = _client() client = _client()
resp = client.get( resp = client.get(
"/results/BRI/lille/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) html = resp.get_data(as_text=True)
@ -407,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.">' 'to Lille Europe on Friday 10 April 2026 via Paddington, St Pancras, and Eurostar.">'
) in html ) in html
assert ( assert (
'<meta property="og:url" content="http://localhost/results/BRI/lille/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 in html
) )
@ -498,7 +547,7 @@ def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(
client = _client() client = _client()
resp = client.get( resp = client.get(
"/results/BRI/paris/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) html = resp.get_data(as_text=True)
@ -567,7 +616,7 @@ def test_results_shows_only_pre_first_reachable_unreachable_services(
client = _client() client = _client()
resp = client.get( resp = client.get(
"/results/BRI/paris/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) html = resp.get_data(as_text=True)
@ -583,7 +632,7 @@ def test_results_shows_eurostar_price_and_total(monkeypatch: Any) -> None:
client = _client() client = _client()
resp = client.get( resp = client.get(
"/results/BRI/paris/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) html = resp.get_data(as_text=True)
@ -640,7 +689,7 @@ def test_results_uses_unique_row_keys_for_same_eurostar(monkeypatch: Any) -> Non
client = _client() client = _client()
resp = client.get( resp = client.get(
"/results/BRI/paris/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) html = resp.get_data(as_text=True)
@ -685,7 +734,7 @@ def test_results_shows_unreachable_service_when_no_trips(monkeypatch: Any) -> No
client = _client() client = _client()
resp = client.get( resp = client.get(
"/results/BRI/paris/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) html = resp.get_data(as_text=True)
@ -703,7 +752,7 @@ def test_results_shows_eurostar_plus_price(monkeypatch: Any) -> None:
client = _client() client = _client()
resp = client.get( resp = client.get(
"/results/BRI/paris/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) html = resp.get_data(as_text=True)
@ -718,7 +767,7 @@ def test_results_selectors_present(monkeypatch: Any) -> None:
client = _client() client = _client()
resp = client.get( resp = client.get(
"/results/BRI/paris/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) html = resp.get_data(as_text=True)
@ -775,7 +824,7 @@ def test_results_preloads_cached_advance_fares(monkeypatch: Any) -> None:
client = _client() client = _client()
resp = client.get( resp = client.get(
"/results/BRI/paris/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) html = resp.get_data(as_text=True)
@ -827,7 +876,7 @@ def test_results_inbound_uses_reverse_legs(monkeypatch: Any) -> None:
) )
client = _client() client = _client()
resp = client.get("/results/BRI/paris/2026-04-10?journey_type=inbound") resp = client.get("/results/BRI/paris/back/2026-04-10")
html = resp.get_data(as_text=True) html = resp.get_data(as_text=True)
assert resp.status_code == 200 assert resp.status_code == 200
@ -917,7 +966,7 @@ def test_results_return_renders_outbound_and_inbound_tables(monkeypatch: Any) ->
) )
client = _client() client = _client()
resp = client.get("/results/BRI/paris/2026-04-10/return/2026-04-17") resp = client.get("/results/BRI/paris/return/2026-04-10/2026-04-17")
html = resp.get_data(as_text=True) html = resp.get_data(as_text=True)
assert resp.status_code == 200 assert resp.status_code == 200
@ -925,11 +974,11 @@ def test_results_return_renders_outbound_and_inbound_tables(monkeypatch: Any) ->
assert "Return: Paris Gare du Nord &rarr; Bristol Temple Meads" in html assert "Return: Paris Gare du Nord &rarr; Bristol Temple Meads" in html
assert "Friday 10 April 2026" in html assert "Friday 10 April 2026" in html
assert "Friday 17 April 2026" in html assert "Friday 17 April 2026" in html
assert "/results/BRI/paris/2026-04-09/return/2026-04-17" in html assert "/results/BRI/paris/return/2026-04-09/2026-04-17" in html
assert "/results/BRI/paris/2026-04-11/return/2026-04-17" in html assert "/results/BRI/paris/return/2026-04-11/2026-04-17" in html
assert "/results/BRI/paris/2026-04-10/return/2026-04-16" in html assert "/results/BRI/paris/return/2026-04-10/2026-04-16" in html
assert "/results/BRI/paris/2026-04-10/return/2026-04-18" in html assert "/results/BRI/paris/return/2026-04-10/2026-04-18" in html
assert "/results/BRI/paris/2026-04-10/return/2026-04-17" in html assert "/results/BRI/paris/return/2026-04-10/2026-04-17" in html
assert "journey_type=return" not in html assert "journey_type=return" not in html
assert "return_date=2026-04-17" not in html assert "return_date=2026-04-17" not in html
assert "Circle 09:10 &rarr; KX 09:25" in html assert "Circle 09:10 &rarr; KX 09:25" in html

View file

@ -239,12 +239,78 @@ def _launch_browser(playwright: Any) -> Any:
pytest.skip(f"Chromium browser unavailable for Playwright: {exc}") 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: def test_single_advance_standard_totals_after_click(single_server: str) -> None:
with sync_playwright() as p: with sync_playwright() as p:
browser = _launch_browser(p) browser = _launch_browser(p)
page = browser.new_page() page = browser.new_page()
page.goto( page.goto(
f"{single_server}/results/BRI/paris/2026-07-20", f"{single_server}/results/BRI/paris/out/2026-07-20",
wait_until="domcontentloaded", wait_until="domcontentloaded",
) )
@ -261,6 +327,81 @@ def test_single_advance_standard_totals_after_click(single_server: str) -> None:
browser.close() 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( def test_single_next_date_advance_standard_labels_unreachable_rows(
monkeypatch: Any, monkeypatch: Any,
) -> None: ) -> None:
@ -344,7 +485,7 @@ def test_single_next_date_advance_standard_labels_unreachable_rows(
page = browser.new_page() page = browser.new_page()
page.goto( page.goto(
f"http://127.0.0.1:{server.server_port}" f"http://127.0.0.1:{server.server_port}"
"/results/BRI/brussels/2026-06-16", "/results/BRI/brussels/out/2026-06-16",
wait_until="domcontentloaded", wait_until="domcontentloaded",
) )
page.get_by_role("link", name="Next →").click() page.get_by_role("link", name="Next →").click()
@ -372,7 +513,7 @@ def test_single_advance_standard_premier_totals_on_initial_url(
browser = _launch_browser(p) browser = _launch_browser(p)
page = browser.new_page() page = browser.new_page()
page.goto( page.goto(
f"{single_server}/results/BRI/paris/2026-07-20" f"{single_server}/results/BRI/paris/out/2026-07-20"
"?nr_class=advance_std&es_class=plus", "?nr_class=advance_std&es_class=plus",
wait_until="domcontentloaded", wait_until="domcontentloaded",
) )
@ -452,7 +593,7 @@ def test_single_advance_first_falls_back_to_walkon_when_unavailable(
page = browser.new_page() page = browser.new_page()
page.goto( page.goto(
f"http://127.0.0.1:{server.server_port}" f"http://127.0.0.1:{server.server_port}"
"/results/BRI/paris/2026-07-20?nr_class=advance_1st&es_class=standard", "/results/BRI/paris/out/2026-07-20?nr_class=advance_1st&es_class=standard",
wait_until="domcontentloaded", wait_until="domcontentloaded",
) )
@ -496,7 +637,7 @@ def test_return_advance_first_standard_premier_totals(local_server: str) -> None
timeout=10000, timeout=10000,
) )
assert "/results/BRI/paris/2026-07-20/return/2026-07-27" in page.url assert "/results/BRI/paris/return/2026-07-20/2026-07-27" in page.url
assert "journey_type=return" not in page.url assert "journey_type=return" not in page.url
assert "return_date=2026-07-27" not in page.url assert "return_date=2026-07-27" not in page.url
assert "nr_class=advance_1st" in page.url assert "nr_class=advance_1st" in page.url
@ -528,7 +669,7 @@ def test_return_calendar_selects_outbound_before_return(local_server: str) -> No
assert "Return: Wed 17 Jun" in page.locator("#cal-hint").inner_text() assert "Return: Wed 17 Jun" in page.locator("#cal-hint").inner_text()
page.locator('button[type="submit"]').click() page.locator('button[type="submit"]').click()
page.wait_for_url("**/results/BRI/paris/2026-06-10/return/2026-06-17", timeout=10000) page.wait_for_url("**/results/BRI/paris/return/2026-06-10/2026-06-17", timeout=10000)
browser.close() browser.close()
@ -539,7 +680,7 @@ def test_return_advance_first_standard_premier_totals_on_initial_url(
browser = _launch_browser(p) browser = _launch_browser(p)
page = browser.new_page() page = browser.new_page()
page.goto( page.goto(
f"{local_server}/results/BRI/paris/2026-07-20/return/2026-07-27" f"{local_server}/results/BRI/paris/return/2026-07-20/2026-07-27"
"?nr_class=advance_1st&es_class=plus", "?nr_class=advance_1st&es_class=plus",
wait_until="domcontentloaded", wait_until="domcontentloaded",
) )

View file

@ -128,11 +128,11 @@ def test_max_connection_enforced() -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Only earliest valid Eurostar per GWR departure # All valid Eurostars per National Rail departure
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_only_earliest_eurostar_per_gwr() -> None: def test_all_valid_eurostars_per_gwr() -> None:
es1 = { es1 = {
"depart_st_pancras": "10:01", "depart_st_pancras": "10:01",
"arrive_destination": "13:34", "arrive_destination": "13:34",
@ -143,9 +143,8 @@ def test_only_earliest_eurostar_per_gwr() -> None:
"arrive_destination": "14:34", "arrive_destination": "14:34",
"destination": "Paris Gare du Nord", "destination": "Paris Gare du Nord",
} }
trips = combine_trips([GWR_FAST], [es1, es2], DATE) trips = combine_trips([GWR_FAST], [es1, es2], DATE, max_connection_minutes=140)
assert len(trips) == 1 assert [t["depart_st_pancras"] for t in trips] == ["10:01", "11:01"]
assert trips[0]["depart_st_pancras"] == "10:01"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -295,3 +294,27 @@ def test_combine_inbound_trips_pairs_eurostar_to_paddington_departure() -> None:
assert trips[0]["arrive_uk_station"] == "18:55" assert trips[0]["arrive_uk_station"] == "18:55"
assert trips[0]["ticket_price"] == 63.60 assert trips[0]["ticket_price"] == 63.60
assert trips[0]["check_in_by"] == "14:42" assert trips[0]["check_in_by"] == "14:42"
def test_combine_inbound_trips_keeps_all_valid_paddington_departures() -> None:
eurostar = [
{
"depart_destination": "15:12",
"arrive_st_pancras": "16:30",
"destination": "Paris Gare du Nord",
}
]
gwr = [
{"depart_paddington": "17:15", "arrive_destination": "18:55"},
{"depart_paddington": "17:45", "arrive_destination": "19:25"},
]
trips = combine_inbound_trips(
eurostar,
gwr,
DATE,
min_connection_minutes=30,
max_connection_minutes=120,
)
assert [t["depart_paddington"] for t in trips] == ["17:15", "17:45"]

View file

@ -179,7 +179,6 @@ def combine_trips(
trips = [] trips = []
for gwr in gwr_trains: for gwr in gwr_trains:
# Find only the earliest viable Eurostar for this GWR departure
for es in eurostar_trains: for es in eurostar_trains:
connection = _is_viable_connection( connection = _is_viable_connection(
gwr, gwr,
@ -223,7 +222,6 @@ def combine_trips(
"ticket_code": fare["code"] if fare else None, "ticket_code": fare["code"] if fare else None,
} }
) )
break # Only the earliest valid Eurostar per GWR departure
trips.sort(key=lambda t: (t["depart_bristol"], t["depart_st_pancras"])) trips.sort(key=lambda t: (t["depart_bristol"], t["depart_st_pancras"]))
return trips return trips
@ -289,7 +287,6 @@ def combine_inbound_trips(
"ticket_code": fare["code"] if fare else None, "ticket_code": fare["code"] if fare else None,
} }
) )
break
trips.sort(key=lambda t: (t["depart_destination"], t["depart_paddington"])) trips.sort(key=lambda t: (t["depart_destination"], t["depart_paddington"]))
return trips return trips