Add Eurostar Plus prices and NR advance fare support
- Eurostar scraper now fetches both Standard and Plus (PLUS class code) prices/seats in a single API call; each service dict gains plus_price and plus_seats fields - GWR fares scraper gains fetch_advance() which makes two sets of paginated calls (standard advance + first-class advance) and returns cheapest per departure; shared _run_pages() generator reduces duplication in fetch() - New /api/advance_fares/<station_crs>/<travel_date> endpoint returns advance fares as JSON, cached for 24 hours - Results page gains NR ticket selector (Walk-on / Std Advance / 1st Advance) and Eurostar selector (Standard / Plus); total column is JS-computed from the selected combination with cheapest/priciest highlighting - Load advance prices button fetches the API lazily; if advance fares are already cached they are embedded in the page and applied on load so the button is hidden automatically Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5583a20143
commit
89a536dfd3
8 changed files with 515 additions and 83 deletions
|
|
@ -248,8 +248,19 @@
|
|||
.date-nav { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; }
|
||||
.switcher-section { margin: 0.9rem 0 1rem; }
|
||||
.section-label { font-size: 0.9rem; font-weight: 600; margin-bottom: 0.45rem; }
|
||||
.filter-row { margin-top: 0.75rem; display: flex; gap: 1.5rem; align-items: center; }
|
||||
.filter-row { margin-top: 0.75rem; display: flex; gap: 1.5rem; align-items: center; flex-wrap: wrap; }
|
||||
.filter-label { font-size: 0.9rem; font-weight: 600; margin-right: 0.5rem; }
|
||||
.btn-secondary {
|
||||
padding: 0.3rem 0.75rem;
|
||||
border: 1px solid #00539f;
|
||||
border-radius: 4px;
|
||||
color: #00539f;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.btn-secondary:hover { background: #f8fbff; }
|
||||
.btn-secondary:disabled { opacity: 0.5; cursor: default; }
|
||||
.card-meta { color: #4a5568; margin: 0; }
|
||||
.footnote { margin-top: 1rem; font-size: 0.82rem; color: #718096; }
|
||||
|
||||
|
|
|
|||
|
|
@ -95,6 +95,28 @@
|
|||
|
||||
{% 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>
|
||||
|
|
@ -108,11 +130,6 @@
|
|||
{% if trips %}
|
||||
{% set best_mins = trips | map(attribute='total_minutes') | min %}
|
||||
{% set worst_mins = trips | map(attribute='total_minutes') | max %}
|
||||
{% set priced_trips = trips | selectattr('total_price') | list %}
|
||||
{% if priced_trips | length > 1 %}
|
||||
{% set min_price = priced_trips | map(attribute='total_price') | min %}
|
||||
{% set max_price = priced_trips | map(attribute='total_price') | max %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% for row in result_rows %}
|
||||
{% if row.row_type == 'trip' and row.total_minutes <= best_mins + 5 and trips | length > 1 %}
|
||||
|
|
@ -126,7 +143,16 @@
|
|||
{% else %}
|
||||
{% set row_class = '' %}
|
||||
{% endif %}
|
||||
<tr class="{{ row_class }}">
|
||||
<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 %}
|
||||
>
|
||||
{% if row.row_type == 'trip' %}
|
||||
<td>
|
||||
<span class="font-bold nowrap">{{ row.depart_bristol }} → {{ row.arrive_paddington }}</span>
|
||||
|
|
@ -144,6 +170,12 @@
|
|||
{% 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>
|
||||
</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>
|
||||
|
|
@ -167,12 +199,14 @@
|
|||
{% endif %}
|
||||
{% if row.eurostar_price is not none %}
|
||||
<br><span class="text-sm font-bold">£{{ "%.2f"|format(row.eurostar_price) }}</span>
|
||||
{% if row.eurostar_seats is not none %}
|
||||
<span class="text-xs text-muted">{{ row.eurostar_seats }} at this price</span>
|
||||
{% endif %}
|
||||
<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 %}
|
||||
</td>
|
||||
<td class="font-bold nowrap">
|
||||
{% if row.total_minutes <= best_mins + 5 and trips | length > 1 %}
|
||||
|
|
@ -182,9 +216,7 @@
|
|||
{% else %}
|
||||
<span class="text-blue">{{ row.total_duration }}</span>
|
||||
{% endif %}
|
||||
{% if row.total_price is not none %}
|
||||
<br><span class="text-sm text-green" style="font-weight:700">£{{ "%.2f"|format(row.total_price) }}{% if min_price is defined and max_price is defined %}{% if row.total_price <= min_price + 10 %} <span title="Cheapest journey">🪙</span>{% elif row.total_price >= max_price - 10 %} <span title="Most expensive journey">💸</span>{% endif %}{% endif %}</span>
|
||||
{% endif %}
|
||||
<span class="total-price-span"></span>
|
||||
</td>
|
||||
{% else %}
|
||||
<td>
|
||||
|
|
@ -202,12 +234,14 @@
|
|||
{% endif %}
|
||||
{% if row.eurostar_price is not none %}
|
||||
<br><span class="text-sm text-dimmed">£{{ "%.2f"|format(row.eurostar_price) }}</span>
|
||||
{% if row.eurostar_seats is not none %}
|
||||
<span class="text-xs text-dimmed">{{ row.eurostar_seats }} at this price</span>
|
||||
{% endif %}
|
||||
<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 %}
|
||||
</td>
|
||||
<td class="text-dimmed">—</td>
|
||||
{% endif %}
|
||||
|
|
@ -221,7 +255,7 @@
|
|||
Paddington → St Pancras connection: {{ min_connection }}–{{ max_connection }} min.
|
||||
GWR walk-on single prices from
|
||||
<a href="https://www.gwr.com/" target="_blank" rel="noopener">gwr.com</a>.
|
||||
Eurostar Standard prices are for 1 adult in GBP; always check
|
||||
Eurostar Standard and Plus 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>
|
||||
|
|
@ -229,6 +263,117 @@
|
|||
<a href="{{ rtt_url }}" target="_blank" rel="noopener">Paddington arrivals on RTT</a>
|
||||
</p>
|
||||
|
||||
<script>
|
||||
var stationCrs = {{ station_crs | tojson }};
|
||||
var travelDate = {{ 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('/api/advance_fares/' + stationCrs + '/' + travelDate)
|
||||
.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