Add tfl_fare.py with circle_line_fare() which returns £3.10 (peak) or £3.00 (off-peak) based on TfL Zone 1 pricing. Peak applies Monday–Friday (excluding England public holidays) 06:30–09:30 and 16:00–19:00. Annotate each circle service with its fare in trip_planner.py, display it alongside the Circle line times in the Transfer column, and include it in the journey Total. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
394 lines
18 KiB
HTML
394 lines
18 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}{{ departure_station_name }} to {{ destination }} via Eurostar{% endblock %}
|
|
{% block og_title %}{{ departure_station_name }} to {{ destination }} via Eurostar{% endblock %}
|
|
{% block og_description %}Train options from {{ departure_station_name }} to {{ destination }} on {{ travel_date_display }} via Paddington, St Pancras, and Eurostar.{% endblock %}
|
|
{% block twitter_title %}{{ departure_station_name }} to {{ destination }} via Eurostar{% endblock %}
|
|
{% block twitter_description %}Train options from {{ departure_station_name }} to {{ destination }} on {{ travel_date_display }} via Paddington, St Pancras, and Eurostar.{% endblock %}
|
|
{% block content %}
|
|
|
|
<p class="back-link">
|
|
<a href="{{ url_for('index') }}">← New search</a>
|
|
</p>
|
|
|
|
<div class="card" style="margin-bottom:1.5rem">
|
|
<h2>
|
|
{{ 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) }}"
|
|
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) }}"
|
|
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() %}
|
|
{% 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, min_connection=url_min_connection, max_connection=url_max_connection) }}"
|
|
>{{ destination_name }}</a>
|
|
{% endif %}
|
|
{% endfor %}
|
|
</div>
|
|
</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">
|
|
{% 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">
|
|
{% for mins in valid_max_connections %}
|
|
<option value="{{ mins }}" {% if mins == max_connection %}selected{% endif %}>{{ mins }} min</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
function applyConnectionFilter() {
|
|
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;
|
|
}
|
|
</script>
|
|
<p class="card-meta">
|
|
{{ gwr_count }} GWR service{{ 's' if gwr_count != 1 }}
|
|
·
|
|
{{ eurostar_count }} Eurostar service{{ 's' if eurostar_count != 1 }}
|
|
{% if from_cache %}
|
|
· <span class="text-muted text-sm">(cached)</span>
|
|
{% endif %}
|
|
</p>
|
|
{% if error %}
|
|
<div class="alert alert-error">
|
|
<strong>Warning:</strong> {{ error }}
|
|
</div>
|
|
{% endif %}
|
|
{% if no_prices_note %}
|
|
<div class="alert alert-warning">
|
|
{{ no_prices_note }}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{% 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>
|
|
<th>National Rail<br><span class="text-xs font-normal text-muted">{{ departure_station_name }} → Paddington</span></th>
|
|
<th class="col-transfer">Transfer<br><span class="text-xs font-normal text-muted">Paddington → St Pancras</span></th>
|
|
<th>Eurostar<br><span class="text-xs font-normal text-muted">St Pancras → {{ destination }}</span></th>
|
|
<th class="nowrap">Total</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% if trips %}
|
|
{% set best_mins = trips | map(attribute='total_minutes') | min %}
|
|
{% set worst_mins = trips | map(attribute='total_minutes') | max %}
|
|
{% endif %}
|
|
{% for row in result_rows %}
|
|
{% if row.row_type == 'trip' and row.total_minutes <= best_mins + 5 and trips | length > 1 %}
|
|
{% set row_class = 'row-fast' %}
|
|
{% elif row.row_type == 'trip' and row.total_minutes >= worst_mins - 5 and trips | length > 1 %}
|
|
{% set row_class = 'row-slow' %}
|
|
{% elif row.row_type == 'unreachable' %}
|
|
{% set row_class = 'row-unreachable' %}
|
|
{% elif loop.index is odd %}
|
|
{% set row_class = 'row-alt' %}
|
|
{% 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 %}
|
|
>
|
|
{% if row.row_type == 'trip' %}
|
|
<td>
|
|
<span class="font-bold nowrap">{{ row.depart_bristol }} → {{ row.arrive_paddington }}</span>
|
|
<span class="text-sm text-muted nowrap">({{ row.gwr_duration }})</span>
|
|
{% if row.headcode or row.arrive_platform %}
|
|
<br><span class="text-xs text-muted">
|
|
{%- if row.headcode %}{{ row.headcode }}{% endif %}
|
|
{%- if row.headcode and row.arrive_platform %} · {% endif %}
|
|
{%- 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>
|
|
</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>
|
|
{% 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>
|
|
<span class="font-bold nowrap">{{ row.depart_st_pancras }} → {{ row.arrive_destination }} <span class="font-normal text-muted" style="font-size:0.85em">(CET)</span></span>
|
|
{% if row.eurostar_duration or row.train_number %}
|
|
<br><span class="text-xs text-muted">
|
|
{%- if row.eurostar_duration %}<span class="nowrap">({{ row.eurostar_duration }})</span>{% endif %}
|
|
{%- if row.eurostar_duration and row.train_number %} · {% endif %}
|
|
{%- 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 %}
|
|
</td>
|
|
<td class="font-bold nowrap">
|
|
{% if row.total_minutes <= best_mins + 5 and trips | length > 1 %}
|
|
<span class="text-green" title="Fastest journey">{{ row.total_duration }} ⚡</span>
|
|
{% elif row.total_minutes >= worst_mins - 5 and trips | length > 1 %}
|
|
<span class="text-red" title="Slowest journey">{{ row.total_duration }} 🐢</span>
|
|
{% else %}
|
|
<span class="text-blue">{{ row.total_duration }}</span>
|
|
{% endif %}
|
|
<span class="total-price-span"></span>
|
|
</td>
|
|
{% else %}
|
|
<td>
|
|
<span class="text-dimmed text-sm" title="Too early to reach from {{ departure_station_name }}">Too early</span>
|
|
</td>
|
|
<td class="col-transfer text-dimmed">—</td>
|
|
<td>
|
|
<span class="font-bold nowrap text-dimmed">{{ row.depart_st_pancras }} → {{ row.arrive_destination }} <span class="font-normal" style="font-size:0.85em">(CET)</span></span>
|
|
{% if row.eurostar_duration or row.train_number %}
|
|
<br><span class="text-xs text-dimmed">
|
|
{%- if row.eurostar_duration %}<span class="nowrap">({{ row.eurostar_duration }})</span>{% endif %}
|
|
{%- if row.eurostar_duration and row.train_number %} · {% endif %}
|
|
{%- 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 %}
|
|
</td>
|
|
<td class="text-dimmed">—</td>
|
|
{% endif %}
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<p class="footnote">
|
|
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 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>
|
|
·
|
|
<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>
|
|
<p>
|
|
{% if gwr_count == 0 and eurostar_count == 0 %}
|
|
Could not retrieve train data. Check your network connection or try again.
|
|
{% elif gwr_count == 0 %}
|
|
No GWR trains found for this date.
|
|
{% elif eurostar_count == 0 %}
|
|
No Eurostar services found for {{ destination }} on this date.
|
|
{% else %}
|
|
No GWR + Eurostar combination has at least a {{ min_connection }}-minute connection at Paddington/St Pancras.
|
|
{% endif %}
|
|
</p>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% endblock %}
|