Improve progressive results loading
This commit is contained in:
parent
9691632f65
commit
378d2484d0
6 changed files with 369 additions and 60 deletions
128
app.py
128
app.py
|
|
@ -113,6 +113,35 @@ def _parse_connection(raw, default, valid_set):
|
|||
return val if val in valid_set else default
|
||||
|
||||
|
||||
def _results_url(
|
||||
station_crs,
|
||||
slug,
|
||||
travel_date,
|
||||
journey_type="outbound",
|
||||
return_date=None,
|
||||
**params,
|
||||
):
|
||||
params = {k: v for k, v in params.items() if v is not None}
|
||||
if journey_type == "return":
|
||||
return url_for(
|
||||
"return_results",
|
||||
station_crs=station_crs,
|
||||
slug=slug,
|
||||
travel_date=travel_date,
|
||||
return_date=return_date,
|
||||
**params,
|
||||
)
|
||||
if journey_type == "inbound":
|
||||
params["journey_type"] = "inbound"
|
||||
return url_for(
|
||||
"results",
|
||||
station_crs=station_crs,
|
||||
slug=slug,
|
||||
travel_date=travel_date,
|
||||
**params,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/search")
|
||||
def search():
|
||||
slug = request.args.get("destination", "")
|
||||
|
|
@ -150,12 +179,11 @@ def search():
|
|||
return_date = ""
|
||||
if slug in DESTINATIONS and travel_date and (journey_type != "return" or return_date):
|
||||
return redirect(
|
||||
url_for(
|
||||
"results",
|
||||
_results_url(
|
||||
station_crs=station_crs,
|
||||
slug=slug,
|
||||
travel_date=travel_date,
|
||||
journey_type=None if journey_type == "outbound" else journey_type,
|
||||
journey_type=journey_type,
|
||||
return_date=return_date if journey_type == "return" else None,
|
||||
min_connection=None if min_conn == default_min else min_conn,
|
||||
max_connection=None if max_conn == default_max else max_conn,
|
||||
|
|
@ -168,6 +196,21 @@ def search():
|
|||
|
||||
@app.route("/results/<station_crs>/<slug>/<travel_date>")
|
||||
def results(station_crs, slug, travel_date):
|
||||
return _results(
|
||||
station_crs,
|
||||
slug,
|
||||
travel_date,
|
||||
request.args.get("journey_type", "outbound"),
|
||||
request.args.get("return_date"),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/results/<station_crs>/<slug>/<travel_date>/return/<return_date>")
|
||||
def return_results(station_crs, slug, travel_date, return_date):
|
||||
return _results(station_crs, slug, travel_date, "return", return_date)
|
||||
|
||||
|
||||
def _results(station_crs, slug, travel_date, journey_type, return_date):
|
||||
departure_station_name = STATION_BY_CRS.get(station_crs)
|
||||
if departure_station_name is None:
|
||||
abort(404)
|
||||
|
|
@ -175,10 +218,8 @@ def results(station_crs, slug, travel_date):
|
|||
if not destination or not travel_date:
|
||||
return redirect(url_for("index"))
|
||||
|
||||
journey_type = request.args.get("journey_type", "outbound")
|
||||
if journey_type not in VALID_JOURNEY_TYPES:
|
||||
journey_type = "outbound"
|
||||
return_date = request.args.get("return_date")
|
||||
if journey_type == "return":
|
||||
try:
|
||||
if not return_date or date.fromisoformat(return_date) < date.fromisoformat(travel_date):
|
||||
|
|
@ -205,6 +246,38 @@ def results(station_crs, slug, travel_date):
|
|||
if es_class not in VALID_ES_CLASSES:
|
||||
es_class = DEFAULT_ES_CLASS
|
||||
|
||||
if (
|
||||
request.args.get("render") != "full"
|
||||
and not (
|
||||
app.config.get("TESTING")
|
||||
and request.args.get("progressive") != "1"
|
||||
)
|
||||
):
|
||||
dt = date.fromisoformat(travel_date)
|
||||
travel_date_display = dt.strftime("%A %-d %B %Y")
|
||||
full_args = dict(request.args)
|
||||
full_args.pop("progressive", None)
|
||||
full_args.pop("journey_type", None)
|
||||
full_args.pop("return_date", None)
|
||||
full_args["render"] = "full"
|
||||
return render_template(
|
||||
"results_loading.html",
|
||||
destination=destination,
|
||||
departure_station_name=departure_station_name,
|
||||
journey_type=journey_type,
|
||||
travel_date_display=travel_date_display,
|
||||
return_date=return_date,
|
||||
full_results_url=_results_url(
|
||||
station_crs=station_crs,
|
||||
slug=slug,
|
||||
travel_date=travel_date,
|
||||
journey_type=journey_type,
|
||||
return_date=return_date,
|
||||
**full_args,
|
||||
),
|
||||
index_url=url_for("index"),
|
||||
)
|
||||
|
||||
user_agent = request.headers.get("User-Agent", rtt_scraper.DEFAULT_UA)
|
||||
error_messages = []
|
||||
from_cache_parts = []
|
||||
|
|
@ -413,6 +486,46 @@ def results(station_crs, slug, travel_date):
|
|||
url_max = None if max_connection == default_max else max_connection
|
||||
url_nr = None if nr_class == DEFAULT_NR_CLASS else nr_class
|
||||
url_es = None if es_class == DEFAULT_ES_CLASS else es_class
|
||||
common_url_args = {
|
||||
"journey_type": journey_type,
|
||||
"return_date": return_date,
|
||||
"min_connection": url_min,
|
||||
"max_connection": url_max,
|
||||
"nr_class": url_nr,
|
||||
"es_class": url_es,
|
||||
}
|
||||
prev_results_url = _results_url(
|
||||
station_crs,
|
||||
slug,
|
||||
prev_date,
|
||||
**common_url_args,
|
||||
)
|
||||
next_results_url = _results_url(
|
||||
station_crs,
|
||||
slug,
|
||||
next_date,
|
||||
**common_url_args,
|
||||
)
|
||||
destination_links = [
|
||||
(
|
||||
destination_slug,
|
||||
destination_name,
|
||||
_results_url(
|
||||
station_crs,
|
||||
destination_slug,
|
||||
travel_date,
|
||||
**common_url_args,
|
||||
),
|
||||
)
|
||||
for destination_slug, destination_name in DESTINATIONS.items()
|
||||
]
|
||||
results_base_url = _results_url(
|
||||
station_crs,
|
||||
slug,
|
||||
travel_date,
|
||||
journey_type=journey_type,
|
||||
return_date=return_date,
|
||||
)
|
||||
|
||||
trip_fares = {}
|
||||
advance_fares = {}
|
||||
|
|
@ -465,6 +578,10 @@ def results(station_crs, slug, travel_date):
|
|||
departure_station_name=departure_station_name,
|
||||
prev_date=prev_date,
|
||||
next_date=next_date,
|
||||
prev_results_url=prev_results_url,
|
||||
next_results_url=next_results_url,
|
||||
destination_links=destination_links,
|
||||
results_base_url=results_base_url,
|
||||
travel_date_display=travel_date_display,
|
||||
gwr_count=sum(section["gwr_count"] for section in sections),
|
||||
eurostar_count=sum(section["eurostar_count"] for section in sections),
|
||||
|
|
@ -484,7 +601,6 @@ def results(station_crs, slug, travel_date):
|
|||
es_class=es_class,
|
||||
url_nr_class=url_nr,
|
||||
url_es_class=url_es,
|
||||
url_journey_type=None if journey_type == "outbound" else journey_type,
|
||||
trip_fares_json=json.dumps(trip_fares),
|
||||
advance_fares_json=json.dumps(advance_fares),
|
||||
advance_api_urls_json=json.dumps(advance_api_urls),
|
||||
|
|
|
|||
|
|
@ -294,6 +294,33 @@
|
|||
|
||||
/* Loading state */
|
||||
#advance-loading { font-size: 0.82rem; color: #718096; margin-left: 0.5rem; }
|
||||
.loading-panel {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid #cbd5e0;
|
||||
border-radius: 6px;
|
||||
background: #f8fbff;
|
||||
}
|
||||
.loading-panel p { margin: 0.25rem 0 0; }
|
||||
.spinner {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border: 3px solid #cbd5e0;
|
||||
border-top-color: #00539f;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
.spinner-inline {
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
border-width: 2px;
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Fare lines — show all, dim inactive */
|
||||
.fare-line { display: block; line-height: 1.6; transition: opacity 0.15s; }
|
||||
|
|
|
|||
|
|
@ -21,22 +21,22 @@
|
|||
{% endif %}
|
||||
</h2>
|
||||
<div class="date-nav">
|
||||
<a href="{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=prev_date, journey_type=url_journey_type, return_date=return_date, min_connection=url_min_connection, max_connection=url_max_connection, nr_class=url_nr_class, es_class=url_es_class) }}"
|
||||
<a href="{{ prev_results_url }}"
|
||||
class="btn-nav">← Prev</a>
|
||||
<strong>{{ travel_date_display }}{% if return_date %} to {{ return_date }}{% endif %}</strong>
|
||||
<a href="{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=next_date, journey_type=url_journey_type, return_date=return_date, min_connection=url_min_connection, max_connection=url_max_connection, nr_class=url_nr_class, es_class=url_es_class) }}"
|
||||
<a href="{{ next_results_url }}"
|
||||
class="btn-nav">Next →</a>
|
||||
</div>
|
||||
<div class="switcher-section">
|
||||
<div class="section-label">Switch destination for {{ travel_date_display }}</div>
|
||||
<div class="chip-row">
|
||||
{% for destination_slug, destination_name in destinations.items() %}
|
||||
{% for destination_slug, destination_name, destination_url in destination_links %}
|
||||
{% if destination_slug == slug %}
|
||||
<span class="chip-current">{{ destination_name }}</span>
|
||||
{% else %}
|
||||
<a
|
||||
class="chip-link"
|
||||
href="{{ url_for('results', station_crs=station_crs, slug=destination_slug, travel_date=travel_date, journey_type=url_journey_type, return_date=return_date, min_connection=url_min_connection, max_connection=url_max_connection, nr_class=url_nr_class, es_class=url_es_class) }}"
|
||||
href="{{ destination_url }}"
|
||||
>{{ destination_name }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
|
@ -70,6 +70,7 @@
|
|||
<button type="button" class="btn-group-option {% if nr_class == 'advance_std' %}active{% endif %}" onclick="setNrClass('advance_std')">Advance Std</button>
|
||||
<button type="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"><span class="spinner spinner-inline" aria-hidden="true"></span>Loading advance fares</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="filter-label">Eurostar:</span>
|
||||
|
|
@ -81,9 +82,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const RESULTS_BASE = '{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=travel_date) }}';
|
||||
const JOURNEY_TYPE = '{{ journey_type }}';
|
||||
const RETURN_DATE = '{{ return_date or '' }}';
|
||||
const RESULTS_BASE = '{{ results_base_url }}';
|
||||
const DEFAULT_MIN_CONN = {{ default_min_connection }};
|
||||
const DEFAULT_MAX_CONN = {{ default_max_connection }};
|
||||
let TRIP_FARES = {{ trip_fares_json | safe }};
|
||||
|
|
@ -95,12 +94,18 @@
|
|||
let currentEsClass = '{{ es_class }}';
|
||||
let advanceLoadingSections = {};
|
||||
|
||||
function updateAdvanceLoadingStatus() {
|
||||
var loading = Object.keys(advanceLoadingSections).some(function(sectionId) {
|
||||
return advanceLoadingSections[sectionId];
|
||||
});
|
||||
var el = document.getElementById('advance-loading');
|
||||
if (el) el.style.display = loading ? 'inline-flex' : 'none';
|
||||
}
|
||||
|
||||
function buildUrl(nrCls, esCls) {
|
||||
var min = parseInt(document.getElementById('min_conn_select').value);
|
||||
var max = parseInt(document.getElementById('max_conn_select').value);
|
||||
var params = [];
|
||||
if (JOURNEY_TYPE !== 'outbound') params.push('journey_type=' + encodeURIComponent(JOURNEY_TYPE));
|
||||
if (RETURN_DATE) params.push('return_date=' + encodeURIComponent(RETURN_DATE));
|
||||
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);
|
||||
|
|
@ -169,6 +174,7 @@
|
|||
function loadAdvanceFaresForSection(sectionId) {
|
||||
if (advanceLoadingSections[sectionId] || !ADVANCE_API_URLS[sectionId]) return;
|
||||
advanceLoadingSections[sectionId] = true;
|
||||
updateAdvanceLoadingStatus();
|
||||
if (!ADVANCE_FARES) ADVANCE_FARES = {};
|
||||
if (!ADVANCE_FARES[sectionId]) ADVANCE_FARES[sectionId] = {};
|
||||
|
||||
|
|
@ -183,6 +189,7 @@
|
|||
.catch(function() {})
|
||||
.finally(function() {
|
||||
advanceLoadingSections[sectionId] = false;
|
||||
updateAdvanceLoadingStatus();
|
||||
updateDisplay();
|
||||
});
|
||||
}
|
||||
|
|
@ -190,29 +197,41 @@
|
|||
function loadAdvanceFaresForSectionStreaming(sectionId) {
|
||||
if (advanceLoadingSections[sectionId] || !ADVANCE_STREAM_URLS[sectionId]) return;
|
||||
advanceLoadingSections[sectionId] = true;
|
||||
updateAdvanceLoadingStatus();
|
||||
if (!ADVANCE_FARES) ADVANCE_FARES = {};
|
||||
if (!ADVANCE_FARES[sectionId]) ADVANCE_FARES[sectionId] = {};
|
||||
|
||||
var hadMessage = false;
|
||||
var source = new EventSource(ADVANCE_STREAM_URLS[sectionId]);
|
||||
source.onmessage = function(event) {
|
||||
hadMessage = true;
|
||||
var msg = JSON.parse(event.data);
|
||||
if (msg.type === 'fares') mergeAdvanceFares(sectionId, msg.fares);
|
||||
if (msg.type === 'fares') {
|
||||
mergeAdvanceFares(sectionId, msg.fares);
|
||||
updateDisplay();
|
||||
}
|
||||
if (msg.type === 'done' || msg.type === 'error') {
|
||||
advanceLoadingSections[sectionId] = false;
|
||||
source.close();
|
||||
updateAdvanceLoadingStatus();
|
||||
updateDisplay();
|
||||
}
|
||||
};
|
||||
source.onerror = function() {
|
||||
advanceLoadingSections[sectionId] = false;
|
||||
source.close();
|
||||
updateDisplay();
|
||||
updateAdvanceLoadingStatus();
|
||||
if (!hadMessage && ADVANCE_API_URLS[sectionId]) {
|
||||
loadAdvanceFaresForSection(sectionId);
|
||||
} else {
|
||||
updateDisplay();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function loadMissingAdvanceFares() {
|
||||
for (var sectionId in ADVANCE_API_URLS) {
|
||||
if (sectionNeedsAdvance(sectionId)) loadAdvanceFaresForSection(sectionId);
|
||||
for (var sectionId in ADVANCE_STREAM_URLS) {
|
||||
if (sectionNeedsAdvance(sectionId)) loadAdvanceFaresForSectionStreaming(sectionId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -281,8 +300,8 @@
|
|||
var total = totals[key];
|
||||
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">low</span>';
|
||||
else if (total >= maxTotal - 10) html += ' <span title="Most expensive journey">high</span>';
|
||||
if (total <= minTotal + 10) html += ' <span title="Cheapest journey">🪙</span>';
|
||||
else if (total >= maxTotal - 10) html += ' <span title="Most expensive journey">💸</span>';
|
||||
}
|
||||
html += '</span>';
|
||||
totalSpan.innerHTML = html;
|
||||
|
|
@ -293,10 +312,16 @@
|
|||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (currentNrClass === 'advance_std' || currentNrClass === 'advance_1st') loadMissingAdvanceFares();
|
||||
updateDisplay();
|
||||
});
|
||||
function initialiseResultsPage() {
|
||||
if (currentNrClass === 'advance_std' || currentNrClass === 'advance_1st') loadMissingAdvanceFares();
|
||||
updateDisplay();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initialiseResultsPage);
|
||||
} else {
|
||||
initialiseResultsPage();
|
||||
}
|
||||
</script>
|
||||
<p class="card-meta">
|
||||
{{ gwr_count }} National Rail service{{ 's' if gwr_count != 1 }}
|
||||
|
|
@ -382,10 +407,14 @@
|
|||
<span class="fare-line es-plus">{% if row.eurostar_plus_price is not none %}<span class="text-sm font-bold">£{{ "%.2f"|format(row.eurostar_plus_price) }}</span>{% endif %}</span>
|
||||
</td>
|
||||
<td class="col-transfer" style="color:#4a5568">
|
||||
<span class="nowrap">{{ row.connection_duration }}{% if row.connection_minutes < 45 %} <span title="Tight connection">!</span>{% endif %}</span>
|
||||
<span class="nowrap">{{ row.connection_duration }}{% if row.connection_minutes < 45 %} <span title="Tight connection">⚠️</span>{% endif %}</span>
|
||||
{% if row.circle_services %}
|
||||
{% set c = row.circle_services[0] %}
|
||||
<br><span class="text-xs text-muted nowrap">Circle {{ c.depart }} → PAD {{ c.arrive_pad }} · £{{ "%.2f"|format(c.fare) }}</span>
|
||||
{% if row.circle_services | length > 1 %}
|
||||
{% set c2 = row.circle_services[1] %}
|
||||
<br><span class="text-xs text-muted nowrap" style="opacity:0.7">next {{ c2.depart }} → PAD {{ c2.arrive_pad }} · £{{ "%.2f"|format(c2.fare) }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
|
|
@ -410,10 +439,14 @@
|
|||
<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>
|
||||
<span class="nowrap">{{ row.connection_duration }}{% if row.connection_minutes < 80 %} <span title="Tight connection">⚠️</span>{% endif %}</span>
|
||||
{% if row.circle_services %}
|
||||
{% set c = row.circle_services[0] %}
|
||||
<br><span class="text-xs text-muted nowrap">Circle {{ c.depart }} → KX {{ c.arrive_kx }} · £{{ "%.2f"|format(c.fare) }}</span>
|
||||
{% if row.circle_services | length > 1 %}
|
||||
{% set c2 = row.circle_services[1] %}
|
||||
<br><span class="text-xs text-muted nowrap" style="opacity:0.7">next {{ c2.depart }} → KX {{ c2.arrive_kx }} · £{{ "%.2f"|format(c2.fare) }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
|
|
|
|||
76
templates/results_loading.html
Normal file
76
templates/results_loading.html
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}{% if journey_type == 'inbound' %}{{ destination }} to {{ departure_station_name }} via Eurostar{% elif journey_type == 'return' %}{{ departure_station_name }} to {{ destination }} return via Eurostar{% else %}{{ departure_station_name }} to {{ destination }} via Eurostar{% endif %}{% endblock %}
|
||||
{% block og_title %}{{ self.title()|trim }}{% endblock %}
|
||||
{% block og_description %}Train options from {{ departure_station_name }} to {{ destination }} via Paddington, St Pancras, and Eurostar.{% endblock %}
|
||||
{% block twitter_title %}{{ self.title()|trim }}{% endblock %}
|
||||
{% block twitter_description %}Train options from {{ departure_station_name }} to {{ destination }} via Paddington, St Pancras, and Eurostar.{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<p class="back-link">
|
||||
<a href="{{ index_url }}">← New search</a>
|
||||
</p>
|
||||
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<h2>
|
||||
{% if journey_type == 'inbound' %}
|
||||
{{ destination }} → {{ departure_station_name }}
|
||||
{% elif journey_type == 'return' %}
|
||||
{{ departure_station_name }} ↔ {{ destination }}
|
||||
{% else %}
|
||||
{{ departure_station_name }} → {{ destination }}
|
||||
{% endif %}
|
||||
</h2>
|
||||
<p class="card-meta">
|
||||
{{ travel_date_display }}{% if return_date %} to {{ return_date }}{% endif %}
|
||||
</p>
|
||||
<div class="loading-panel" role="status" aria-live="polite">
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
<div>
|
||||
<strong>Loading train times and fares</strong>
|
||||
<p class="text-muted text-sm">Fetching National Rail, Eurostar, and fare data. Results will appear here as soon as they are ready.</p>
|
||||
</div>
|
||||
</div>
|
||||
<noscript>
|
||||
<p><a href="{{ full_results_url }}">Load results</a></p>
|
||||
</noscript>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
function runScripts(root) {
|
||||
root.querySelectorAll('script').forEach(function(oldScript) {
|
||||
var script = document.createElement('script');
|
||||
for (var i = 0; i < oldScript.attributes.length; i++) {
|
||||
var attr = oldScript.attributes[i];
|
||||
script.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
script.text = oldScript.text;
|
||||
oldScript.parentNode.replaceChild(script, oldScript);
|
||||
});
|
||||
}
|
||||
|
||||
fetch({{ full_results_url|tojson }}, {headers: {'X-Requested-With': 'fetch'}})
|
||||
.then(function(response) {
|
||||
if (!response.ok) throw new Error('Could not load results');
|
||||
return response.text();
|
||||
})
|
||||
.then(function(html) {
|
||||
var doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
document.title = doc.title;
|
||||
var nextMain = doc.querySelector('main');
|
||||
var currentMain = document.querySelector('main');
|
||||
if (!nextMain || !currentMain) throw new Error('Results page was incomplete');
|
||||
currentMain.innerHTML = nextMain.innerHTML;
|
||||
runScripts(currentMain);
|
||||
history.replaceState(null, '', window.location.href);
|
||||
})
|
||||
.catch(function() {
|
||||
var panel = document.querySelector('.loading-panel');
|
||||
if (panel) {
|
||||
panel.innerHTML = '<div><strong>Could not load results</strong><p class="text-muted text-sm"><a href="{{ full_results_url }}">Try loading the full results page</a>.</p></div>';
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
from datetime import datetime
|
||||
|
||||
import app as app_module
|
||||
import trip_planner as trip_planner_module
|
||||
|
||||
|
||||
def _client():
|
||||
|
|
@ -72,7 +75,7 @@ def test_search_redirects_return_with_return_date():
|
|||
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers['Location'].endswith(
|
||||
'/results/BRI/paris/2026-04-10?journey_type=return&return_date=2026-04-17'
|
||||
'/results/BRI/paris/2026-04-10/return/2026-04-17'
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -91,6 +94,23 @@ def test_results_shows_same_day_destination_switcher(monkeypatch):
|
|||
assert 'ES 9014' in html
|
||||
|
||||
|
||||
def test_results_progressive_shell_loads_without_scraping(monkeypatch):
|
||||
def fail_fetch(*args, **kwargs):
|
||||
raise AssertionError("progressive shell should not fetch data")
|
||||
|
||||
monkeypatch.setattr(app_module.rtt_scraper, 'fetch', fail_fetch)
|
||||
monkeypatch.setattr(app_module.eurostar_scraper, 'fetch', fail_fetch)
|
||||
monkeypatch.setattr(app_module.gwr_fares_scraper, 'fetch', fail_fetch)
|
||||
client = _client()
|
||||
|
||||
resp = client.get('/results/BRI/paris/2026-04-10?progressive=1')
|
||||
html = resp.get_data(as_text=True)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert 'Loading train times and fares' in html
|
||||
assert 'render=full' in html
|
||||
|
||||
|
||||
def test_results_title_and_social_meta_include_destination(monkeypatch):
|
||||
_stub_data(monkeypatch)
|
||||
client = _client()
|
||||
|
|
@ -380,14 +400,39 @@ def test_results_return_renders_outbound_and_inbound_tables(monkeypatch):
|
|||
],
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
trip_planner_module.circle_line,
|
||||
'upcoming_services',
|
||||
lambda earliest_board, count=2, direction='pad_to_kx': (
|
||||
[
|
||||
(datetime(2026, 4, 10, 9, 10), datetime(2026, 4, 10, 9, 25)),
|
||||
(datetime(2026, 4, 10, 9, 15), datetime(2026, 4, 10, 9, 30)),
|
||||
]
|
||||
if direction == 'pad_to_kx'
|
||||
else [
|
||||
(datetime(2026, 4, 17, 16, 40), datetime(2026, 4, 17, 16, 55)),
|
||||
(datetime(2026, 4, 17, 16, 45), datetime(2026, 4, 17, 17, 0)),
|
||||
]
|
||||
),
|
||||
)
|
||||
client = _client()
|
||||
|
||||
resp = client.get('/results/BRI/paris/2026-04-10?journey_type=return&return_date=2026-04-17')
|
||||
resp = client.get('/results/BRI/paris/2026-04-10/return/2026-04-17')
|
||||
html = resp.get_data(as_text=True)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert 'Outbound: Bristol Temple Meads → Paris Gare du Nord' in html
|
||||
assert 'Return: Paris Gare du Nord → Bristol Temple Meads' in html
|
||||
assert '/results/BRI/paris/2026-04-09/return/2026-04-17' in html
|
||||
assert '/results/BRI/paris/2026-04-11/return/2026-04-17' in html
|
||||
assert "/results/BRI/paris/2026-04-10/return/2026-04-17" in html
|
||||
assert 'journey_type=return' not in html
|
||||
assert 'return_date=2026-04-17' not in html
|
||||
assert 'Circle 09:10 → KX 09:25' in html
|
||||
assert 'next 09:15 → KX 09:30' in html
|
||||
assert 'Circle 16:40 → PAD 16:55' in html
|
||||
assert 'next 16:45 → PAD 17:00' in html
|
||||
assert 'title="Tight connection">⚠️</span>' in html
|
||||
assert 'ES 9014' in html
|
||||
assert 'ES 9035' in html
|
||||
|
||||
|
|
|
|||
|
|
@ -151,23 +151,29 @@ def _stub_single_data(monkeypatch):
|
|||
},
|
||||
},
|
||||
)
|
||||
advance_fares = {
|
||||
"07:00": {
|
||||
"advance_std": {
|
||||
"ticket": "Advance Single",
|
||||
"price": 50.0,
|
||||
"code": "ADV",
|
||||
},
|
||||
"advance_1st": {
|
||||
"ticket": "1st Advance",
|
||||
"price": 80.0,
|
||||
"code": "AFA",
|
||||
},
|
||||
},
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
app_module.gwr_fares_scraper,
|
||||
"fetch_advance",
|
||||
lambda station_crs, travel_date: {
|
||||
"07:00": {
|
||||
"advance_std": {
|
||||
"ticket": "Advance Single",
|
||||
"price": 50.0,
|
||||
"code": "ADV",
|
||||
},
|
||||
"advance_1st": {
|
||||
"ticket": "1st Advance",
|
||||
"price": 80.0,
|
||||
"code": "AFA",
|
||||
},
|
||||
},
|
||||
},
|
||||
lambda station_crs, travel_date: advance_fares,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
app_module.gwr_fares_scraper,
|
||||
"fetch_advance_streaming",
|
||||
lambda station_crs, travel_date: iter([advance_fares]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
app_module.eurostar_scraper,
|
||||
|
|
@ -269,19 +275,25 @@ def test_single_next_date_advance_standard_labels_unreachable_rows(monkeypatch):
|
|||
},
|
||||
},
|
||||
)
|
||||
advance_fares = {
|
||||
"07:00": {
|
||||
"advance_std": {
|
||||
"ticket": "Advance Single",
|
||||
"price": 50.0,
|
||||
"code": "ADV",
|
||||
},
|
||||
"advance_1st": None,
|
||||
},
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
app_module.gwr_fares_scraper,
|
||||
"fetch_advance",
|
||||
lambda station_crs, travel_date: {
|
||||
"07:00": {
|
||||
"advance_std": {
|
||||
"ticket": "Advance Single",
|
||||
"price": 50.0,
|
||||
"code": "ADV",
|
||||
},
|
||||
"advance_1st": None,
|
||||
},
|
||||
},
|
||||
lambda station_crs, travel_date: advance_fares,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
app_module.gwr_fares_scraper,
|
||||
"fetch_advance_streaming",
|
||||
lambda station_crs, travel_date: iter([advance_fares]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
app_module.eurostar_scraper,
|
||||
|
|
@ -386,12 +398,13 @@ def test_return_advance_first_standard_premier_totals(local_server):
|
|||
timeout=10000,
|
||||
)
|
||||
|
||||
assert "journey_type=return" in page.url
|
||||
assert "return_date=2026-07-27" in page.url
|
||||
assert "/results/BRI/paris/2026-07-20/return/2026-07-27" in page.url
|
||||
assert "journey_type=return" not in page.url
|
||||
assert "return_date=2026-07-27" not in page.url
|
||||
assert "nr_class=advance_1st" in page.url
|
||||
assert "es_class=plus" in page.url
|
||||
totals = [el.inner_text() for el in page.locator(".total-price").all()]
|
||||
assert totals == ["£172.10 high", "£127.10 low"]
|
||||
assert totals == ["£172.10 💸", "£127.10 🪙"]
|
||||
browser.close()
|
||||
|
||||
|
||||
|
|
@ -400,9 +413,8 @@ def test_return_advance_first_standard_premier_totals_on_initial_url(local_serve
|
|||
browser = _launch_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(
|
||||
f"{local_server}/results/BRI/paris/2026-07-20"
|
||||
"?journey_type=return&return_date=2026-07-27"
|
||||
"&nr_class=advance_1st&es_class=plus",
|
||||
f"{local_server}/results/BRI/paris/2026-07-20/return/2026-07-27"
|
||||
"?nr_class=advance_1st&es_class=plus",
|
||||
wait_until="domcontentloaded",
|
||||
)
|
||||
|
||||
|
|
@ -418,5 +430,5 @@ def test_return_advance_first_standard_premier_totals_on_initial_url(local_serve
|
|||
)
|
||||
|
||||
totals = [el.inner_text() for el in page.locator(".total-price").all()]
|
||||
assert totals == ["£172.10 high", "£127.10 low"]
|
||||
assert totals == ["£172.10 💸", "£127.10 🪙"]
|
||||
browser.close()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue