Remove obsolete connection template context
This commit is contained in:
parent
1c230ce0d6
commit
5272956050
9 changed files with 423 additions and 229 deletions
140
app.py
140
app.py
|
|
@ -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:
|
||||||
|
|
@ -417,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:
|
||||||
|
|
@ -461,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,
|
||||||
)
|
)
|
||||||
|
|
@ -512,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
|
||||||
|
|
@ -587,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,
|
||||||
|
|
@ -684,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"
|
||||||
)
|
)
|
||||||
|
|
@ -752,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(
|
||||||
|
|
@ -899,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
|
||||||
|
|
||||||
|
|
@ -908,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
|
||||||
|
|
@ -932,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,
|
||||||
}
|
}
|
||||||
|
|
@ -1005,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}
|
||||||
|
|
@ -1032,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,
|
||||||
|
|
@ -1055,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
|
||||||
|
|
@ -1096,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"
|
||||||
|
|
||||||
|
|
@ -1186,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]
|
||||||
|
|
@ -1195,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
|
||||||
|
|
@ -1211,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,
|
||||||
}
|
}
|
||||||
|
|
@ -1342,19 +1316,10 @@ def _results(
|
||||||
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,
|
||||||
|
|
@ -1375,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),
|
||||||
|
|
@ -1396,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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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%);
|
||||||
|
|
|
||||||
|
|
@ -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 → St Pancras)
|
Minimum outbound connection (Paddington → St 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 → St Pancras)
|
Minimum back connection (St Pancras → 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 Pancras → 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>
|
||||||
|
|
|
||||||
|
|
@ -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>';
|
||||||
|
|
|
||||||
|
|
@ -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 %} · {% 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 %} · {% 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 %} · {% 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 %} · {% 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>
|
||||||
|
|
|
||||||
|
|
@ -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>';
|
||||||
|
|
|
||||||
|
|
@ -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/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")
|
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:
|
def test_nr_weekday_cache_key_includes_timetable_period() -> None:
|
||||||
key = app_module._nr_weekday_cache_key("to_paddington", "BRI", "2026-06-22")
|
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()
|
client = _client()
|
||||||
|
|
||||||
resp = client.get(
|
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)
|
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 "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/out/2026-04-10?min_connection=60&max_connection=120"
|
"/results/BRI/brussels/out/2026-04-10?min_connection=60"
|
||||||
in html
|
in html
|
||||||
)
|
)
|
||||||
assert (
|
assert (
|
||||||
"/results/BRI/rotterdam/out/2026-04-10?min_connection=60&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
|
||||||
|
|
@ -155,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)
|
||||||
|
|
@ -182,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/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)
|
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 → 08:45" in html
|
assert "07:00 → 08:45" in html
|
||||||
assert "10:01 → 13:34" in html
|
assert "10:01 → 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/out/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
|
||||||
|
|
@ -403,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/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)
|
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.">'
|
'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/out/2026-04-10?min_connection=60&max_connection=120">'
|
'<meta property="og:url" content="http://localhost/results/BRI/lille/out/2026-04-10?min_connection=60">'
|
||||||
in html
|
in html
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -509,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/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)
|
html = resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
@ -578,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/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)
|
html = resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
@ -594,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/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)
|
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()
|
client = _client()
|
||||||
|
|
||||||
resp = client.get(
|
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)
|
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()
|
client = _client()
|
||||||
|
|
||||||
resp = client.get(
|
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)
|
html = resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
@ -714,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/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)
|
html = resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
@ -729,7 +767,7 @@ def test_results_selectors_present(monkeypatch: Any) -> None:
|
||||||
client = _client()
|
client = _client()
|
||||||
|
|
||||||
resp = client.get(
|
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)
|
html = resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
@ -786,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/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)
|
html = resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,72 @@ 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)
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue