Stream advance fares and selectable ticket classes
This commit is contained in:
parent
2f3f01171c
commit
a5023d0672
4 changed files with 441 additions and 204 deletions
113
app.py
113
app.py
|
|
@ -2,9 +2,10 @@
|
|||
Combine GWR Bristol→Paddington trains with Eurostar St Pancras→destination trains.
|
||||
"""
|
||||
|
||||
from flask import Flask, render_template, redirect, url_for, request, abort, jsonify
|
||||
from flask import Flask, render_template, redirect, url_for, request, abort, jsonify, Response, stream_with_context
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
|
||||
from cache import get_cached, set_cached
|
||||
|
|
@ -80,6 +81,10 @@ def index():
|
|||
|
||||
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_NR_CLASSES = {'walkon', 'advance_std', 'advance_1st'}
|
||||
VALID_ES_CLASSES = {'standard', 'plus'}
|
||||
DEFAULT_NR_CLASS = 'walkon'
|
||||
DEFAULT_ES_CLASS = 'standard'
|
||||
|
||||
|
||||
def _get_defaults():
|
||||
|
|
@ -111,6 +116,12 @@ def search():
|
|||
max_conn = _parse_connection(
|
||||
request.args.get("max_connection"), default_max, VALID_MAX_CONNECTIONS
|
||||
)
|
||||
nr_class = request.args.get("nr_class", DEFAULT_NR_CLASS)
|
||||
if nr_class not in VALID_NR_CLASSES:
|
||||
nr_class = DEFAULT_NR_CLASS
|
||||
es_class = request.args.get("es_class", DEFAULT_ES_CLASS)
|
||||
if es_class not in VALID_ES_CLASSES:
|
||||
es_class = DEFAULT_ES_CLASS
|
||||
if slug in DESTINATIONS and travel_date:
|
||||
return redirect(
|
||||
url_for(
|
||||
|
|
@ -120,6 +131,8 @@ def search():
|
|||
travel_date=travel_date,
|
||||
min_connection=None if min_conn == default_min else min_conn,
|
||||
max_connection=None if max_conn == default_max else max_conn,
|
||||
nr_class=None if nr_class == DEFAULT_NR_CLASS else nr_class,
|
||||
es_class=None if es_class == DEFAULT_ES_CLASS else es_class,
|
||||
)
|
||||
)
|
||||
return redirect(url_for("index"))
|
||||
|
|
@ -141,11 +154,21 @@ def results(station_crs, slug, travel_date):
|
|||
max_connection = _parse_connection(
|
||||
request.args.get("max_connection"), default_max, VALID_MAX_CONNECTIONS
|
||||
)
|
||||
nr_class = request.args.get("nr_class", DEFAULT_NR_CLASS)
|
||||
if nr_class not in VALID_NR_CLASSES:
|
||||
nr_class = DEFAULT_NR_CLASS
|
||||
es_class = request.args.get("es_class", DEFAULT_ES_CLASS)
|
||||
if es_class not in VALID_ES_CLASSES:
|
||||
es_class = DEFAULT_ES_CLASS
|
||||
|
||||
# Redirect to clean URL when both params are at their defaults
|
||||
if (
|
||||
"min_connection" in request.args or "max_connection" in request.args
|
||||
) and min_connection == default_min and max_connection == default_max:
|
||||
# Redirect to clean URL when all params are at their defaults
|
||||
_clean_url_params = ["min_connection", "max_connection", "nr_class", "es_class"]
|
||||
if any(k in request.args for k in _clean_url_params) and (
|
||||
min_connection == default_min
|
||||
and max_connection == default_max
|
||||
and nr_class == DEFAULT_NR_CLASS
|
||||
and es_class == DEFAULT_ES_CLASS
|
||||
):
|
||||
return redirect(
|
||||
url_for("results", station_crs=station_crs, slug=slug, travel_date=travel_date)
|
||||
)
|
||||
|
|
@ -287,6 +310,39 @@ def results(station_crs, slug, travel_date):
|
|||
|
||||
url_min = None if min_connection == default_min else min_connection
|
||||
url_max = None if max_connection == default_max else max_connection
|
||||
url_nr = None if nr_class == DEFAULT_NR_CLASS else nr_class
|
||||
url_es = None if es_class == DEFAULT_ES_CLASS else es_class
|
||||
|
||||
# Build per-row fare data for JS consumption
|
||||
trip_fares = {}
|
||||
for row in result_rows:
|
||||
stp = row.get("depart_st_pancras")
|
||||
if not stp:
|
||||
continue
|
||||
circle_svcs = row.get("circle_services") or []
|
||||
circle_fare = circle_svcs[0]["fare"] if circle_svcs else 0
|
||||
walkon = (
|
||||
{"price": row["ticket_price"], "ticket": row.get("ticket_name", "")}
|
||||
if row.get("ticket_price") is not None
|
||||
else None
|
||||
)
|
||||
es_std = (
|
||||
{"price": row["eurostar_price"], "seats": row.get("eurostar_seats")}
|
||||
if row.get("eurostar_price") is not None
|
||||
else None
|
||||
)
|
||||
es_plus = (
|
||||
{"price": row["eurostar_plus_price"], "seats": row.get("eurostar_plus_seats")}
|
||||
if row.get("eurostar_plus_price") is not None
|
||||
else None
|
||||
)
|
||||
trip_fares[stp] = {
|
||||
"depart_bristol": row.get("depart_bristol"),
|
||||
"walkon": walkon,
|
||||
"es_standard": es_std,
|
||||
"es_plus": es_plus,
|
||||
"circle_fare": circle_fare,
|
||||
}
|
||||
|
||||
return render_template(
|
||||
"results.html",
|
||||
|
|
@ -316,7 +372,14 @@ def results(station_crs, slug, travel_date):
|
|||
default_max_connection=default_max,
|
||||
url_min_connection=url_min,
|
||||
url_max_connection=url_max,
|
||||
cached_advance_fares=cached_advance_fares,
|
||||
nr_class=nr_class,
|
||||
es_class=es_class,
|
||||
url_nr_class=url_nr,
|
||||
url_es_class=url_es,
|
||||
trip_fares_json=json.dumps(trip_fares),
|
||||
advance_fares_json=json.dumps(cached_advance_fares),
|
||||
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_CONNECTIONS),
|
||||
valid_max_connections=sorted(VALID_MAX_CONNECTIONS),
|
||||
)
|
||||
|
|
@ -338,5 +401,43 @@ def api_advance_fares(station_crs, travel_date):
|
|||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/advance_fares_stream/<station_crs>/<travel_date>")
|
||||
def api_advance_fares_stream(station_crs, travel_date):
|
||||
if station_crs not in STATION_BY_CRS:
|
||||
abort(404)
|
||||
cache_key = f"gwr_advance_{station_crs}_{travel_date}"
|
||||
|
||||
def generate():
|
||||
cached = get_cached(cache_key, ttl=24 * 3600)
|
||||
if cached is not None:
|
||||
yield f"data: {json.dumps({'type': 'fares', 'fares': cached})}\n\n"
|
||||
yield f"data: {json.dumps({'type': 'done'})}\n\n"
|
||||
return
|
||||
|
||||
accumulated: dict = {}
|
||||
try:
|
||||
for page_fares in gwr_fares_scraper.fetch_advance_streaming(station_crs, travel_date):
|
||||
for dep_time, fare_data in page_fares.items():
|
||||
if dep_time not in accumulated:
|
||||
accumulated[dep_time] = {"advance_std": None, "advance_1st": None}
|
||||
if fare_data.get("advance_std"):
|
||||
accumulated[dep_time]["advance_std"] = fare_data["advance_std"]
|
||||
if fare_data.get("advance_1st"):
|
||||
accumulated[dep_time]["advance_1st"] = fare_data["advance_1st"]
|
||||
yield f"data: {json.dumps({'type': 'fares', 'fares': page_fares})}\n\n"
|
||||
except Exception as e:
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
|
||||
return
|
||||
|
||||
set_cached(cache_key, accumulated)
|
||||
yield f"data: {json.dumps({'type': 'done'})}\n\n"
|
||||
|
||||
return Response(
|
||||
stream_with_context(generate()),
|
||||
mimetype="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True, host="0.0.0.0")
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ def _run_pages(station_crs: str, travel_date: str, first_class: bool = False):
|
|||
body["standardclass"] = False
|
||||
resp = client.post(_API_URL, json=body)
|
||||
resp.raise_for_status()
|
||||
data = resp.json().get("data", {})
|
||||
data = resp.json().get("data") or {}
|
||||
conversation_token = data.get("conversationToken")
|
||||
for journey in data.get("outwardOpenPureReturnFare", []):
|
||||
dep_iso = journey.get("departureTime", "")
|
||||
|
|
@ -99,6 +99,39 @@ def _run_pages(station_crs: str, travel_date: str, first_class: bool = False):
|
|||
later = True
|
||||
|
||||
|
||||
def _run_pages_batched(station_crs: str, travel_date: str, first_class: bool = False):
|
||||
"""
|
||||
Like _run_pages but yields one list of (dep_time, fares_list) per API page call,
|
||||
allowing callers to stream results a page at a time.
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
with httpx.Client(headers=_headers(), timeout=30) as client:
|
||||
conversation_token = None
|
||||
later = False
|
||||
for _ in range(_MAX_PAGES):
|
||||
body = _request_body(station_crs, travel_date, conversation_token, later)
|
||||
if first_class:
|
||||
body["firstclass"] = True
|
||||
body["standardclass"] = False
|
||||
resp = client.post(_API_URL, json=body)
|
||||
resp.raise_for_status()
|
||||
data = resp.json().get("data") or {}
|
||||
conversation_token = data.get("conversationToken")
|
||||
batch = []
|
||||
for journey in data.get("outwardOpenPureReturnFare", []):
|
||||
dep_iso = journey.get("departureTime", "")
|
||||
dep_time = dep_iso[11:16]
|
||||
if not dep_time or dep_time in seen:
|
||||
continue
|
||||
seen.add(dep_time)
|
||||
batch.append((dep_time, journey.get("journeyFareDetails", [])))
|
||||
if batch:
|
||||
yield batch
|
||||
if not data.get("showLaterOutward", False):
|
||||
break
|
||||
later = True
|
||||
|
||||
|
||||
def fetch(station_crs: str, travel_date: str) -> dict[str, dict]:
|
||||
"""
|
||||
Fetch GWR walk-on single fares from station_crs to London Paddington on travel_date.
|
||||
|
|
@ -192,3 +225,69 @@ def fetch_advance(station_crs: str, travel_date: str) -> dict[str, dict]:
|
|||
}
|
||||
for t in all_times
|
||||
}
|
||||
|
||||
|
||||
def fetch_advance_streaming(station_crs: str, travel_date: str):
|
||||
"""
|
||||
Generator yielding partial advance fare dicts one GWR API page at a time.
|
||||
|
||||
Each yield is {dep_time: {'advance_std': dict|None, 'advance_1st': dict|None}}.
|
||||
Two passes are made (standard class then first class); each page of results is
|
||||
yielded immediately so callers can stream prices to clients as they arrive.
|
||||
"""
|
||||
# Pass 1: standard class advance fares
|
||||
for batch in _run_pages_batched(station_crs, travel_date, first_class=False):
|
||||
page: dict[str, dict] = {}
|
||||
for dep_time, fares in batch:
|
||||
cheapest = None
|
||||
for fare in fares:
|
||||
code = fare.get("ticketTypeCode")
|
||||
if code in _WALKON_CODES:
|
||||
continue
|
||||
if not fare.get("isStandardClass"):
|
||||
continue
|
||||
price_pence = fare.get("fare", 0)
|
||||
if cheapest is None or price_pence < cheapest["price_pence"]:
|
||||
cheapest = {
|
||||
"ticket": fare.get("ticketType", ""),
|
||||
"price": price_pence / 100,
|
||||
"price_pence": price_pence,
|
||||
"code": code,
|
||||
}
|
||||
if cheapest:
|
||||
page[dep_time] = {
|
||||
"advance_std": {
|
||||
"ticket": cheapest["ticket"],
|
||||
"price": cheapest["price"],
|
||||
"code": cheapest["code"],
|
||||
},
|
||||
"advance_1st": None,
|
||||
}
|
||||
if page:
|
||||
yield page
|
||||
|
||||
# Pass 2: first class advance fares
|
||||
for batch in _run_pages_batched(station_crs, travel_date, first_class=True):
|
||||
page = {}
|
||||
for dep_time, fares in batch:
|
||||
cheapest = None
|
||||
for fare in fares:
|
||||
price_pence = fare.get("fare", 0)
|
||||
if cheapest is None or price_pence < cheapest["price_pence"]:
|
||||
cheapest = {
|
||||
"ticket": fare.get("ticketType", ""),
|
||||
"price": price_pence / 100,
|
||||
"price_pence": price_pence,
|
||||
"code": fare.get("ticketTypeCode"),
|
||||
}
|
||||
if cheapest:
|
||||
page[dep_time] = {
|
||||
"advance_std": None,
|
||||
"advance_1st": {
|
||||
"ticket": cheapest["ticket"],
|
||||
"price": cheapest["price"],
|
||||
"code": cheapest["code"],
|
||||
},
|
||||
}
|
||||
if page:
|
||||
yield page
|
||||
|
|
|
|||
|
|
@ -281,6 +281,24 @@
|
|||
.empty-state p:first-child { font-size: 1.1rem; margin-bottom: 0.5rem; }
|
||||
.empty-state p:last-child { font-size: 0.9rem; }
|
||||
|
||||
/* Ticket class button group */
|
||||
.btn-group { display: inline-flex; border: 1px solid #cbd5e0; border-radius: 4px; overflow: hidden; vertical-align: middle; }
|
||||
.btn-group-option { padding: 0.28rem 0.65rem; font-size: 0.82rem; background: #fff; border: none; border-right: 1px solid #cbd5e0; cursor: pointer; color: #374151; white-space: nowrap; }
|
||||
.btn-group-option:last-child { border-right: none; }
|
||||
.btn-group-option.active { background: #00539f; color: #fff; font-weight: 600; }
|
||||
.btn-group-option:hover:not(.active) { background: #f0f4f8; }
|
||||
|
||||
/* Flash animation for total price */
|
||||
@keyframes price-flash { 0%,100% { background-color: transparent; } 40% { background-color: #fef08a; } }
|
||||
.price-flash { animation: price-flash 0.7s ease-out; border-radius: 3px; }
|
||||
|
||||
/* Loading state */
|
||||
#advance-loading { font-size: 0.82rem; color: #718096; margin-left: 0.5rem; }
|
||||
|
||||
/* Fare lines — show all, dim inactive */
|
||||
.fare-line { display: block; line-height: 1.6; transition: opacity 0.15s; }
|
||||
.fare-inactive { opacity: 0.4; }
|
||||
|
||||
/* Utilities */
|
||||
.text-muted { color: #718096; }
|
||||
.text-dimmed { color: #a0aec0; }
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@
|
|||
{{ departure_station_name }} → {{ destination }}
|
||||
</h2>
|
||||
<div class="date-nav">
|
||||
<a href="{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=prev_date, min_connection=url_min_connection, max_connection=url_max_connection) }}"
|
||||
<a href="{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=prev_date, min_connection=url_min_connection, max_connection=url_max_connection, nr_class=url_nr_class, es_class=url_es_class) }}"
|
||||
class="btn-nav">← Prev</a>
|
||||
<strong>{{ travel_date_display }}</strong>
|
||||
<a href="{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=next_date, min_connection=url_min_connection, max_connection=url_max_connection) }}"
|
||||
<a href="{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=next_date, min_connection=url_min_connection, max_connection=url_max_connection, nr_class=url_nr_class, es_class=url_es_class) }}"
|
||||
class="btn-nav">Next →</a>
|
||||
</div>
|
||||
<div class="switcher-section">
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
{% else %}
|
||||
<a
|
||||
class="chip-link"
|
||||
href="{{ url_for('results', station_crs=station_crs, slug=destination_slug, travel_date=travel_date, min_connection=url_min_connection, max_connection=url_max_connection) }}"
|
||||
href="{{ url_for('results', station_crs=station_crs, slug=destination_slug, travel_date=travel_date, min_connection=url_min_connection, max_connection=url_max_connection, nr_class=url_nr_class, es_class=url_es_class) }}"
|
||||
>{{ destination_name }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
|
@ -38,40 +38,225 @@
|
|||
</div>
|
||||
<div class="filter-row">
|
||||
<div>
|
||||
<label for="min_conn_select" class="filter-label">
|
||||
Min connection:
|
||||
</label>
|
||||
<select id="min_conn_select"
|
||||
onchange="applyConnectionFilter()"
|
||||
class="select-inline">
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<label for="max_conn_select" class="filter-label">
|
||||
Max connection:
|
||||
</label>
|
||||
<select id="max_conn_select"
|
||||
onchange="applyConnectionFilter()"
|
||||
class="select-inline">
|
||||
<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 class="filter-row" style="margin-top:0.5rem">
|
||||
<div>
|
||||
<span class="filter-label">NR ticket:</span>
|
||||
<div class="btn-group">
|
||||
<button class="btn-group-option {% if nr_class == 'walkon' %}active{% endif %}" onclick="setNrClass('walkon')">Walk-on Std</button>
|
||||
<button class="btn-group-option {% if nr_class == 'advance_std' %}active{% endif %}" onclick="setNrClass('advance_std')">Advance Std</button>
|
||||
<button class="btn-group-option {% if nr_class == 'advance_1st' %}active{% endif %}" onclick="setNrClass('advance_1st')">Advance 1st</button>
|
||||
</div>
|
||||
<span id="advance-loading" style="display:none">Loading…</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="filter-label">Eurostar:</span>
|
||||
<div class="btn-group">
|
||||
<button class="btn-group-option {% if es_class == 'standard' %}active{% endif %}" onclick="setEsClass('standard')">Standard</button>
|
||||
<button class="btn-group-option {% if es_class == 'plus' %}active{% endif %}" onclick="setEsClass('plus')">Standard Premier</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function applyConnectionFilter() {
|
||||
const RESULTS_BASE = '{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=travel_date) }}';
|
||||
const DEFAULT_MIN_CONN = {{ default_min_connection }};
|
||||
const DEFAULT_MAX_CONN = {{ default_max_connection }};
|
||||
const ADVANCE_FARES_STREAM_URL = '{{ advance_fares_stream_url }}';
|
||||
let TRIP_FARES = {{ trip_fares_json | safe }};
|
||||
let ADVANCE_FARES = {{ advance_fares_json | safe }};
|
||||
let currentNrClass = '{{ nr_class }}';
|
||||
let currentEsClass = '{{ es_class }}';
|
||||
let advanceLoading = false;
|
||||
|
||||
function buildUrl(nrCls, esCls) {
|
||||
var min = parseInt(document.getElementById('min_conn_select').value);
|
||||
var max = parseInt(document.getElementById('max_conn_select').value);
|
||||
var base = '{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=travel_date) }}';
|
||||
var params = [];
|
||||
if (min !== {{ default_min_connection }}) params.push('min_connection=' + min);
|
||||
if (max !== {{ default_max_connection }}) params.push('max_connection=' + max);
|
||||
window.location = params.length ? base + '?' + params.join('&') : base;
|
||||
if (min !== DEFAULT_MIN_CONN) params.push('min_connection=' + min);
|
||||
if (max !== DEFAULT_MAX_CONN) params.push('max_connection=' + max);
|
||||
if (nrCls !== 'walkon') params.push('nr_class=' + nrCls);
|
||||
if (esCls !== 'standard') params.push('es_class=' + esCls);
|
||||
return params.length ? RESULTS_BASE + '?' + params.join('&') : RESULTS_BASE;
|
||||
}
|
||||
|
||||
function applyConnectionFilter() {
|
||||
window.location = buildUrl(currentNrClass, currentEsClass);
|
||||
}
|
||||
|
||||
function setNrClass(cls) {
|
||||
currentNrClass = cls;
|
||||
document.querySelectorAll('.btn-group-option[onclick^="setNrClass"]').forEach(function(btn) {
|
||||
btn.classList.toggle('active', btn.getAttribute('onclick') === "setNrClass('" + cls + "')");
|
||||
});
|
||||
history.replaceState(null, '', buildUrl(cls, currentEsClass));
|
||||
if ((cls === 'advance_std' || cls === 'advance_1st') && ADVANCE_FARES === null) {
|
||||
loadAdvanceFares();
|
||||
} else {
|
||||
updateDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
function setEsClass(cls) {
|
||||
currentEsClass = cls;
|
||||
document.querySelectorAll('.btn-group-option[onclick^="setEsClass"]').forEach(function(btn) {
|
||||
btn.classList.toggle('active', btn.getAttribute('onclick') === "setEsClass('" + cls + "')");
|
||||
});
|
||||
history.replaceState(null, '', buildUrl(currentNrClass, cls));
|
||||
updateDisplay();
|
||||
}
|
||||
|
||||
function loadAdvanceFares() {
|
||||
advanceLoading = true;
|
||||
if (ADVANCE_FARES === null) ADVANCE_FARES = {};
|
||||
document.getElementById('advance-loading').style.display = 'inline';
|
||||
|
||||
var source = new EventSource(ADVANCE_FARES_STREAM_URL);
|
||||
|
||||
source.onmessage = function(event) {
|
||||
var msg = JSON.parse(event.data);
|
||||
if (msg.type === 'fares') {
|
||||
for (var time in msg.fares) {
|
||||
if (!ADVANCE_FARES[time]) ADVANCE_FARES[time] = {advance_std: null, advance_1st: null};
|
||||
if (msg.fares[time].advance_std) ADVANCE_FARES[time].advance_std = msg.fares[time].advance_std;
|
||||
if (msg.fares[time].advance_1st) ADVANCE_FARES[time].advance_1st = msg.fares[time].advance_1st;
|
||||
}
|
||||
updateDisplay();
|
||||
} else if (msg.type === 'done') {
|
||||
advanceLoading = false;
|
||||
document.getElementById('advance-loading').style.display = 'none';
|
||||
source.close();
|
||||
updateDisplay();
|
||||
} else if (msg.type === 'error') {
|
||||
advanceLoading = false;
|
||||
document.getElementById('advance-loading').textContent = 'Failed to load advance fares.';
|
||||
source.close();
|
||||
}
|
||||
};
|
||||
|
||||
source.onerror = function() {
|
||||
advanceLoading = false;
|
||||
document.getElementById('advance-loading').style.display = 'none';
|
||||
source.close();
|
||||
};
|
||||
}
|
||||
|
||||
function fmtPrice(p) {
|
||||
return '\u00a3' + p.toFixed(2);
|
||||
}
|
||||
|
||||
function fareHtml(fare) {
|
||||
return '<span class="text-sm font-bold">' + fmtPrice(fare.price) + '</span>'
|
||||
+ (fare.ticket ? ' <span class="text-xs text-muted">' + fare.ticket + '</span>' : '')
|
||||
+ (fare.seats != null ? ' <span class="text-xs text-muted">' + fare.seats + ' at this price</span>' : '');
|
||||
}
|
||||
|
||||
function updateDisplay() {
|
||||
// First pass: collect totals for min/max emoji badges
|
||||
var totals = {};
|
||||
document.querySelectorAll('tr[data-stp]').forEach(function(tr) {
|
||||
var stp = tr.getAttribute('data-stp');
|
||||
var row = TRIP_FARES[stp];
|
||||
if (!row) return;
|
||||
var advFares = ADVANCE_FARES && row.depart_bristol ? ADVANCE_FARES[row.depart_bristol] : null;
|
||||
var nrFare = currentNrClass === 'walkon' ? row.walkon
|
||||
: advFares ? (currentNrClass === 'advance_std' ? advFares.advance_std : advFares.advance_1st)
|
||||
: null;
|
||||
var esFare = currentEsClass === 'standard' ? row.es_standard : row.es_plus;
|
||||
if (nrFare && esFare) totals[stp] = nrFare.price + esFare.price + (row.circle_fare || 0);
|
||||
});
|
||||
var totalValues = Object.values(totals);
|
||||
var minTotal = totalValues.length > 1 ? Math.min.apply(null, totalValues) : null;
|
||||
var maxTotal = totalValues.length > 1 ? Math.max.apply(null, totalValues) : null;
|
||||
var flash = false;
|
||||
|
||||
document.querySelectorAll('tr[data-stp]').forEach(function(tr) {
|
||||
var stp = tr.getAttribute('data-stp');
|
||||
var row = TRIP_FARES[stp];
|
||||
if (!row) return;
|
||||
var advFares = ADVANCE_FARES && row.depart_bristol ? ADVANCE_FARES[row.depart_bristol] : null;
|
||||
|
||||
// NR fares — walk-on always shown; advance shown when loaded
|
||||
var walkonEl = tr.querySelector('.nr-walkon');
|
||||
var advStdEl = tr.querySelector('.nr-advance-std');
|
||||
var adv1stEl = tr.querySelector('.nr-advance-1st');
|
||||
|
||||
if (walkonEl) {
|
||||
walkonEl.innerHTML = row.walkon ? fareHtml(row.walkon) : '<span class="text-sm text-muted">\u2013</span>';
|
||||
walkonEl.classList.toggle('fare-inactive', currentNrClass !== 'walkon');
|
||||
}
|
||||
if (advStdEl) {
|
||||
var aStd = advFares && advFares.advance_std;
|
||||
advStdEl.innerHTML = aStd ? fareHtml(aStd) : (advanceLoading ? '<span class="text-sm text-muted">\u2026</span>' : '');
|
||||
advStdEl.classList.toggle('fare-inactive', currentNrClass !== 'advance_std');
|
||||
}
|
||||
if (adv1stEl) {
|
||||
var a1st = advFares && advFares.advance_1st;
|
||||
adv1stEl.innerHTML = a1st ? fareHtml(a1st) : (advanceLoading ? '<span class="text-sm text-muted">\u2026</span>' : '');
|
||||
adv1stEl.classList.toggle('fare-inactive', currentNrClass !== 'advance_1st');
|
||||
}
|
||||
|
||||
// ES fares — always show both
|
||||
var esStdEl = tr.querySelector('.es-standard');
|
||||
var esPlusEl = tr.querySelector('.es-plus');
|
||||
if (esStdEl) {
|
||||
esStdEl.innerHTML = row.es_standard ? fareHtml(row.es_standard) : '<span class="text-sm text-muted">\u2013</span>';
|
||||
esStdEl.classList.toggle('fare-inactive', currentEsClass !== 'standard');
|
||||
}
|
||||
if (esPlusEl) {
|
||||
esPlusEl.innerHTML = row.es_plus ? fareHtml(row.es_plus) : '<span class="text-sm text-muted">\u2013</span>';
|
||||
esPlusEl.classList.toggle('fare-inactive', currentEsClass !== 'plus');
|
||||
}
|
||||
|
||||
// Total
|
||||
var totalSpan = tr.querySelector('.total-price');
|
||||
if (totalSpan) {
|
||||
if (stp in totals) {
|
||||
var total = totals[stp];
|
||||
var html = '<span class="text-sm text-green" style="font-weight:700">' + fmtPrice(total);
|
||||
if (minTotal !== null && maxTotal !== null) {
|
||||
if (total <= minTotal + 10) html += ' <span title="Cheapest journey">\uD83E\uDE99</span>';
|
||||
else if (total >= maxTotal - 10) html += ' <span title="Most expensive journey">\uD83D\uDCB8</span>';
|
||||
}
|
||||
html += '</span>';
|
||||
totalSpan.innerHTML = html;
|
||||
flash = true;
|
||||
} else {
|
||||
totalSpan.innerHTML = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
if (flash) flashTotals();
|
||||
}
|
||||
|
||||
function flashTotals() {
|
||||
document.querySelectorAll('.total-price').forEach(function(el) {
|
||||
el.classList.remove('price-flash');
|
||||
void el.offsetWidth; // force reflow to restart animation
|
||||
el.classList.add('price-flash');
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
updateDisplay();
|
||||
if ((currentNrClass === 'advance_std' || currentNrClass === 'advance_1st') && ADVANCE_FARES === null) {
|
||||
loadAdvanceFares();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<p class="card-meta">
|
||||
{{ gwr_count }} GWR service{{ 's' if gwr_count != 1 }}
|
||||
|
|
@ -95,28 +280,6 @@
|
|||
|
||||
{% if trips or unreachable_morning_services %}
|
||||
<div class="card">
|
||||
{% if trips %}
|
||||
<div class="filter-row" style="margin-bottom:0.75rem;margin-top:0">
|
||||
<div>
|
||||
<label for="nr-type-select" class="filter-label">NR ticket:</label>
|
||||
<select id="nr-type-select" class="select-inline" onchange="updatePrices()">
|
||||
<option value="walkon">Walk-on</option>
|
||||
<option value="advanceStd" disabled>Std Advance</option>
|
||||
<option value="advanceFirst" disabled>1st Class Advance</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="es-type-select" class="filter-label">Eurostar:</label>
|
||||
<select id="es-type-select" class="select-inline" onchange="updatePrices()">
|
||||
<option value="esStd">Standard</option>
|
||||
<option value="esPlus">Plus</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="load-advance-btn" class="btn-secondary" onclick="loadAdvanceFares()">
|
||||
Load advance prices
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<table class="results-table">
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
@ -143,16 +306,7 @@
|
|||
{% else %}
|
||||
{% set row_class = '' %}
|
||||
{% endif %}
|
||||
<tr class="{{ row_class }}"
|
||||
{% if row.row_type == 'trip' %}
|
||||
data-depart="{{ row.depart_bristol }}"
|
||||
data-walkon="{{ row.ticket_price if row.ticket_price is not none else '' }}"
|
||||
data-advance-std=""
|
||||
data-advance-first=""
|
||||
data-es-std="{{ row.eurostar_price if row.eurostar_price is not none else '' }}"
|
||||
data-es-plus="{{ row.eurostar_plus_price if row.eurostar_plus_price is not none else '' }}"
|
||||
{% endif %}
|
||||
>
|
||||
<tr class="{{ row_class }}" data-stp="{{ row.depart_st_pancras }}">
|
||||
{% if row.row_type == 'trip' %}
|
||||
<td>
|
||||
<span class="font-bold nowrap">{{ row.depart_bristol }} → {{ row.arrive_paddington }}</span>
|
||||
|
|
@ -164,18 +318,9 @@
|
|||
{%- if row.arrive_platform %}Plat {{ row.arrive_platform }}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if row.ticket_price is not none %}
|
||||
<br><span class="text-sm font-bold">£{{ "%.2f"|format(row.ticket_price) }}</span>
|
||||
<span class="text-xs text-muted">{{ row.ticket_name }}</span>
|
||||
{% else %}
|
||||
<br><span class="text-sm text-muted">–</span>
|
||||
{% endif %}
|
||||
<span class="nr-advance-std-display" style="display:none">
|
||||
<br><span class="text-xs text-muted">Adv std: </span><span class="text-sm nr-advance-std-text"></span>
|
||||
</span>
|
||||
<span class="nr-advance-first-display" style="display:none">
|
||||
<br><span class="text-xs text-muted">Adv 1st: </span><span class="text-sm nr-advance-first-text"></span>
|
||||
</span>
|
||||
<span class="fare-line nr-walkon"></span>
|
||||
<span class="fare-line nr-advance-std"></span>
|
||||
<span class="fare-line nr-advance-1st"></span>
|
||||
</td>
|
||||
<td class="col-transfer" style="color:#4a5568">
|
||||
<span class="nowrap">{{ row.connection_duration }}{% if row.connection_minutes < 80 %} <span title="Tight connection">⚠️</span>{% endif %}</span>
|
||||
|
|
@ -197,16 +342,8 @@
|
|||
{%- if row.train_number %}{% for part in row.train_number.split(' + ') %}<span class="nowrap">{{ part }}</span>{% if not loop.last %} + {% endif %}{% endfor %}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if row.eurostar_price is not none %}
|
||||
<br><span class="text-sm font-bold">£{{ "%.2f"|format(row.eurostar_price) }}</span>
|
||||
<span class="text-xs text-muted">Std{% if row.eurostar_seats is not none %} · {{ row.eurostar_seats }}{% endif %}</span>
|
||||
{% else %}
|
||||
<br><span class="text-sm text-muted">–</span>
|
||||
{% endif %}
|
||||
{% if row.eurostar_plus_price is not none %}
|
||||
<br><span class="text-sm">£{{ "%.2f"|format(row.eurostar_plus_price) }}</span>
|
||||
<span class="text-xs text-muted">Plus{% if row.eurostar_plus_seats is not none %} · {{ row.eurostar_plus_seats }}{% endif %}</span>
|
||||
{% endif %}
|
||||
<span class="fare-line es-standard"></span>
|
||||
<span class="fare-line es-plus"></span>
|
||||
</td>
|
||||
<td class="font-bold nowrap">
|
||||
{% if row.total_minutes <= best_mins + 5 and trips | length > 1 %}
|
||||
|
|
@ -216,7 +353,7 @@
|
|||
{% else %}
|
||||
<span class="text-blue">{{ row.total_duration }}</span>
|
||||
{% endif %}
|
||||
<span class="total-price-span"></span>
|
||||
<br><span class="total-price"></span>
|
||||
</td>
|
||||
{% else %}
|
||||
<td>
|
||||
|
|
@ -232,16 +369,8 @@
|
|||
{%- if row.train_number %}{% for part in row.train_number.split(' + ') %}<span class="nowrap">{{ part }}</span>{% if not loop.last %} + {% endif %}{% endfor %}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if row.eurostar_price is not none %}
|
||||
<br><span class="text-sm text-dimmed">£{{ "%.2f"|format(row.eurostar_price) }}</span>
|
||||
<span class="text-xs text-dimmed">Std{% if row.eurostar_seats is not none %} · {{ row.eurostar_seats }}{% endif %}</span>
|
||||
{% else %}
|
||||
<br><span class="text-sm text-dimmed">–</span>
|
||||
{% endif %}
|
||||
{% if row.eurostar_plus_price is not none %}
|
||||
<br><span class="text-sm text-dimmed">£{{ "%.2f"|format(row.eurostar_plus_price) }}</span>
|
||||
<span class="text-xs text-dimmed">Plus{% if row.eurostar_plus_seats is not none %} · {{ row.eurostar_plus_seats }}{% endif %}</span>
|
||||
{% endif %}
|
||||
<span class="fare-line es-standard"></span>
|
||||
<span class="fare-line es-plus"></span>
|
||||
</td>
|
||||
<td class="text-dimmed">—</td>
|
||||
{% endif %}
|
||||
|
|
@ -253,9 +382,9 @@
|
|||
|
||||
<p class="footnote">
|
||||
Paddington → St Pancras connection: {{ min_connection }}–{{ max_connection }} min.
|
||||
GWR walk-on single prices from
|
||||
GWR walk-on and advance prices from
|
||||
<a href="https://www.gwr.com/" target="_blank" rel="noopener">gwr.com</a>.
|
||||
Eurostar Standard and Plus prices are for 1 adult in GBP; always check
|
||||
Eurostar Standard and Standard Premier prices are for 1 adult in GBP; always check
|
||||
<a href="{{ eurostar_url }}" target="_blank" rel="noopener">eurostar.com</a> to book.
|
||||
·
|
||||
<a href="{{ rtt_station_url }}" target="_blank" rel="noopener">{{ departure_station_name }} departures on RTT</a>
|
||||
|
|
@ -263,116 +392,6 @@
|
|||
<a href="{{ rtt_url }}" target="_blank" rel="noopener">Paddington arrivals on RTT</a>
|
||||
</p>
|
||||
|
||||
<script>
|
||||
var advanceFaresUrl = {{ url_for('api_advance_fares', station_crs=station_crs, travel_date=travel_date) | tojson }};
|
||||
var cachedAdvanceFares = {{ cached_advance_fares | tojson }};
|
||||
|
||||
function updatePrices() {
|
||||
var nrSel = document.getElementById('nr-type-select').value;
|
||||
var esSel = document.getElementById('es-type-select').value;
|
||||
var rows = document.querySelectorAll('tr[data-depart]');
|
||||
var validTotals = [];
|
||||
|
||||
rows.forEach(function(row) {
|
||||
var nrVal = row.dataset[nrSel];
|
||||
var esVal = row.dataset[esSel];
|
||||
var totalSpan = row.querySelector('.total-price-span');
|
||||
if (!totalSpan) return;
|
||||
|
||||
var nrPrice = nrVal !== '' && nrVal !== undefined ? parseFloat(nrVal) : null;
|
||||
var esPrice = esVal !== '' && esVal !== undefined ? parseFloat(esVal) : null;
|
||||
|
||||
if (nrPrice !== null && esPrice !== null) {
|
||||
var total = nrPrice + esPrice;
|
||||
totalSpan.dataset.total = total;
|
||||
validTotals.push(total);
|
||||
} else {
|
||||
delete totalSpan.dataset.total;
|
||||
totalSpan.innerHTML = '';
|
||||
}
|
||||
});
|
||||
|
||||
var minTotal = validTotals.length ? Math.min.apply(null, validTotals) : null;
|
||||
var maxTotal = validTotals.length ? Math.max.apply(null, validTotals) : null;
|
||||
|
||||
rows.forEach(function(row) {
|
||||
var totalSpan = row.querySelector('.total-price-span');
|
||||
if (!totalSpan || !('total' in totalSpan.dataset)) return;
|
||||
var total = parseFloat(totalSpan.dataset.total);
|
||||
var html = '<br><span class="text-sm text-green" style="font-weight:700">£' + total.toFixed(2);
|
||||
if (minTotal !== null && maxTotal !== null && validTotals.length > 1) {
|
||||
if (total <= minTotal + 10) html += ' <span title="Cheapest journey">\uD83E\uDE99</span>';
|
||||
else if (total >= maxTotal - 10) html += ' <span title="Most expensive journey">\uD83D\uDCB8</span>';
|
||||
}
|
||||
html += '</span>';
|
||||
totalSpan.innerHTML = html;
|
||||
});
|
||||
}
|
||||
|
||||
function loadAdvanceFares() {
|
||||
var btn = document.getElementById('load-advance-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Loading\u2026';
|
||||
|
||||
fetch(advanceFaresUrl)
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.error) {
|
||||
btn.textContent = 'Error';
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
applyAdvanceFares(data);
|
||||
updatePrices();
|
||||
})
|
||||
.catch(function() {
|
||||
btn.textContent = 'Error \u2014 try again';
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function applyAdvanceFares(data) {
|
||||
var hasStd = false, hasFirst = false;
|
||||
document.querySelectorAll('tr[data-depart]').forEach(function(row) {
|
||||
var dep = row.dataset.depart;
|
||||
var fares = data[dep];
|
||||
if (!fares) return;
|
||||
if (fares.advance_std) {
|
||||
row.dataset.advanceStd = fares.advance_std.price;
|
||||
var span = row.querySelector('.nr-advance-std-display');
|
||||
var text = row.querySelector('.nr-advance-std-text');
|
||||
if (span && text) {
|
||||
text.textContent = '\u00a3' + fares.advance_std.price.toFixed(2) + ' ' + fares.advance_std.ticket;
|
||||
span.style.display = '';
|
||||
}
|
||||
hasStd = true;
|
||||
}
|
||||
if (fares.advance_1st) {
|
||||
row.dataset.advanceFirst = fares.advance_1st.price;
|
||||
var span1 = row.querySelector('.nr-advance-first-display');
|
||||
var text1 = row.querySelector('.nr-advance-first-text');
|
||||
if (span1 && text1) {
|
||||
text1.textContent = '\u00a3' + fares.advance_1st.price.toFixed(2) + ' ' + fares.advance_1st.ticket;
|
||||
span1.style.display = '';
|
||||
}
|
||||
hasFirst = true;
|
||||
}
|
||||
});
|
||||
var nrSelect = document.getElementById('nr-type-select');
|
||||
if (nrSelect) {
|
||||
if (hasStd) nrSelect.querySelector('[value="advanceStd"]').disabled = false;
|
||||
if (hasFirst) nrSelect.querySelector('[value="advanceFirst"]').disabled = false;
|
||||
}
|
||||
var btn = document.getElementById('load-advance-btn');
|
||||
if (btn) btn.style.display = 'none';
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (cachedAdvanceFares) applyAdvanceFares(cachedAdvanceFares);
|
||||
updatePrices();
|
||||
});
|
||||
</script>
|
||||
|
||||
{% else %}
|
||||
<div class="card empty-state">
|
||||
<p>No valid journeys found.</p>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue